mirror of
https://github.com/github/spec-kit.git
synced 2026-08-15 14:02:04 +02:00
* feat(cli): honor SPECIFY_INIT_DIR in the specify CLI project resolver The shell resolver honors SPECIFY_INIT_DIR (#2892), but the Python CLI did not: it resolved the project as Path.cwd() + a .specify/ check and never read the override. So setup-plan.sh respected it while `specify integration install` ignored it, and you still had to cd into the member project. Route project resolution through a shared _resolve_init_dir_override() that applies the shell resolver's validation rules (relative to cwd, must exist and contain .specify/, hard error, no fallback, same error strings). It's wired into _require_specify_project() — the chokepoint for every project-scoped subcommand (integration/extension/workflow/preset/...) — and the `workflow run <file>` standalone path, which re-applies its symlinked-.specify guard on the override branch too. init is unchanged: it creates .specify/, so the must-pre-exist rule doesn't apply. The resolver canonicalizes symlinks via Path.resolve() while the shell keeps the logical path; they agree for non-symlinked paths (documented in the resolver). Tests in tests/test_init_dir_cli.py mirror the strict cases from test_init_dir.py through the CLI; conftest now strips SPECIFY_* for the whole suite so a stray export can't perturb the now-env-reading resolver. Docs note the CLI applies the same rules. Discussion: github/spec-kit#2834 (Disclosure: I used an AI coding agent to audit the call sites and resolver, draft the change, and run an adversarial code review; reviewed by me.) * fix(cli): honor SPECIFY_INIT_DIR for bundle commands Assisted-by: Codex (model: GPT-5, autonomous) * fix(bundler): refuse symlinked .specify on the SPECIFY_INIT_DIR override path find_project_root refuses a symlinked .specify (following it could read/write outside the tree, and a test pins that), but the SPECIFY_INIT_DIR override added for bundle commands returned early and skipped that guard: _resolve_init_dir_override validates .specify with is_dir(), which follows symlinks. So `specify bundle` accepted via the override a layout the cwd path rejects. Re-check the override result with the same guard, plus a regression test. (Disclosure: found via an AI code review and fixed with an AI coding agent; reviewed by me.) * fix(cli): keep SPECIFY_INIT_DIR strict for bundles Treat an explicit symlinked SPECIFY_INIT_DIR project as a hard bundle error instead of returning no project, which could initialize the current directory. Align the docs with the actual unset resolver behavior. Assisted-by: Codex (model: GPT-5, autonomous) * docs(core): note symlinked .specify handling differs across CLI surfaces A symlinked .specify is followed by integration/extension/workflow (matching the shell resolver) but refused by bundle and workflow run <file> (write confinement). Document the asymmetry so it reads as intentional. (Disclosure: AI-assisted; reviewed by me.) * docs(core): reframe symlinked .specify note around the override invariant Per maintainer feedback on #3186: SPECIFY_INIT_DIR relocates where the project is, not how a surface treats symlinks. Each surface keeps its cwd-path stance (write surfaces refuse a symlinked .specify, read/config surfaces follow it), so the split is one policy relocated, not an inconsistency. * docs: address Copilot review on resolver docstrings - _project.py: the error messages "mirror" the shell wording rather than "match" it (the CLI renders a Rich `Error:` line, the shell a plain `ERROR:`). - find_project_root: document that honoring SPECIFY_INIT_DIR when start is None can raise typer.Exit / BundlerError, so the Path | None signature isn't surprising to direct callers. * docs(bundler): note require_project_root inherits the override raise behavior find_project_root can raise typer.Exit / BundlerError under the SPECIFY_INIT_DIR override (start=None); require_project_root inherits that, so document it alongside its own BundlerError-on-missing-project. * docs: clarify symlinked project root behavior Assisted-by: OpenAI Codex (model: GPT-5, autonomous) * Address SPECIFY_INIT_DIR review feedback Assisted-by: OpenAI Codex (model: GPT-5, autonomous) * Route workflow JSON errors to stderr Assisted-by: OpenAI Codex (model: GPT-5, autonomous)
89 lines
3.5 KiB
Python
89 lines
3.5 KiB
Python
"""Spec Kit project detection and active-integration resolution."""
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
from ..._project import _resolve_init_dir_override
|
|
from .. import BundlerError
|
|
from .yamlio import ensure_within, load_json
|
|
|
|
DEFAULT_INTEGRATION = "copilot"
|
|
|
|
|
|
def find_project_root(start: Path | None = None) -> Path | None:
|
|
"""Return the nearest ancestor (incl. *start*) containing a ``.specify/`` dir, or 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".
|
|
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"
|
|
if marker.is_dir() and not marker.is_symlink():
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def require_project_root(start: Path | None = None) -> Path:
|
|
"""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(
|
|
"Not a Spec Kit project (no .specify/ directory). "
|
|
"Run 'specify bundle init' or 'specify init' first."
|
|
)
|
|
return root
|
|
|
|
|
|
def active_integration(project_root: Path) -> str | None:
|
|
"""Return the project's active integration id, if recorded.
|
|
|
|
Spec Kit records the chosen integration in ``.specify/integration.json``
|
|
during init. Returns None when it cannot be determined (e.g. agnostic).
|
|
"""
|
|
marker = Path(project_root) / ".specify" / "integration.json"
|
|
# Confine the read (mirrors records/catalog IO): refuse to follow a
|
|
# symlinked or traversal-escaping .specify that resolves outside
|
|
# project_root. An escape is treated as "not determinable".
|
|
try:
|
|
marker = ensure_within(project_root, marker)
|
|
except BundlerError:
|
|
return None
|
|
if not marker.exists():
|
|
return None
|
|
try:
|
|
data = load_json(marker)
|
|
except BundlerError:
|
|
return None
|
|
if isinstance(data, dict):
|
|
value = data.get("integration") or data.get("id") or data.get("active")
|
|
if isinstance(value, str) and value:
|
|
return value
|
|
return None
|