Skip to content

feat(cli): honor SPECIFY_INIT_DIR in the specify CLI project resolver#3186

Open
PascalThuet wants to merge 13 commits into
github:mainfrom
PascalThuet:feat/init-dir-python-cli
Open

feat(cli): honor SPECIFY_INIT_DIR in the specify CLI project resolver#3186
PascalThuet wants to merge 13 commits into
github:mainfrom
PascalThuet:feat/init-dir-python-cli

Conversation

@PascalThuet

@PascalThuet PascalThuet commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Description

Follow-up to #2892, per the go-ahead on #2834.

SPECIFY_INIT_DIR only worked in the shell scripts; the Python CLI ignored it, so you still had to cd into the member project to run specify integration / extension / workflow. This makes the CLI's project resolver honor it too, with the same validation rules as the shell: relative to cwd, must exist and contain .specify/, hard error, no fallback. init is unchanged (it creates .specify/).

No --project flag, per your note that the env var is the exception, not the rule.

Testing

  • Tested locally with specify --help
  • Ran the full test suite (4555 passed, 66 skipped)
  • Tested with a sample project

New tests in tests/test_init_dir_cli.py cover targeting from outside the project, path normalization, and the no-fallback error cases.

AI Disclosure

  • I did not use AI assistance for this contribution
  • I did use AI assistance (describe below)

AI agent used to audit the call sites, draft the change, and review the diff; reviewed by me.

@PascalThuet PascalThuet requested a review from mnriem as a code owner June 26, 2026 06:01
@PascalThuet PascalThuet force-pushed the feat/init-dir-python-cli branch from 63a6aa0 to 163d525 Compare June 26, 2026 06:06
@PascalThuet

Copy link
Copy Markdown
Contributor Author

One design point worth a call before merge: how SPECIFY_INIT_DIR should treat a symlinked .specify/ at the target. The surfaces differ today, and the split is deliberate but worth confirming.

  • integration / extension / workflow follow a symlinked .specify (matching the shell resolver, which uses cd && pwd + [ -d .specify ] and follows symlinks).
  • bundle and workflow run <file> refuse it, since they build/traverse and already enforce symlink confinement on the cwd path.

The tension: uniform behavior and shell parity can't both hold. Making the first group refuse symlinks would diverge from the shell contract #2892 established; making the second group follow them would weaken the bundler's write confinement. So I kept each surface aligned with its existing cwd-path stance and documented the split in core.md.

If you'd rather have one rule, I can make it uniformly strict (refuse a symlinked .specify everywhere in the CLI, accepting that the CLI becomes stricter than the shell). Which way do you want it?

@mnriem

mnriem commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Let's go with what you have — keep the split. I want to reframe why, though, because "shell parity" undersells it.

The rule I'd like documented isn't "match the shell," it's:

SPECIFY_INIT_DIR changes where the project is, not how a surface treats symlinks.

Each surface's override path should mirror its existing cwd-path stance, which is exactly what you've done:

  • bundle / workflow run <file> already refuse a symlinked .specify on the cwd path because they traverse and write — refusing it under the override too keeps write confinement intact.
  • integration / extension / workflow already follow it on the cwd path (read/config only, no escape risk) — so they follow it under the override too.

So the difference between the two groups isn't an inconsistency to apologize for; it's the same per-surface symlink policy we already ship, just relocated. Going uniformly strict would either make a surface's override path stricter than its own cwd path (a new inconsistency), or change the cwd behavior of the read surfaces — which is scope creep and would break a legitimate pattern (a shared .specify symlinked into several member projects, which our monorepo guide makes plausible). The security upside on read-only surfaces is negligible, so I don't think the trade is worth it.

One small ask before merge: please reword the core.md "Symlinked project roots" note to lead with the invariant above rather than the shell-parity framing — something like "the override relocates the project root; each surface keeps its existing symlink stance (write surfaces refuse a symlinked .specify, read surfaces follow it)." With that wording tweak this is good to merge.

PascalThuet added a commit to PascalThuet/spec-kit that referenced this pull request Jun 26, 2026
…iant

Per maintainer feedback on github#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.
@PascalThuet

Copy link
Copy Markdown
Contributor Author

Done. Reworded the core.md note to lead with the invariant (the override relocates the project root; each surface keeps its cwd-path symlink stance). Pushed in 9b2ba6f.

The shell resolver honors SPECIFY_INIT_DIR (github#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#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.)
Assisted-by: Codex (model: GPT-5, autonomous)
…ide 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.)
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)
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.)
…iant

Per maintainer feedback on github#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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR extends the SPECIFY_INIT_DIR project-root override (previously honored by the Bash/PowerShell scripts) to the Python specify CLI so project-scoped commands can target a member project from outside its directory (e.g., monorepo root) without cd, using the same strict validation semantics (no fallback on invalid values).

Changes:

  • Adds a shared Python helper to resolve/validate SPECIFY_INIT_DIR and wires it into the CLI project-resolution chokepoint and workflow run <file> file-mode.
  • Ensures bundler project-root detection honors the override and preserves the “symlinked .specify/ is unsafe” guardrails.
  • Adds CLI-level tests plus docs/changelog updates describing the new behavior and monorepo usage.
Show a summary per file
File Description
src/specify_cli/_project.py New shared helper to resolve/validate SPECIFY_INIT_DIR for Python CLI callers.
src/specify_cli/__init__.py Makes _require_specify_project() and workflow run <file> honor the override; tightens .specify symlink messaging for file-mode runs.
src/specify_cli/bundler/lib/project.py Applies the override to bundler project-root detection (with explicit symlinked-.specify refusal).
src/specify_cli/integrations/_helpers.py Re-exports the override resolver to preserve the existing helper import surface pattern.
src/specify_cli/integrations/_scaffold_commands.py Clarifies that scaffolding targets the Spec Kit source repo layout, so the override does not apply.
tests/test_init_dir_cli.py New end-to-end CLI tests covering override targeting, normalization, and strict no-fallback errors.
tests/integration/test_bundler_security_paths.py Adds regression coverage to ensure the override path also refuses symlinked .specify.
tests/conftest.py Adds an autouse fixture to strip inherited SPECIFY_* env vars for deterministic tests.
docs/reference/core.md Updates SPECIFY_INIT_DIR reference to include Python CLI behavior and symlink stance notes.
docs/guides/monorepo.md Documents using SPECIFY_INIT_DIR with specify CLI subcommands from a monorepo root.
CHANGELOG.md Adds a changelog entry for the new CLI behavior.

Review details

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 11/11 changed files
  • Comments generated: 2
  • Review effort level: Low

Comment thread src/specify_cli/_project.py Outdated
Comment thread src/specify_cli/bundler/lib/project.py

@mnriem mnriem left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please address Copilot feedback

- _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.
@PascalThuet

Copy link
Copy Markdown
Contributor Author

Addressed both Copilot notes in 80316e6 (docstrings only): reworded the _project.py error-message line to "mirror" the shell wording rather than "match" it (the CLI renders a Rich Error: line, the shell a plain ERROR:), and documented that find_project_root can raise typer.Exit / BundlerError under the SPECIFY_INIT_DIR override when start is None, so the Path | None signature isn't surprising to direct callers.

…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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

  • Files reviewed: 11/11 changed files
  • Comments generated: 1
  • Review effort level: Low

Comment thread docs/reference/core.md Outdated

> **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.
@mnriem

mnriem commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

Assisted-by: OpenAI Codex (model: GPT-5, autonomous)
Assisted-by: OpenAI Codex (model: GPT-5, autonomous)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

  • Files reviewed: 11/11 changed files
  • Comments generated: 1
  • Review effort level: Low

Comment thread src/specify_cli/_project.py Outdated

import typer

from ._console import console
@mnriem

mnriem commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback. If not applicable, please explain why. And revert changes to CHANGELOG.md as it is automatically generated

Assisted-by: OpenAI Codex (model: GPT-5, autonomous)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

  • Files reviewed: 10/10 changed files
  • Comments generated: 3
  • Review effort level: Low

Comment on lines +36 to +52
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)
Comment thread src/specify_cli/__init__.py Outdated
Comment on lines 514 to 519
# 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,
)
Comment on lines +523 to +529
"""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
@mnriem

mnriem commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

Assisted-by: OpenAI Codex (model: GPT-5, autonomous)
Assisted-by: OpenAI Codex (model: GPT-5, autonomous)
@PascalThuet

Copy link
Copy Markdown
Contributor Author

Posted on behalf of @PascalThuet by OpenAI Codex (model: GPT-5).

Addressed the latest Copilot review round in commit eed7ff34850df854f2d913027da09a9dff349d5f, then merged origin/main in 276edcc. SPECIFY_INIT_DIR validation errors now go through err_console so JSON stdout stays clean, _require_specify_project imports the resolver directly from _project, and the missing-project error now mentions SPECIFY_INIT_DIR with the standardized "Spec Kit" wording.

CHANGELOG.md is not modified in this PR; the manual changelog entry was reverted because the file is generated.

Validation: targeted pytest suite passed locally: 349 passed.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

Comments suppressed due to low confidence (1)

src/specify_cli/init.py:777

  • In workflow run file mode, this error is also printed via console.print (stdout). To keep --json stdout clean on failures, route it to err_console (stderr).
        if specify_dir.exists() and not specify_dir.is_dir():
            console.print("[red]Error:[/red] .specify path exists but is not a directory")
            raise typer.Exit(1)
  • Files reviewed: 14/14 changed files
  • Comments generated: 2
  • Review effort level: Low

Comment on lines 771 to 774
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)
Comment on lines 10 to 13
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 (
@mnriem

mnriem commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants