Files
github-spec-kit/docs/guides/monorepo.md
Pascal THUET 490566847c feat(cli): honor SPECIFY_INIT_DIR in the specify CLI project resolver (#3186)
* feat(cli): honor SPECIFY_INIT_DIR in the specify CLI project resolver

The shell resolver honors SPECIFY_INIT_DIR (#2892), but the Python CLI did
not: it resolved the project as Path.cwd() + a .specify/ check and never read
the override. So setup-plan.sh respected it while `specify integration install`
ignored it, and you still had to cd into the member project.

Route project resolution through a shared _resolve_init_dir_override() that
applies the shell resolver's validation rules (relative to cwd, must exist and
contain .specify/, hard error, no fallback, same error strings). It's wired into
_require_specify_project() — the chokepoint for every project-scoped subcommand
(integration/extension/workflow/preset/...) — and the `workflow run <file>`
standalone path, which re-applies its symlinked-.specify guard on the override
branch too. init is unchanged: it creates .specify/, so the must-pre-exist rule
doesn't apply.

The resolver canonicalizes symlinks via Path.resolve() while the shell keeps the
logical path; they agree for non-symlinked paths (documented in the resolver).

Tests in tests/test_init_dir_cli.py mirror the strict cases from test_init_dir.py
through the CLI; conftest now strips SPECIFY_* for the whole suite so a stray
export can't perturb the now-env-reading resolver. Docs note the CLI applies the
same rules.

Discussion: github/spec-kit#2834

(Disclosure: I used an AI coding agent to audit the call sites and resolver,
draft the change, and run an adversarial code review; reviewed by me.)

* fix(cli): honor SPECIFY_INIT_DIR for bundle commands

Assisted-by: Codex (model: GPT-5, autonomous)

* fix(bundler): refuse symlinked .specify on the SPECIFY_INIT_DIR override path

find_project_root refuses a symlinked .specify (following it could read/write
outside the tree, and a test pins that), but the SPECIFY_INIT_DIR override added
for bundle commands returned early and skipped that guard:
_resolve_init_dir_override validates .specify with is_dir(), which follows
symlinks. So `specify bundle` accepted via the override a layout the cwd path
rejects. Re-check the override result with the same guard, plus a regression test.

(Disclosure: found via an AI code review and fixed with an AI coding agent;
reviewed by me.)

* fix(cli): keep SPECIFY_INIT_DIR strict for bundles

Treat an explicit symlinked SPECIFY_INIT_DIR project as a hard bundle error instead of returning no project, which could initialize the current directory. Align the docs with the actual unset resolver behavior.

Assisted-by: Codex (model: GPT-5, autonomous)

* docs(core): note symlinked .specify handling differs across CLI surfaces

A symlinked .specify is followed by integration/extension/workflow (matching the
shell resolver) but refused by bundle and workflow run <file> (write
confinement). Document the asymmetry so it reads as intentional.

(Disclosure: AI-assisted; reviewed by me.)

* docs(core): reframe symlinked .specify note around the override invariant

Per maintainer feedback on #3186: SPECIFY_INIT_DIR relocates where the project
is, not how a surface treats symlinks. Each surface keeps its cwd-path stance
(write surfaces refuse a symlinked .specify, read/config surfaces follow it),
so the split is one policy relocated, not an inconsistency.

* docs: address Copilot review on resolver docstrings

- _project.py: the error messages "mirror" the shell wording rather than
  "match" it (the CLI renders a Rich `Error:` line, the shell a plain `ERROR:`).
- find_project_root: document that honoring SPECIFY_INIT_DIR when start is None
  can raise typer.Exit / BundlerError, so the Path | None signature isn't
  surprising to direct callers.

* docs(bundler): note require_project_root inherits the override raise behavior

find_project_root can raise typer.Exit / BundlerError under the SPECIFY_INIT_DIR
override (start=None); require_project_root inherits that, so document it
alongside its own BundlerError-on-missing-project.

* docs: clarify symlinked project root behavior

Assisted-by: OpenAI Codex (model: GPT-5, autonomous)

* Address SPECIFY_INIT_DIR review feedback

Assisted-by: OpenAI Codex (model: GPT-5, autonomous)

* Route workflow JSON errors to stderr

Assisted-by: OpenAI Codex (model: GPT-5, autonomous)
2026-07-01 15:55:18 -05:00

4.9 KiB

Using Spec Kit in a Monorepo

A Spec Kit project is directory-scoped: the project is whichever directory contains .specify/. A monorepo can hold several independent Spec Kit projects under one repository root, each with its own .specify/, specs/, constitution, and feature numbering.

Root resolution already prefers the nearest .specify/ over the Git toplevel, so commands run from inside a member project resolve to that project, not the repo root.

Layout

my-monorepo/
├── .git/                     # one Git repository at the root
├── apps/
│   ├── web/
│   │   └── .specify/         # Spec Kit project "web"
│   │       └── memory/constitution.md
│   └── api/
│       └── .specify/         # Spec Kit project "api"
│           └── memory/constitution.md
└── packages/
    └── ui/
        └── .specify/         # Spec Kit project "ui"

Initialize each member project independently:

specify init apps/web --integration claude
specify init apps/api --integration claude

Each project keeps its own specs/ directory and numbers features independently (apps/web/specs/001-…, apps/api/specs/001-…).

Working inside a member project

The default workflow is unchanged: change into the project directory and run the slash commands. Root resolution finds the nearest .specify/.

cd apps/web
# then run /speckit.specify, /speckit.plan, … in your agent

Targeting a member project from the repo root

For non-interactive or CI runs where you do not want to cd, set SPECIFY_INIT_DIR to the member project root (the directory containing .specify/). Relative paths resolve against the current directory.

# operate on apps/web from the monorepo root (no cd required)
export SPECIFY_INIT_DIR=apps/web

The path must exist and contain .specify/. If it does not, the command errors and does not fall back to the current directory or the Git toplevel. This is deliberate: a typo never writes specs into the wrong project. A nonexistent path is reported as you typed it; a path that exists but is not a Spec Kit project is reported as its resolved absolute path:

# SPECIFY_INIT_DIR=apps/wbe  (typo: no such directory)
ERROR: SPECIFY_INIT_DIR does not point to an existing directory: apps/wbe

# SPECIFY_INIT_DIR=apps  (exists, but has no .specify/ of its own)
ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): /home/you/my-monorepo/apps

SPECIFY_INIT_DIR selects the project; SPECIFY_FEATURE_DIRECTORY selects the feature within it. They compose: set both to pick a project and a feature non-interactively. See the SPECIFY_INIT_DIR reference for the full contract and the two-axes model.

The specify CLI's project-scoped subcommands honor the same variable, so they target a member project from the root without cd too:

export SPECIFY_INIT_DIR=apps/web
specify workflow list          # lists apps/web's workflows
specify integration status     # reports apps/web's integration

The validation rules are the same: the path must exist and contain .specify/, with no fallback to the current directory.

How SPECIFY_INIT_DIR reaches your agent

SPECIFY_INIT_DIR is read by the shell scripts that the slash commands invoke (get_repo_root in Bash, Get-RepoRoot in PowerShell). It takes effect only when it is present in the environment of the shell that runs those scripts.

  • Scripted / CI runs: export it in the same shell that drives the commands; it is reliable there.
  • Interactive agents: whether an exported variable reaches the shell tool an agent uses is agent-specific. Export SPECIFY_INIT_DIR before launching the agent, and verify once (e.g. run /speckit.specify and confirm the new feature landed under the intended project's specs/).

Git in a monorepo

Note

Spec Kit project files are scoped to the resolved project root, but Git operations still run in the containing Git work tree. In a monorepo with a single Git repository at the root and projects in subdirectories, feature branch creation creates or switches branches in the shared root repository. Spec directories still live under the selected member project, while the Git branch namespace is shared by the whole monorepo. Manage branches and commits at the repository root, or initialize Git per member project if you want isolated per-project branch namespaces.

Constitutions

Each member project has its own .specify/memory/constitution.md and /speckit.constitution edits the local project's file. Spec Kit does not provide a built-in base/inheritance mechanism; if you want one constitution to reference shared rules elsewhere in the monorepo, you need to maintain that wiring yourself. Otherwise, duplicate or sync shared engineering rules per project.