Skip to content

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

Open
PascalThuet wants to merge 6 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 6 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.
@PascalThuet PascalThuet force-pushed the feat/init-dir-python-cli branch from 9b2ba6f to 97c4720 Compare June 26, 2026 18:24
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.

2 participants