Skip to content

Commit 63a6aa0

Browse files
committed
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: #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.)
1 parent b6b74d4 commit 63a6aa0

8 files changed

Lines changed: 309 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
<!-- insert new changelog below this comment -->
44

5+
- feat(cli): the `specify` CLI now honors `SPECIFY_INIT_DIR` for every project-scoped subcommand (`integration`, `extension`, `workflow`, `preset`, …) and `workflow run <file>`, applying the same validation rules as the shell resolver, so they can target a member project from a monorepo root without `cd`
6+
57
## [0.11.6] - 2026-06-23
68

79
### Changed

docs/guides/monorepo.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,18 @@ feature non-interactively. See the
7777
[`SPECIFY_INIT_DIR` reference](../reference/core.md#environment-variables) for
7878
the full contract and the two-axes model.
7979

80+
The `specify` CLI's project-scoped subcommands honor the same variable, so they
81+
target a member project from the root without `cd` too:
82+
83+
```bash
84+
export SPECIFY_INIT_DIR=apps/web
85+
specify workflow list # lists apps/web's workflows
86+
specify integration status # reports apps/web's integration
87+
```
88+
89+
The validation rules are the same: the path must exist and contain `.specify/`,
90+
with no fallback to the current directory.
91+
8092
## How `SPECIFY_INIT_DIR` reaches your agent
8193

8294
`SPECIFY_INIT_DIR` is read by the shell scripts that the slash commands invoke

docs/reference/core.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ specify init my-project --integration copilot --preset compliance
5050

5151
| Variable | Description |
5252
| ----------------- | ------------------------------------------------------------------------ |
53-
| `SPECIFY_INIT_DIR` | Target a member project from outside its directory (e.g. a monorepo root) without `cd`, for non-interactive / CI use. Set it to the **project root** — the directory *containing* `.specify/` (relative paths resolve against the current directory). The path must exist and contain `.specify/`, otherwise the command errors and does **not** fall back to the current directory. Resolved once in the core root helper (`get_repo_root` in Bash, `Get-RepoRoot` in PowerShell), so it is honored by the core feature scripts (`/speckit.plan`, `/speckit.tasks`, …) and the Git extension's feature-branch creation, which inherit it. When unset, the project is detected by searching upward from the current directory as before. |
53+
| `SPECIFY_INIT_DIR` | Target a member project from outside its directory (e.g. a monorepo root) without `cd`, for non-interactive / CI use. Set it to the **project root** — the directory *containing* `.specify/` (relative paths resolve against the current directory). The path must exist and contain `.specify/`, otherwise the command errors and does **not** fall back to the current directory. Resolved once in the core root helper (`get_repo_root` in Bash, `Get-RepoRoot` in PowerShell), so it is honored by the core feature scripts (`/speckit.plan`, `/speckit.tasks`, …) and the Git extension's feature-branch creation, which inherit it. The `specify` CLI applies the **same** validation rules to every project-scoped subcommand (`specify integration …`, `specify extension …`, `specify workflow …`, `specify preset …`, and the rest that operate on a `.specify/` project), so those can target a member project too. When unset, the project is detected by searching upward from the current directory as before. |
5454
| `SPECIFY_FEATURE_DIRECTORY` | Override the active feature directory *within* the resolved project (takes precedence over `.specify/feature.json`). Relative paths resolve under the project root. Combine with `SPECIFY_INIT_DIR` to pick both the project and the feature non-interactively. |
5555
| `SPECIFY_FEATURE` | Override feature detection for non-Git repositories. Set to the feature directory name (e.g., `001-photo-albums`) to work on a specific feature when not using Git branches. Must be set in the context of the agent prior to using `/speckit.plan` or follow-up commands. |
5656

src/specify_cli/__init__.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -590,12 +590,25 @@ def version(
590590
# Re-exported from integrations/_helpers.py to preserve the public import surface.
591591
from .integrations._helpers import ( # noqa: E402
592592
_clear_init_options_for_integration as _clear_init_options_for_integration,
593+
_resolve_init_dir_override as _resolve_init_dir_override,
593594
_update_init_options_for_integration as _update_init_options_for_integration,
594595
)
595596

596597

597598
def _require_specify_project() -> Path:
598-
"""Return the current project root if it is a spec-kit project, else exit."""
599+
"""Return the project root if it is a spec-kit project, else exit.
600+
601+
Honors the ``SPECIFY_INIT_DIR`` override (same validation rules as the shell
602+
scripts) so a member project can be targeted from a monorepo root without
603+
``cd``. This is the resolution chokepoint for *every* project-scoped
604+
subcommand — ``integration``, ``extension``, ``workflow``, ``preset``, and the
605+
rest that operate on an existing ``.specify/`` project — so the override
606+
applies to all of them uniformly. When the override is unset, the project is
607+
the current directory, as before.
608+
"""
609+
override = _resolve_init_dir_override()
610+
if override is not None:
611+
return override
599612
project_root = Path.cwd()
600613
if (project_root / ".specify").is_dir():
601614
return project_root
@@ -819,12 +832,18 @@ def workflow_run(
819832
is_file_source = source_path.suffix.lower() in (".yml", ".yaml") and source_path.is_file()
820833

821834
if is_file_source:
822-
# When running a YAML file directly, use cwd as project root
823-
# without requiring a .specify/ project directory.
824-
project_root = Path.cwd()
835+
# When running a YAML file directly, use cwd as project root without
836+
# requiring a .specify/ project directory — unless SPECIFY_INIT_DIR
837+
# explicitly names a project, in which case the strict override applies.
838+
# Either way, refuse a symlinked .specify (a planted-symlink guard): the
839+
# override resolver follows symlinks via is_dir(), so re-check here so the
840+
# override path is as strict as the cwd path.
841+
override = _resolve_init_dir_override()
842+
project_root = override if override is not None else Path.cwd()
825843
specify_dir = project_root / ".specify"
826844
if specify_dir.is_symlink():
827-
console.print("[red]Error:[/red] Refusing to use symlinked .specify path in current directory")
845+
where = " in current directory" if override is None else f": {specify_dir}"
846+
console.print(f"[red]Error:[/red] Refusing to use symlinked .specify path{where}")
828847
raise typer.Exit(1)
829848
if specify_dir.exists() and not specify_dir.is_dir():
830849
console.print("[red]Error:[/red] .specify path exists but is not a directory")

src/specify_cli/integrations/_helpers.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,47 @@ def _get_speckit_version() -> str:
3434
return _commands.get_speckit_version()
3535

3636

37+
def _resolve_init_dir_override() -> Path | None:
38+
"""Resolve the ``SPECIFY_INIT_DIR`` project override for the Python CLI.
39+
40+
Applies the same validation rules as the shell resolver
41+
(``resolve_specify_init_dir`` in ``scripts/bash/common.sh``): the value names
42+
the project root — the directory *containing* ``.specify/`` — and is strict.
43+
Relative paths resolve against the current directory; the path must exist and
44+
contain ``.specify/``, otherwise this hard-errors with no fallback to cwd
45+
(which would silently operate on the wrong project's files). The error strings
46+
match the shell resolver so the two surfaces read consistently.
47+
48+
Returns the validated absolute project root, or ``None`` when the variable is
49+
unset/empty, in which case callers keep their existing cwd-based behavior.
50+
51+
Note: this canonicalizes symlinks via :meth:`Path.resolve` (physical path),
52+
whereas the shell ``cd -- "$X" && pwd`` keeps the logical path. The two agree
53+
for non-symlinked paths; a symlinked ``SPECIFY_INIT_DIR`` can resolve to
54+
different strings across the surfaces. The canonical form is the safer choice
55+
here (a stable project identity), so this is a deliberate, documented variance,
56+
not a parity guarantee on the resolved string.
57+
"""
58+
raw = os.environ.get("SPECIFY_INIT_DIR", "")
59+
if not raw:
60+
return None
61+
# Relative values resolve against cwd; an absolute value stands alone (Path's
62+
# `/` drops the left operand when the right is absolute). resolve() also
63+
# collapses a trailing slash and canonicalizes symlinks.
64+
init_root = (Path.cwd() / raw).resolve()
65+
if not init_root.is_dir():
66+
console.print(
67+
f"[red]Error:[/red] SPECIFY_INIT_DIR does not point to an existing directory: {raw}"
68+
)
69+
raise typer.Exit(1)
70+
if not (init_root / ".specify").is_dir():
71+
console.print(
72+
f"[red]Error:[/red] SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): {init_root}"
73+
)
74+
raise typer.Exit(1)
75+
return init_root
76+
77+
3778
# ---------------------------------------------------------------------------
3879
# JSON read / write helpers
3980
# ---------------------------------------------------------------------------

src/specify_cli/integrations/_scaffold_commands.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ def integration_scaffold(
3232
"""Create a minimal built-in integration package and test skeleton."""
3333
from ..integration_scaffold import scaffold_integration
3434

35+
# scaffold targets the Spec Kit *source* repo layout (_is_spec_kit_repo_root),
36+
# not a .specify/ member project, so SPECIFY_INIT_DIR does not apply here.
3537
project_root = Path.cwd()
3638
try:
3739
result = scaffold_integration(project_root, key, integration_type.value)

tests/conftest.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,20 @@ def _isolate_auth_config(monkeypatch):
8383
monkeypatch.setattr(_auth_http, "_config_cache", None)
8484

8585

86+
@pytest.fixture(autouse=True)
87+
def _strip_specify_env(monkeypatch):
88+
"""Drop any inherited SPECIFY_* vars for every test.
89+
90+
The Python CLI's project resolver (`_require_specify_project`) now honors
91+
SPECIFY_INIT_DIR, and the shell resolvers honor SPECIFY_FEATURE* — so a
92+
developer or CI runner with any SPECIFY_* var exported would silently
93+
retarget (or hard-error) the many command/script tests that resolve a
94+
project. Stripping them here keeps resolution tests deterministic; a test
95+
that wants an override sets it explicitly via monkeypatch afterwards."""
96+
for key in [k for k in os.environ if k.startswith("SPECIFY_")]:
97+
monkeypatch.delenv(key, raising=False)
98+
99+
86100
@pytest.fixture
87101
def clean_environ(monkeypatch):
88102
"""Strip any real GH_TOKEN / GITHUB_TOKEN from the test environment."""

tests/test_init_dir_cli.py

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
"""Tests for the SPECIFY_INIT_DIR override in the Python CLI (`specify`).
2+
3+
PR #2892 taught the shell resolver (`get_repo_root` / `Get-RepoRoot`) to honor
4+
SPECIFY_INIT_DIR, so the core slash-command scripts can target a member project
5+
from a monorepo root. This extends the same validation rules to the Python CLI's
6+
project resolution — `_require_specify_project()` (the chokepoint for every
7+
project-scoped subcommand) and the `workflow run <file>` standalone-YAML path —
8+
so those can target a member project without `cd` too.
9+
10+
The contract mirrors `tests/test_init_dir.py` (the shell side): the value names
11+
the project root (the directory *containing* `.specify/`), relative paths
12+
resolve against cwd, and an invalid value hard-errors with no silent fallback to
13+
cwd. See proposals/monorepo-support and github/spec-kit discussion #2834.
14+
15+
SPECIFY_* vars are stripped from the environment for every test by the autouse
16+
`_strip_specify_env` fixture in conftest.py; tests that want an override set it
17+
explicitly via monkeypatch.
18+
"""
19+
20+
import pytest
21+
import yaml
22+
from typer.testing import CliRunner
23+
24+
from specify_cli import app
25+
26+
runner = CliRunner()
27+
28+
29+
def _make_project(root, name):
30+
"""Create <root>/<name>/.specify (the minimal Spec Kit project marker)."""
31+
proj = root / name
32+
(proj / ".specify").mkdir(parents=True)
33+
return proj
34+
35+
36+
def _workflow_yaml(wf_id):
37+
"""A minimal valid standalone workflow YAML with a single no-op shell step."""
38+
return yaml.dump(
39+
{
40+
"schema_version": "1.0",
41+
"workflow": {
42+
"id": wf_id,
43+
"name": wf_id,
44+
"version": "1.0.0",
45+
"description": f"standalone workflow {wf_id}",
46+
},
47+
"steps": [{"id": "noop", "type": "shell", "run": "echo done"}],
48+
}
49+
)
50+
51+
52+
# ── chokepoint: _require_specify_project() via `workflow list` ───────────────
53+
# `workflow list` is the lightest subcommand routed through the chokepoint: it
54+
# resolves the project, then reads <project>/.specify/workflows/. An empty
55+
# project prints "No workflows installed"; a failed resolution prints the error
56+
# and exits non-zero.
57+
58+
59+
def test_override_redirects_to_sibling_from_nonproject_cwd(tmp_path, monkeypatch):
60+
"""A valid SPECIFY_INIT_DIR resolves the target even when cwd is not itself a
61+
project — without the override this would error 'Not a spec-kit project'."""
62+
elsewhere = tmp_path / "elsewhere"
63+
elsewhere.mkdir()
64+
web = _make_project(tmp_path, "web")
65+
monkeypatch.chdir(elsewhere)
66+
monkeypatch.setenv("SPECIFY_INIT_DIR", str(web))
67+
68+
result = runner.invoke(app, ["workflow", "list"])
69+
assert result.exit_code == 0, result.output
70+
assert "No workflows installed" in result.output
71+
72+
73+
def test_override_relative_path_normalized_against_cwd(tmp_path, monkeypatch):
74+
web = _make_project(tmp_path, "web")
75+
monkeypatch.chdir(tmp_path)
76+
monkeypatch.setenv("SPECIFY_INIT_DIR", "web")
77+
78+
result = runner.invoke(app, ["workflow", "list"])
79+
assert result.exit_code == 0, result.output
80+
assert "No workflows installed" in result.output
81+
assert web.exists()
82+
83+
84+
def test_override_trailing_slash_tolerated(tmp_path, monkeypatch):
85+
_make_project(tmp_path, "web")
86+
monkeypatch.chdir(tmp_path)
87+
monkeypatch.setenv("SPECIFY_INIT_DIR", "web/")
88+
89+
result = runner.invoke(app, ["workflow", "list"])
90+
assert result.exit_code == 0, result.output
91+
assert "No workflows installed" in result.output
92+
93+
94+
def test_unset_override_uses_cwd(tmp_path, monkeypatch):
95+
"""With SPECIFY_INIT_DIR unset, the project is the current directory."""
96+
cwd_proj = _make_project(tmp_path, "cwd")
97+
monkeypatch.chdir(cwd_proj)
98+
99+
result = runner.invoke(app, ["workflow", "list"])
100+
assert result.exit_code == 0, result.output
101+
assert "No workflows installed" in result.output
102+
103+
104+
def test_empty_override_treated_as_unset(tmp_path, monkeypatch):
105+
"""An empty SPECIFY_INIT_DIR behaves as unset (falls through to cwd), not as
106+
'.' — which from a deep non-project cwd would otherwise diverge."""
107+
cwd_proj = _make_project(tmp_path, "cwd")
108+
monkeypatch.chdir(cwd_proj)
109+
monkeypatch.setenv("SPECIFY_INIT_DIR", "")
110+
111+
result = runner.invoke(app, ["workflow", "list"])
112+
assert result.exit_code == 0, result.output
113+
assert "No workflows installed" in result.output
114+
115+
116+
def test_override_nonexistent_errors_no_fallback(tmp_path, monkeypatch):
117+
"""A non-existent path hard-errors even from inside a valid project, proving
118+
there is no silent fallback to the cwd project."""
119+
cwd_proj = _make_project(tmp_path, "cwd")
120+
monkeypatch.chdir(cwd_proj)
121+
monkeypatch.setenv("SPECIFY_INIT_DIR", str(tmp_path / "does_not_exist"))
122+
123+
result = runner.invoke(app, ["workflow", "list"])
124+
assert result.exit_code != 0
125+
assert "does not point to an existing directory" in result.output
126+
assert "No workflows installed" not in result.output # no fallback to cwd
127+
128+
129+
def test_override_without_specify_errors_no_fallback(tmp_path, monkeypatch):
130+
"""A path that exists but lacks .specify/ hard-errors, no fallback."""
131+
cwd_proj = _make_project(tmp_path, "cwd")
132+
nodot = tmp_path / "nodot"
133+
nodot.mkdir()
134+
monkeypatch.chdir(cwd_proj)
135+
monkeypatch.setenv("SPECIFY_INIT_DIR", str(nodot))
136+
137+
result = runner.invoke(app, ["workflow", "list"])
138+
assert result.exit_code != 0
139+
assert "not a Spec Kit project" in result.output
140+
assert "No workflows installed" not in result.output
141+
142+
143+
def test_override_file_path_errors_no_fallback(tmp_path, monkeypatch):
144+
"""A path that is a file (not a directory) hard-errors with the
145+
existing-directory message."""
146+
cwd_proj = _make_project(tmp_path, "cwd")
147+
a_file = tmp_path / "afile"
148+
a_file.write_text("x")
149+
monkeypatch.chdir(cwd_proj)
150+
monkeypatch.setenv("SPECIFY_INIT_DIR", str(a_file))
151+
152+
result = runner.invoke(app, ["workflow", "list"])
153+
assert result.exit_code != 0
154+
assert "does not point to an existing directory" in result.output
155+
156+
157+
# ── bypass: `workflow run <file>` ────────────────────────────────────────────
158+
159+
160+
def test_override_redirects_workflow_run_file(tmp_path, monkeypatch):
161+
"""Running a standalone YAML with SPECIFY_INIT_DIR set uses the target as the
162+
project root: run artifacts land under the target, not cwd."""
163+
web = _make_project(tmp_path, "web")
164+
elsewhere = tmp_path / "elsewhere"
165+
elsewhere.mkdir()
166+
workflow_file = elsewhere / "wf.yml"
167+
workflow_file.write_text(_workflow_yaml("override-run"), encoding="utf-8")
168+
monkeypatch.chdir(elsewhere)
169+
monkeypatch.setenv("SPECIFY_INIT_DIR", str(web))
170+
171+
result = runner.invoke(app, ["workflow", "run", str(workflow_file)], catch_exceptions=False)
172+
assert result.exit_code == 0, result.output
173+
assert (web / ".specify" / "workflows" / "runs").is_dir()
174+
assert not (elsewhere / ".specify").exists() # cwd was not used as the project
175+
176+
177+
def test_override_invalid_errors_workflow_run_file(tmp_path, monkeypatch):
178+
"""An invalid SPECIFY_INIT_DIR hard-errors the file path too — no fallback to
179+
cwd's standalone-YAML behavior."""
180+
elsewhere = tmp_path / "elsewhere"
181+
elsewhere.mkdir()
182+
workflow_file = elsewhere / "wf.yml"
183+
workflow_file.write_text(_workflow_yaml("x"), encoding="utf-8")
184+
monkeypatch.chdir(elsewhere)
185+
monkeypatch.setenv("SPECIFY_INIT_DIR", str(tmp_path / "does_not_exist"))
186+
187+
result = runner.invoke(app, ["workflow", "run", str(workflow_file)])
188+
assert result.exit_code != 0
189+
assert "does not point to an existing directory" in result.output
190+
191+
192+
def test_override_rejects_symlinked_specify(tmp_path, monkeypatch):
193+
"""`workflow run <file>` refuses a symlinked .specify under the override
194+
target, matching the guard the cwd path applies (the override resolver's
195+
is_dir() check follows symlinks, so this is re-checked on the override path)."""
196+
web = tmp_path / "web"
197+
web.mkdir()
198+
real = tmp_path / "real-specify"
199+
real.mkdir()
200+
try:
201+
(web / ".specify").symlink_to(real, target_is_directory=True)
202+
except (OSError, NotImplementedError):
203+
pytest.skip("Symlinks are not available in this environment")
204+
elsewhere = tmp_path / "elsewhere"
205+
elsewhere.mkdir()
206+
workflow_file = elsewhere / "wf.yml"
207+
workflow_file.write_text(_workflow_yaml("symlink-run"), encoding="utf-8")
208+
monkeypatch.chdir(elsewhere)
209+
monkeypatch.setenv("SPECIFY_INIT_DIR", str(web))
210+
211+
result = runner.invoke(app, ["workflow", "run", str(workflow_file)])
212+
assert result.exit_code != 0
213+
assert "Refusing to use symlinked .specify path" in result.output

0 commit comments

Comments
 (0)