Skip to content
Open
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

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

- 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` (#3186)

## [0.11.9] - 2026-06-26

### Changed
Expand Down
12 changes: 12 additions & 0 deletions docs/guides/monorepo.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/reference/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,14 @@ 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, 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. |

> **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.** `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 <file>`) refuse a symlinked `.specify/` to preserve write confinement; read/config surfaces (`integration`, `extension`, `workflow`) follow it.

## Check Installed Tools

```bash
Expand Down
29 changes: 24 additions & 5 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
53 changes: 53 additions & 0 deletions src/specify_cli/_project.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""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
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.

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
28 changes: 27 additions & 1 deletion src/specify_cli/bundler/lib/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -15,7 +16,26 @@ 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()
if override is not None:
# An explicit override is strict: do not return None here, because
# bundle install treats None as "init the current directory".
Comment thread
PascalThuet marked this conversation as resolved.
if (override / ".specify").is_symlink():
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()
for candidate in (current, *current.parents):
marker = candidate / ".specify"
Expand All @@ -25,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(
Expand Down
1 change: 1 addition & 0 deletions src/specify_cli/integrations/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/specify_cli/integrations/_scaffold_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
19 changes: 19 additions & 0 deletions tests/integration/test_bundler_security_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,22 @@ 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_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)."""
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))
with pytest.raises(BundlerError, match="symlinked \\.specify"):
find_project_root(None)
Loading