From ce441cbc692279ab50ed00d023211f1bad23f79f Mon Sep 17 00:00:00 2001 From: Pascal Date: Wed, 24 Jun 2026 12:52:50 +0200 Subject: [PATCH 1/8] feat(cli): honor SPECIFY_INIT_DIR in the specify CLI project resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` 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.) --- CHANGELOG.md | 2 + docs/guides/monorepo.md | 12 + docs/reference/core.md | 2 +- src/specify_cli/__init__.py | 29 ++- src/specify_cli/integrations/_helpers.py | 41 ++++ .../integrations/_scaffold_commands.py | 2 + tests/conftest.py | 14 ++ tests/test_init_dir_cli.py | 213 ++++++++++++++++++ 8 files changed, 309 insertions(+), 6 deletions(-) create mode 100644 tests/test_init_dir_cli.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c036a1884..f005bc3e2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ +- feat(cli): the `specify` CLI now honors `SPECIFY_INIT_DIR` for every project-scoped subcommand (`integration`, `extension`, `workflow`, `preset`, …) and `workflow run `, applying the same validation rules as the shell resolver, so they can target a member project from a monorepo root without `cd` (#3186) + ## [0.11.9] - 2026-06-26 ### Changed diff --git a/docs/guides/monorepo.md b/docs/guides/monorepo.md index b143699256..48abd1372c 100644 --- a/docs/guides/monorepo.md +++ b/docs/guides/monorepo.md @@ -77,6 +77,18 @@ feature non-interactively. See the [`SPECIFY_INIT_DIR` reference](../reference/core.md#environment-variables) 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: + +```bash +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 diff --git a/docs/reference/core.md b/docs/reference/core.md index 0b6ad5b14e..8a7880dd17 100644 --- a/docs/reference/core.md +++ b/docs/reference/core.md @@ -50,7 +50,7 @@ specify init my-project --integration copilot --preset compliance | Variable | Description | | ----------------- | ------------------------------------------------------------------------ | -| `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. | +| `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. | | `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. | | `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. | diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 6713549d35..d0ce750e82 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -590,12 +590,25 @@ def version( # Re-exported from integrations/_helpers.py to preserve the public import surface. from .integrations._helpers import ( # noqa: E402 _clear_init_options_for_integration as _clear_init_options_for_integration, + _resolve_init_dir_override as _resolve_init_dir_override, _update_init_options_for_integration as _update_init_options_for_integration, ) def _require_specify_project() -> Path: - """Return the current project root if it is a spec-kit project, else exit.""" + """Return the project root if it is a spec-kit project, else exit. + + Honors the ``SPECIFY_INIT_DIR`` override (same validation rules as the shell + scripts) so a member project can be targeted from a monorepo root without + ``cd``. This is the resolution chokepoint for *every* project-scoped + subcommand — ``integration``, ``extension``, ``workflow``, ``preset``, and the + rest that operate on an existing ``.specify/`` project — so the override + applies to all of them uniformly. When the override is unset, the project is + the current directory, as before. + """ + override = _resolve_init_dir_override() + if override is not None: + return override project_root = Path.cwd() if (project_root / ".specify").is_dir(): return project_root @@ -819,12 +832,18 @@ def workflow_run( is_file_source = source_path.suffix.lower() in (".yml", ".yaml") and source_path.is_file() if is_file_source: - # When running a YAML file directly, use cwd as project root - # without requiring a .specify/ project directory. - project_root = Path.cwd() + # When running a YAML file directly, use cwd as project root without + # requiring a .specify/ project directory — unless SPECIFY_INIT_DIR + # explicitly names a project, in which case the strict override applies. + # Either way, refuse a symlinked .specify (a planted-symlink guard): the + # override resolver follows symlinks via is_dir(), so re-check here so the + # override path is as strict as the cwd path. + override = _resolve_init_dir_override() + project_root = override if override is not None else Path.cwd() specify_dir = project_root / ".specify" if specify_dir.is_symlink(): - console.print("[red]Error:[/red] Refusing to use symlinked .specify path in current directory") + where = " in current directory" if override is None else f": {specify_dir}" + console.print(f"[red]Error:[/red] Refusing to use symlinked .specify path{where}") raise typer.Exit(1) if specify_dir.exists() and not specify_dir.is_dir(): console.print("[red]Error:[/red] .specify path exists but is not a directory") diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py index f8a696a866..29568e2b71 100644 --- a/src/specify_cli/integrations/_helpers.py +++ b/src/specify_cli/integrations/_helpers.py @@ -34,6 +34,47 @@ def _get_speckit_version() -> str: return _commands.get_speckit_version() +def _resolve_init_dir_override() -> Path | None: + """Resolve the ``SPECIFY_INIT_DIR`` project override for the Python CLI. + + Applies the same validation rules as the shell resolver + (``resolve_specify_init_dir`` in ``scripts/bash/common.sh``): the value names + the project root — the directory *containing* ``.specify/`` — and is strict. + Relative paths resolve against the current directory; the path must exist and + contain ``.specify/``, otherwise this hard-errors with no fallback to cwd + (which would silently operate on the wrong project's files). The error strings + match the shell resolver so the two surfaces read consistently. + + Returns the validated absolute project root, or ``None`` when the variable is + unset/empty, in which case callers keep their existing cwd-based behavior. + + Note: this canonicalizes symlinks via :meth:`Path.resolve` (physical path), + whereas the shell ``cd -- "$X" && pwd`` keeps the logical path. The two agree + for non-symlinked paths; a symlinked ``SPECIFY_INIT_DIR`` can resolve to + different strings across the surfaces. The canonical form is the safer choice + here (a stable project identity), so this is a deliberate, documented variance, + not a parity guarantee on the resolved string. + """ + raw = os.environ.get("SPECIFY_INIT_DIR", "") + if not raw: + return None + # Relative values resolve against cwd; an absolute value stands alone (Path's + # `/` drops the left operand when the right is absolute). resolve() also + # collapses a trailing slash and canonicalizes symlinks. + init_root = (Path.cwd() / raw).resolve() + if not init_root.is_dir(): + console.print( + f"[red]Error:[/red] SPECIFY_INIT_DIR does not point to an existing directory: {raw}" + ) + raise typer.Exit(1) + if not (init_root / ".specify").is_dir(): + console.print( + f"[red]Error:[/red] SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): {init_root}" + ) + raise typer.Exit(1) + return init_root + + # --------------------------------------------------------------------------- # JSON read / write helpers # --------------------------------------------------------------------------- diff --git a/src/specify_cli/integrations/_scaffold_commands.py b/src/specify_cli/integrations/_scaffold_commands.py index f5b4ad3acf..4a5d392dca 100644 --- a/src/specify_cli/integrations/_scaffold_commands.py +++ b/src/specify_cli/integrations/_scaffold_commands.py @@ -32,6 +32,8 @@ def integration_scaffold( """Create a minimal built-in integration package and test skeleton.""" from ..integration_scaffold import scaffold_integration + # scaffold targets the Spec Kit *source* repo layout (_is_spec_kit_repo_root), + # not a .specify/ member project, so SPECIFY_INIT_DIR does not apply here. project_root = Path.cwd() try: result = scaffold_integration(project_root, key, integration_type.value) diff --git a/tests/conftest.py b/tests/conftest.py index 4ef643e121..94fb8c31b0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -83,6 +83,20 @@ def _isolate_auth_config(monkeypatch): monkeypatch.setattr(_auth_http, "_config_cache", None) +@pytest.fixture(autouse=True) +def _strip_specify_env(monkeypatch): + """Drop any inherited SPECIFY_* vars for every test. + + The Python CLI's project resolver (`_require_specify_project`) now honors + SPECIFY_INIT_DIR, and the shell resolvers honor SPECIFY_FEATURE* — so a + developer or CI runner with any SPECIFY_* var exported would silently + retarget (or hard-error) the many command/script tests that resolve a + project. Stripping them here keeps resolution tests deterministic; a test + that wants an override sets it explicitly via monkeypatch afterwards.""" + for key in [k for k in os.environ if k.startswith("SPECIFY_")]: + monkeypatch.delenv(key, raising=False) + + @pytest.fixture def clean_environ(monkeypatch): """Strip any real GH_TOKEN / GITHUB_TOKEN from the test environment.""" diff --git a/tests/test_init_dir_cli.py b/tests/test_init_dir_cli.py new file mode 100644 index 0000000000..ec9ffbe7dc --- /dev/null +++ b/tests/test_init_dir_cli.py @@ -0,0 +1,213 @@ +"""Tests for the SPECIFY_INIT_DIR override in the Python CLI (`specify`). + +PR #2892 taught the shell resolver (`get_repo_root` / `Get-RepoRoot`) to honor +SPECIFY_INIT_DIR, so the core slash-command scripts can target a member project +from a monorepo root. This extends the same validation rules to the Python CLI's +project resolution — `_require_specify_project()` (the chokepoint for every +project-scoped subcommand) and the `workflow run ` standalone-YAML path — +so those can target a member project without `cd` too. + +The contract mirrors `tests/test_init_dir.py` (the shell side): the value names +the project root (the directory *containing* `.specify/`), relative paths +resolve against cwd, and an invalid value hard-errors with no silent fallback to +cwd. See proposals/monorepo-support and github/spec-kit discussion #2834. + +SPECIFY_* vars are stripped from the environment for every test by the autouse +`_strip_specify_env` fixture in conftest.py; tests that want an override set it +explicitly via monkeypatch. +""" + +import pytest +import yaml +from typer.testing import CliRunner + +from specify_cli import app + +runner = CliRunner() + + +def _make_project(root, name): + """Create //.specify (the minimal Spec Kit project marker).""" + proj = root / name + (proj / ".specify").mkdir(parents=True) + return proj + + +def _workflow_yaml(wf_id): + """A minimal valid standalone workflow YAML with a single no-op shell step.""" + return yaml.dump( + { + "schema_version": "1.0", + "workflow": { + "id": wf_id, + "name": wf_id, + "version": "1.0.0", + "description": f"standalone workflow {wf_id}", + }, + "steps": [{"id": "noop", "type": "shell", "run": "echo done"}], + } + ) + + +# ── chokepoint: _require_specify_project() via `workflow list` ─────────────── +# `workflow list` is the lightest subcommand routed through the chokepoint: it +# resolves the project, then reads /.specify/workflows/. An empty +# project prints "No workflows installed"; a failed resolution prints the error +# and exits non-zero. + + +def test_override_redirects_to_sibling_from_nonproject_cwd(tmp_path, monkeypatch): + """A valid SPECIFY_INIT_DIR resolves the target even when cwd is not itself a + project — without the override this would error 'Not a spec-kit project'.""" + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + web = _make_project(tmp_path, "web") + monkeypatch.chdir(elsewhere) + monkeypatch.setenv("SPECIFY_INIT_DIR", str(web)) + + result = runner.invoke(app, ["workflow", "list"]) + assert result.exit_code == 0, result.output + assert "No workflows installed" in result.output + + +def test_override_relative_path_normalized_against_cwd(tmp_path, monkeypatch): + web = _make_project(tmp_path, "web") + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("SPECIFY_INIT_DIR", "web") + + result = runner.invoke(app, ["workflow", "list"]) + assert result.exit_code == 0, result.output + assert "No workflows installed" in result.output + assert web.exists() + + +def test_override_trailing_slash_tolerated(tmp_path, monkeypatch): + _make_project(tmp_path, "web") + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("SPECIFY_INIT_DIR", "web/") + + result = runner.invoke(app, ["workflow", "list"]) + assert result.exit_code == 0, result.output + assert "No workflows installed" in result.output + + +def test_unset_override_uses_cwd(tmp_path, monkeypatch): + """With SPECIFY_INIT_DIR unset, the project is the current directory.""" + cwd_proj = _make_project(tmp_path, "cwd") + monkeypatch.chdir(cwd_proj) + + result = runner.invoke(app, ["workflow", "list"]) + assert result.exit_code == 0, result.output + assert "No workflows installed" in result.output + + +def test_empty_override_treated_as_unset(tmp_path, monkeypatch): + """An empty SPECIFY_INIT_DIR behaves as unset (falls through to cwd), not as + '.' — which from a deep non-project cwd would otherwise diverge.""" + cwd_proj = _make_project(tmp_path, "cwd") + monkeypatch.chdir(cwd_proj) + monkeypatch.setenv("SPECIFY_INIT_DIR", "") + + result = runner.invoke(app, ["workflow", "list"]) + assert result.exit_code == 0, result.output + assert "No workflows installed" in result.output + + +def test_override_nonexistent_errors_no_fallback(tmp_path, monkeypatch): + """A non-existent path hard-errors even from inside a valid project, proving + there is no silent fallback to the cwd project.""" + cwd_proj = _make_project(tmp_path, "cwd") + monkeypatch.chdir(cwd_proj) + monkeypatch.setenv("SPECIFY_INIT_DIR", str(tmp_path / "does_not_exist")) + + result = runner.invoke(app, ["workflow", "list"]) + assert result.exit_code != 0 + assert "does not point to an existing directory" in result.output + assert "No workflows installed" not in result.output # no fallback to cwd + + +def test_override_without_specify_errors_no_fallback(tmp_path, monkeypatch): + """A path that exists but lacks .specify/ hard-errors, no fallback.""" + cwd_proj = _make_project(tmp_path, "cwd") + nodot = tmp_path / "nodot" + nodot.mkdir() + monkeypatch.chdir(cwd_proj) + monkeypatch.setenv("SPECIFY_INIT_DIR", str(nodot)) + + result = runner.invoke(app, ["workflow", "list"]) + assert result.exit_code != 0 + assert "not a Spec Kit project" in result.output + assert "No workflows installed" not in result.output + + +def test_override_file_path_errors_no_fallback(tmp_path, monkeypatch): + """A path that is a file (not a directory) hard-errors with the + existing-directory message.""" + cwd_proj = _make_project(tmp_path, "cwd") + a_file = tmp_path / "afile" + a_file.write_text("x") + monkeypatch.chdir(cwd_proj) + monkeypatch.setenv("SPECIFY_INIT_DIR", str(a_file)) + + result = runner.invoke(app, ["workflow", "list"]) + assert result.exit_code != 0 + assert "does not point to an existing directory" in result.output + + +# ── bypass: `workflow run ` ──────────────────────────────────────────── + + +def test_override_redirects_workflow_run_file(tmp_path, monkeypatch): + """Running a standalone YAML with SPECIFY_INIT_DIR set uses the target as the + project root: run artifacts land under the target, not cwd.""" + web = _make_project(tmp_path, "web") + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + workflow_file = elsewhere / "wf.yml" + workflow_file.write_text(_workflow_yaml("override-run"), encoding="utf-8") + monkeypatch.chdir(elsewhere) + monkeypatch.setenv("SPECIFY_INIT_DIR", str(web)) + + result = runner.invoke(app, ["workflow", "run", str(workflow_file)], catch_exceptions=False) + assert result.exit_code == 0, result.output + assert (web / ".specify" / "workflows" / "runs").is_dir() + assert not (elsewhere / ".specify").exists() # cwd was not used as the project + + +def test_override_invalid_errors_workflow_run_file(tmp_path, monkeypatch): + """An invalid SPECIFY_INIT_DIR hard-errors the file path too — no fallback to + cwd's standalone-YAML behavior.""" + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + workflow_file = elsewhere / "wf.yml" + workflow_file.write_text(_workflow_yaml("x"), encoding="utf-8") + monkeypatch.chdir(elsewhere) + monkeypatch.setenv("SPECIFY_INIT_DIR", str(tmp_path / "does_not_exist")) + + result = runner.invoke(app, ["workflow", "run", str(workflow_file)]) + assert result.exit_code != 0 + assert "does not point to an existing directory" in result.output + + +def test_override_rejects_symlinked_specify(tmp_path, monkeypatch): + """`workflow run ` refuses a symlinked .specify under the override + target, matching the guard the cwd path applies (the override resolver's + is_dir() check follows symlinks, so this is re-checked on the override path).""" + web = tmp_path / "web" + web.mkdir() + real = tmp_path / "real-specify" + real.mkdir() + try: + (web / ".specify").symlink_to(real, target_is_directory=True) + except (OSError, NotImplementedError): + pytest.skip("Symlinks are not available in this environment") + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + workflow_file = elsewhere / "wf.yml" + workflow_file.write_text(_workflow_yaml("symlink-run"), encoding="utf-8") + monkeypatch.chdir(elsewhere) + monkeypatch.setenv("SPECIFY_INIT_DIR", str(web)) + + result = runner.invoke(app, ["workflow", "run", str(workflow_file)]) + assert result.exit_code != 0 + assert "Refusing to use symlinked .specify path" in result.output From 33991d94a2a998703f82ab2ead8833be87000c92 Mon Sep 17 00:00:00 2001 From: Pascal Date: Fri, 26 Jun 2026 09:39:54 +0200 Subject: [PATCH 2/8] fix(cli): honor SPECIFY_INIT_DIR for bundle commands Assisted-by: Codex (model: GPT-5, autonomous) --- src/specify_cli/_project.py | 51 ++++++++++++++++++++++++ src/specify_cli/bundler/lib/project.py | 6 +++ src/specify_cli/integrations/_helpers.py | 42 +------------------ tests/test_init_dir_cli.py | 24 +++++++++++ 4 files changed, 82 insertions(+), 41 deletions(-) create mode 100644 src/specify_cli/_project.py diff --git a/src/specify_cli/_project.py b/src/specify_cli/_project.py new file mode 100644 index 0000000000..035c00d6fe --- /dev/null +++ b/src/specify_cli/_project.py @@ -0,0 +1,51 @@ +"""Shared project-resolution helpers for the Specify CLI.""" + +from __future__ import annotations + +import os +from pathlib import Path + +import typer + +from ._console import console + + +def _resolve_init_dir_override() -> Path | None: + """Resolve the ``SPECIFY_INIT_DIR`` project override for the Python CLI. + + Applies the same validation rules as the shell resolver + (``resolve_specify_init_dir`` in ``scripts/bash/common.sh``): the value names + the project root — the directory *containing* ``.specify/`` — and is strict. + Relative paths resolve against the current directory; the path must exist and + contain ``.specify/``, otherwise this hard-errors with no fallback to cwd + (which would silently operate on the wrong project's files). The error strings + match the shell resolver so the two surfaces read consistently. + + Returns the validated absolute project root, or ``None`` when the variable is + unset/empty, in which case callers keep their existing cwd-based behavior. + + Note: this canonicalizes symlinks via :meth:`Path.resolve` (physical path), + whereas the shell ``cd -- "$X" && pwd`` keeps the logical path. The two agree + for non-symlinked paths; a symlinked ``SPECIFY_INIT_DIR`` can resolve to + different strings across the surfaces. The canonical form is the safer choice + here (a stable project identity), so this is a deliberate, documented variance, + not a parity guarantee on the resolved string. + """ + raw = os.environ.get("SPECIFY_INIT_DIR", "") + if not raw: + return None + # Relative values resolve against cwd; an absolute value stands alone (Path's + # `/` drops the left operand when the right is absolute). resolve() also + # collapses a trailing slash and canonicalizes symlinks. + init_root = (Path.cwd() / raw).resolve() + if not init_root.is_dir(): + console.print( + f"[red]Error:[/red] SPECIFY_INIT_DIR does not point to an existing directory: {raw}" + ) + raise typer.Exit(1) + if not (init_root / ".specify").is_dir(): + console.print( + f"[red]Error:[/red] SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): {init_root}" + ) + raise typer.Exit(1) + return init_root diff --git a/src/specify_cli/bundler/lib/project.py b/src/specify_cli/bundler/lib/project.py index 66b8a1b27b..9297553217 100644 --- a/src/specify_cli/bundler/lib/project.py +++ b/src/specify_cli/bundler/lib/project.py @@ -3,6 +3,7 @@ from pathlib import Path +from ..._project import _resolve_init_dir_override from .. import BundlerError from .yamlio import ensure_within, load_json @@ -16,6 +17,11 @@ def find_project_root(start: Path | None = None) -> Path | None: could read/write outside the intended tree, and other CLI surfaces refuse it for the same reason. """ + if start is None: + override = _resolve_init_dir_override() + if override is not None: + return override + current = Path(start or Path.cwd()).resolve() for candidate in (current, *current.parents): marker = candidate / ".specify" diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py index 29568e2b71..955228b599 100644 --- a/src/specify_cli/integrations/_helpers.py +++ b/src/specify_cli/integrations/_helpers.py @@ -9,6 +9,7 @@ from .._agent_config import SCRIPT_TYPE_CHOICES from .._console import console +from .._project import _resolve_init_dir_override as _resolve_init_dir_override # noqa: F401 from ..integration_runtime import ( invoke_separator_for_integration as _invoke_separator_for_integration, resolve_integration_options as _resolve_integration_options_impl, @@ -34,47 +35,6 @@ def _get_speckit_version() -> str: return _commands.get_speckit_version() -def _resolve_init_dir_override() -> Path | None: - """Resolve the ``SPECIFY_INIT_DIR`` project override for the Python CLI. - - Applies the same validation rules as the shell resolver - (``resolve_specify_init_dir`` in ``scripts/bash/common.sh``): the value names - the project root — the directory *containing* ``.specify/`` — and is strict. - Relative paths resolve against the current directory; the path must exist and - contain ``.specify/``, otherwise this hard-errors with no fallback to cwd - (which would silently operate on the wrong project's files). The error strings - match the shell resolver so the two surfaces read consistently. - - Returns the validated absolute project root, or ``None`` when the variable is - unset/empty, in which case callers keep their existing cwd-based behavior. - - Note: this canonicalizes symlinks via :meth:`Path.resolve` (physical path), - whereas the shell ``cd -- "$X" && pwd`` keeps the logical path. The two agree - for non-symlinked paths; a symlinked ``SPECIFY_INIT_DIR`` can resolve to - different strings across the surfaces. The canonical form is the safer choice - here (a stable project identity), so this is a deliberate, documented variance, - not a parity guarantee on the resolved string. - """ - raw = os.environ.get("SPECIFY_INIT_DIR", "") - if not raw: - return None - # Relative values resolve against cwd; an absolute value stands alone (Path's - # `/` drops the left operand when the right is absolute). resolve() also - # collapses a trailing slash and canonicalizes symlinks. - init_root = (Path.cwd() / raw).resolve() - if not init_root.is_dir(): - console.print( - f"[red]Error:[/red] SPECIFY_INIT_DIR does not point to an existing directory: {raw}" - ) - raise typer.Exit(1) - if not (init_root / ".specify").is_dir(): - console.print( - f"[red]Error:[/red] SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): {init_root}" - ) - raise typer.Exit(1) - return init_root - - # --------------------------------------------------------------------------- # JSON read / write helpers # --------------------------------------------------------------------------- diff --git a/tests/test_init_dir_cli.py b/tests/test_init_dir_cli.py index ec9ffbe7dc..2f4195c8ed 100644 --- a/tests/test_init_dir_cli.py +++ b/tests/test_init_dir_cli.py @@ -91,6 +91,18 @@ def test_override_trailing_slash_tolerated(tmp_path, monkeypatch): assert "No workflows installed" in result.output +def test_override_redirects_bundle_commands(tmp_path, monkeypatch): + web = _make_project(tmp_path, "web") + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + monkeypatch.chdir(elsewhere) + monkeypatch.setenv("SPECIFY_INIT_DIR", str(web)) + + result = runner.invoke(app, ["bundle", "list"]) + assert result.exit_code == 0, result.output + assert "No bundles installed" in result.output + + def test_unset_override_uses_cwd(tmp_path, monkeypatch): """With SPECIFY_INIT_DIR unset, the project is the current directory.""" cwd_proj = _make_project(tmp_path, "cwd") @@ -126,6 +138,18 @@ def test_override_nonexistent_errors_no_fallback(tmp_path, monkeypatch): assert "No workflows installed" not in result.output # no fallback to cwd +def test_override_nonexistent_errors_bundle_commands_no_fallback(tmp_path, monkeypatch): + """Bundle commands also honor the strict override contract.""" + cwd_proj = _make_project(tmp_path, "cwd") + monkeypatch.chdir(cwd_proj) + monkeypatch.setenv("SPECIFY_INIT_DIR", str(tmp_path / "does_not_exist")) + + result = runner.invoke(app, ["bundle", "list"]) + assert result.exit_code != 0 + assert "does not point to an existing directory" in result.output + assert "No bundles installed" not in result.output + + def test_override_without_specify_errors_no_fallback(tmp_path, monkeypatch): """A path that exists but lacks .specify/ hard-errors, no fallback.""" cwd_proj = _make_project(tmp_path, "cwd") From 6520d56be970cd8db2e58ae790663ccde63ebf02 Mon Sep 17 00:00:00 2001 From: Pascal Date: Fri, 26 Jun 2026 10:25:00 +0200 Subject: [PATCH 3/8] 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.) --- src/specify_cli/bundler/lib/project.py | 6 ++++++ .../integration/test_bundler_security_paths.py | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/specify_cli/bundler/lib/project.py b/src/specify_cli/bundler/lib/project.py index 9297553217..5831544367 100644 --- a/src/specify_cli/bundler/lib/project.py +++ b/src/specify_cli/bundler/lib/project.py @@ -20,6 +20,12 @@ def find_project_root(start: Path | None = None) -> Path | None: if start is None: override = _resolve_init_dir_override() if override is not None: + # Match the loop guard below: a symlinked .specify is not accepted + # as a project root (following it could read/write outside the tree). + # _resolve_init_dir_override validates .specify with is_dir(), which + # follows symlinks, so re-check here. + if (override / ".specify").is_symlink(): + return None return override current = Path(start or Path.cwd()).resolve() diff --git a/tests/integration/test_bundler_security_paths.py b/tests/integration/test_bundler_security_paths.py index 85c64919cf..36afc69eaa 100644 --- a/tests/integration/test_bundler_security_paths.py +++ b/tests/integration/test_bundler_security_paths.py @@ -171,3 +171,21 @@ def test_find_project_root_ignores_symlinked_specify(tmp_path: Path): pytest.skip("symlinks not supported on this platform") # A symlinked .specify must not be accepted as a project root. assert find_project_root(project) is None + + +def test_find_project_root_override_ignores_symlinked_specify(tmp_path: Path, monkeypatch): + """The SPECIFY_INIT_DIR override path refuses a symlinked .specify too, + matching the cwd loop path (regression: the override returned early and + skipped the symlink guard).""" + from specify_cli.bundler.lib.project import find_project_root + + real = tmp_path / "real-specify" + real.mkdir() + project = tmp_path / "project" + project.mkdir() + try: + (project / ".specify").symlink_to(real, target_is_directory=True) + except (OSError, NotImplementedError): + pytest.skip("symlinks not supported on this platform") + monkeypatch.setenv("SPECIFY_INIT_DIR", str(project)) + assert find_project_root(None) is None From eb33446705b227fda0f3a4f2d31f21214781c900 Mon Sep 17 00:00:00 2001 From: Pascal Date: Fri, 26 Jun 2026 10:54:19 +0200 Subject: [PATCH 4/8] 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/reference/core.md | 2 +- src/specify_cli/bundler/lib/project.py | 11 +++++----- .../test_bundler_security_paths.py | 5 +++-- tests/test_init_dir_cli.py | 22 +++++++++++++++++++ 4 files changed, 32 insertions(+), 8 deletions(-) diff --git a/docs/reference/core.md b/docs/reference/core.md index 8a7880dd17..519c3cceb8 100644 --- a/docs/reference/core.md +++ b/docs/reference/core.md @@ -50,7 +50,7 @@ specify init my-project --integration copilot --preset compliance | Variable | Description | | ----------------- | ------------------------------------------------------------------------ | -| `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. | +| `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, Bash/PowerShell helpers keep their existing upward search; the `specify` CLI keeps its project-scoped resolver cwd-only unless a command explicitly defines broader detection (for example, bundle commands). | | `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. | | `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. | diff --git a/src/specify_cli/bundler/lib/project.py b/src/specify_cli/bundler/lib/project.py index 5831544367..9db8da7795 100644 --- a/src/specify_cli/bundler/lib/project.py +++ b/src/specify_cli/bundler/lib/project.py @@ -20,12 +20,13 @@ def find_project_root(start: Path | None = None) -> Path | None: if start is None: override = _resolve_init_dir_override() if override is not None: - # Match the loop guard below: a symlinked .specify is not accepted - # as a project root (following it could read/write outside the tree). - # _resolve_init_dir_override validates .specify with is_dir(), which - # follows symlinks, so re-check here. + # An explicit override is strict: do not return None here, because + # bundle install treats None as "init the current directory". if (override / ".specify").is_symlink(): - return None + raise BundlerError( + "SPECIFY_INIT_DIR is not a safe Spec Kit project " + f"(symlinked .specify/ directory is not allowed): {override}" + ) return override current = Path(start or Path.cwd()).resolve() diff --git a/tests/integration/test_bundler_security_paths.py b/tests/integration/test_bundler_security_paths.py index 36afc69eaa..0c01fe6406 100644 --- a/tests/integration/test_bundler_security_paths.py +++ b/tests/integration/test_bundler_security_paths.py @@ -173,7 +173,7 @@ def test_find_project_root_ignores_symlinked_specify(tmp_path: Path): assert find_project_root(project) is None -def test_find_project_root_override_ignores_symlinked_specify(tmp_path: Path, monkeypatch): +def test_find_project_root_override_errors_on_symlinked_specify(tmp_path: Path, monkeypatch): """The SPECIFY_INIT_DIR override path refuses a symlinked .specify too, matching the cwd loop path (regression: the override returned early and skipped the symlink guard).""" @@ -188,4 +188,5 @@ def test_find_project_root_override_ignores_symlinked_specify(tmp_path: Path, mo except (OSError, NotImplementedError): pytest.skip("symlinks not supported on this platform") monkeypatch.setenv("SPECIFY_INIT_DIR", str(project)) - assert find_project_root(None) is None + with pytest.raises(BundlerError, match="symlinked \\.specify"): + find_project_root(None) diff --git a/tests/test_init_dir_cli.py b/tests/test_init_dir_cli.py index 2f4195c8ed..1cd8c8e336 100644 --- a/tests/test_init_dir_cli.py +++ b/tests/test_init_dir_cli.py @@ -150,6 +150,28 @@ def test_override_nonexistent_errors_bundle_commands_no_fallback(tmp_path, monke assert "No bundles installed" not in result.output +def test_override_symlinked_specify_errors_bundle_init_no_fallback(tmp_path, monkeypatch): + """A symlinked override .specify must not make bundle init fall back to cwd.""" + web = tmp_path / "web" + web.mkdir() + real = tmp_path / "real-specify" + real.mkdir() + try: + (web / ".specify").symlink_to(real, target_is_directory=True) + except (OSError, NotImplementedError): + pytest.skip("Symlinks are not available in this environment") + + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + monkeypatch.chdir(elsewhere) + monkeypatch.setenv("SPECIFY_INIT_DIR", str(web)) + + result = runner.invoke(app, ["bundle", "init", "--offline"]) + assert result.exit_code != 0 + assert "symlinked .specify" in result.output + assert not (elsewhere / ".specify").exists() + + def test_override_without_specify_errors_no_fallback(tmp_path, monkeypatch): """A path that exists but lacks .specify/ hard-errors, no fallback.""" cwd_proj = _make_project(tmp_path, "cwd") From 8a426e745511a61d01ebc7a890a82c9bd969f7dd Mon Sep 17 00:00:00 2001 From: Pascal Date: Fri, 26 Jun 2026 13:26:09 +0200 Subject: [PATCH 5/8] 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 (write confinement). Document the asymmetry so it reads as intentional. (Disclosure: AI-assisted; reviewed by me.) --- docs/reference/core.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/reference/core.md b/docs/reference/core.md index 519c3cceb8..9942497bb2 100644 --- a/docs/reference/core.md +++ b/docs/reference/core.md @@ -56,6 +56,8 @@ specify init my-project --integration copilot --preset compliance > **Two resolution axes.** `SPECIFY_INIT_DIR` selects the **project** (which directory contains `.specify/`); `SPECIFY_FEATURE_DIRECTORY` / `.specify/feature.json` select the **feature** within that project. They are independent — project first, then feature. +> **Symlinked project roots.** A symlinked `.specify/` at the target is followed by `integration`, `extension`, and `workflow` (matching the shell resolver, which follows symlinks), but refused by `bundle` and `workflow run `, which require an unsymlinked root for write confinement. + ## Check Installed Tools ```bash From 97c472011bd1765c3b29ab0cb731b0fe95e960e9 Mon Sep 17 00:00:00 2001 From: Pascal Date: Fri, 26 Jun 2026 20:19:10 +0200 Subject: [PATCH 6/8] 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/reference/core.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/core.md b/docs/reference/core.md index 9942497bb2..871c5b59ac 100644 --- a/docs/reference/core.md +++ b/docs/reference/core.md @@ -56,7 +56,7 @@ specify init my-project --integration copilot --preset compliance > **Two resolution axes.** `SPECIFY_INIT_DIR` selects the **project** (which directory contains `.specify/`); `SPECIFY_FEATURE_DIRECTORY` / `.specify/feature.json` select the **feature** within that project. They are independent — project first, then feature. -> **Symlinked project roots.** A symlinked `.specify/` at the target is followed by `integration`, `extension`, and `workflow` (matching the shell resolver, which follows symlinks), but refused by `bundle` and `workflow run `, which require an unsymlinked root for write confinement. +> **Symlinked project roots.** `SPECIFY_INIT_DIR` relocates *where* the project is, not *how* a surface treats symlinks: each surface keeps its existing cwd-path stance. Surfaces that traverse and write (`bundle`, `workflow run `) refuse a symlinked `.specify/` to preserve write confinement; read/config surfaces (`integration`, `extension`, `workflow`) follow it. ## Check Installed Tools From 80316e6b397a246bd85e52a5bf20775461f75d50 Mon Sep 17 00:00:00 2001 From: Pascal Date: Sat, 27 Jun 2026 00:48:49 +0200 Subject: [PATCH 7/8] 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. --- src/specify_cli/_project.py | 6 ++++-- src/specify_cli/bundler/lib/project.py | 7 +++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/_project.py b/src/specify_cli/_project.py index 035c00d6fe..512c7dfc52 100644 --- a/src/specify_cli/_project.py +++ b/src/specify_cli/_project.py @@ -18,8 +18,10 @@ def _resolve_init_dir_override() -> Path | None: the project root — the directory *containing* ``.specify/`` — and is strict. Relative paths resolve against the current directory; the path must exist and contain ``.specify/``, otherwise this hard-errors with no fallback to cwd - (which would silently operate on the wrong project's files). The error strings - match the shell resolver so the two surfaces read consistently. + (which would silently operate on the wrong project's files). The error + messages mirror the shell resolver's wording (rendered here as a Rich + ``Error:`` line, plain ``ERROR:`` in the shell) so the two surfaces read + consistently. Returns the validated absolute project root, or ``None`` when the variable is unset/empty, in which case callers keep their existing cwd-based behavior. diff --git a/src/specify_cli/bundler/lib/project.py b/src/specify_cli/bundler/lib/project.py index 9db8da7795..0b0c23c0a3 100644 --- a/src/specify_cli/bundler/lib/project.py +++ b/src/specify_cli/bundler/lib/project.py @@ -16,6 +16,13 @@ def find_project_root(start: Path | None = None) -> Path | None: A symlinked ``.specify`` is not accepted as a project root: following it could read/write outside the intended tree, and other CLI surfaces refuse it for the same reason. + + When *start* is ``None`` the ``SPECIFY_INIT_DIR`` override is honored first + (see :func:`specify_cli._project._resolve_init_dir_override`). With an + explicit override this may **raise** rather than return: a set-but-invalid + value raises ``typer.Exit`` and a symlinked ``.specify`` raises + ``BundlerError``. That is deliberate — returning ``None`` would let + ``bundle init``/``install`` silently fall back to the current directory. """ if start is None: override = _resolve_init_dir_override() From 066a191b84b7efa11516b278a53605d6906ea317 Mon Sep 17 00:00:00 2001 From: Pascal Date: Sat, 27 Jun 2026 01:00:39 +0200 Subject: [PATCH 8/8] 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. --- src/specify_cli/bundler/lib/project.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/bundler/lib/project.py b/src/specify_cli/bundler/lib/project.py index 0b0c23c0a3..6b9e9642f7 100644 --- a/src/specify_cli/bundler/lib/project.py +++ b/src/specify_cli/bundler/lib/project.py @@ -45,7 +45,13 @@ def find_project_root(start: Path | None = None) -> Path | None: def require_project_root(start: Path | None = None) -> Path: - """Return the Spec Kit project root or raise an actionable error.""" + """Return the Spec Kit project root or raise an actionable error. + + Inherits :func:`find_project_root`'s override behavior: when *start* is + ``None``, a set-but-invalid ``SPECIFY_INIT_DIR`` raises ``typer.Exit`` and a + symlinked ``.specify`` raises ``BundlerError`` before this returns. A missing + project (no override) raises ``BundlerError``. + """ root = find_project_root(start) if root is None: raise BundlerError(