Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.12.0] - 2026-06-29

### 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 command treats symlinks: each command keeps its existing cwd-path stance. Commands that traverse and write project files through broad input paths (`bundle`, `workflow run <file>`) refuse a symlinked `.specify/` to preserve write confinement. Other project-scoped commands keep their existing behavior when `SPECIFY_INIT_DIR` points at a project root, which may include following a symlinked `.specify/`.

## 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 @@ -514,12 +514,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 @@ -743,12 +756,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