Compare commits

..

32 Commits

Author SHA1 Message Date
Manfred Riem
c8664f9f6a Potential fix for pull request finding 'Empty except'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-06-04 12:23:09 -05:00
copilot-swe-agent[bot]
fc9ce2cfec Changes before error encountered
Agent-Logs-Url: https://github.com/github/spec-kit/sessions/9bf72d24-ce5d-4f1b-8803-d75f9c366793
2026-06-04 16:47:21 +00:00
copilot-swe-agent[bot]
d24d3b18cf Initial plan 2026-06-04 16:35:06 +00:00
Samir Abed
34ce66139e feat: add support for rovodev (#2539)
* feat: add support for rovodev

* fixup! feat: add support for rovodev

* fixup! feat: add support for rovodev

* fixup! feat: add support for rovodev

* fixup! feat: add support for rovodev

* fixup! feat: add support for rovodev

* fixup! feat: add support for rovodev

* fixup! feat: add support for rovodev

* fixup! feat: add support for rovodev

* fixup! feat: add support for rovodev

* fixup! feat: add support for rovodev

* fixup! feat: add support for rovodev
2026-06-04 11:34:05 -05:00
Manfred Riem
6355cec8de chore: release 0.9.4, begin 0.9.5.dev0 development (#2853)
* chore: bump version to 0.9.4

* chore: begin 0.9.5.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-04 11:19:11 -05:00
Huy Do
141119efea feat(workflows): add JSON output for workflow run resume and status (#2814)
* feat(workflows): add --json output to workflow run, resume, and status

Adds an opt-in `--json` flag to `workflow run`, `workflow resume`, and
`workflow status` that emits a single machine-readable object (run_id,
workflow_id, status, current step; status also reports per-step states
and a runs list) for automation and external orchestrators.

JSON is written via a small `_emit_workflow_json` helper using plain
stdout, so Rich markup, highlighting, and line-wrapping can never alter
the emitted object. Default human-readable output and exit codes are
unchanged when `--json` is omitted. Reference docs updated.

Closes #2811.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(workflows): keep --json stdout clean while steps write output

Suppressing the banner and the step-start callback was not enough to
guarantee a single parseable JSON object on stdout: individual steps still
write there while the engine runs. The gate step prints its prompt, and the
prompt step runs a CLI subprocess that inherits the process's stdout file
descriptor — either can corrupt the JSON stream for interactive runs or
integration-backed workflows.

Wrap engine.execute()/engine.resume() in a file-descriptor-level redirect
(dup2) when --json is set, so both Python-level writes and inherited-fd
subprocess output go to stderr while stdout carries only the emitted JSON.
Step progress stays visible on stderr. status does not run the engine, so
it is unaffected.

Tests cover both pollution channels (a Python print and a real subprocess)
via fd-level capture, and the inactive no-op path. Docs note the
stdout/stderr split.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(workflows): fix stray escape sequence in --json redirect comments

The redirect helper's docstring and its test comment wrote ``print``\s,
which renders as "print\s" rather than "prints". Replace with plain
"prints".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 11:11:39 -05:00
bigsmartben
e094cbdb6e Update workflow-preset community catalog to v1.3.2 (#2841) 2026-06-04 10:18:48 -05:00
minbang
a9a759450d fix: recover active skills registration for extensions (#2803)
Extension command registration now resolves the active skills directory before writing command artifacts. This lets initialized skills-backed agents recover a missing active skills directory while preserving the existing preset registration behavior.

Add regression coverage for missing active skills directories, shared skills directories, and symlinked parent guards.

Fixes #2769.

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-06-04 09:53:31 -05:00
One-TheOnly
8e5643d4ff fix(cursor-agent): enable headless CLI dispatch end-to-end (-p --trust --approve-mcps --force + Windows .cmd shim resolution) (#2631)
* fix(cursor-agent): enable CLI dispatch via ``-p --trust`` headless mode

Restores the ability for ``specify workflow run`` to dispatch the
cursor-agent CLI, complementing the existing in-IDE skill flow.
Without this fix, ``specify workflow run speckit --input
integration=cursor-agent ...`` fails with a misleading
``CLI not found or not installed`` error even when the CLI is
installed (since cursor-agent had ``requires_cli=False`` and an
unset ``build_exec_args``).

The cursor-agent CLI (>= 2026.05.16) supports headless execution
via ``-p`` (print mode with full tool access including write/shell)
and ``--trust`` (bypass Workspace Trust prompt). Without ``--trust``
the CLI exits non-zero in non-TTY contexts (verified locally).

Changes to ``src/specify_cli/integrations/cursor_agent/__init__.py``:

* ``config.requires_cli``: ``False`` -> ``True``
* ``config.install_url``: ``None`` -> Cursor CLI docs URL
* Override ``build_exec_args()`` to emit
  ``[cursor-agent, -p, --trust, <prompt>, ...]``
  with optional ``--model`` and ``--output-format json`` flags,
  mirroring the shape used by ``claude``/``codex``/``gemini``.

Tests:

* 34 existing cursor-agent tests still pass.
* 6 new tests in ``TestCursorAgentCliDispatch`` pin
  ``requires_cli``, ``install_url``, and the exact argv shape
  (default, text-output, with-model, and the hyphenated skill
  invocation form ``/speckit-<name>``).
* Full repo: 1085 / 1085 passed, no regressions.

Fixes #2629

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(integrations): resolve ``.cmd``/``.bat`` shims before subprocess.run

On Windows, ``shutil.which`` honors ``PATHEXT`` and locates wrappers
like ``cursor-agent.cmd`` and ``codex.cmd``, but Python's
``subprocess.run`` calls ``CreateProcess`` which does **not** consult
``PATHEXT`` and therefore fails with ``WinError 2`` on a bare argv
like ``[cursor-agent, ...]``.

Resolve ``exec_args[0]`` via ``shutil.which`` in
``IntegrationBase.dispatch_command`` so ``.cmd``/``.bat`` shims work
transparently. On POSIX this is a no-op for absolute paths and a
harmless lookup otherwise.

Verified locally on Windows 10 + cursor-agent 2026.05.16:
without this fix, ``specify workflow run speckit --input
integration=cursor-agent`` fails with ``FileNotFoundError`` even
after the cursor-agent integration starts producing valid exec
args (per the prior commit on this branch).

Tests:

* New: 2 cursor-agent tests pin the shim-resolution + passthrough
  behavior (``test_dispatch_command_resolves_cmd_shim_for_subprocess``
  and ``test_dispatch_command_passthrough_when_shutil_which_finds_nothing``).
* Updated: ``tests/test_workflows.py::TestCommandStep::test_dispatch_with_mock_cli``
  was mocking ``shutil.which`` only at the ``command`` step level
  and not at the ``base`` level, which made it environment-sensitive
  (fails locally when the real ``claude`` CLI is on PATH).  Added the
  matching base-level patch and updated the argv-assertion to reflect
  the resolved path. ``test_dispatch_failure_returns_failed_status``
  gets the same patch for consistency.
* Full repo: 2867 passed, 0 regression from this PR. The 12 remaining
  pre-existing failures are unrelated Windows ``symlink`` privilege
  failures (``WinError 1314``) on a non-admin Windows runner.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cursor-agent): inject --approve-mcps --force for headless MCP/tool access

The previous commit (1c55988) wired up ``-p --trust`` so the CLI launches
in headless mode without the Workspace Trust prompt, but that alone is
not enough to let ``specify workflow run`` drive a real speckit feature
end-to-end with cursor-agent on Windows. Two more flags are required:

* ``--approve-mcps``: without it, every MCP server configured in
  ``.cursor/mcp.json`` stays ``not loaded (needs approval)``, and any
  tool call against them is silently dropped. We hit this immediately
  trying to read a DingTalk PRD from a remote MCP server during the
  ``/speckit-specify`` step.
* ``--force``: without it, the agent halts on the first tool-call
  approval prompt (the tool call gets rejected and the workflow exits
  non-zero with a misleading message). With ``--force`` cursor-agent
  matches the implicit "trusted environment" semantics that ``claude -p``
  and ``codex --exec`` already have by default -- which is the right
  semantics for an unattended ``specify workflow run`` invocation.

Verified end-to-end on Windows 10 + cursor-agent 2026.05.16-0338208:

* ``cursor-agent -p --trust --approve-mcps --force --output-format text``
  + a ``/speckit-specify`` prompt that included a DingTalk URL produced
  a full spec.md (31.5 KB) plus checklists/requirements.md in ~10.7 min,
  reading the source PRD through the ``dingtalk-doc`` remote MCP server,
  deciding the ``specs/`` subpath itself, and updating
  ``.specify/feature.json`` and ``specs/menu-dictionary.md`` along the
  way -- no human-in-the-loop, no source PRD ever touched the filesystem.
* Without ``--approve-mcps`` the same prompt errors with the tool call
  rejected message; without ``--force`` the agent stops at the first
  non-MCP tool call.

Tests:

* ``test_build_exec_args_*`` updated to pin the new four-flag prefix.
* New ``test_build_exec_args_contains_mandatory_headless_flags`` asserts
  the four flags are always present together.
* ``test_dispatch_command_resolves_cmd_shim_for_subprocess`` updated to
  match the new argv layout.
* All 43 cursor-agent tests pass; no other tests touched.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(cursor-agent): express dispatch support via build_exec_args() instead of requires_cli

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(cursor-agent): use urlparse hostname check and cover dispatch without requires_cli

Co-authored-by: Cursor <cursoragent@cursor.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: 刘一 <liuyi@oureman.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-04 09:48:33 -05:00
Manfred Riem
3a67dad8d2 Update Superpowers Implementation Bridge extension to v1.0.2 (#2852)
* Update Superpowers Implementation Bridge extension to v1.0.2

Update speckit-superpowers-bridge extension submitted by @lihan3238:
- extensions/catalog.community.json (version, download_url, updated_at)

The download URL now uses the stable latest-release alias
(speckit-superpowers-bridge.zip) per the maintainer's distribution policy.

Closes #2848

* Pin speckit-superpowers-bridge download_url to v1.0.2

Use the version-pinned release asset URL instead of the
releases/latest/download alias so the catalog entry tracks the
specific version declared in the entry rather than silently
following future releases. Matches the pinning convention used
by other entries in the catalog.
2026-06-04 09:12:26 -05:00
Manfred Riem
829740e296 docs(agents): add PR review response guidance to AGENTS.md (#2850)
* docs(agents): add PR review response guidance to prevent comment flooding

Adds a 'Responding to PR Review Comments' section to AGENTS.md so agents
acting on PRs stop posting one reply per review comment. Directs them to
post one summary comment per review round, disclose their identity and
the human they're acting for, never click 'Resolve conversation', and
re-request review once per round rather than after every push.

Closes #2849

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-04 08:53:32 -05:00
Copilot
40d832f90a Allow specify workflow run to execute YAML files without a project (#2825)
* Initial plan

* feat: add --workflow option to init command for post-init workflow execution

* chore: remove unused import in test file

* refactor: allow workflow run without project when given a YAML file path

Instead of adding --workflow to init, make `specify workflow run ./file.yml`
work without requiring a .specify/ project directory. When the source is a
YAML file that exists on disk, cwd is used as the project root. When it's a
workflow ID, the .specify/ project requirement is preserved.

* Handle standalone workflow path edge cases

* Fix USERPROFILE env var portability and docs notation

* Fix workflow YAML path detection to require regular files

* Harden workflow run against unsafe .specify paths

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-04 07:09:54 -05:00
Grissiom.GuRui
659a41a6cc feat(extensions): add --force flag to extension add for overwrite reinstall (#2530)
* feat(extensions): add --force flag to extension add for overwrite reinstall

Add --force support to `specify extension add` that allows overwriting
an already-installed extension without manually removing it first.

- install_from_directory() and install_from_zip() accept force=True,
  automatically calling remove() before installation
- The --force CLI flag works with all install modes (--dev, --from URL,
  bundled, and catalog)
- Config files (*-config.yml) are preserved across force reinstall
- Error message suggests --force when extension is already installed
- 6 new tests covering unit and CLI force reinstall flows

* fix: address PR review feedback on --force implementation

- Remove unused `backup_config_dir` variable assignment (Ruff F841)
- Defer `remove()` until after `_validate_install_conflicts()` to prevent
  data loss if validation fails mid-reinstall
- Use `TemporaryDirectory` instead of `NamedTemporaryFile` in ZIP test
  to avoid Windows file-locking failures

* fix: only restore config backup when --force actually triggers a remove

When --force is used but the extension is not already installed, the
backup restore/cleanup should not run. Previously it could resurrect
stale config files from a previous removal and delete the backup
directory unnecessarily.

* fix: address Copilot review feedback on --force implementation

- Clear stale backup dir before remove() so only fresh backups are restored
- Restore only config files (*-config.yml, *-config.local.yml) from backup
- Remove trailing \n from --force console message (console.print adds newline)

* fix: handle non-directory paths in backup cleanup/restore

- Use is_dir() before rmtree/iterdir on backup path to avoid crashes
  when .backup/<id> exists as a file or symlink
- Remove unused manifest1 variable in test_install_force_reinstall

* fix: handle symlinks in backup cleanup/restore and correct CLI message

- Check is_symlink() before is_dir() in backup cleanup and restore:
  Path.is_dir() follows symlinks (returns True for symlink-to-dir) but
  shutil.rmtree() raises OSError on symlinks. Handle symlinks by
  unlinking them instead.
- Skip symlink entries during config file restore.
- Change --force dev-install message from "Reinstalling" to
  "Installing [...] (will overwrite if already installed)" because
  --force also works for first-time installs.
2026-06-03 16:34:24 -05:00
Manfred Riem
df09fd49c6 chore: release 0.9.3, begin 0.9.4.dev0 development (#2836)
* chore: bump version to 0.9.3

* chore: begin 0.9.4.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-03 16:28:33 -05:00
Pascal THUET
4028c50af8 fix: render script command hints with active agent separator (#2649)
* fix script command hints for agent separators

* Address command hint review feedback

* chore: remove whitespace-only PR churn

* test: fix PowerShell command hint invocation

* fix: preserve hyphens in script command hints

* fix: render managed script command hints
2026-06-03 16:24:13 -05:00
darion-yaphet
67fecd357a chore(tests): fix ruff lint violations in tests/ (#2827)
Clear pre-existing lint debt flagged by repo-wide `ruff check` (the lint
config only scopes src/, so tests/ had drifted). No behavior change.

- F401/F541: drop unused imports and redundant f-string prefixes (autofix)
- E741: rename ambiguous `l` to `ln` in comprehensions
- E702: split semicolon-joined statements onto separate lines
- F841: drop unused bindings while keeping the side-effecting calls
  (_minimal_feature, install_from_directory)

Full suite: 3344 passed, 40 skipped. ruff check (repo-wide): clean.
2026-06-03 16:02:26 -05:00
Quratulain-bilal
bb2b49d0ae fix(workflows): validate run_id in RunState.load before touching the … (#2813)
* fix(workflows): validate run_id in RunState.load before touching the filesystem

``RunState.load(run_id, project_root)`` interpolates ``run_id`` directly
into ``project_root / ".specify" / "workflows" / "runs" / run_id`` and
then calls ``state_path.exists()`` and ``json.load`` on the result. The
run_id is reachable from user input via ``specify workflow resume
<run_id>`` (CLI argument) and via ``SPECKIT_WORKFLOW_RUN_ID`` (env var
override on the engine's run path), so a value like ``../escape``
turns ``runs_dir`` into ``.specify/workflows/escape/`` and:

  * ``state_path.exists()`` becomes a file-existence oracle for any
    path the process can read.
  * if a ``state.json`` exists at the traversed location (planted by
    a malicious dependency, a misconfigured shared workspace, or an
    older spec-kit version that happened to write there),
    ``json.load`` parses it and the workflow resumes under the
    attacker-chosen ``workflow_id`` / step state.
  * a subsequent ``state.save()`` then writes back to the traversed
    location, persisting the corruption.

``RunState.__init__`` already validates ``run_id`` against
``r'^[a-zA-Z0-9][a-zA-Z0-9_-]*$'`` — but that check runs on
``state_data["run_id"]`` *after* ``load`` has already done the file
lookup, which is too late to prevent the disclosure.

This change extracts the pattern into a class-level constant
``_RUN_ID_PATTERN`` and a single ``_validate_run_id`` classmethod so
``__init__`` and ``load`` cannot drift, then calls the validator at the
top of ``load`` before any path is built. Mirrors the precedent in
``src/specify_cli/agents.py::_ensure_within_directory`` (used at line
437 of that file) which guards extension-install paths against the
same threat model.

Regression tests parametrize 9 traversal vectors (``../escape``,
``..``, ``../../etc/passwd``, ``foo/bar``, ``foo\bar``, ``.hidden``,
``-flag``, ``foo\x00bar``, empty) and plant a malicious ``state.json``
outside ``runs/`` so a missing guard would surface as a successful
load rather than the ambiguous ``FileNotFoundError``. A second test
asserts ``__init__`` and ``load`` reject the same representative
malformed ID, so future changes to one path can't silently drift from
the other.

* test(workflows): exercise RunState.load in shared-validation test, fix __init__ empty-string asymmetry

Copilot's review on this PR pointed out that
test_init_and_load_share_validation claimed to verify both entry
points share the same validation rules but never actually called
RunState.load — only __init__ and the shared
_validate_run_id helper. A regression in load (e.g. someone
deleting the cls._validate_run_id(run_id) call before the path is
built) would slip through even though __init__ and the helper
stayed aligned, defeating the whole point of the test.

Tightening the test surfaced a real asymmetry the previous version was
silently masking:

    self.run_id = run_id or str(uuid.uuid4())[:8]

The truthiness fallback meant RunState(run_id="") silently
substituted a UUID and skipped validation, while
RunState.load("", project_root) correctly rejected the empty
string. The two entry points diverged on the empty-string vector.
That is exactly the drift the test name claimed to defend against —
and the original test missed it.

Changes
-------

* engine.py: __init__ now distinguishes run_id is None
  (caller omitted it → auto-generate UUID) from an empty string
  (caller provided it → must validate like any other value). Both
  paths still flow through _validate_run_id, but only the
  explicit-None case auto-generates.

* test_workflows.py: test_init_and_load_share_validation is
  now parametrized over one representative vector per category from
  test_load_rejects_path_traversal (parent traversal, embedded
  separator, leading non-alphanumeric, empty string) and asserts that
  *all three* entry points — __init__, _validate_run_id, and
  load — reject the same input. Adding load to the assertion
  is the substantive fix Copilot asked for; keeping __init__ and
  the helper alongside it makes any future drift between the three
  immediately observable instead of having to read three separate
  tests.

Verification
------------

pytest tests/test_workflows.py — 168 passed (was 165 before the
parametrize expansion; __init__ empty-string vector would have
failed the new test against the old engine code, confirming the
asymmetry was real).
2026-06-03 14:26:07 -05:00
김준호
ac2cb5daf5 feat(cli): implement specify self upgrade (#2475)
* feat(cli): implement specify self upgrade

* fix(cli): normalize self-upgrade prerelease tags

* fix(cli): tighten self-upgrade diagnostics

* fix(cli): harden self-upgrade verification parsing

* fix(cli): sanitize self-check fallback tags

* fix(cli): harden self-check release display

* fix(cli): validate resolved upgrade tags

* fix(cli): tolerate invalid install metadata

* test(cli): align upgrade network mocks

* fix(cli): respect relative installer paths

* fix(cli): tighten upgrade failure handling

* fix(cli): align installer path diagnostics

* fix(cli): validate release and version output

* fix(cli): clarify source checkout guidance

* fix(cli): harden upgrade detection helpers

* fix(cli): avoid echoing invalid release tags

* fix(cli): tolerate argv path resolve failures

* chore: remove self-upgrade formatting-only diffs

* fix: address self-upgrade review feedback

* fix: address self-upgrade review followups

* fix: address self-upgrade review edge cases

* fix: address self-upgrade review docs

* fix: refine self-upgrade review followups

* fix: address self-upgrade review cleanup

* fix: handle self-upgrade review edge cases

* fix: address self-upgrade review nits

* fix: address follow-up self-upgrade review

* fix: resolve self-upgrade review and Windows CI failures

- README: promote "Optional Commands" to ### so it is a sibling of
  "Core Commands" under "Available Slash Commands" (consistent heading
  levels; avoids the h2->h4 jump a revert would create).
- _version: allow --tag prerelease/dev and build-metadata suffixes to
  compose (e.g. v1.0.0-rc1+build.42), matching PEP 440 / semver; the
  Version() check still enforces canonical validity.
- tests: compare resolved argv0 as Path objects instead of POSIX strings
  so the assertion holds on Windows; skip the relative-installer-path
  executable-bit tests on Windows via a new requires_posix marker (they
  rely on chmod/X_OK semantics and chdir-into-tmp teardown that do not
  hold there). Add a combined prerelease+build-metadata tag test.

* fix: address second self-upgrade review round

- self_check: clarify that the "up to date" branch is reached only for
  parseable latest tags (the unparseable case returns earlier), so the
  InvalidVersion fallback assumption is not reintroduced.
- self_upgrade: compare target/current as Version instances directly
  instead of re-parsing the canonical strings through _is_newer; the
  empty-current case stays explicit via the not-None guard.
- tests: document the intentional broad GH_/GITHUB_ env scrub with a test
  asserting non-credential context vars (GH_HOST, GITHUB_REPOSITORY, …) are
  stripped from the installer subprocess env — a deliberate fail-safe that
  also catches credential-adjacent names without a recognized suffix.

* fix: address third self-upgrade review round

- self_upgrade: unify the no-op short-circuits on packaging Version
  equality instead of canonical-string equality. Version("1.0") equals
  Version("1.0.0") but their str() forms differ, so the old check could
  misreport an equal install as "already on latest release or newer".
  Both the unpinned and pinned branches now use Version comparison.
- self_upgrade: compare the verified version as a parsed Version against
  the target so a non-version verifier result is a mismatch (exit 2)
  rather than a coincidental canonical-string match.
- resolver: map HTTP 429 (Too Many Requests / secondary rate limit) to
  the rate-limited category so users get the same actionable token hint
  as 403.
- _is_github_credential_env_key: document the precise (intentionally
  broad) scrub matching contract in the docstring.
- tests: add a trailing-zero Version-equality regression test and a
  parametrized HTTP-status categorization test (429 -> rate limited;
  404/502 -> verbatim).

* fix: address fourth self-upgrade review round

- self_upgrade: label a pinned target older than the installed version as
  "Downgrading" rather than "Upgrading" so `--tag <older>` is not mistaken
  for a forward upgrade.
- resolver: drop the unused `typing.Optional` import and annotate the
  `--tag` option as `str | None`, consistent with the rest of the module
  (verified Typer resolves it on the supported Python versions).
- _is_github_credential_env_key: add `_PASSWORD` and `_CREDENTIALS` to the
  recognized credential suffixes and document that only these shapes are
  scrubbed (not blanket coverage).
- tests: assert the precise exit code (1) for the re-raised transient
  OSError path; skip the InvalidMetadataError test on Pythons where the
  real exception is absent instead of fabricating it; update the pinned
  downgrade test to expect the "Downgrading" label.

* fix: accept uppercase V prefix in --tag

Fold a leading uppercase `V` (a common paste) to the canonical lowercase
`v` before validating `--tag`. The remainder of the tag stays
case-sensitive on purpose: the validated value is used verbatim as a git
ref, which is case-sensitive on GitHub, so rewriting label/build-metadata
casing could point at a tag that does not exist. Adds a normalization test.
2026-06-03 12:04:54 -05:00
Huy Do
1732b9b62e feat(workflows): allow resume to accept updated workflow inputs (#2815)
`workflow resume` now accepts `--input key=value` (the same flag and
parsing as `workflow run`, via a shared `_parse_input_values` helper).
Supplied values are merged over the run's persisted inputs and
re-resolved through the existing typed-validation path
(`_resolve_inputs`), so a resumed/re-run step sees the updated inputs
and ill-typed values fail fast. Keys not supplied keep their persisted
values; resuming without `--input` is unchanged. Reference docs updated.

Distinct from #2405 (file-reference inputs at run time): this is about
supplying inputs at resume time, reusing the existing input model.

Closes #2812.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 12:04:07 -05:00
WangX
1f9eaf3ff3 catalog: rename "superpowers-bridge" to "superspec" (v1.0.1) (#2772)
* catalog: rename "superpowers-bridge" to "superspec" (v1.0.1)

* fix: address Copilot feedback (sync top-level updated_at, rename docs entry)
2026-06-03 08:36:26 -05:00
Rafael Figuereo
9e05195d24 fix(cli): force UTF-8 stdout/stderr on Windows to prevent UnicodeEncodeError (#2817)
On Windows, when stdout/stderr are not a UTF-8 TTY (output piped, redirected
to a file, or running under a legacy code page such as cp1252), Rich cannot
encode the banner and box-drawing glyphs, so the CLI aborts with a
UnicodeEncodeError traceback instead of printing. This breaks basic commands
like `specify --help` and `specify version` whenever their output is captured
rather than written to an interactive terminal.

Reconfigure sys.stdout/sys.stderr to UTF-8 with errors="replace" at the
main() entry point on win32 so output degrades gracefully instead of crashing.
The change is a no-op on POSIX, is guarded by try/except so it can never make
stream setup worse, and lives at the CLI entry point only -- importing
specify_cli as a library does not touch global streams.

Verified on Windows 11 (cp1252): `specify --help` piped and `specify version`
redirected to a file both render correctly and exit 0 without setting
PYTHONUTF8 / PYTHONIOENCODING.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 08:32:14 -05:00
WOLIKIMCHENG
6d511acfb9 fix(plan): clarify quickstart validation guide scope (#2805)
Co-authored-by: root <kinsonnee@gmail.com>
2026-06-03 08:07:42 -05:00
Manfred Riem
06c76533cb chore: release 0.9.2, begin 0.9.3.dev0 development (#2823)
* chore: bump version to 0.9.2

* chore: begin 0.9.3.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-02 17:52:31 -05:00
Thorsten Hindermann
9768b1eb88 Update agent parity governance preset catalog entry (#2777) 2026-06-02 17:45:10 -05:00
lselvar
c9c02ae790 fix: resolve GitHub release asset API URL for private repo extension downloads (#2792)
* fix: resolve GitHub release asset API URL for private repo downloads

For private or SSO-protected GitHub repos, browser release download URLs
redirect to HTML/SSO instead of the ZIP asset. This commit resolves the
asset via the GitHub REST API and downloads with Accept: application/octet-stream,
falling back to the original URL if the API call fails.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: support direct GitHub REST release asset URLs in extension downloads

When a catalog download_url is already a GitHub REST release asset URL
(https://api.github.com/repos/<owner>/<repo>/releases/assets/<id>),
skip the release metadata lookup and download directly with
Accept: application/octet-stream. This complements the browser URL
resolution from the previous commit, covering catalogs that reference
the REST API directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 17:15:36 -05:00
lselvar
d79a514b30 fix: remove unsupported mode: frontmatter from Copilot skills mode (fixes #2799) (#2819)
VS Code Copilot Agent Skills do not support the `mode:` frontmatter field.
The generated SKILL.md files included `mode: speckit.<stem>` injected by
CopilotIntegration.post_process_skill_content(), which had no effect in
VS Code and could cause confusion. Simplify post_process_skill_content to
delegate directly to _CopilotSkillsHelper without injecting mode:.

Update tests to assert mode: is absent from generated skill frontmatter.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 17:14:08 -05:00
darion-yaphet
ee17b04784 refactor(integrations): co-locate integration commands in integrations/ domain dir (PR-5/8) (#2720)
* refactor(integrations): co-locate integration commands in integrations/ domain dir

- Remove commands/ stubs (handlers will live in domain dirs)
- Move all integration CLI handlers out of __init__.py into integrations/
- Split into focused modules under integrations/:
    _helpers.py           (340 lines) — domain helpers
    _install_commands.py  (306 lines) — install / uninstall
    _migrate_commands.py  (487 lines) — switch / upgrade
    _query_commands.py    (442 lines) — list / use / search / info / catalog
    _commands.py           (34 lines) — app objects + register()
- __init__.py reduced by ~1400 lines; integration block replaced with register() call
- Fix patch paths in tests to new module locations

* fix(integrations): restore original integration list output in refactor

Preserve the CLI Required column, post-table default/installed summary,
and no-installed guidance that were dropped during the no-behavior-change
refactor of integration list into _query_commands.py.

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix(integrations): restore _clear/_update_init_options public imports

The refactor that split integration commands moved
_clear_init_options_for_integration and _update_init_options_for_integration
into integrations/_helpers.py, but tests still import them from the top-level
specify_cli package, causing ImportError. Re-export them with explicit aliases
at the end of __init__.py to preserve the public import surface.

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-02 12:21:19 -05:00
Manfred Riem
a1b8de68bc Update Product Forge extension to v1.6.0 (#2820)
* Update Product Forge extension to v1.6.0

Update product-forge extension submitted by @VaiYav:\n- extensions/catalog.community.json (version, download_url, description, provides, updated_at)\n- docs/community/extensions.md community extensions table\n\nCloses #2800

* Fix Product Forge typography in catalog/docs

Replace ASCII '->' with Unicode '→' in Product Forge descriptions to match existing catalog/docs typography.
2026-06-02 11:24:42 -05:00
Huy Do
7bab0568c5 feat(workflows): add continue_on_error step field for non-halting failures (#2663)
* feat(workflows): add continue_on_error step field

Adds an optional `continue_on_error: bool` field on every step.
When set to `true` and the step fails, the engine records the
result (`exit_code`, `stderr` on `steps.<id>.output` plus `status`
as a sibling key on `steps.<id>`) and continues to the next sibling
step instead of halting the run. Downstream `if`, `switch`, or
`gate` steps can then branch on
`{{ steps.<id>.output.exit_code }}` to route the recovery path.

Engine details
--------------
`WorkflowEngine._execute_steps` now consults the step config when a
step returns `StepStatus.FAILED`:

- Gate aborts (`output.aborted`) always halt the run — operator
  decisions take precedence over the flag.
- Otherwise, if `continue_on_error` is the literal `True`, log a
  `step_continue_on_error` event and proceed to the next sibling.
  The runtime check uses identity comparison (`is True`) rather
  than truthiness, so truthy non-bool values like the string
  `"true"` cannot silently change run semantics even if a caller
  bypasses `validate_workflow()`.
- Otherwise, behave as before: log `step_failed`, set
  `RunStatus.FAILED`, and return.

Validation
----------
`_validate_steps` rejects non-bool values for `continue_on_error`.
Coerced strings like `"true"` are not accepted so authoring
mistakes surface at validation time rather than silently changing
run semantics.

Tests
-----
`TestContinueOnError` in `tests/test_workflows.py` (8 tests):
- `test_undeclared_failure_halts_run` — default halt behaviour.
- `test_declared_and_fired_continues_run` — flag + fail → continue.
- `test_declared_but_step_succeeded_is_noop` — flag + success → no-op.
- `test_if_branch_routes_around_failure` — end-to-end recovery.
- `test_gate_abort_still_halts_with_continue_on_error` — abort
  always halts.
- `test_validation_rejects_non_bool_continue_on_error` — `"true"`
  rejected at validation.
- `test_validation_accepts_bool_continue_on_error` — `true`/`false`
  pass cleanly.
- `test_engine_ignores_truthy_non_bool_continue_on_error` —
  defense-in-depth: engine ignores string `"true"` even when
  validation is bypassed.

Rebased onto current upstream/main (post #2664 merge); the new
`TestContinueOnError` class sits immediately after upstream's
`TestContextRunId` so the two feature suites coexist cleanly.

Closes #2591.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(workflows): restore runtime context section, clarify gate prompt

Two Copilot findings on d0b9e00:

1. The `### Runtime Context` documentation for `{{ context.* }}` was
   lost during the rebase onto current main (the squash dropped the
   anchor where #2664 had added it). Restored under `## Expressions`
   so users can find `context.run_id` semantics and examples.

2. The continue_on_error example gate had message "Retry or skip?"
   but used the default `options: [approve, reject]` with `on_reject:
   skip`, which implied an automatic retry path that gates do not
   provide. Reworded the message to match the actual approve/reject
   semantics and added an explicit note that retry requires either
   custom gate options + downstream branching or a wrapper loop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(workflows): clarify continue_on_error scope — returned FAILED only

Copilot finding on d0b9e00:

The README's "Error Handling" intro implied `continue_on_error` covers
"any other runtime error raised during step execution", but the engine
only consults the flag when a step returns `StepResult(status=FAILED, ...)`.
Exceptions raised out of `step_impl.execute()` propagate to
`WorkflowEngine.execute()`, where the catch-all logs `workflow_failed`
and re-raises — the step result is never recorded, and the flag is
never consulted.

Audited the whole PR diff for the same overclaim:

1. workflows/README.md — main fix. Reworded the Error Handling intro to
   "any step that returns StepResult(status=FAILED, ...)" and promoted
   the parenthetical structural-validation note into the Notes block.
   Added a new "Scope: returned failures only" note that names the
   exception path explicitly and tells step authors how to bring the
   flag into scope for exceptional code (catch internally and return
   FAILED with the failure encoded in `output`).

2. tests/test_workflows.py — section comment used "when an executable
   step fails", same ambiguity. Tightened to "when a step returns
   StepResult(status=FAILED, ...)" and added a sentence calling out
   that unhandled exceptions are out of scope.

3. src/specify_cli/workflows/engine.py — already correct ("any step
   that returns FAILED" in the validator comment; "lets the pipeline
   route around the failure" in the execute path). No change.

Engine semantics and test bodies are unchanged. Docs-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(workflows): clarify on_reject:skip semantics — engine returns COMPLETED, not auto-skip

Copilot finding on b8982a7:

The README example's gate message said "reject to skip the rest of this
branch", and the explanatory paragraph claimed [approve, reject] map
to "continue" vs "skip the rest of this branch". The engine does not
implement automatic branch-skipping. `on_reject: skip` returns
`StepStatus.COMPLETED` (gate/__init__.py:65-66); the next sibling step
runs unconditionally unless the author wires a downstream `if` reading
`{{ steps.<gate-id>.output.choice }}`.

Two fixes:

1. Restructured the YAML example so it actually demonstrates the
   manual-branching pattern: added a `recover` if-step after the gate
   that conditions on `steps.review.output.choice == 'approve'`. Now
   the example shows the real workflow author's responsibility instead
   of implying the engine does it.

2. Replaced the trailing paragraph with three precise notes:
   - both gate options return COMPLETED; `on_reject: skip` controls
     abort behaviour only, not sibling-skipping
   - all three `on_reject` values enumerated with their actual engine
     semantics (FAILED+aborted / COMPLETED / PAUSED)
   - the original retry-loop guidance retained as the third bullet

Updated the gate message in the example to match — "reject to leave the
failure recorded and move on" instead of "reject to skip the rest of
this branch".

Audited the whole PR diff for the same overclaim: no other instance.
Engine semantics, validation, and test bodies are unchanged. Docs-only.

161/161 tests/test_workflows.py pass locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(workflows): clarify gate's role — surfaces, doesn't programmatically branch

Audit follow-up to 393ac6b — three sites repeated the same minor
overclaim about gates being one of the "branch on it" step types
alongside `if` and `switch`:

1. workflows/README.md (the "downstream `if`, `switch`, or `gate`
   steps can branch on it" sentence introducing the example)
2. engine.py:236 (validator inline comment)
3. engine.py:657 (execute-path inline comment)

A `gate` step does not have a `condition` or `expression` field — it
only evaluates expressions for `message` and `show_file` (gate/__init__.py:29,36).
Programmatic branching happens in `if`/`switch`; a gate surfaces the
value to a human operator via message interpolation, and the operator's
choice is recorded in `output.choice` for a *subsequent* `if`/`switch`
to route on.

Reworded all three sites consistently: "a downstream `if` or `switch`
can branch on it (or a `gate` can surface it to the operator via
message interpolation)". The README example already demonstrates this
distinction — the gate carries `{{ }}` template variables in its
message and the `recover` if-step downstream is what actually branches
on the choice.

Engine semantics, validation, and test bodies are unchanged. Docs-only
on the README; comment-only on engine.py.

161/161 tests/test_workflows.py pass locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(workflows): use qualified StepStatus.* instead of bare FAILED/COMPLETED/PAUSED

Three Copilot inline comments on workflows/README.md lines 226, 282, 288
flagged that ``StepResult(status=FAILED, ...)`` is not valid Python —
``StepResult.status`` is a ``StepStatus`` enum value, so the
documented form should be ``StepStatus.FAILED``.

Audited the whole PR diff for the same shorthand. The bare unqualified
form appears in three files added/modified by this PR:

1. workflows/README.md (6 sites) — three ``StepResult(status=FAILED, ...)``
   parentheticals, plus the on_reject Notes bullet listing the three
   step statuses (``FAILED``, ``COMPLETED``, ``PAUSED``).

2. tests/test_workflows.py (4 sites) — section header for
   TestContinueOnError, two test-method docstrings, one inline comment
   about a gate's TTY-fallback behaviour.

3. src/specify_cli/workflows/engine.py (1 site) — the validator inline
   comment added in d0b9e00 said "returns FAILED" where the engine
   code itself uses ``StepStatus.FAILED``.

All 11 sites normalised to the qualified ``StepStatus.<name>`` form so
the docs / test docstrings / inline comments match what readers will
actually find in the engine code and the tests. Engine semantics,
validation, and test bodies are unchanged.

161/161 tests/test_workflows.py pass locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 10:10:07 -05:00
Srikanth Patchava
7c558ab241 chore: add .editorconfig for consistent code formatting (#2366)
Signed-off-by: Srikanth Patchava <srpatcha@users.noreply.github.com>
Co-authored-by: Srikanth Patchava <srpatcha@users.noreply.github.com>
2026-06-02 09:46:04 -05:00
Eldar Shlomi
39921ddd3b fix(shared-infra): record skipped files in speckit.manifest.json (#2483)
* fix(shared-infra): record skipped files in speckit.manifest.json

`install_shared_infra` skipped files that already existed on disk
when `force=False`, but the skip branches in both the scripts loop
and the templates loop only appended to `skipped_files` without
calling `manifest.record_existing`. So when the function ran with a
fresh manifest against an already-populated `.specify/` tree (e.g.
after the manifest was deleted, corrupted, or extracted out of band),
every file went down the skip path, `planned_copies` /
`planned_templates` stayed empty, and `manifest.save()` wrote an
empty `files` field — leaving the integration believing nothing was
installed.

Record every skipped file in the manifest, but only when it is not
already tracked. This preserves the original hash for files that
were previously recorded so `check_modified()` (used by
`integration use` to decide whether a user has customized a
template) keeps working correctly.

Add `TestSpeckitManifestRecordsSkippedFiles` in
`tests/integrations/test_integration_claude.py` covering both the
fresh-skip path and the recover-after-lost-manifest path.

Fixes #2107

* fix(shared-infra): guard manifest.record_existing against non-file dst

Address Copilot review feedback on PR #2483. The previous fix called
``manifest.record_existing(rel_skip)`` from the skip branch of both
loops in ``install_shared_infra``, which would crash with
``IsADirectoryError`` (or another ``OSError``) if a directory or other
non-regular-file happened to exist at the expected destination path —
since ``record_existing`` opens the file to compute its SHA-256.

Three coordinated fixes:

1. ``IntegrationManifest.record_existing`` now validates its
   precondition: it raises ``ValueError`` if the path is a symlink or
   is not a regular file. The docstring already promised "an
   already-existing file"; this enforces it. The symlink check runs on
   the un-resolved path because ``_validate_rel_path`` calls
   ``resolve()``, which would silently follow the symlink. Mirrors the
   existing ``_ensure_safe_manifest_destination`` precedent in the
   same module.

2. In ``install_shared_infra``'s scripts and templates skip branches,
   guard the ``record_existing`` call with ``dst.is_file()`` and wrap
   it in ``try/except (OSError, ValueError)``. A directory collision,
   permission error, or TOCTOU race no longer aborts the whole
   install — the user gets a per-path warning, the path still
   surfaces in ``skipped_files``, and the rest of the install
   continues.

3. ``_read_manifest_files`` in the regression test no longer falls
   back to ``data.get("_files")`` (Copilot's low-confidence finding):
   the silent fallback could mask a schema regression where the
   public ``files`` key is renamed. It now asserts ``"files" in data``
   and that the value is a dict.

Add two regression tests in ``TestSpeckitManifestRecordsSkippedFiles``
covering the directory-at-destination edge case for both the scripts
loop and the templates loop. Both verify (a) install does not crash,
(b) the non-file path is not recorded in the manifest, and (c) the
path still surfaces in the user-visible warning.

The "shared infrastructure file(s)" warning text is changed to
"path(s)" so it remains accurate when non-file entries appear in the
list.

Refs #2107

* fix(manifest): lexical pre-check for record_existing + add error-case tests

Address Copilot review (2026-05-11, review id 4266902103):

1. `record_existing` was calling `(self.project_root / rel).is_symlink()`
   BEFORE validating containment. For absolute paths or paths containing
   `..`, this performed a filesystem stat outside the project root before
   `_validate_rel_path()` raised. Add a cheap lexical pre-check that
   delegates to `_validate_rel_path()` for the canonical error messages,
   so the symlink stat only ever runs on paths that are already lexically
   inside the project root.

2. Add focused unit tests in `tests/integrations/test_manifest.py` for
   the symlink and non-regular-file error paths, including:
     - symlink target rejection
     - dangling symlink rejection (caught by the symlink guard before
       the is_file check)
     - directory path rejection (is_file == False)
     - missing-path rejection (is_file == False)
     - absolute-path lexical pre-check
   The Copilot reviewer noted these guards had no focused coverage in
   `test_manifest.py`, only via the `test_integration_claude.py`
   regression test.

3. The third Copilot finding (repeated `dict(self._files)` copies via
   `manifest.files` in the skip branches) is already resolved on this
   branch by using `prior_hashes` — the function-scope snapshot taken at
   the top of `install_shared_infra` — for the membership check, instead
   of `manifest.files`.

AI disclosure: drafted with assistance from Claude (Opus 4.7).

* fix(manifest): track recovered files separately + symlink-ancestor + canonical-path guards

Address Copilot review id 4309888722 (2026-05-18) on PR #2483:

1. Recovery semantics (shared_infra.py:371, 412) — install_shared_infra
   now passes ``recovered=True`` when re-recording a skipped existing
   file. This flag funnels into a new ``recovered_files`` array in the
   manifest JSON, so a future ``refresh_managed`` run can distinguish
   "hash I produced" from "hash I observed on a file that may be a user
   customization" and avoid silent overwrite without ``--refresh-shared-infra``.
   Schema is purely additive: ``files: dict[str, str]`` is unchanged; the
   new ``recovered_files: list[str]`` is omitted when empty.

2. Symlinked ancestor (manifest.py:172) — ``record_existing`` now walks
   every component of the rel path and rejects any symlinked ancestor,
   not just a symlinked leaf. Catches ``linked_dir/file.txt`` where
   ``linked_dir`` is a symlink, which previously slipped past the leaf-only
   ``is_symlink()`` check and was resolved through by ``_validate_rel_path``.
   Mirrors the component-walk pattern in ``_ensure_safe_manifest_directory``.

3. Misleading "escapes project root" message (manifest.py:168) — paths
   like ``dir/../file.txt`` normalize inside the project, so the old
   message lied about what was wrong. New message: "Manifest paths must
   be canonical; '..' segments are not allowed". Still rejects (canonical
   keys are required so ``check_modified``/``uninstall`` cannot key the
   same file under two paths).

Tests: 7 new test methods across TestManifestRecoveredFiles and
TestRecordExistingNewGuards covering all 4 Copilot findings. Full suite
passes locally.

🤖 AI disclosure: drafted with assistance from Claude (Opus 4.7).

* fix(manifest): normalize is_recovered input through _validate_rel_path

Address Copilot review comment id 4309888722 round-5 (2026-05-21) on PR #2483:

``is_recovered()`` previously checked ``self._recovered_files`` membership
with bare ``Path(rel).as_posix()``, while ``record_existing()`` stores keys
via ``_validate_rel_path(rel, root).relative_to(root).as_posix()``. The two
normalizations disagreed on absolute paths and paths that escape the
project root — ``is_recovered`` would silently return False for inputs that
``record_existing`` would have refused entirely.

The fix routes ``is_recovered`` through the same ``_validate_rel_path``
pipeline; ``ValueError`` from the validator is caught and converted to
False so query semantics stay exception-free (Python ``__contains__``
convention).

Tests: 2 new methods in ``TestManifestRecoveredFiles``:
- ``test_is_recovered_absolute_path_returns_false``
- ``test_is_recovered_escaping_path_returns_false``

🤖 AI disclosure: drafted with assistance from Claude (Opus 4.7).

* fix(manifest): clear recovered marker on managed re-record + reject '..' in is_recovered

Address Copilot Round-7 review comments on PR #2483:

1. record_existing(recovered=False) and record_file now BOTH discard the
   path from _recovered_files. The marker is meant to flag "we observed
   this file but cannot vouch it's a managed baseline" — once the same
   path is re-recorded as managed (either explicitly or by writing fresh
   bytes), the marker is stale and must clear so refresh_managed and
   future is_recovered queries return the truthful answer.

2. is_recovered now applies the same canonical-key guard as record_existing
   (rejects absolute paths and '..' segments lexically before delegating
   to _validate_rel_path). Such paths can never be stored keys, so the
   query correctly returns False without depending on _validate_rel_path
   semantics that diverged from record_existing's stricter contract.

record_file docstring updated to mention the side-effect on recovered
markers.

Tests: 3 new methods in TestManifestRecoveredFiles covering
record_existing(false) clearing, record_file clearing, and is_recovered
dotdot rejection.

* test(manifest): update is_recovered comments to reflect Round-7 lexical guard

Round 8 — addresses Copilot review comment on tests/integrations/test_manifest.py:362.

After Round-7 (1dbf0c2), is_recovered() rejects absolute paths and '..' segments
up front via a lexical guard, returning False without calling _validate_rel_path
at all. The test comments still described the prior "_validate_rel_path raises;
we catch" code path, which is misleading for readers.

Updated comments in both:
  - test_is_recovered_absolute_path_returns_false (Copilot's exact target)
  - test_is_recovered_escaping_path_returns_false (same comment-class issue;
    fixed preemptively to avoid a Round-9 finding on the same drift)

Pure documentation change. Test assertions and behavior unchanged; all manifest
tests still green.

* fix(manifest): document OS errors on record_existing + filter orphan recovered_files on load

Round 9 — addresses Copilot review on PR #2483:

1. record_existing's docstring now documents OSError/PermissionError as
   possible raises (in addition to ValueError) — the implementation has
   always been able to raise them from is_symlink, is_file, or the
   file-read used to hash, but the contract did not reflect that.
   Callers should be prepared for both surfaces.

2. load() now filters recovered_files entries that don't correspond to
   keys in files. An externally-edited or partially-corrupted manifest
   can deserialize with orphan recovered paths; rather than reject the
   whole manifest (too strict on the upgrade path), we drop the orphans
   and let the inconsistency self-correct on the next save(). is_recovered
   then returns the truthful False for the orphan.

Tests: new test_load_filters_recovered_files_not_in_files asserting an
orphan recovered entry is dropped on load.
2026-06-02 08:06:31 -05:00
Manfred Riem
d82eed859c chore: release 0.9.1, begin 0.9.2.dev0 development (#2818)
* chore: bump version to 0.9.1

* chore: begin 0.9.2.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-02 07:35:12 -05:00
88 changed files with 10408 additions and 1990 deletions

28
.editorconfig Normal file
View File

@@ -0,0 +1,28 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
indent_style = space
indent_size = 4
[*.{yml,yaml}]
indent_size = 2
[*.{json,jsonc}]
indent_size = 2
[*.md]
indent_size = 2
trim_trailing_whitespace = false
[*.{sh,bash}]
indent_size = 4
[*.{ps1,psm1,psd1}]
indent_size = 4
[Makefile]
indent_style = tab

View File

@@ -147,12 +147,12 @@ class CodexIntegration(SkillsIntegration):
| Field | Location | Purpose |
|---|---|---|
| `key` | Class attribute | Unique identifier; for CLI-based integrations (`requires_cli: True`), must match the CLI executable name |
| `key` | Class attribute | Unique identifier; for most CLI-based integrations this matches the executable name, but see `cli_executable` below for exceptions |
| `config` | Class attribute (dict) | Agent metadata: `name`, `folder`, `commands_subdir`, `install_url`, `requires_cli` |
| `registrar_config` | Class attribute (dict) | Command output config: `dir`, `format`, `args` placeholder, file `extension` |
| `context_file` | Class attribute (str or None) | Path to agent context/instructions file (e.g., `"CLAUDE.md"`, `".github/copilot-instructions.md"`) |
**Key design rule:** For CLI-based integrations (`requires_cli: True`), `key` must be the actual executable name (e.g., `"cursor-agent"` not `"cursor"`). This ensures `shutil.which(key)` works for CLI-tool checks without special-case mappings. IDE-based integrations (`requires_cli: False`) should use their canonical identifier (e.g., `"windsurf"`, `"copilot"`).
**Key design rule:** For CLI-based integrations (`requires_cli: True`), `key` should generally match the CLI executable name so that the default `is_cli_available()` check works without any override. When the executable name differs from the key (e.g., RovoDev's key is `"rovodev"` but the binary is `"acli"`), override the `cli_executable` property or `is_cli_available()` method — see [§6 Optional overrides](#6-optional-overrides) below. IDE-based integrations (`requires_cli: False`) should use their canonical identifier (e.g., `"windsurf"`, `"copilot"`).
### 3. Register it
@@ -222,11 +222,37 @@ The base classes handle most work automatically. Override only when the agent de
| Override | When to use | Example |
|---|---|---|
| `cli_executable` | Binary name differs from `key` | RovoDev: key `"rovodev"`, binary `"acli"` → override returns `"acli"` |
| `is_cli_available()` | Multiple binary names or non-PATH installs | Claude checks `~/.claude/local/`; Kiro accepts both `kiro-cli` and `kiro` |
| `command_filename(template_name)` | Custom file naming or extension | Copilot → `speckit.{name}.agent.md` |
| `options()` | Integration-specific CLI flags via `--integration-options` | Codex → `--skills` flag, Copilot → `--skills` flag |
| `setup()` | Custom install logic (companion files, settings merge) | Copilot → `.agent.md` + `.prompt.md` + `.vscode/settings.json` (default) or `speckit-<name>/SKILL.md` (skills mode) |
| `teardown()` | Custom uninstall logic | Rarely needed; base handles manifest-tracked files |
**`cli_executable` property** — Return the binary name to look up on `PATH` for tool-availability checks. The default implementation returns `self.key`. Override when the executable name differs from the integration key:
```python
@property
def cli_executable(self) -> str:
return "acli" # e.g. RovoDev: key="rovodev", binary="acli"
```
**`is_cli_available()` method** — Return `True` if the integration's CLI tool is installed. The default implementation calls `shutil.which(self.cli_executable)`. Override for more complex detection:
```python
def is_cli_available(self) -> bool:
# Multiple binary names (Kiro):
return shutil.which("kiro-cli") is not None or shutil.which("kiro") is not None
# Non-PATH install locations (Claude):
import specify_cli._utils as _utils_mod
if _utils_mod.CLAUDE_LOCAL_PATH.is_file() or _utils_mod.CLAUDE_NPM_LOCAL_PATH.is_file():
return True
return shutil.which(self.cli_executable) is not None
```
`is_cli_available()` is used by `check_tool()` in `_utils.py` and by both `CommandStep` and `PromptStep` workflow steps to gate CLI dispatch. No hardcoded special cases should be added to those callers — encode detection logic in the integration class instead.
**Example — Copilot (fully custom `setup`):**
Copilot extends `IntegrationBase` directly because it creates `.agent.md` commands, companion `.prompt.md` files, and merges `.vscode/settings.json`. It also supports a `--skills` mode that scaffolds `speckit-<name>/SKILL.md` under `.github/skills/` using composition with an internal `_CopilotSkillsHelper`. See `src/specify_cli/integrations/copilot/__init__.py` for the full implementation.
@@ -423,9 +449,20 @@ When an issue exists, include its number immediately after the prefix — this i
---
## Responding to PR Review Comments
- If you are an agent working on behalf of a human, **disclose your identity in your PR comment** — name the agent (and model, if applicable) and the human you are acting for (e.g., "Posted on behalf of @user by GitHub Copilot (model: &lt;name-if-known&gt;)").
- Post **one** top-level summary comment per review round listing what changed and the commit SHA. Do not reply on every individual comment.
- Reply inline only when context is needed (disagreement, deferral, non-obvious fix). Keep it to a sentence or two.
- **Never click "Resolve conversation"** — that belongs to the reviewer or PR author.
- No emoji, no celebratory framing, no checklist mirroring the reviewer's items, no restating what the reviewer wrote.
- Re-request review once per round (when all feedback is addressed), not after every intermediate push.
---
## Common Pitfalls
1. **Using shorthand keys for CLI-based integrations**: For CLI-based integrations (`requires_cli: True`), the `key` must match the executable name (e.g., `"cursor-agent"` not `"cursor"`). `shutil.which(key)` is used for CLI tool checks — mismatches require special-case mappings. IDE-based integrations (`requires_cli: False`) are not subject to this constraint.
1. **Using shorthand keys for CLI-based integrations**: For CLI-based integrations (`requires_cli: True`), `key` should generally match the executable name. When it cannot (e.g., the binary name differs), override `cli_executable` or `is_cli_available()` on the integration class. Do **not** add special-case mappings to `check_tool()`, `CommandStep`, or `PromptStep`.
2. **Forgetting context configuration**: The bundled `agent-context` extension reads from `.specify/extensions/agent-context/agent-context-config.yml`. New integrations only need to set `context_file` on the class — markers and dispatcher scripts are managed centrally.
3. **Incorrect `requires_cli` value**: Set to `True` only for agents that have a CLI tool; set to `False` for IDE-based agents.
4. **Wrong argument format**: Use `$ARGUMENTS` for Markdown agents, `{{args}}` for TOML agents.

View File

@@ -2,6 +2,48 @@
<!-- insert new changelog below this comment -->
## [0.9.4] - 2026-06-04
### Changed
- feat(workflows): add JSON output for workflow run resume and status (#2814)
- Update workflow-preset community catalog to v1.3.2 (#2841)
- fix: recover active skills registration for extensions (#2803)
- fix(cursor-agent): enable headless CLI dispatch end-to-end (-p --trust --approve-mcps --force + Windows .cmd shim resolution) (#2631)
- Update Superpowers Implementation Bridge extension to v1.0.2 (#2852)
- docs(agents): add PR review response guidance to AGENTS.md (#2850)
- Allow `specify workflow run` to execute YAML files without a project (#2825)
- feat(extensions): add --force flag to extension add for overwrite reinstall (#2530)
- chore: release 0.9.3, begin 0.9.4.dev0 development (#2836)
## [0.9.3] - 2026-06-03
### Changed
- fix: render script command hints with active agent separator (#2649)
- chore(tests): fix ruff lint violations in tests/ (#2827)
- fix(workflows): validate run_id in RunState.load before touching the … (#2813)
- feat(cli): implement specify self upgrade (#2475)
- feat(workflows): allow resume to accept updated workflow inputs (#2815)
- catalog: rename "superpowers-bridge" to "superspec" (v1.0.1) (#2772)
- fix(cli): force UTF-8 stdout/stderr on Windows to prevent UnicodeEncodeError (#2817)
- fix(plan): clarify quickstart validation guide scope (#2805)
- chore: release 0.9.2, begin 0.9.3.dev0 development (#2823)
## [0.9.2] - 2026-06-02
### Changed
- Update agent parity governance preset catalog entry (#2777)
- fix: resolve GitHub release asset API URL for private repo extension downloads (#2792)
- fix: remove unsupported mode: frontmatter from Copilot skills mode (fixes #2799) (#2819)
- refactor(integrations): co-locate integration commands in integrations/ domain dir (PR-5/8) (#2720)
- Update Product Forge extension to v1.6.0 (#2820)
- feat(workflows): add continue_on_error step field for non-halting failures (#2663)
- chore: add .editorconfig for consistent code formatting (#2366)
- fix(shared-infra): record skipped files in speckit.manifest.json (#2483)
- chore: release 0.9.1, begin 0.9.2.dev0 development (#2818)
## [0.9.1] - 2026-06-02
### Changed

View File

@@ -59,6 +59,24 @@ specify init my-project --integration copilot
cd my-project
```
To check for updates or upgrade the installed CLI, use the self-management commands. See the [Upgrade Guide](./docs/upgrade.md) for detailed scenarios and customization options.
```bash
# Check whether a newer release is available (read-only — does not modify anything)
specify self check
# Preview what would run, without actually upgrading
specify self upgrade --dry-run
# Upgrade in place to the latest stable release (auto-detects uv tool vs pipx install)
specify self upgrade
# Or pin a specific release tag (replace vX.Y.Z[suffix] with your desired release tag)
specify self upgrade --tag vX.Y.Z[suffix]
```
Bare `specify self upgrade` executes immediately, matching the no-prompt behavior of commands like `pip install -U` and `npm update`. For `uv tool` installs, it runs `uv tool install specify-cli --force --from <git ref>` under the hood so pinned release tags work, including dev, alpha/beta/rc, or build metadata suffixes. `uvx` (ephemeral) runs and source checkouts are detected and produce path-specific guidance instead of running an installer. Set `SPECIFY_UPGRADE_TIMEOUT_SECS` to cap how long the installer subprocess may run (default: no timeout — interrupt with `Ctrl+C` if needed).
### 3. Establish project principles
Launch your coding agent in the project directory. Most agents expose spec-kit as `/speckit.*` slash commands; Codex CLI in skills mode uses `$speckit-*` instead.
@@ -133,7 +151,7 @@ Run `specify integration list` to see all available integrations in your install
After running `specify init`, your AI coding agent will have access to these slash commands for structured development. For integrations that support skills mode, passing `--integration <agent> --integration-options="--skills"` installs agent skills instead of slash-command prompt files.
#### Core Commands
### Core Commands
Essential commands for the Spec-Driven Development workflow:
@@ -146,7 +164,7 @@ Essential commands for the Spec-Driven Development workflow:
| `/speckit.taskstoissues` | `speckit-taskstoissues`| Convert generated task lists into GitHub issues for tracking and execution |
| `/speckit.implement` | `speckit-implement` | Execute all tasks to build the feature according to the plan |
#### Optional Commands
### Optional Commands
Additional commands for enhanced quality and validation:

View File

@@ -79,7 +79,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) |
| PR Bridge | Auto-generate pull request descriptions, checklists, and summaries from spec artifacts | `process` | Read-only | [spec-kit-pr-bridge-](https://github.com/Quratulain-bilal/spec-kit-pr-bridge-) |
| Presetify | Create and validate presets and preset catalogs | `process` | Read+Write | [presetify](https://github.com/mnriem/spec-kit-extensions/tree/main/presetify) |
| Product Forge | Full product lifecycle from research to release — portfolio, lite mode, monorepo, optional V-Model | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) |
| Product Forge | Full product lifecycle from research to release — express/lite/standard/v-model tracks, living spec + traceability, structured journeys → E2E, monorepo, and selectable doc-structure strategies | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) |
| Product Spec Extension | Generates PRFAQ, Lean PRD, stakeholder summaries, and technical designs from engineering specs | `docs` | Read+Write | [spec-kit-product](https://github.com/d0whc3r/spec-kit-product) |
| Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) |
| Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | `visibility` | Read-only | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) |
@@ -114,8 +114,8 @@ The following community-contributed extensions are available in [`catalog.commun
| Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) |
| Status Report | Project status, feature progress, and next-action recommendations for spec-driven workflows | `visibility` | Read-only | [Open-Agent-Tools/spec-kit-status](https://github.com/Open-Agent-Tools/spec-kit-status) |
| Superpowers Bridge | Orchestrates obra/superpowers skills within the spec-kit SDD workflow across the full lifecycle (clarification, TDD, review, verification, critique, debugging, branch completion) | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) |
| Superpowers Bridge (WangX0111) | Bridges spec-kit with obra/superpowers (brainstorming, TDD, subagent, code-review) into a unified, resumable workflow with graceful degradation and session progress tracking | `process` | Read+Write | [superspec](https://github.com/WangX0111/superspec) |
| Superpowers Implementation Bridge | Thin orchestrator between Spec Kit (design) and Superpowers (implementation). Cross-agent. | `process` | Read+Write | [speckit-superpowers-bridge](https://github.com/lihan3238/speckit-superpowers-bridge) |
| Superspec | Bridges spec-kit with obra/superpowers (brainstorming, TDD, subagent, code-review) into a unified, resumable workflow with graceful degradation and session progress tracking | `process` | Read+Write | [superspec](https://github.com/WangX0111/superspec) |
| Team Assign | Assign tasks.md items to human engineers, split into subtasks, and generate a per-engineer workboard | `process` | Read+Write | [spec-kit-team-assign](https://github.com/tarunkumarbhati/spec-kit-team-assign) |
| Time Machine | Retroactively apply the full SDD workflow to existing codebases — analyse, spec, and ship feature-by-feature | `process` | Read+Write | [spec-kit-time-machine](https://github.com/teeyo/spec-kit-time-machine) |
| TinySpec | Lightweight single-file workflow for small tasks — skip the heavy multi-step SDD process | `process` | Read+Write | [spec-kit-tinyspec](https://github.com/Quratulain-bilal/spec-kit-tinyspec) |

View File

@@ -8,7 +8,7 @@ The following community-contributed presets customize how Spec Kit behaves — o
| Preset | Purpose | Provides | Requires | URL |
|--------|---------|----------|----------|-----|
| A11Y Governance | Adds WCAG 2.2 AA accessibility checks, bilingual DE/EN delivery, CEFR-B2 readability, CLI accessibility, and inclusive-content guidance | 9 templates, 3 commands | — | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-governance) |
| Agent Parity Governance | Keeps shared AI-agent instructions aligned across project-defined agent guidance surfaces and documents intentional deviations | 6 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
| Agent Parity Governance | Keeps shared AI-agent instructions aligned and adds agent-neutral Spec Kit model-routing guidance across project-defined agent guidance surfaces | 9 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
| Architecture Governance | Adds secure architecture governance: trust boundaries, threat modeling, STRIDE/CAPEC, S-ADRs, Zero Trust applicability, and OWASP SAMM | 11 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) |
| Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) |

View File

@@ -88,6 +88,8 @@ specify version
This helps verify you are running the official Spec Kit build from GitHub, not an unrelated package with the same name.
**Stay current:** Run `specify self check` periodically to learn whether a newer release is available — it is read-only and never modifies your installation. When you are ready to upgrade, follow the [Upgrade Guide](./upgrade.md).
After initialization, you should see the following commands available in your coding agent:
- `/speckit.specify` - Create specifications

View File

@@ -33,6 +33,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [Qoder CLI](https://qoder.com/cli) | `qodercli` | |
| [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | |
| [Roo Code](https://roocode.com/) | `roo` | |
| [RovoDev](https://www.atlassian.com/software/rovo-dev) | `rovodev` | Generates `.rovodev/skills/`, prompt wrappers, and `prompts.yml`; runtime dispatch uses `acli rovodev` |
| [SHAI (OVHcloud)](https://github.com/ovh/shai) | `shai` | |
| [Tabnine CLI](https://docs.tabnine.com/main/getting-started/tabnine-cli) | `tabnine` | |
| [Trae](https://www.trae.ai/) | `trae` | Skills-based integration; skills are installed automatically |

View File

@@ -11,6 +11,7 @@ specify workflow run <source>
| Option | Description |
| ------------------- | -------------------------------------------------------- |
| `-i` / `--input` | Pass input values as `key=value` (repeatable) |
| `--json` | Emit the run outcome as a single JSON object |
Runs a workflow from a catalog ID, URL, or local file path. Inputs declared by the workflow can be provided via `--input` or will be prompted interactively.
@@ -20,7 +21,25 @@ Example:
specify workflow run speckit -i spec="Build a kanban board with drag-and-drop task management" -i scope=full
```
> **Note:** All workflow commands require a project already initialized with `specify init`.
With `--json`, a single machine-readable object is printed instead of formatted text (the default output is unchanged when the flag is omitted):
```bash
specify workflow run my-pipeline.yml --json
```
```json
{
"run_id": "662bf791",
"workflow_id": "build-and-review",
"status": "paused",
"current_step_id": "review",
"current_step_index": 0
}
```
`workflow_id` is the `workflow.id` declared inside the YAML, not the file name. The object is printed exactly as shown — pretty-printed with two-space indentation, on plain stdout with no Rich markup — so it always parses. While the workflow runs under `--json`, any progress a step would print (for example a gate prompt, or output from a prompt step's CLI subprocess) is redirected to stderr, so stdout carries only the JSON object. Read the object from stdout; leave stderr attached to the terminal or capture it separately.
> **Note:** Most workflow commands require a project already initialized with `specify init`. The exception is `specify workflow run <local-file.{yml,yaml}>`, which can run outside a project; in that case, run state is stored under the current directory's `.specify/workflows/runs/<run_id>/`.
## Resume a Workflow
@@ -28,14 +47,29 @@ specify workflow run speckit -i spec="Build a kanban board with drag-and-drop ta
specify workflow resume <run_id>
```
| Option | Description |
| ------------------- | -------------------------------------------------------- |
| `-i` / `--input` | Updated input values as `key=value` (repeatable) |
| `--json` | Emit the resume outcome as a single JSON object |
Resumes a paused or failed workflow run from the exact step where it stopped. Useful after responding to a gate step or fixing an issue that caused a failure.
Supplied `--input` values are merged over the run's stored inputs and re-validated against the workflow's input types, then the blocked step is re-run with the updated values. This lets a run continue with information that only became available after it paused, or with a corrected value after a failure:
```bash
specify workflow resume <run_id> --input cmd="exit 0"
```
## Workflow Status
```bash
specify workflow status [<run_id>]
```
| Option | Description |
| ------------------- | -------------------------------------------------------- |
| `--json` | Emit run status (or the runs list) as a JSON object |
Shows the status of a specific run, or lists all runs if no ID is given. Run states: `created`, `running`, `completed`, `paused`, `failed`, `aborted`.
## List Installed Workflows

View File

@@ -8,8 +8,10 @@
| What to Upgrade | Command | When to Use |
|----------------|---------|-------------|
| **CLI Tool Only** | `uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z` | Get latest CLI features without touching project files |
| **CLI Tool Only (pipx)** | `pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z` | Reinstall/upgrade a pipx-installed CLI to a specific release |
| **CLI Tool (recommended)** | `specify self upgrade` | Latest stable release, in place. Auto-detects whether you installed via `uv tool` or `pipx`. |
| **CLI Tool — pin a version** | `specify self upgrade --tag vX.Y.Z[suffix]` | Upgrade to a specific release tag instead of the latest stable. Suffixes are limited to dev, alpha/beta/rc, and/or build metadata forms. |
| **CLI Tool — manual fallback** | `uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z` | When `specify self upgrade` isn't available (older installs) or when you want explicit control. |
| **CLI Tool — manual fallback (pipx)** | `pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z` | Same as above, for pipx installs. |
| **Project Files** | `specify init --here --force --integration <your-agent>` | Update slash commands, templates, and scripts in your project |
| **Both** | Run CLI upgrade, then project update | Recommended for major version updates |
@@ -19,12 +21,32 @@
The CLI tool (`specify`) is separate from your project files. Upgrade it to get the latest features and bug fixes.
Before upgrading, you can check whether a newer released version is available:
### Recommended: `specify self upgrade`
The CLI ships with two self-management commands that handle the common case automatically:
```bash
# Check whether a newer release is available (read-only — does not modify anything)
specify self check
# Preview what would run, without actually upgrading
specify self upgrade --dry-run
# Upgrade in place to the latest stable release (auto-detects uv tool vs pipx install)
specify self upgrade
# Or pin a specific release tag (replace vX.Y.Z[suffix] with the tag you want)
specify self upgrade --tag vX.Y.Z[suffix]
```
Bare `specify self upgrade` executes immediately, matching the no-prompt behavior of commands like `pip install -U` and `npm update`. The CLI classifies your runtime into one of: `uv tool`, `pipx`, `uvx (ephemeral)`, source checkout, or unsupported. Only `uv tool` and `pipx` are upgraded automatically; for `uv tool` installs, it runs `uv tool install specify-cli --force --from <git ref>` under the hood so pinned release tags work. The other paths print path-specific guidance and exit 0 without touching anything.
Pinned tags must start with `vMAJOR.MINOR.PATCH`. Optional suffixes are limited to dev, alpha/beta/rc, and/or build metadata forms such as `v1.0.0-rc1`, `v0.8.0.dev0`, `v0.8.0+build.42`, or the combination `v1.0.0-rc1+build.42`; branch names, hash refs, `latest`, and bare versions without `v` are rejected.
Set `SPECIFY_UPGRADE_TIMEOUT_SECS` to cap how long the installer subprocess may run (default: no timeout — interrupt with `Ctrl+C` if needed). If that internal timeout fires, `specify self upgrade` exits 124 and reports that it timed out while waiting for the installer subprocess, including the configured timeout and manual retry command. A real installer exit code 124 is propagated with `Upgrade failed. Installer exit code: 124.`, so scripts should treat exit 124 as ambiguous and inspect the message when they need to distinguish the two cases.
If your installed CLI is older than the release that introduced `specify self upgrade`, use the manual equivalents below. These commands are also useful when you want explicit control over the installer command.
### If you installed with `uv tool install`
Upgrade to a specific release (check [Releases](https://github.com/github/spec-kit/releases) for the latest tag):
@@ -54,10 +76,14 @@ pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z
### Verify the upgrade
```bash
# Confirms the CLI is working and shows installed tools
specify check
# Confirms the installed version against the latest GitHub release
specify self check
```
This shows installed tools and confirms the CLI is working. Use `specify version` to confirm which persistent CLI version is currently on your `PATH`.
`specify check` shows the surrounding tool environment; `specify self check` is read-only and tells you whether you're now on the latest release (`Up to date: X.Y.Z`) or if a newer one became available between releases.
---
@@ -186,8 +212,8 @@ Restart your IDE to refresh the command list.
### Scenario 1: "I just want new slash commands"
```bash
# Upgrade CLI (if using persistent install)
uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git
# Upgrade CLI (auto-detects uv tool vs pipx install)
specify self upgrade
# Update project files to get new commands
specify init --here --force --integration copilot
@@ -204,7 +230,7 @@ cp .specify/memory/constitution.md /tmp/constitution-backup.md
cp -r .specify/templates /tmp/templates-backup
# 2. Upgrade CLI
uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git
specify self upgrade
# 3. Update project
specify init --here --force --integration copilot
@@ -388,15 +414,19 @@ Only Spec Kit infrastructure files:
### "CLI upgrade doesn't seem to work"
If a command behaves like an older Spec Kit version, first check for local CLI drift:
If a command behaves like an older Spec Kit version, first ask the CLI itself:
```bash
# Read-only — prints "Up to date: X.Y.Z" or "Update available: X.Y.Z → vY.Z.W"
specify self check
# Preview the install method, current version, and target tag the upgrade would use
specify self upgrade --dry-run
```
`specify check` is an offline environment scan; `specify self check` is the CLI version lookup.
Verify the installation:
If `self check` shows the wrong version, verify the installation:
```bash
# Check installed tools

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-06-01T00:00:00Z",
"updated_at": "2026-06-04T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"aide": {
@@ -2055,10 +2055,10 @@
"product-forge": {
"name": "Product Forge",
"id": "product-forge",
"description": "Full product lifecycle from research to release — portfolio, lite mode, monorepo, optional V-Model",
"description": "Full product lifecycle from research to release — express/lite/standard/v-model tracks, living spec + traceability, structured journeys → E2E, monorepo, and selectable doc-structure strategies",
"author": "VaiYav",
"version": "1.5.1",
"download_url": "https://github.com/VaiYav/speckit-product-forge/archive/refs/tags/v1.5.1.zip",
"version": "1.6.0",
"download_url": "https://github.com/VaiYav/speckit-product-forge/archive/refs/tags/v1.6.0.zip",
"repository": "https://github.com/VaiYav/speckit-product-forge",
"homepage": "https://github.com/VaiYav/speckit-product-forge",
"documentation": "https://github.com/VaiYav/speckit-product-forge/blob/main/README.md",
@@ -2068,7 +2068,7 @@
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 29,
"commands": 31,
"hooks": 0
},
"tags": [
@@ -2082,7 +2082,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-03-28T00:00:00Z",
"updated_at": "2026-04-24T15:52:00Z"
"updated_at": "2026-06-02T00:00:00Z"
},
"qa": {
"name": "QA Testing Extension",
@@ -2756,8 +2756,8 @@
"id": "speckit-superpowers-bridge",
"description": "Thin orchestrator between Spec Kit (design) and Superpowers (implementation). Cross-agent.",
"author": "lihan3238",
"version": "0.7.0",
"download_url": "https://github.com/lihan3238/speckit-superpowers-bridge/releases/download/v0.7.0/speckit-superpowers-bridge-v0.7.0.zip",
"version": "1.0.2",
"download_url": "https://github.com/lihan3238/speckit-superpowers-bridge/releases/download/v1.0.2/speckit-superpowers-bridge-v1.0.2.zip",
"repository": "https://github.com/lihan3238/speckit-superpowers-bridge",
"homepage": "https://github.com/lihan3238/speckit-superpowers-bridge",
"documentation": "https://github.com/lihan3238/speckit-superpowers-bridge#readme",
@@ -2798,7 +2798,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-05-15T00:00:00Z",
"updated_at": "2026-05-28T00:00:00Z"
"updated_at": "2026-06-04T00:00:00Z"
},
"speckit-utils": {
"name": "SDD Utilities",
@@ -3039,13 +3039,13 @@
"created_at": "2026-03-30T00:00:00Z",
"updated_at": "2026-05-24T01:07:34Z"
},
"superpowers-bridge": {
"name": "Superpowers Bridge",
"id": "superpowers-bridge",
"superspec": {
"name": "Superspec",
"id": "superspec",
"description": "Bridges spec-kit workflows with obra/superpowers capabilities for brainstorming, TDD, code review, and resumable execution.",
"author": "WangX0111",
"version": "1.0.0",
"download_url": "https://github.com/WangX0111/superspec/archive/refs/tags/v1.0.0.zip",
"version": "1.0.1",
"download_url": "https://github.com/WangX0111/superspec/archive/refs/tags/v1.0.1.zip",
"repository": "https://github.com/WangX0111/superspec",
"homepage": "https://github.com/WangX0111/superspec",
"documentation": "https://github.com/WangX0111/superspec/blob/main/README.md",
@@ -3070,7 +3070,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-04-22T00:00:00Z",
"updated_at": "2026-04-22T00:00:00Z"
"updated_at": "2026-05-30T00:00:00Z"
},
"sync": {
"name": "Spec Sync",
@@ -3607,4 +3607,4 @@
"updated_at": "2026-04-13T00:00:00Z"
}
}
}
}

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-05-13T00:00:00Z",
"updated_at": "2026-06-02T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json",
"integrations": {
"claude": {
@@ -174,6 +174,15 @@
"repository": "https://github.com/github/spec-kit",
"tags": ["ide"]
},
"rovodev": {
"id": "rovodev",
"name": "RovoDev ACLI",
"version": "1.0.0",
"description": "Atlassian RovoDev integration",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["cli", "atlassian"]
},
"bob": {
"id": "bob",
"name": "IBM Bob",

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-05-28T00:00:00Z",
"updated_at": "2026-06-03T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
"presets": {
"a11y-governance": {
@@ -34,11 +34,11 @@
"agent-parity-governance": {
"name": "Agent Parity Governance",
"id": "agent-parity-governance",
"version": "0.1.0",
"description": "Keeps shared AI-agent guidance aligned across a project-defined set of agent instruction surfaces.",
"version": "0.2.0",
"description": "Keeps shared AI-agent guidance aligned and adds agent-neutral Spec Kit model-routing guidance across declared agent instruction surfaces.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/archive/refs/tags/v0.1.0.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/archive/refs/tags/v0.2.0.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/blob/main/README.md",
"license": "MIT",
@@ -46,18 +46,20 @@
"speckit_version": ">=0.8.0"
},
"provides": {
"templates": 6,
"templates": 9,
"commands": 3
},
"tags": [
"agents",
"governance",
"parity",
"agent-md",
"agent-guidance",
"model-routing",
"multi-agent"
],
"created_at": "2026-04-27T00:00:00Z",
"updated_at": "2026-04-27T00:00:00Z"
"updated_at": "2026-05-31T00:00:00Z"
},
"aide-in-place": {
"name": "AIDE In-Place Migration",
@@ -540,7 +542,7 @@
],
"created_at": "2026-04-30T00:00:00Z",
"updated_at": "2026-04-30T00:00:00Z"
},
},
"toc-navigation": {
"name": "Table of Contents Navigation",
"id": "toc-navigation",
@@ -593,11 +595,11 @@
"workflow-preset": {
"name": "Workflow Preset",
"id": "workflow-preset",
"version": "1.3.1",
"version": "1.3.2",
"description": "Behavior-first specification, design artifacts, and agent-native handoff orchestration.",
"author": "bigsmartben",
"repository": "https://github.com/bigsmartben/spec-kit-workflow-preset",
"download_url": "https://github.com/bigsmartben/spec-kit-workflow-preset/releases/download/v1.3.1/spec-kit-workflow-preset-v1.3.1.zip",
"download_url": "https://github.com/bigsmartben/spec-kit-workflow-preset/releases/download/v1.3.2/spec-kit-workflow-preset-v1.3.2.zip",
"homepage": "https://github.com/bigsmartben/spec-kit-workflow-preset",
"documentation": "https://github.com/bigsmartben/spec-kit-workflow-preset/blob/main/README.md",
"license": "MIT",
@@ -616,7 +618,7 @@
"handoff"
],
"created_at": "2026-05-27T00:00:00Z",
"updated_at": "2026-05-28T00:00:00Z"
"updated_at": "2026-06-03T00:00:00Z"
}
}
}

View File

@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.9.1"
version = "0.9.5.dev0"
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
requires-python = ">=3.11"
dependencies = [

View File

@@ -117,20 +117,20 @@ check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
# Validate required directories and files
if [[ ! -d "$FEATURE_DIR" ]]; then
echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2
echo "Run __SPECKIT_COMMAND_SPECIFY__ first to create the feature structure." >&2
echo "Run $(format_speckit_command specify "$REPO_ROOT") first to create the feature structure." >&2
exit 1
fi
if [[ ! -f "$IMPL_PLAN" ]]; then
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
echo "Run __SPECKIT_COMMAND_PLAN__ first to create the implementation plan." >&2
echo "Run $(format_speckit_command plan "$REPO_ROOT") first to create the implementation plan." >&2
exit 1
fi
# Check for tasks.md if required
if $REQUIRE_TASKS && [[ ! -f "$TASKS" ]]; then
echo "ERROR: tasks.md not found in $FEATURE_DIR" >&2
echo "Run __SPECKIT_COMMAND_TASKS__ first to create the task list." >&2
echo "Run $(format_speckit_command tasks "$REPO_ROOT") first to create the task list." >&2
exit 1
fi

View File

@@ -307,6 +307,83 @@ has_jq() {
command -v jq >/dev/null 2>&1
}
get_invoke_separator() {
local repo_root="${1:-$(get_repo_root)}"
if [[ "${_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT:-}" == "$repo_root" && -n "${_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE:-}" ]]; then
printf '%s\n' "$_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE"
return 0
fi
local integration_json="$repo_root/.specify/integration.json"
local separator="."
local parsed_with_jq=0
if [[ -f "$integration_json" ]]; then
if command -v jq >/dev/null 2>&1; then
local jq_separator
if jq_separator=$(jq -r '(.default_integration // .integration // "") as $k | if $k == "" then "." else (.integration_settings[$k].invoke_separator // ".") end' "$integration_json" 2>/dev/null); then
parsed_with_jq=1
case "$jq_separator" in
"."|"-") separator="$jq_separator" ;;
esac
fi
fi
if [[ "$parsed_with_jq" -eq 0 ]] && command -v python3 >/dev/null 2>&1; then
if separator=$(python3 - "$integration_json" <<'PY' 2>/dev/null
import json
import sys
try:
with open(sys.argv[1], encoding="utf-8") as fh:
state = json.load(fh)
key = state.get("default_integration") or state.get("integration") or ""
settings = state.get("integration_settings")
separator = "."
if isinstance(key, str) and isinstance(settings, dict):
entry = settings.get(key)
if isinstance(entry, dict) and entry.get("invoke_separator") in {".", "-"}:
separator = entry["invoke_separator"]
print(separator)
except Exception:
print(".")
PY
); then
case "$separator" in
"."|"-") ;;
*) separator="." ;;
esac
else
separator="."
fi
fi
fi
_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root"
_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE="$separator"
printf '%s\n' "$separator"
}
format_speckit_command() {
local command_name="$1"
local repo_root="${2:-$(get_repo_root)}"
local separator
if [[ "${_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT:-}" == "$repo_root" && -n "${_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE:-}" ]]; then
separator="$_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE"
else
separator=$(get_invoke_separator "$repo_root")
_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root"
_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE="$separator"
fi
command_name="${command_name#/}"
command_name="${command_name#speckit.}"
command_name="${command_name#speckit-}"
command_name="${command_name//./$separator}"
printf '/speckit%s%s\n' "$separator" "$command_name"
}
# Escape a string for safe embedding in a JSON value (fallback when jq is unavailable).
# Handles backslash, double-quote, and JSON-required control character escapes (RFC 8259).
json_escape() {

View File

@@ -35,13 +35,13 @@ fi
if [[ ! -f "$IMPL_PLAN" ]]; then
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
echo "Run __SPECKIT_COMMAND_PLAN__ first to create the implementation plan." >&2
echo "Run $(format_speckit_command plan "$REPO_ROOT") first to create the implementation plan." >&2
exit 1
fi
if [[ ! -f "$FEATURE_SPEC" ]]; then
echo "ERROR: spec.md not found in $FEATURE_DIR" >&2
echo "Run __SPECKIT_COMMAND_SPECIFY__ first to create the feature structure." >&2
echo "Run $(format_speckit_command specify "$REPO_ROOT") first to create the feature structure." >&2
exit 1
fi

View File

@@ -89,20 +89,23 @@ if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit:$paths.HAS_GI
# Validate required directories and files
if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) {
Write-Output "ERROR: Feature directory not found: $($paths.FEATURE_DIR)"
Write-Output "Run __SPECKIT_COMMAND_SPECIFY__ first to create the feature structure."
$specifyCommand = Format-SpecKitCommand -CommandName 'specify' -RepoRoot $paths.REPO_ROOT
Write-Output "Run $specifyCommand first to create the feature structure."
exit 1
}
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
Write-Output "ERROR: plan.md not found in $($paths.FEATURE_DIR)"
Write-Output "Run __SPECKIT_COMMAND_PLAN__ first to create the implementation plan."
$planCommand = Format-SpecKitCommand -CommandName 'plan' -RepoRoot $paths.REPO_ROOT
Write-Output "Run $planCommand first to create the implementation plan."
exit 1
}
# Check for tasks.md if required
if ($RequireTasks -and -not (Test-Path $paths.TASKS -PathType Leaf)) {
Write-Output "ERROR: tasks.md not found in $($paths.FEATURE_DIR)"
Write-Output "Run __SPECKIT_COMMAND_TASKS__ first to create the task list."
$tasksCommand = Format-SpecKitCommand -CommandName 'tasks' -RepoRoot $paths.REPO_ROOT
Write-Output "Run $tasksCommand first to create the task list."
exit 1
}

View File

@@ -355,6 +355,58 @@ function Test-DirHasFiles {
}
}
function Get-InvokeSeparator {
param([string]$RepoRoot = (Get-RepoRoot))
if ($null -eq $script:SpecKitInvokeSeparatorCache) {
$script:SpecKitInvokeSeparatorCache = @{}
}
if ($script:SpecKitInvokeSeparatorCache.ContainsKey($RepoRoot)) {
return $script:SpecKitInvokeSeparatorCache[$RepoRoot]
}
$separator = '.'
$integrationJson = Join-Path $RepoRoot '.specify/integration.json'
if (Test-Path -LiteralPath $integrationJson -PathType Leaf) {
try {
$state = Get-Content -LiteralPath $integrationJson -Raw | ConvertFrom-Json
$key = if ($state.default_integration) { [string]$state.default_integration } elseif ($state.integration) { [string]$state.integration } else { '' }
if ($key -and $state.integration_settings) {
$settingProperty = $state.integration_settings.PSObject.Properties[$key]
if ($settingProperty) {
$setting = $settingProperty.Value
if ($setting -and ($setting.invoke_separator -eq '.' -or $setting.invoke_separator -eq '-')) {
$separator = [string]$setting.invoke_separator
}
}
}
} catch {
$separator = '.'
}
}
$script:SpecKitInvokeSeparatorCache[$RepoRoot] = $separator
return $separator
}
function Format-SpecKitCommand {
param(
[Parameter(Mandatory = $true)][string]$CommandName,
[string]$RepoRoot = (Get-RepoRoot)
)
$separator = Get-InvokeSeparator -RepoRoot $RepoRoot
$name = $CommandName.TrimStart('/')
if ($name.StartsWith('speckit.')) {
$name = $name.Substring(8)
} elseif ($name.StartsWith('speckit-')) {
$name = $name.Substring(8)
}
$name = $name -replace '\.', $separator
return "/speckit$separator$name"
}
# Find a usable Python 3 executable (python3, python, or py -3).
# Returns the command/arguments as an array, or $null if none found.
function Get-Python3Command {

View File

@@ -28,13 +28,15 @@ if (-not (Test-FeatureJsonMatchesFeatureDir -RepoRoot $paths.REPO_ROOT -ActiveFe
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
[Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)")
[Console]::Error.WriteLine("Run __SPECKIT_COMMAND_PLAN__ first to create the implementation plan.")
$planCommand = Format-SpecKitCommand -CommandName 'plan' -RepoRoot $paths.REPO_ROOT
[Console]::Error.WriteLine("Run $planCommand first to create the implementation plan.")
exit 1
}
if (-not (Test-Path $paths.FEATURE_SPEC -PathType Leaf)) {
[Console]::Error.WriteLine("ERROR: spec.md not found in $($paths.FEATURE_DIR)")
[Console]::Error.WriteLine("Run __SPECKIT_COMMAND_SPECIFY__ first to create the feature structure.")
$specifyCommand = Format-SpecKitCommand -CommandName 'specify' -RepoRoot $paths.REPO_ROOT
[Console]::Error.WriteLine("Run $specifyCommand first to create the feature structure.")
exit 1
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
"""Helpers for interpreting persisted init options."""
import json
from collections.abc import Mapping
from pathlib import Path
from typing import Any
INIT_OPTIONS_FILE = ".specify/init-options.json"
def save_init_options(project_path: Path, options: dict[str, Any]) -> None:
"""Persist the CLI options used during ``specify init``."""
dest = project_path / INIT_OPTIONS_FILE
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(
json.dumps(options, indent=2, sort_keys=True, ensure_ascii=False),
encoding="utf-8",
)
def load_init_options(project_path: Path) -> dict[str, Any]:
"""Load persisted init options, returning an empty dict when unavailable."""
path = project_path / INIT_OPTIONS_FILE
if not path.exists():
return {}
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError, UnicodeError):
return {}
return payload if isinstance(payload, dict) else {}
def is_ai_skills_enabled(opts: Mapping[str, Any] | None) -> bool:
"""Return True only when init options explicitly enable AI skills."""
return isinstance(opts, Mapping) and opts.get("ai_skills") is True

View File

@@ -38,32 +38,44 @@ def run_command(cmd: list[str], check_return: bool = True, capture: bool = False
def check_tool(tool: str, tracker=None) -> bool:
"""Check if a tool is installed. Optionally update tracker.
For tools that correspond to a registered integration the check is
delegated to ``IntegrationBase.is_cli_available()`` so that each
integration can encode its own detection logic (e.g. multiple
binary names, non-PATH install locations). Unknown tools fall back
to a plain ``shutil.which`` look-up.
Args:
tool: Name of the tool to check
tool: Name of the tool to check (typically an integration key)
tracker: StepTracker | None to update with results
Returns:
True if tool is found, False otherwise
"""
# Special handling for Claude CLI local installs
# See: https://github.com/github/spec-kit/issues/123
# See: https://github.com/github/spec-kit/issues/550
# Claude Code can be installed in two local paths:
# 1. ~/.claude/local/claude (after `claude migrate-installer`)
# 2. ~/.claude/local/node_modules/.bin/claude (npm-local install, e.g. via nvm)
# Neither path may be on the system PATH, so we check them explicitly.
if tool == "claude":
if CLAUDE_LOCAL_PATH.is_file() or CLAUDE_NPM_LOCAL_PATH.is_file():
if tracker:
tracker.complete(tool, "available")
return True
found: bool
if tool == "kiro-cli":
# Kiro currently supports both executable names. Prefer kiro-cli and
# accept kiro as a compatibility fallback.
found = shutil.which("kiro-cli") is not None or shutil.which("kiro") is not None
else:
found = shutil.which(tool) is not None
# Delegate to the integration's is_cli_available() when the tool
# key matches a registered integration. This removes the need for
# hard-coded special cases here (e.g. Claude local paths, kiro dual
# binaries, rovodev/acli mismatch). See issue #2597.
try:
from specify_cli.integrations import get_integration
impl = get_integration(tool)
if impl is not None:
found = impl.is_cli_available()
if tracker:
if found:
tracker.complete(tool, "available")
else:
tracker.error(tool, "not found")
return found
except ImportError as exc:
# Integrations module is unavailable in this environment; fall back
# to PATH-based detection below for non-integration tools.
_ = exc
# Fallback for non-integration tools (e.g. "git").
found = shutil.which(tool) is not None
if tracker:
if found:

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,8 @@ from typing import Any, Dict, List, Optional
import yaml
from ._init_options import is_ai_skills_enabled, load_init_options
def _build_agent_configs() -> dict[str, Any]:
"""Derive CommandRegistrar.AGENT_CONFIGS from INTEGRATION_REGISTRY."""
@@ -359,11 +361,6 @@ class CommandRegistrar:
agent_name: str, frontmatter: dict, body: str, project_root: Path
) -> str:
"""Resolve script placeholders for skills-backed agents."""
try:
from . import load_init_options
except ImportError:
return body
if not isinstance(frontmatter, dict):
frontmatter = {}
@@ -474,6 +471,29 @@ class CommandRegistrar:
return False
return os.path.normpath(name) == name
@staticmethod
def _same_lexical_path(left: Path, right: Path) -> bool:
"""Compare paths after lexical normalization without resolving symlinks."""
return os.path.normcase(os.path.normpath(os.fspath(left))) == os.path.normcase(
os.path.normpath(os.fspath(right))
)
@staticmethod
def _active_skills_agent(project_root: Path) -> Optional[str]:
"""Return the initialized skills-backed agent, if skills mode is active."""
opts = load_init_options(project_root)
if not isinstance(opts, dict):
return None
agent = opts.get("ai")
if not isinstance(agent, str) or not agent:
return None
# Kimi is a native skills integration; when ai_skills is not boolean
# True, Kimi still uses its existing SKILL.md layout.
if not is_ai_skills_enabled(opts) and agent != "kimi":
return None
return agent
def register_commands(
self,
agent_name: str,
@@ -806,6 +826,7 @@ class CommandRegistrar:
project_root: Path,
context_note: str = None,
link_outputs: bool = False,
create_missing_active_skills_dir: bool = False,
) -> Dict[str, List[str]]:
"""Register commands for all detected agents in the project.
@@ -817,6 +838,11 @@ class CommandRegistrar:
context_note: Custom context comment for markdown output
link_outputs: If True, create dev-mode symlinks for rendered
command files when supported by the OS.
create_missing_active_skills_dir: If True, attempt missing-dir
recovery only for the active initialized skills-backed agent.
Recovery requires active skills mode (or Kimi's existing native
skills directory) and is skipped when safe resolution or
creation fails.
Returns:
Dictionary mapping agent names to list of registered commands
@@ -824,7 +850,17 @@ class CommandRegistrar:
results = {}
self._ensure_configs()
active_skills_agent = (
self._active_skills_agent(project_root)
if create_missing_active_skills_dir else None
)
active_created_skills_dir: Optional[Path] = None
for agent_name, agent_config in self.AGENT_CONFIGS.items():
active_skills_output = (
agent_name == active_skills_agent
and agent_config.get("extension") == "/SKILL.md"
)
recovered_active_skills_dir: Optional[Path] = None
# Check detect_dir first (project-local marker) if configured,
# falling back to the resolved dir for output. This prevents
# global dirs (e.g. ~/.hermes/skills) from causing false
@@ -832,13 +868,55 @@ class CommandRegistrar:
detect_dir_str = agent_config.get("detect_dir")
if detect_dir_str:
detect_path = project_root / detect_dir_str
if not detect_path.exists():
continue
if not detect_path.is_dir():
if not active_skills_output:
continue
try:
from . import resolve_active_skills_dir
recovered_active_skills_dir = (
resolve_active_skills_dir(project_root)
)
except (ValueError, OSError):
continue
if recovered_active_skills_dir is None or not detect_path.is_dir():
continue
active_created_skills_dir = recovered_active_skills_dir
agent_dir = self._resolve_agent_dir(
agent_name, agent_config, project_root,
)
if agent_dir.exists():
agent_dir_existed = agent_dir.is_dir()
register_missing_active_skills_agent = (
not agent_dir_existed
and active_skills_output
)
if register_missing_active_skills_agent:
if recovered_active_skills_dir is None:
try:
from . import resolve_active_skills_dir
recovered_active_skills_dir = (
resolve_active_skills_dir(project_root)
)
except (ValueError, OSError):
continue
if recovered_active_skills_dir is None:
continue
active_created_skills_dir = recovered_active_skills_dir
# Shared skill dirs such as .agents/skills should not make
# later integrations look detected when the active agent just
# recreated the directory during this registration pass.
created_by_active_agent = (
active_created_skills_dir is not None
and self._same_lexical_path(agent_dir, active_created_skills_dir)
and agent_name != active_skills_agent
)
should_register = (
agent_dir_existed and not created_by_active_agent
) or register_missing_active_skills_agent
if should_register:
try:
registered = self.register_commands(
agent_name,
@@ -852,8 +930,16 @@ class CommandRegistrar:
)
if registered:
results[agent_name] = registered
if register_missing_active_skills_agent:
active_created_skills_dir = (
recovered_active_skills_dir or agent_dir
)
except ValueError:
continue
except OSError:
if register_missing_active_skills_agent:
continue
raise
return results
@@ -892,12 +978,12 @@ class CommandRegistrar:
detect_dir_str = agent_config.get("detect_dir")
if detect_dir_str:
detect_path = project_root / detect_dir_str
if not detect_path.exists():
if not detect_path.is_dir():
continue
agent_dir = self._resolve_agent_dir(
agent_name, agent_config, project_root,
)
if agent_dir.exists():
if agent_dir.is_dir():
try:
registered = self.register_commands(
agent_name,

View File

@@ -1,2 +0,0 @@
"""specify extension * commands — placeholder for future extraction."""
from __future__ import annotations

View File

@@ -151,13 +151,15 @@ def register(app: typer.Typer) -> None:
# Lazy imports to avoid circular dependency — __init__.py imports this module
from .. import (
_install_shared_infra_or_exit,
_parse_integration_options,
_print_cli_warning,
_update_agent_context_config_file,
_write_integration_json,
ensure_executable_scripts,
save_init_options,
)
from ..integrations._commands import (
_parse_integration_options,
_write_integration_json,
)
from ..integration_runtime import with_integration_setting as _with_integration_setting
show_banner()

View File

@@ -1,2 +0,0 @@
"""specify integration * commands — placeholder for future extraction."""
from __future__ import annotations

View File

@@ -1,2 +0,0 @@
"""specify preset * commands — placeholder for future extraction."""
from __future__ import annotations

View File

@@ -1,2 +0,0 @@
"""specify workflow * commands — placeholder for future extraction."""
from __future__ import annotations

View File

@@ -26,14 +26,15 @@ from packaging import version as pkg_version
from packaging.specifiers import SpecifierSet, InvalidSpecifier
from .catalogs import CatalogEntry as BaseCatalogEntry, CatalogStackBase
from ._init_options import is_ai_skills_enabled
_FALLBACK_CORE_COMMAND_NAMES = frozenset({
"analyze",
"checklist",
"clarify",
"constitution",
"implement",
"plan",
"checklist",
"specify",
"tasks",
"taskstoissues",
@@ -830,15 +831,53 @@ class ExtensionManager:
be created due to symlink, containment, or permission issues so
that callers can fall back gracefully.
"""
from . import resolve_active_skills_dir, _print_cli_warning
from . import (
_print_cli_warning,
load_init_options,
resolve_active_skills_dir,
)
def _ensure_usable(skills_dir: Path) -> Optional[Path]:
try:
skills_dir.mkdir(parents=True, exist_ok=True)
if not skills_dir.is_dir():
raise NotADirectoryError(f"{skills_dir} is not a directory")
except (OSError, ValueError) as exc:
_print_cli_warning(
"resolve", "skills directory", str(skills_dir), exc,
continuing="Continuing without skill registration.",
)
return None
return skills_dir
try:
return resolve_active_skills_dir(self.project_root)
skills_dir = resolve_active_skills_dir(self.project_root)
except (ValueError, OSError) as exc:
_print_cli_warning(
"resolve", "skills directory", None, exc,
continuing="Continuing without skill registration.",
)
return None
if skills_dir is None:
return None
opts = load_init_options(self.project_root)
if not isinstance(opts, dict):
return _ensure_usable(skills_dir)
selected_ai = opts.get("ai")
if not isinstance(selected_ai, str) or not selected_ai:
return _ensure_usable(skills_dir)
from .agents import CommandRegistrar
registrar = CommandRegistrar()
agent_config = registrar.AGENT_CONFIGS.get(selected_ai)
if agent_config and agent_config.get("extension") == "/SKILL.md":
agent_skills_dir = registrar._resolve_agent_dir(
selected_ai, agent_config, self.project_root
)
return _ensure_usable(agent_skills_dir)
return _ensure_usable(skills_dir)
def _register_extension_skills(
self,
@@ -1173,6 +1212,7 @@ class ExtensionManager:
register_commands: bool = True,
priority: int = 10,
link_commands: bool = False,
force: bool = False,
) -> ExtensionManifest:
"""Install extension from a local directory.
@@ -1183,6 +1223,8 @@ class ExtensionManager:
priority: Resolution priority (lower = higher precedence, default 10)
link_commands: If True, register rendered agent artifacts as
symlinks to a dev cache when supported by the OS.
force: If True and extension is already installed, remove it first
before proceeding with installation
Returns:
Installed extension manifest
@@ -1204,14 +1246,34 @@ class ExtensionManager:
# Check if already installed
if self.registry.is_installed(manifest.id):
raise ExtensionError(
f"Extension '{manifest.id}' is already installed. "
f"Use 'specify extension remove {manifest.id}' first."
)
if not force:
raise ExtensionError(
f"Extension '{manifest.id}' is already installed. "
f"Use 'specify extension remove {manifest.id}' first, "
f"or retry with --force to overwrite."
)
# Reject manifests that would shadow core commands or installed extensions.
self._validate_install_conflicts(manifest)
# Remove existing installation AFTER all validations pass so that a
# validation failure doesn't leave the user with a half-uninstalled
# extension (configs stranded in .backup/).
did_remove = False
if force and self.registry.is_installed(manifest.id):
# Clear any stale backup from a previous remove so that only the
# backup produced by the current remove() call is restored later.
backup_config_dir = self.extensions_dir / ".backup" / manifest.id
# Check is_symlink first: is_dir() follows symlinks so a
# symlink-to-directory would pass, but rmtree() raises on them.
if backup_config_dir.is_symlink():
backup_config_dir.unlink()
elif backup_config_dir.is_dir():
shutil.rmtree(backup_config_dir)
elif backup_config_dir.exists():
backup_config_dir.unlink()
did_remove = self.remove(manifest.id)
# Install extension
dest_dir = self.extensions_dir / manifest.id
if dest_dir.exists():
@@ -1226,7 +1288,11 @@ class ExtensionManager:
registrar = CommandRegistrar()
# Register for all detected agents
registered_commands = registrar.register_commands_for_all_agents(
manifest, dest_dir, self.project_root, link_outputs=link_commands
manifest,
dest_dir,
self.project_root,
link_outputs=link_commands,
create_missing_active_skills_dir=True,
)
# Auto-register extension commands as agent skills when --ai-skills
@@ -1239,6 +1305,26 @@ class ExtensionManager:
hook_executor = HookExecutor(self.project_root)
hook_executor.register_hooks(manifest)
# Restore config files from backup when --force triggered a removal.
# Only restore *.yml config files to match what remove() backs up,
# so unexpected artifacts in .backup/ are not resurrected.
if did_remove:
backup_config_dir = self.extensions_dir / ".backup" / manifest.id
# is_symlink first: is_dir() follows symlinks, but rmtree()
# raises on them — and we shouldn't follow symlinks to restore.
if backup_config_dir.is_symlink():
backup_config_dir.unlink()
elif backup_config_dir.is_dir():
for cfg_file in backup_config_dir.iterdir():
if cfg_file.is_file() and not cfg_file.is_symlink() and (
cfg_file.name.endswith("-config.yml") or
cfg_file.name.endswith("-config.local.yml")
):
shutil.copy2(cfg_file, dest_dir / cfg_file.name)
shutil.rmtree(backup_config_dir)
elif backup_config_dir.exists():
backup_config_dir.unlink()
# Update registry
self.registry.add(manifest.id, {
"version": manifest.version,
@@ -1257,6 +1343,7 @@ class ExtensionManager:
zip_path: Path,
speckit_version: str,
priority: int = 10,
force: bool = False,
) -> ExtensionManifest:
"""Install extension from ZIP file.
@@ -1264,6 +1351,8 @@ class ExtensionManager:
zip_path: Path to extension ZIP file
speckit_version: Current spec-kit version
priority: Resolution priority (lower = higher precedence, default 10)
force: If True and extension is already installed, remove it first
before proceeding with installation
Returns:
Installed extension manifest
@@ -1310,7 +1399,9 @@ class ExtensionManager:
raise ValidationError("No extension.yml found in ZIP file")
# Install from extracted directory
return self.install_from_directory(extension_dir, speckit_version, priority=priority)
return self.install_from_directory(
extension_dir, speckit_version, priority=priority, force=force
)
def remove(self, extension_id: str, keep_config: bool = False) -> bool:
"""Remove an installed extension.
@@ -1492,9 +1583,10 @@ class ExtensionManager:
init_options = {}
active_agent = init_options.get("ai")
ai_skills_enabled = is_ai_skills_enabled(init_options)
skills_mode_active = (
active_agent == agent_name
and bool(init_options.get("ai_skills"))
and ai_skills_enabled
and bool(agent_config)
and agent_config.get("extension") != "/SKILL.md"
)
@@ -1688,6 +1780,7 @@ class CommandRegistrar:
extension_dir: Path,
project_root: Path,
link_outputs: bool = False,
create_missing_active_skills_dir: bool = False,
) -> Dict[str, List[str]]:
"""Register extension commands for all detected agents."""
context_note = f"\n<!-- Extension: {manifest.id} -->\n<!-- Config: .specify/extensions/{manifest.id}/ -->\n"
@@ -1695,6 +1788,7 @@ class CommandRegistrar:
manifest.commands, manifest.id, extension_dir, project_root,
context_note=context_note,
link_outputs=link_outputs,
create_missing_active_skills_dir=create_missing_active_skills_dir,
)
def unregister_commands(
@@ -1749,13 +1843,59 @@ class ExtensionCatalog(CatalogStackBase):
from specify_cli.authentication.http import build_request
return build_request(url)
def _open_url(self, url: str, timeout: int = 10):
def _open_url(
self,
url: str,
timeout: int = 10,
extra_headers: Optional[Dict[str, str]] = None,
):
"""Open a URL with provider-based auth, trying each configured provider.
Delegates to :func:`specify_cli.authentication.http.open_url`.
"""
from specify_cli.authentication.http import open_url
return open_url(url, timeout)
return open_url(url, timeout, extra_headers=extra_headers)
def _resolve_github_release_asset_api_url(
self,
download_url: str,
timeout: int = 60,
) -> Optional[str]:
"""Resolve a GitHub release asset URL to its API asset URL."""
import urllib.error
from urllib.parse import unquote, urlparse
parsed = urlparse(download_url)
parts = [unquote(part) for part in parsed.path.strip("/").split("/")]
if (
parsed.hostname == "api.github.com"
and len(parts) >= 6
and parts[:1] == ["repos"]
and parts[3:5] == ["releases", "assets"]
):
return download_url
if parsed.hostname != "github.com":
return None
if len(parts) < 6 or parts[2:4] != ["releases", "download"]:
return None
owner, repo, tag = parts[0], parts[1], parts[4]
asset_name = "/".join(parts[5:])
release_url = f"https://api.github.com/repos/{owner}/{repo}/releases/tags/{tag}"
try:
with self._open_url(release_url, timeout=timeout) as response:
release_data = json.loads(response.read())
except (urllib.error.URLError, json.JSONDecodeError):
return None
for asset in release_data.get("assets", []):
if asset.get("name") == asset_name and asset.get("url"):
return str(asset["url"])
return None
def get_active_catalogs(self) -> List[CatalogEntry]:
"""Get the ordered list of active catalogs.
@@ -2155,9 +2295,15 @@ class ExtensionCatalog(CatalogStackBase):
zip_filename = f"{extension_id}-{version}.zip"
zip_path = target_dir / zip_filename
extra_headers = None
resolved_download_url = self._resolve_github_release_asset_api_url(download_url)
if resolved_download_url:
download_url = resolved_download_url
extra_headers = {"Accept": "application/octet-stream"}
# Download the ZIP file
try:
with self._open_url(download_url, timeout=60) as response:
with self._open_url(download_url, timeout=60, extra_headers=extra_headers) as response:
zip_data = response.read()
zip_path.write_bytes(zip_data)
@@ -2430,10 +2576,11 @@ class HookExecutor:
init_options = self._load_init_options()
selected_ai = init_options.get("ai")
codex_skill_mode = selected_ai == "codex" and bool(init_options.get("ai_skills"))
claude_skill_mode = selected_ai == "claude" and bool(init_options.get("ai_skills"))
ai_skills_enabled = is_ai_skills_enabled(init_options)
codex_skill_mode = selected_ai == "codex" and ai_skills_enabled
claude_skill_mode = selected_ai == "claude" and ai_skills_enabled
kimi_skill_mode = selected_ai == "kimi"
cursor_skill_mode = selected_ai == "cursor-agent" and bool(init_options.get("ai_skills"))
cursor_skill_mode = selected_ai == "cursor-agent" and ai_skills_enabled
cline_mode = selected_ai == "cline"
skill_name = self._skill_name_from_command(command_id)
@@ -2690,7 +2837,7 @@ class HookExecutor:
if not isinstance(config, dict):
config = {}
# We don't save yet, as there are no hooks to unregister,
# We don't save yet, as there are no hooks to unregister,
# but unregister_extension above might have already saved a normalized config.
return

View File

@@ -74,6 +74,7 @@ def _register_builtins() -> None:
from .qodercli import QodercliIntegration
from .qwen import QwenIntegration
from .roo import RooIntegration
from .rovodev import RovodevIntegration
from .shai import ShaiIntegration
from .tabnine import TabnineIntegration
from .trae import TraeIntegration
@@ -108,6 +109,7 @@ def _register_builtins() -> None:
_register(QodercliIntegration())
_register(QwenIntegration())
_register(RooIntegration())
_register(RovodevIntegration())
_register(ShaiIntegration())
_register(TabnineIntegration())
_register(TraeIntegration())

View File

@@ -0,0 +1,34 @@
"""specify integration * commands — app objects and register() entry point."""
from __future__ import annotations
import typer
from .._assets import get_speckit_version # noqa: F401 — re-exported for monkeypatching in tests
# Re-export helpers used by commands/init.py and tests
from ._helpers import ( # noqa: F401
_cli_error_detail,
_cli_phase_label,
_parse_integration_options,
_write_integration_json,
)
integration_app = typer.Typer(
name="integration",
help="Manage coding agent integrations",
add_completion=False,
)
integration_catalog_app = typer.Typer(
name="catalog",
help="Manage integration catalog sources",
add_completion=False,
)
integration_app.add_typer(integration_catalog_app, name="catalog")
def register(app: typer.Typer) -> None:
from . import _install_commands # noqa: F401 — registers handlers via decorators
from . import _migrate_commands # noqa: F401
from . import _query_commands # noqa: F401
app.add_typer(integration_app, name="integration")

View File

@@ -0,0 +1,402 @@
"""specify integration helpers — internal utilities shared across command modules."""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
import typer
from .._agent_config import SCRIPT_TYPE_CHOICES
from .._console import console
from ..integration_runtime import (
invoke_separator_for_integration as _invoke_separator_for_integration,
resolve_integration_options as _resolve_integration_options_impl,
with_integration_setting as _with_integration_setting,
)
from ..integration_state import (
INTEGRATION_JSON,
INTEGRATION_STATE_SCHEMA,
integration_setting as _integration_setting,
try_read_integration_json as _try_read_integration_json,
write_integration_json as _write_integration_json_file,
)
def _get_speckit_version() -> str:
"""Return the current Spec Kit version.
Resolved lazily through ``_commands.get_speckit_version`` so that tests
that monkeypatch ``specify_cli.integrations._commands.get_speckit_version``
still affect helpers called from the command handlers.
"""
from . import _commands # noqa: PLC0415 — intentional late import to avoid circular + enable patching
return _commands.get_speckit_version()
# ---------------------------------------------------------------------------
# JSON read / write helpers
# ---------------------------------------------------------------------------
def _read_integration_json(project_root: Path) -> dict[str, Any]:
"""Load ``.specify/integration.json``. Returns normalized state when present.
Delegates the parse / schema-guard logic to the shared
:func:`_try_read_integration_json` helper so the CLI and workflow engine
cannot drift on validation rules. Each error variant is translated into
the existing loud-fail UX (console message + ``typer.Exit(1)``).
"""
path = project_root / INTEGRATION_JSON
state, error = _try_read_integration_json(project_root)
if error is None:
return state or {}
if error.kind == "decode":
console.print(f"[red]Error:[/red] {path} contains invalid JSON or is not valid UTF-8.")
console.print(f"Please fix or delete {INTEGRATION_JSON} and retry.")
console.print(f"[dim]Details:[/dim] {error.detail}")
elif error.kind == "os":
console.print(f"[red]Error:[/red] Could not read {path}.")
console.print(f"Please fix file permissions or delete {INTEGRATION_JSON} and retry.")
console.print(f"[dim]Details:[/dim] {error.detail}")
elif error.kind == "not_object":
console.print(
f"[red]Error:[/red] {path} must contain a JSON object, got {error.detail}."
)
console.print(f"Please fix or delete {INTEGRATION_JSON} and retry.")
elif error.kind == "schema_too_new":
console.print(
f"[red]Error:[/red] {path} uses integration state schema {error.schema}, "
f"but this CLI only supports schema {INTEGRATION_STATE_SCHEMA}."
)
console.print("Please upgrade Spec Kit before modifying integrations.")
raise typer.Exit(1)
def _write_integration_json(
project_root: Path,
integration_key: str | None,
installed_integrations: list[str] | None = None,
integration_settings: dict[str, dict[str, Any]] | None = None,
) -> None:
"""Write ``.specify/integration.json`` with legacy-compatible state."""
_write_integration_json_file(
project_root,
version=_get_speckit_version(),
integration_key=integration_key,
installed_integrations=installed_integrations,
settings=integration_settings,
)
# ---------------------------------------------------------------------------
# init-options.json helpers
# ---------------------------------------------------------------------------
def _refresh_init_options_speckit_version(project_root: Path) -> None:
"""Refresh only the Spec Kit version recorded in init-options.json."""
from .. import load_init_options, save_init_options
opts = load_init_options(project_root)
if not isinstance(opts, dict) or not opts:
return
opts["speckit_version"] = _get_speckit_version()
save_init_options(project_root, opts)
def _clear_init_options_for_integration(project_root: Path, integration_key: str) -> None:
"""Clear active integration keys from init-options.json when they match.
Also clears ``context_file`` from the agent-context extension config so
no stale path is left behind when the integration is uninstalled.
"""
from .. import (
_AGENT_CTX_EXT_CONFIG,
_update_agent_context_config_file,
load_init_options,
save_init_options,
)
opts = load_init_options(project_root)
has_legacy_context_keys = ("context_file" in opts) or ("context_markers" in opts)
# Remove legacy fields that older versions may have written.
opts.pop("context_file", None)
opts.pop("context_markers", None)
if opts.get("integration") == integration_key or opts.get("ai") == integration_key:
opts.pop("integration", None)
opts.pop("ai", None)
opts.pop("ai_skills", None)
save_init_options(project_root, opts)
# Clear context_file in the extension config if it already exists.
# Avoid creating the config (and parent dirs) in projects where the
# agent-context extension was never installed.
ext_cfg_path = project_root / _AGENT_CTX_EXT_CONFIG
if ext_cfg_path.exists():
_update_agent_context_config_file(
project_root, "", preserve_markers=True
)
elif has_legacy_context_keys:
save_init_options(project_root, opts)
def _remove_integration_json(project_root: Path) -> None:
"""Remove ``.specify/integration.json`` if it exists."""
path = project_root / INTEGRATION_JSON
if path.exists():
path.unlink()
# ---------------------------------------------------------------------------
# Error sentinels
# ---------------------------------------------------------------------------
_MANIFEST_READ_ERRORS = (ValueError, FileNotFoundError, OSError, UnicodeDecodeError)
class _SharedTemplateRefreshError(RuntimeError):
"""Raised when default integration metadata should not be persisted."""
# ---------------------------------------------------------------------------
# Script type resolution
# ---------------------------------------------------------------------------
def _normalize_script_type(script_type: str, source: str) -> str:
"""Normalize and validate a script type from CLI/config sources."""
normalized = script_type.strip().lower()
if normalized in SCRIPT_TYPE_CHOICES:
return normalized
console.print(
f"[red]Error:[/red] Invalid script type {script_type!r} from {source}. "
f"Expected one of: {', '.join(sorted(SCRIPT_TYPE_CHOICES.keys()))}."
)
raise typer.Exit(1)
def _resolve_script_type(project_root: Path, script_type: str | None) -> str:
"""Resolve the script type from the CLI flag or init-options.json."""
from .. import load_init_options
if script_type:
return _normalize_script_type(script_type, "--script")
opts = load_init_options(project_root)
saved = opts.get("script")
if isinstance(saved, str) and saved.strip():
return _normalize_script_type(saved, ".specify/init-options.json")
return "ps" if os.name == "nt" else "sh"
def _resolve_integration_script_type(
project_root: Path,
state: dict[str, Any],
key: str,
script_type: str | None = None,
) -> str:
"""Resolve script type for an integration, preferring stored settings."""
if script_type:
return _normalize_script_type(script_type, "--script")
stored = _integration_setting(state, key).get("script")
if isinstance(stored, str) and stored.strip():
return _normalize_script_type(stored, f"{INTEGRATION_JSON} integration_settings.{key}.script")
return _resolve_script_type(project_root, None)
# ---------------------------------------------------------------------------
# Integration options
# ---------------------------------------------------------------------------
def _parse_integration_options(integration: Any, raw_options: str) -> dict[str, Any] | None:
"""Parse --integration-options string into a dict matching the integration's declared options.
Returns ``None`` when no options are provided.
"""
import shlex
parsed: dict[str, Any] = {}
tokens = shlex.split(raw_options)
declared_options = list(integration.options())
declared = {opt.name.lstrip("-"): opt for opt in declared_options}
allowed = ", ".join(sorted(opt.name for opt in declared_options))
i = 0
while i < len(tokens):
token = tokens[i]
if not token.startswith("-"):
console.print(f"[red]Error:[/red] Unexpected integration option value '{token}'.")
if allowed:
console.print(f"Allowed options: {allowed}")
raise typer.Exit(1)
name = token.lstrip("-")
value: str | None = None
# Handle --name=value syntax
if "=" in name:
name, value = name.split("=", 1)
opt = declared.get(name)
if not opt:
console.print(f"[red]Error:[/red] Unknown integration option '{token}'.")
if allowed:
console.print(f"Allowed options: {allowed}")
raise typer.Exit(1)
key = name.replace("-", "_")
if opt.is_flag:
if value is not None:
console.print(f"[red]Error:[/red] Option '{opt.name}' is a flag and does not accept a value.")
raise typer.Exit(1)
parsed[key] = True
i += 1
elif value is not None:
parsed[key] = value
i += 1
elif i + 1 < len(tokens) and not tokens[i + 1].startswith("-"):
parsed[key] = tokens[i + 1]
i += 2
else:
console.print(f"[red]Error:[/red] Option '{opt.name}' requires a value.")
raise typer.Exit(1)
return parsed or None
def _resolve_integration_options(
integration: Any,
state: dict[str, Any],
key: str,
raw_options: str | None,
) -> tuple[str | None, dict[str, Any] | None]:
"""Resolve raw and parsed options for an integration operation."""
return _resolve_integration_options_impl(
integration,
state,
key,
raw_options,
parse_options=_parse_integration_options,
)
def _update_init_options_for_integration(
project_root: Path,
integration: Any,
script_type: str | None = None,
) -> None:
"""Update init-options.json and the agent-context extension config to
reflect *integration* as the active one.
``context_file`` and ``context_markers`` are stored in the agent-context
extension config (``.specify/extensions/agent-context/agent-context-config.yml``),
not in ``init-options.json``. Existing user-customised markers are
always preserved when the config already exists; invalid marker values
are silently ignored at runtime by ``_resolve_context_markers()`` which
falls back to the class-level defaults.
"""
from .. import (
_AGENT_CTX_EXT_CONFIG,
_update_agent_context_config_file,
load_init_options,
save_init_options,
)
from .base import SkillsIntegration
opts = load_init_options(project_root)
opts["integration"] = integration.key
opts["ai"] = integration.key
# Remove legacy fields if they were written by an older version.
opts.pop("context_file", None)
opts.pop("context_markers", None)
opts["speckit_version"] = _get_speckit_version()
if script_type:
opts["script"] = script_type
if isinstance(integration, SkillsIntegration) or getattr(integration, "_skills_mode", False):
opts["ai_skills"] = True
else:
opts.pop("ai_skills", None)
# Update the agent-context extension config BEFORE init-options.json
# so a failure here doesn't leave init-options partially updated.
ext_cfg_path = project_root / _AGENT_CTX_EXT_CONFIG
if ext_cfg_path.exists():
_update_agent_context_config_file(
project_root,
integration.context_file,
preserve_markers=True,
)
elif integration.context_file:
# Extension config doesn't exist yet (extension not installed).
# Write defaults so scripts have something to read.
_update_agent_context_config_file(
project_root,
integration.context_file,
preserve_markers=False,
)
save_init_options(project_root, opts)
# ---------------------------------------------------------------------------
# Default integration persistence
# ---------------------------------------------------------------------------
def _set_default_integration(
project_root: Path,
state: dict[str, Any],
key: str,
integration: Any,
installed_keys: list[str],
*,
script_type: str | None = None,
raw_options: str | None = None,
parsed_options: dict[str, Any] | None = None,
refresh_templates: bool = True,
refresh_templates_force: bool = False,
refresh_hint: str | None = None,
) -> None:
"""Persist *key* as default and align active runtime metadata."""
from .. import _install_shared_infra
resolved_script = _resolve_integration_script_type(project_root, state, key, script_type)
settings = _with_integration_setting(
state,
key,
integration,
script_type=resolved_script,
raw_options=raw_options,
parsed_options=parsed_options,
)
if refresh_templates:
try:
_install_shared_infra(
project_root,
resolved_script,
invoke_separator=_invoke_separator_for_integration(
integration, {"integration_settings": settings}, key, parsed_options
),
force=refresh_templates_force,
refresh_managed=True,
refresh_hint=refresh_hint,
)
except (ValueError, OSError) as exc:
raise _SharedTemplateRefreshError(
f"Failed to refresh shared infrastructure for '{key}': {exc}"
) from exc
_write_integration_json(project_root, key, installed_keys, settings)
_update_init_options_for_integration(project_root, integration, script_type=resolved_script)
def _set_default_integration_or_exit(*args: Any, **kwargs: Any) -> None:
try:
_set_default_integration(*args, **kwargs)
except _SharedTemplateRefreshError as exc:
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)
# ---------------------------------------------------------------------------
# CLI formatting helpers (re-exported from _commands.py)
# ---------------------------------------------------------------------------
def _cli_error_detail(exc: BaseException) -> str:
"""Return a compact one-line exception detail for CLI output."""
return str(exc).replace("\n", " ").strip() or exc.__class__.__name__
def _cli_phase_label(phase: str, target_kind: str, target: str | None = None) -> str:
"""Format a stable operation label for user-visible diagnostics."""
label = f"{phase} {target_kind}".strip()
if target:
label = f"{label} '{target}'"
return label

View File

@@ -0,0 +1,309 @@
"""specify integration install / uninstall command handlers."""
from __future__ import annotations
import os
import typer
from .._console import console
from .._utils import _display_project_path
from ..integration_runtime import (
invoke_separator_for_integration as _invoke_separator_for_integration,
with_integration_setting as _with_integration_setting,
)
from ..integration_state import (
dedupe_integration_keys as _dedupe_integration_keys,
default_integration_key as _default_integration_key,
installed_integration_keys as _installed_integration_keys,
integration_settings as _integration_settings,
)
from ._commands import integration_app
from ._helpers import (
_MANIFEST_READ_ERRORS,
_clear_init_options_for_integration,
_cli_error_detail,
_cli_phase_label,
_get_speckit_version,
_read_integration_json,
_refresh_init_options_speckit_version,
_remove_integration_json,
_resolve_integration_options,
_resolve_script_type,
_set_default_integration_or_exit,
_update_init_options_for_integration,
_write_integration_json,
)
@integration_app.command("install")
def integration_install(
key: str = typer.Argument(help="Integration key to install (e.g. claude, copilot)"),
script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
force: bool = typer.Option(False, "--force", help="Allow multi-install when integrations are not declared safe"),
integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'),
):
"""Install an integration into an existing project."""
from . import INTEGRATION_REGISTRY, get_integration
from .manifest import IntegrationManifest
from .. import _require_specify_project, _install_shared_infra_or_exit
project_root = _require_specify_project()
integration = get_integration(key)
if integration is None:
console.print(f"[red]Error:[/red] Unknown integration '{key}'")
available = ", ".join(sorted(INTEGRATION_REGISTRY.keys()))
console.print(f"Available integrations: {available}")
raise typer.Exit(1)
current = _read_integration_json(project_root)
default_key = _default_integration_key(current)
installed_keys = _installed_integration_keys(current)
if key in installed_keys:
console.print(f"[yellow]Integration '{key}' is already installed.[/yellow]")
if default_key == key:
console.print("It is already the default integration.")
else:
console.print(
f"To make it the default integration, run "
f"[cyan]specify integration use {key}[/cyan]."
)
console.print(
f"To refresh its managed files or options, run "
f"[cyan]specify integration upgrade {key}[/cyan]."
)
console.print("No files were changed.")
raise typer.Exit(0)
if installed_keys and not force:
unsafe_keys = []
for installed_key in installed_keys:
installed_integration = get_integration(installed_key)
if not installed_integration or not getattr(installed_integration, "multi_install_safe", False):
unsafe_keys.append(installed_key)
if unsafe_keys or not getattr(integration, "multi_install_safe", False):
console.print(
f"[red]Error:[/red] Installed integrations: {', '.join(installed_keys)}."
)
if default_key:
console.print(f"Default integration: [cyan]{default_key}[/cyan].")
console.print(
"Installing multiple integrations is only automatic when all involved "
"integrations are declared multi-install safe."
)
console.print(
f"To replace the default integration, run "
f"[cyan]specify integration switch {key}[/cyan]."
)
console.print(
f"To install '{key}' alongside the existing integrations anyway, "
"retry the same install command with [cyan]--force[/cyan]."
)
raise typer.Exit(1)
selected_script = _resolve_script_type(project_root, script)
# Build parsed options from --integration-options so the integration
# can determine its effective invoke separator before shared infra
# is installed.
raw_options, parsed_options = _resolve_integration_options(
integration, current, key, integration_options
)
# Ensure shared infrastructure is present (safe to run unconditionally;
# _install_shared_infra merges missing files without overwriting).
infra_integration = integration
infra_key = key
infra_parsed = parsed_options
if default_key:
default_integration = get_integration(default_key)
if default_integration is not None:
infra_integration = default_integration
infra_key = default_key
_, infra_parsed = _resolve_integration_options(
default_integration, current, default_key, None
)
_install_shared_infra_or_exit(
project_root,
selected_script,
invoke_separator=_invoke_separator_for_integration(
infra_integration, current, infra_key, infra_parsed
),
)
if os.name != "nt":
from .. import ensure_executable_scripts
ensure_executable_scripts(project_root)
manifest = IntegrationManifest(
integration.key, project_root, version=_get_speckit_version()
)
try:
integration.setup(
project_root, manifest,
parsed_options=parsed_options,
script_type=selected_script,
raw_options=raw_options,
)
manifest.save()
new_installed = _dedupe_integration_keys([*installed_keys, integration.key])
new_default = default_key or integration.key
settings = _with_integration_setting(
current,
integration.key,
integration,
script_type=selected_script,
raw_options=raw_options,
parsed_options=parsed_options,
)
_write_integration_json(project_root, new_default, new_installed, settings)
if new_default == integration.key:
_update_init_options_for_integration(project_root, integration, script_type=selected_script)
else:
_refresh_init_options_speckit_version(project_root)
except Exception as exc:
# Attempt rollback of any files written by setup
try:
integration.teardown(project_root, manifest, force=True)
except Exception as rollback_err:
# Suppress so the original setup error remains the primary failure
from .. import _print_cli_warning
_print_cli_warning(
"rollback",
"integration",
key,
rollback_err,
continuing="The original install failure is still the primary error.",
)
if installed_keys:
_write_integration_json(
project_root, default_key, installed_keys, _integration_settings(current)
)
else:
_remove_integration_json(project_root)
console.print(
f"[red]Error:[/red] Failed to {_cli_phase_label('install', 'integration', key)}: "
f"{_cli_error_detail(exc)}"
)
raise typer.Exit(1)
name = (integration.config or {}).get("name", key)
console.print(f"\n[green]✓[/green] Integration '{name}' installed successfully")
if default_key:
console.print(f"[dim]Default integration remains:[/dim] [cyan]{default_key}[/cyan]")
@integration_app.command("uninstall")
def integration_uninstall(
key: str = typer.Argument(None, help="Integration key to uninstall (default: current integration)"),
force: bool = typer.Option(False, "--force", help="Remove files even if modified"),
):
"""Uninstall an integration, safely preserving modified files."""
from . import get_integration
from .manifest import IntegrationManifest
from .. import _require_specify_project
project_root = _require_specify_project()
current = _read_integration_json(project_root)
default_key = _default_integration_key(current)
installed_keys = _installed_integration_keys(current)
if key is None:
if not default_key:
console.print("[yellow]No integration is currently installed.[/yellow]")
raise typer.Exit(0)
key = default_key
if key not in installed_keys:
console.print(f"[red]Error:[/red] Integration '{key}' is not installed.")
raise typer.Exit(1)
integration = get_integration(key)
manifest_path = project_root / ".specify" / "integrations" / f"{key}.manifest.json"
if not manifest_path.exists():
console.print(f"[yellow]No manifest found for integration '{key}'. Nothing to uninstall.[/yellow]")
remaining = [installed for installed in installed_keys if installed != key]
new_default = default_key if default_key != key else (remaining[0] if remaining else None)
if remaining:
if default_key == key and new_default and (new_integration := get_integration(new_default)):
raw_options, parsed_options = _resolve_integration_options(
new_integration, current, new_default, None
)
_set_default_integration_or_exit(
project_root,
current,
new_default,
new_integration,
remaining,
raw_options=raw_options,
parsed_options=parsed_options,
)
else:
_write_integration_json(
project_root, new_default, remaining, _integration_settings(current)
)
else:
_remove_integration_json(project_root)
if default_key == key:
_clear_init_options_for_integration(project_root, key)
raise typer.Exit(0)
try:
manifest = IntegrationManifest.load(key, project_root)
except _MANIFEST_READ_ERRORS as exc:
console.print(f"[red]Error:[/red] Integration manifest for '{key}' is unreadable.")
console.print(f"Manifest: {manifest_path}")
console.print(
f"To recover, delete the unreadable manifest, run "
f"[cyan]specify integration uninstall {key}[/cyan] to clear stale metadata, "
f"then run [cyan]specify integration install {key}[/cyan] to regenerate."
)
console.print(f"[dim]Details:[/dim] {exc}")
raise typer.Exit(1)
if not integration:
console.print(
f"[yellow]Warning:[/yellow] Integration '{key}' not found "
"in registry. Falling back to manifest-based cleanup."
)
removed, skipped = manifest.uninstall(project_root, force=force)
else:
removed, skipped = integration.teardown(project_root, manifest, force=force)
remaining = [installed for installed in installed_keys if installed != key]
new_default = default_key if default_key != key else (remaining[0] if remaining else None)
if remaining:
if default_key == key and new_default and (new_integration := get_integration(new_default)):
raw_options, parsed_options = _resolve_integration_options(
new_integration, current, new_default, None
)
_set_default_integration_or_exit(
project_root,
current,
new_default,
new_integration,
remaining,
raw_options=raw_options,
parsed_options=parsed_options,
)
else:
_write_integration_json(
project_root, new_default, remaining, _integration_settings(current)
)
else:
_remove_integration_json(project_root)
if default_key == key:
_clear_init_options_for_integration(project_root, key)
name = (integration.config or {}).get("name", key) if integration else key
console.print(f"\n[green]✓[/green] Integration '{name}' uninstalled")
if removed:
console.print(f" Removed {len(removed)} file(s)")
if skipped:
console.print(f"\n[yellow]⚠[/yellow] {len(skipped)} modified file(s) were preserved:")
for path in skipped:
rel = _display_project_path(project_root, path)
console.print(f" {rel}")

View File

@@ -0,0 +1,490 @@
"""specify integration switch / upgrade command handlers."""
from __future__ import annotations
import os
import typer
from .._console import console
from ..integration_runtime import (
invoke_separator_for_integration as _invoke_separator_for_integration,
with_integration_setting as _with_integration_setting,
)
from ..integration_state import (
dedupe_integration_keys as _dedupe_integration_keys,
default_integration_key as _default_integration_key,
installed_integration_keys as _installed_integration_keys,
integration_settings as _integration_settings,
)
from ._commands import integration_app
from ._helpers import (
_MANIFEST_READ_ERRORS,
_SharedTemplateRefreshError,
_clear_init_options_for_integration,
_cli_error_detail,
_cli_phase_label,
_get_speckit_version,
_read_integration_json,
_refresh_init_options_speckit_version,
_remove_integration_json,
_resolve_integration_options,
_resolve_integration_script_type,
_resolve_script_type,
_set_default_integration,
_set_default_integration_or_exit,
_update_init_options_for_integration,
_write_integration_json,
)
@integration_app.command("switch")
def integration_switch(
target: str = typer.Argument(help="Integration key to switch to"),
script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
force: bool = typer.Option(False, "--force", help="Force removal of modified files during uninstall of the previous integration"),
refresh_shared_infra: bool = typer.Option(False, "--refresh-shared-infra", help="Also overwrite shared infrastructure files even if you customized them (otherwise customizations are preserved)"),
integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the target integration'),
):
"""Switch from the current integration to a different one."""
from . import INTEGRATION_REGISTRY, get_integration
from .manifest import IntegrationManifest
from .. import _print_cli_warning, _require_specify_project, _install_shared_infra_or_exit
project_root = _require_specify_project()
target_integration = get_integration(target)
if target_integration is None:
console.print(f"[red]Error:[/red] Unknown integration '{target}'")
available = ", ".join(sorted(INTEGRATION_REGISTRY.keys()))
console.print(f"Available integrations: {available}")
raise typer.Exit(1)
current = _read_integration_json(project_root)
installed_keys = _installed_integration_keys(current)
installed_key = _default_integration_key(current)
if installed_key == target:
if integration_options is not None:
console.print(
"[red]Error:[/red] --integration-options cannot be used when switching "
"to an already installed integration."
)
console.print(
f"Run [cyan]specify integration upgrade {target} --integration-options ...[/cyan] "
"to update managed files/options."
)
raise typer.Exit(1)
if force:
raw_options, parsed_options = _resolve_integration_options(
target_integration, current, target, None
)
_set_default_integration_or_exit(
project_root,
current,
target,
target_integration,
installed_keys,
raw_options=raw_options,
parsed_options=parsed_options,
refresh_templates_force=True,
)
console.print(
f"\n[green]✓[/green] Default integration remains [bold]{target}[/bold]; "
"shared infrastructure refreshed."
)
raise typer.Exit(0)
console.print(f"[yellow]Integration '{target}' is already the default integration. Nothing to switch.[/yellow]")
raise typer.Exit(0)
if target in installed_keys:
if integration_options is not None:
console.print(
"[red]Error:[/red] --integration-options cannot be used when switching "
"to an already installed integration."
)
console.print(
f"Run [cyan]specify integration upgrade {target} --integration-options ...[/cyan] "
f"to update managed files/options, then [cyan]specify integration use {target}[/cyan]."
)
raise typer.Exit(1)
raw_options, parsed_options = _resolve_integration_options(
target_integration, current, target, None
)
_set_default_integration_or_exit(
project_root,
current,
target,
target_integration,
installed_keys,
raw_options=raw_options,
parsed_options=parsed_options,
refresh_templates_force=force,
)
console.print(f"\n[green]✓[/green] Default integration set to [bold]{target}[/bold].")
raise typer.Exit(0)
selected_script = _resolve_script_type(project_root, script)
# Phase 1: Uninstall current integration (if any)
if installed_key:
current_integration = get_integration(installed_key)
manifest_path = project_root / ".specify" / "integrations" / f"{installed_key}.manifest.json"
if current_integration and manifest_path.exists():
console.print(f"Uninstalling current integration: [cyan]{installed_key}[/cyan]")
try:
old_manifest = IntegrationManifest.load(installed_key, project_root)
except _MANIFEST_READ_ERRORS as exc:
console.print(f"[red]Error:[/red] Could not read integration manifest for '{installed_key}': {manifest_path}")
console.print(f"[dim]{exc}[/dim]")
console.print(
f"To recover, delete the unreadable manifest at {manifest_path}, "
f"run [cyan]specify integration uninstall {installed_key}[/cyan], then retry."
)
raise typer.Exit(1)
removed, skipped = current_integration.teardown(
project_root, old_manifest, force=force,
)
if removed:
console.print(f" Removed {len(removed)} file(s)")
if skipped:
console.print(f" [yellow]⚠[/yellow] {len(skipped)} modified file(s) preserved")
elif not current_integration and manifest_path.exists():
# Integration removed from registry but manifest exists — use manifest-only uninstall
console.print(f"Uninstalling unknown integration '{installed_key}' via manifest")
try:
old_manifest = IntegrationManifest.load(installed_key, project_root)
removed, skipped = old_manifest.uninstall(project_root, force=force)
if removed:
console.print(f" Removed {len(removed)} file(s)")
if skipped:
console.print(f" [yellow]⚠[/yellow] {len(skipped)} modified file(s) preserved")
except _MANIFEST_READ_ERRORS as exc:
console.print(f"[yellow]Warning:[/yellow] Could not read manifest for '{installed_key}': {exc}")
else:
console.print(f"[red]Error:[/red] Integration '{installed_key}' is installed but has no manifest.")
console.print(
f"Run [cyan]specify integration uninstall {installed_key}[/cyan] to clear metadata, "
f"then retry [cyan]specify integration switch {target}[/cyan]."
)
raise typer.Exit(1)
# Unregister extension commands for the old agent so they don't
# remain as orphans in the old agent's directory.
try:
from ..extensions import ExtensionManager
ext_mgr = ExtensionManager(project_root)
ext_mgr.unregister_agent_artifacts(installed_key)
except Exception as ext_err:
_print_cli_warning(
"clean up extension artifacts for",
"integration",
installed_key,
ext_err,
continuing="Continuing with integration switch; old extension artifacts may need manual cleanup.",
)
# Clear metadata so a failed Phase 2 doesn't leave stale references
installed_keys = [installed for installed in installed_keys if installed != installed_key]
_clear_init_options_for_integration(project_root, installed_key)
if installed_keys:
fallback_key = installed_keys[0]
fallback_integration = get_integration(fallback_key)
if fallback_integration is not None:
raw_options, parsed_options = _resolve_integration_options(
fallback_integration, current, fallback_key, None
)
_set_default_integration_or_exit(
project_root,
current,
fallback_key,
fallback_integration,
installed_keys,
raw_options=raw_options,
parsed_options=parsed_options,
)
else:
_write_integration_json(
project_root, fallback_key, installed_keys, _integration_settings(current)
)
else:
_remove_integration_json(project_root)
current = _read_integration_json(project_root)
# Build parsed options from --integration-options so the integration
# can determine its effective invoke separator before shared infra
# is installed.
raw_options, parsed_options = _resolve_integration_options(
target_integration, current, target, integration_options
)
# Refresh shared infrastructure to the current CLI version. Switching
# integrations is exactly when stale vendored shared scripts (e.g.
# update-agent-context.sh that pre-dates the target integration's
# supported-agent list) would silently break the new integration.
#
# Use refresh_managed=True so only files that match their previously
# recorded hash are overwritten — user customizations are detected via
# hash divergence and preserved with a warning. Pass
# --refresh-shared-infra to overwrite customizations as well. See #2293.
_install_shared_infra_or_exit(
project_root,
selected_script,
force=refresh_shared_infra,
refresh_managed=True,
invoke_separator=_invoke_separator_for_integration(
target_integration, current, target, parsed_options
),
refresh_hint=(
"To overwrite customizations, re-run with "
"[cyan]specify integration switch ... --refresh-shared-infra[/cyan]."
),
)
if os.name != "nt":
from .. import ensure_executable_scripts
ensure_executable_scripts(project_root)
# Phase 2: Install target integration
console.print(f"Installing integration: [cyan]{target}[/cyan]")
manifest = IntegrationManifest(
target_integration.key, project_root, version=_get_speckit_version()
)
try:
target_integration.setup(
project_root, manifest,
parsed_options=parsed_options,
script_type=selected_script,
raw_options=raw_options,
)
manifest.save()
_set_default_integration(
project_root,
current,
target_integration.key,
target_integration,
_dedupe_integration_keys([*installed_keys, target_integration.key]),
script_type=selected_script,
raw_options=raw_options,
parsed_options=parsed_options,
)
# Re-register extension commands for the new agent so that
# previously-installed extensions are available in the new integration.
try:
from ..extensions import ExtensionManager
ext_mgr = ExtensionManager(project_root)
ext_mgr.register_enabled_extensions_for_agent(target)
except Exception as ext_err:
_print_cli_warning(
"register extension artifacts for",
"integration",
target,
ext_err,
continuing="The integration switch succeeded, but installed extensions may need re-registration.",
)
except Exception as exc:
# Attempt rollback of any files written by setup
try:
target_integration.teardown(project_root, manifest, force=True)
except Exception as rollback_err:
# Suppress so the original setup error remains the primary failure
_print_cli_warning(
"rollback",
"integration",
target,
rollback_err,
continuing="The original switch failure is still the primary error.",
)
if installed_keys:
fallback_key = installed_keys[0]
fallback_integration = get_integration(fallback_key)
if fallback_integration is not None:
raw_options, parsed_options = _resolve_integration_options(
fallback_integration, current, fallback_key, None
)
try:
_set_default_integration(
project_root,
current,
fallback_key,
fallback_integration,
installed_keys,
raw_options=raw_options,
parsed_options=parsed_options,
)
except _SharedTemplateRefreshError as restore_err:
console.print(
f"[yellow]Warning:[/yellow] Failed to restore default "
f"integration '{fallback_key}': {restore_err}"
)
else:
_write_integration_json(
project_root, fallback_key, installed_keys, _integration_settings(current)
)
else:
_remove_integration_json(project_root)
console.print(
f"[red]Error:[/red] Failed to {_cli_phase_label('install', 'integration', target)} "
f"during switch: {_cli_error_detail(exc)}"
)
raise typer.Exit(1)
name = (target_integration.config or {}).get("name", target)
console.print(f"\n[green]✓[/green] Switched to integration '{name}'")
@integration_app.command("upgrade")
def integration_upgrade(
key: str | None = typer.Argument(None, help="Integration key to upgrade (default: current integration)"),
force: bool = typer.Option(False, "--force", help="Force upgrade even if files are modified"),
script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
integration_options: str | None = typer.Option(None, "--integration-options", help="Options for the integration"),
):
"""Upgrade an integration by reinstalling with diff-aware file handling.
Compares manifest hashes to detect locally modified files and
blocks the upgrade unless --force is used.
"""
from . import get_integration
from .manifest import IntegrationManifest
from .. import _require_specify_project, _install_shared_infra_or_exit, _install_shared_infra
project_root = _require_specify_project()
current = _read_integration_json(project_root)
installed_key = _default_integration_key(current)
installed_keys = _installed_integration_keys(current)
if key is None:
if not installed_key:
console.print("[yellow]No integration is currently installed.[/yellow]")
raise typer.Exit(0)
key = installed_key
if key not in installed_keys:
console.print(f"[red]Error:[/red] Integration '{key}' is not installed.")
raise typer.Exit(1)
integration = get_integration(key)
if integration is None:
console.print(f"[red]Error:[/red] Unknown integration '{key}'")
raise typer.Exit(1)
manifest_path = project_root / ".specify" / "integrations" / f"{key}.manifest.json"
if not manifest_path.exists():
console.print(f"[yellow]No manifest found for integration '{key}'. Nothing to upgrade.[/yellow]")
console.print(f"Run [cyan]specify integration install {key}[/cyan] to perform a fresh install.")
raise typer.Exit(0)
try:
old_manifest = IntegrationManifest.load(key, project_root)
except _MANIFEST_READ_ERRORS as exc:
console.print(f"[red]Error:[/red] Integration manifest for '{key}' is unreadable: {exc}")
raise typer.Exit(1)
# Detect modified files via manifest hashes
modified = old_manifest.check_modified()
if modified and not force:
console.print(f"[yellow]⚠[/yellow] {len(modified)} file(s) have been modified since installation:")
for rel in modified:
console.print(f" {rel}")
console.print("\nUse [cyan]--force[/cyan] to overwrite modified files, or resolve manually.")
raise typer.Exit(1)
selected_script = _resolve_integration_script_type(project_root, current, key, script)
# Build parsed options from --integration-options so the integration
# can determine its effective invoke separator before shared infra
# is installed.
raw_options, parsed_options = _resolve_integration_options(
integration, current, key, integration_options
)
# Ensure shared infrastructure is up to date; --force overwrites existing files.
infra_integration = integration
infra_key = key
infra_parsed = parsed_options
if installed_key and installed_key != key:
default_integration = get_integration(installed_key)
if default_integration is not None:
infra_integration = default_integration
infra_key = installed_key
_, infra_parsed = _resolve_integration_options(
default_integration, current, installed_key, None
)
_install_shared_infra_or_exit(
project_root,
selected_script,
force=force,
invoke_separator=_invoke_separator_for_integration(
infra_integration, current, infra_key, infra_parsed
),
)
if os.name != "nt":
from .. import ensure_executable_scripts
ensure_executable_scripts(project_root)
# Phase 1: Install new files (overwrites existing; old-only files remain)
console.print(f"Upgrading integration: [cyan]{key}[/cyan]")
new_manifest = IntegrationManifest(key, project_root, version=_get_speckit_version())
try:
integration.setup(
project_root,
new_manifest,
parsed_options=parsed_options,
script_type=selected_script,
raw_options=raw_options,
)
settings = _with_integration_setting(
current,
key,
integration,
script_type=selected_script,
raw_options=raw_options,
parsed_options=parsed_options,
)
if installed_key == key:
try:
_install_shared_infra(
project_root,
selected_script,
invoke_separator=_invoke_separator_for_integration(
integration, {"integration_settings": settings}, key, parsed_options
),
force=force,
refresh_managed=True,
)
except (ValueError, OSError) as exc:
raise _SharedTemplateRefreshError(
f"Failed to refresh shared infrastructure for '{key}': {exc}"
) from exc
new_manifest.save()
_write_integration_json(project_root, installed_key, installed_keys, settings)
if installed_key == key:
_update_init_options_for_integration(project_root, integration, script_type=selected_script)
else:
_refresh_init_options_speckit_version(project_root)
except Exception as exc:
# Don't teardown — setup overwrites in-place, so teardown would
# delete files that were working before the upgrade. Just report.
console.print(f"[red]Error:[/red] Failed to {_cli_phase_label('upgrade', 'integration', key)}.")
console.print(f"[dim]Details:[/dim] {_cli_error_detail(exc)}")
console.print("[yellow]The previous integration files may still be in place.[/yellow]")
raise typer.Exit(1)
# Phase 2: Remove stale files from old manifest that are not in the new one
old_files = old_manifest.files
new_files = new_manifest.files
stale_keys = set(old_files) - set(new_files)
if stale_keys:
stale_manifest = IntegrationManifest(key, project_root, version="stale-cleanup")
stale_manifest._files = {k: old_files[k] for k in stale_keys}
stale_removed, _ = stale_manifest.uninstall(project_root, force=True)
if stale_removed:
console.print(f" Removed {len(stale_removed)} stale file(s) from previous install")
name = (integration.config or {}).get("name", key)
console.print(f"\n[green]✓[/green] Integration '{name}' upgraded successfully")

View File

@@ -0,0 +1,464 @@
"""specify integration list/use/search/info + catalog list/add/remove command handlers."""
from __future__ import annotations
import os
from typing import Optional
import typer
from rich.table import Table
from .._console import console
from ..integration_state import (
default_integration_key as _default_integration_key,
installed_integration_keys as _installed_integration_keys,
)
from ._commands import integration_app, integration_catalog_app
from ._helpers import (
_read_integration_json,
_resolve_integration_options,
_set_default_integration_or_exit,
)
@integration_app.command("list")
def integration_list(
catalog: bool = typer.Option(False, "--catalog", help="Browse full catalog (built-in + community)"),
):
"""List available integrations and installed status."""
from . import INTEGRATION_REGISTRY
from .. import _require_specify_project
project_root = _require_specify_project()
current = _read_integration_json(project_root)
default_key = _default_integration_key(current)
installed_keys = set(_installed_integration_keys(current))
if catalog:
from .catalog import IntegrationCatalog, IntegrationCatalogError
ic = IntegrationCatalog(project_root)
try:
entries = ic.search()
except IntegrationCatalogError as exc:
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)
if not entries:
console.print("[yellow]No integrations found in catalog.[/yellow]")
return
table = Table(title="Integration Catalog")
table.add_column("ID", style="cyan")
table.add_column("Name")
table.add_column("Version")
table.add_column("Source")
table.add_column("Status")
table.add_column("Multi-install Safe")
for entry in sorted(entries, key=lambda e: e["id"]):
eid = entry["id"]
cat_name = entry.get("_catalog_name", "")
install_allowed = entry.get("_install_allowed", True)
if eid == default_key:
status = "[green]installed (default)[/green]"
elif eid in installed_keys:
status = "[green]installed[/green]"
elif eid in INTEGRATION_REGISTRY:
status = "built-in"
elif install_allowed is False:
status = "discovery-only"
else:
status = ""
safe = ""
if eid in INTEGRATION_REGISTRY:
reg_integ = INTEGRATION_REGISTRY[eid]
safe = "yes" if getattr(reg_integ, "multi_install_safe", False) else "no"
table.add_row(
eid,
entry.get("name", eid),
entry.get("version", ""),
cat_name,
status,
safe,
)
console.print(table)
return
if not INTEGRATION_REGISTRY:
console.print("[yellow]No integrations available.[/yellow]")
return
table = Table(title="Coding Agent Integrations")
table.add_column("Key", style="cyan")
table.add_column("Name")
table.add_column("Status")
table.add_column("CLI Required")
table.add_column("Multi-install Safe")
for key in sorted(INTEGRATION_REGISTRY.keys()):
integration = INTEGRATION_REGISTRY[key]
cfg = integration.config or {}
name = cfg.get("name", key)
requires_cli = cfg.get("requires_cli", False)
if key == default_key:
status = "[green]installed (default)[/green]"
elif key in installed_keys:
status = "[green]installed[/green]"
else:
status = ""
cli_req = "yes" if requires_cli else "no (IDE)"
safe = "yes" if getattr(integration, "multi_install_safe", False) else "no"
table.add_row(key, name, status, cli_req, safe)
console.print(table)
if installed_keys:
console.print(f"\n[dim]Default integration:[/dim] [cyan]{default_key or 'none'}[/cyan]")
console.print(f"[dim]Installed integrations:[/dim] [cyan]{', '.join(sorted(installed_keys))}[/cyan]")
else:
console.print("\n[yellow]No integration currently installed.[/yellow]")
console.print("Install one with: [cyan]specify integration install <key>[/cyan]")
@integration_app.command("use")
def integration_use(
key: str = typer.Argument(help="Installed integration key to make the default"),
force: bool = typer.Option(False, "--force", help="Overwrite existing shared infrastructure files, including customizations, while changing the default"),
):
"""Set the default integration without uninstalling other integrations."""
from . import get_integration
from .. import _require_specify_project
project_root = _require_specify_project()
current = _read_integration_json(project_root)
installed_keys = _installed_integration_keys(current)
if key not in installed_keys:
console.print(f"[red]Error:[/red] Integration '{key}' is not installed.")
if installed_keys:
console.print(f"[yellow]Installed integrations:[/yellow] {', '.join(installed_keys)}")
else:
console.print("Install one with: [cyan]specify integration install <key>[/cyan]")
raise typer.Exit(1)
integration = get_integration(key)
if integration is None:
console.print(f"[red]Error:[/red] Unknown integration '{key}'")
raise typer.Exit(1)
raw_options, parsed_options = _resolve_integration_options(integration, current, key, None)
_set_default_integration_or_exit(
project_root,
current,
key,
integration,
installed_keys,
raw_options=raw_options,
parsed_options=parsed_options,
refresh_templates_force=force,
refresh_hint=(
"To overwrite customizations, re-run with "
f"[cyan]specify integration use {key} --force[/cyan]."
),
)
console.print(f"[green]✓[/green] Default integration set to [bold]{key}[/bold].")
# ===== Integration catalog discovery commands =====
#
# These commands mirror the workflow catalog CLI shape:
# - `search` / `info` for discovery over the active catalog stack
# - `catalog list/add/remove` for managing catalog sources
#
# They deliberately do NOT add `integration add/remove/enable/disable/
# set-priority`: integrations are single-active (install / uninstall / switch),
# not additive like extensions and presets.
@integration_app.command("search")
def integration_search(
query: Optional[str] = typer.Argument(None, help="Search query (optional)"),
tag: Optional[str] = typer.Option(None, "--tag", help="Filter by tag"),
author: Optional[str] = typer.Option(None, "--author", help="Filter by author"),
):
"""Search for integrations in the active catalog stack."""
from . import INTEGRATION_REGISTRY
from .catalog import (
IntegrationCatalog,
IntegrationCatalogError,
IntegrationValidationError,
)
from .. import _require_specify_project
project_root = _require_specify_project()
integration_config = _read_integration_json(project_root)
installed_key = _default_integration_key(integration_config)
catalog = IntegrationCatalog(project_root)
try:
results = catalog.search(query=query, tag=tag, author=author)
except IntegrationValidationError as exc:
console.print(f"[red]Error:[/red] {exc}")
console.print(
"\nTip: Check the configuration file path shown above for invalid catalog configuration "
"(for example, .specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml)."
)
raise typer.Exit(1)
except IntegrationCatalogError as exc:
console.print(f"[red]Error:[/red] {exc}")
if os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip():
console.print(
"\nTip: Check the SPECKIT_INTEGRATION_CATALOG_URL environment variable for an invalid "
"catalog URL, or unset it to use the configured catalog files "
"(.specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml)."
)
else:
console.print("\nTip: The catalog may be temporarily unavailable. Try again later.")
raise typer.Exit(1)
if not results:
console.print("\n[yellow]No integrations found matching criteria[/yellow]")
if query or tag or author:
console.print("\nTry:")
console.print(" • Broader search terms")
console.print(" • Remove filters")
console.print(" • specify integration search (show all)")
return
console.print(f"\n[green]Found {len(results)} integration(s):[/green]\n")
for integ in sorted(results, key=lambda e: e.get("id", "")):
iid = integ.get("id", "?")
name = integ.get("name", iid)
version = integ.get("version", "?")
console.print(f"[bold]{name}[/bold] ({iid}) v{version}")
desc = integ.get("description", "")
if desc:
console.print(f" {desc}")
console.print(f"\n [dim]Author:[/dim] {integ.get('author', 'Unknown')}")
tags = integ.get("tags", [])
if isinstance(tags, list) and tags:
console.print(f" [dim]Tags:[/dim] {', '.join(str(t) for t in tags)}")
cat_name = integ.get("_catalog_name", "")
install_allowed = integ.get("_install_allowed", True)
if cat_name:
if install_allowed:
console.print(f" [dim]Catalog:[/dim] {cat_name}")
else:
console.print(
f" [dim]Catalog:[/dim] {cat_name} "
"[yellow](discovery only — not installable)[/yellow]"
)
if iid == installed_key:
console.print("\n [green]✓ Installed[/green] (currently active)")
elif iid in INTEGRATION_REGISTRY:
console.print(f"\n [cyan]Install:[/cyan] specify integration install {iid}")
elif install_allowed:
console.print(
"\n [yellow]Found in catalog.[/yellow] Only built-in integration IDs "
"can be installed with 'specify integration install'."
)
else:
console.print(
f"\n [yellow]⚠[/yellow] Not directly installable from '{cat_name}'."
)
console.print()
@integration_app.command("info")
def integration_info(
integration_id: str = typer.Argument(..., help="Integration ID"),
):
"""Show catalog details for a single integration."""
from . import INTEGRATION_REGISTRY
from .catalog import (
IntegrationCatalog,
IntegrationCatalogError,
IntegrationValidationError,
)
from .. import _require_specify_project
project_root = _require_specify_project()
catalog = IntegrationCatalog(project_root)
installed_key = _default_integration_key(_read_integration_json(project_root))
try:
info = catalog.get_integration_info(integration_id)
except IntegrationCatalogError as exc:
info = None
# Keep the live exception so the fallback branch below can give
# different guidance for local-config vs. network failures.
catalog_error: Optional[IntegrationCatalogError] = exc
else:
catalog_error = None
if info:
name = info.get("name", integration_id)
version = info.get("version", "?")
console.print(f"\n[bold cyan]{name}[/bold cyan] ({integration_id}) v{version}")
if info.get("description"):
console.print(f" {info['description']}")
console.print()
console.print(f" [dim]Author:[/dim] {info.get('author', 'Unknown')}")
if info.get("license"):
console.print(f" [dim]License:[/dim] {info['license']}")
tags = info.get("tags", [])
if isinstance(tags, list) and tags:
console.print(f" [dim]Tags:[/dim] {', '.join(str(t) for t in tags)}")
cat_name = info.get("_catalog_name", "")
install_allowed = info.get("_install_allowed", True)
if cat_name:
install_note = "" if install_allowed else " [yellow](discovery only)[/yellow]"
console.print(f" [dim]Source catalog:[/dim] {cat_name}{install_note}")
if info.get("repository"):
console.print(f" [dim]Repository:[/dim] {info['repository']}")
if integration_id == installed_key:
console.print("\n [green]✓ Installed[/green] (currently active)")
elif integration_id in INTEGRATION_REGISTRY:
console.print("\n [dim]Built-in integration (not currently active)[/dim]")
return
if integration_id in INTEGRATION_REGISTRY:
integration = INTEGRATION_REGISTRY[integration_id]
cfg = integration.config or {}
name = cfg.get("name", integration_id)
console.print(f"\n[bold cyan]{name}[/bold cyan] ({integration_id})")
console.print(" [dim]Built-in integration (not listed in catalog)[/dim]")
if integration_id == installed_key:
console.print("\n [green]✓ Installed[/green] (currently active)")
if catalog_error:
console.print(f"\n[yellow]Catalog unavailable:[/yellow] {catalog_error}")
return
if catalog_error:
console.print(f"[red]Error:[/red] Could not query integration catalog: {catalog_error}")
if isinstance(catalog_error, IntegrationValidationError):
console.print(
"\nCheck the configuration file path shown above "
"(.specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml), "
"or use a built-in integration ID directly."
)
elif os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip():
console.print(
"\nCheck whether SPECKIT_INTEGRATION_CATALOG_URL is set correctly and reachable, "
"or unset it to use the configured catalog files, or use a built-in integration ID directly."
)
else:
console.print("\nTry again when online, or use a built-in integration ID directly.")
else:
console.print(f"[red]Error:[/red] Integration '{integration_id}' not found")
console.print("\nTry: specify integration search")
raise typer.Exit(1)
@integration_catalog_app.command("list")
def integration_catalog_list():
"""List configured integration catalog sources."""
from .catalog import IntegrationCatalog, IntegrationCatalogError
from .. import _require_specify_project
project_root = _require_specify_project()
catalog = IntegrationCatalog(project_root)
env_override = os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip()
try:
if env_override:
project_configs = None
configs = catalog.get_catalog_configs()
else:
project_configs = catalog.get_project_catalog_configs()
configs = project_configs if project_configs is not None else catalog.get_catalog_configs()
except IntegrationCatalogError as exc:
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)
console.print("\n[bold cyan]Integration Catalog Sources:[/bold cyan]\n")
if env_override:
console.print(
" SPECKIT_INTEGRATION_CATALOG_URL is set; it supersedes configured catalog files."
)
console.print(
" Project/user catalog sources are not active while the env override is set.\n"
)
console.print("[bold]Active catalog source from environment (non-removable here):[/bold]\n")
elif project_configs is None:
console.print(" No project-level catalog sources configured.\n")
console.print("[bold]Active catalog sources (non-removable here):[/bold]\n")
else:
console.print("[bold]Project catalog sources (removable):[/bold]\n")
for i, cfg in enumerate(configs):
install_status = (
"[green]install allowed[/green]"
if cfg.get("install_allowed")
else "[yellow]discovery only[/yellow]"
)
raw_name = cfg.get("name")
display_name = str(raw_name).strip() if raw_name is not None else ""
if not display_name:
display_name = f"catalog-{i + 1}"
if env_override or project_configs is None:
console.print(f" - [bold]{display_name}[/bold] — {install_status}")
else:
console.print(f" [{i}] [bold]{display_name}[/bold] — {install_status}")
console.print(f" {cfg.get('url', '')}")
if cfg.get("description"):
console.print(f" [dim]{cfg['description']}[/dim]")
console.print()
@integration_catalog_app.command("add")
def integration_catalog_add(
url: str = typer.Argument(
...,
help=(
"Catalog URL to add (HTTPS required, except http://localhost, "
"http://127.0.0.1, or http://[::1] for local testing)"
),
),
name: Optional[str] = typer.Option(None, "--name", help="Catalog name"),
):
"""Add an integration catalog source to the project config."""
from .catalog import IntegrationCatalog, IntegrationCatalogError
from .. import _require_specify_project
project_root = _require_specify_project()
catalog = IntegrationCatalog(project_root)
# Normalize once here so the success message reflects what was actually
# stored. ``IntegrationCatalog.add_catalog`` strips again defensively.
normalized_url = url.strip()
try:
catalog.add_catalog(normalized_url, name)
except IntegrationCatalogError as exc:
# Covers both URL validation (base class) and config-file validation
# (IntegrationValidationError subclass).
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)
console.print(f"[green]✓[/green] Catalog source added: {normalized_url}")
@integration_catalog_app.command("remove")
def integration_catalog_remove(
index: int = typer.Argument(..., help="Catalog index to remove (from 'catalog list')"),
):
"""Remove an integration catalog source by 0-based index."""
from .catalog import IntegrationCatalog, IntegrationCatalogError
from .. import _require_specify_project
project_root = _require_specify_project()
catalog = IntegrationCatalog(project_root)
try:
removed_name = catalog.remove_catalog(index)
except IntegrationCatalogError as exc:
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)
console.print(f"[green]✓[/green] Catalog source '{removed_name}' removed")

View File

@@ -34,6 +34,21 @@ _HOOK_COMMAND_NOTE = (
"For example, `speckit.git.commit` → `/speckit-git-commit`.\n"
)
_CORE_COMMAND_TEMPLATE_ORDER = (
"analyze",
"clarify",
"constitution",
"implement",
"plan",
"checklist",
"specify",
"tasks",
"taskstoissues",
)
_CORE_COMMAND_TEMPLATE_RANK = {
command: index for index, command in enumerate(_CORE_COMMAND_TEMPLATE_ORDER)
}
# ---------------------------------------------------------------------------
# IntegrationOption
@@ -147,6 +162,45 @@ class IntegrationBase(ABC):
"""
return None
@property
def cli_executable(self) -> str:
"""Executable name used for CLI availability detection.
Defaults to ``self.key``. Integrations whose CLI binary name
differs from the integration key should override this property.
For example, RovoDev's key is ``"rovodev"`` but the binary is
``"acli"``, so its override returns ``"acli"``.
This property is used by :meth:`is_cli_available` and by
``check_tool()`` when checking whether the integration's CLI
tool is installed. It intentionally does **not** honour the
``SPECKIT_INTEGRATION_<KEY>_EXECUTABLE`` env-var override — that
variable controls which binary is *executed* at runtime (see
:meth:`_resolve_executable`), whereas ``cli_executable`` names
the tool to *detect* on ``PATH``.
See issue #2597.
"""
return self.key
def is_cli_available(self) -> bool:
"""Return ``True`` if this integration's CLI tool is installed.
The default implementation checks ``shutil.which(self.cli_executable)``.
Integrations with non-standard install locations or multiple
possible binary names should override this method.
Examples of integrations that override this:
* **ClaudeIntegration** — also checks ``~/.claude/local/`` paths
that are not on ``PATH``.
* **KiroCliIntegration** — accepts both ``kiro-cli`` and the
legacy ``kiro`` binary name.
See issue #2597.
"""
return shutil.which(self.cli_executable) is not None
def _resolve_executable(self) -> str:
"""Return the executable for this integration's CLI tool.
@@ -270,6 +324,16 @@ class IntegrationBase(ABC):
)
raise NotImplementedError(msg)
# Windows: ``subprocess.run`` calls ``CreateProcess`` which does not
# consult ``PATHEXT``, so a bare command name like ``cursor-agent``
# that resolves to ``cursor-agent.cmd`` fails with ``WinError 2``.
# Resolve via ``shutil.which`` (which does honor ``PATHEXT``) so
# ``.cmd``/``.bat`` shims work transparently. On POSIX this is a
# no-op for absolute paths and a harmless lookup otherwise.
resolved = shutil.which(exec_args[0])
if resolved:
exec_args = [resolved, *exec_args[1:]]
cwd = str(project_root) if project_root else None
if stream:
@@ -345,11 +409,19 @@ class IntegrationBase(ABC):
return None
def list_command_templates(self) -> list[Path]:
"""Return sorted list of command template files from the shared directory."""
"""Return ordered list of command template files from the shared directory."""
cmd_dir = self.shared_commands_dir()
if not cmd_dir or not cmd_dir.is_dir():
return []
return sorted(f for f in cmd_dir.iterdir() if f.is_file() and f.suffix == ".md")
return sorted(
(f for f in cmd_dir.iterdir() if f.is_file() and f.suffix == ".md"),
key=lambda f: (
_CORE_COMMAND_TEMPLATE_RANK.get(
f.stem, len(_CORE_COMMAND_TEMPLATE_ORDER)
),
f.name,
),
)
def command_filename(self, template_name: str) -> str:
"""Return the destination filename for a command template.

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import shutil
from pathlib import Path
from typing import Any
@@ -45,6 +46,27 @@ class ClaudeIntegration(SkillsIntegration):
context_file = "CLAUDE.md"
multi_install_safe = True
def is_cli_available(self) -> bool:
"""Return ``True`` if the Claude Code CLI is installed.
Claude Code can be installed in multiple locations, not all of
which are on ``PATH``:
1. ``~/.claude/local/claude`` — ``claude migrate-installer``
2. ``~/.claude/local/node_modules/.bin/claude`` — npm-local install (nvm)
3. Anywhere on ``PATH`` — global npm install
See issues #123, #550, and #2597.
"""
import specify_cli._utils as _utils_mod
if (
_utils_mod.CLAUDE_LOCAL_PATH.is_file()
or _utils_mod.CLAUDE_NPM_LOCAL_PATH.is_file()
):
return True
return shutil.which(self.cli_executable) is not None
@staticmethod
def inject_argument_hint(content: str, hint: str) -> str:
"""Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter.

View File

@@ -283,58 +283,13 @@ class CopilotIntegration(IntegrationBase):
return f"speckit.{template_name}.agent.md"
def post_process_skill_content(self, content: str) -> str:
"""Inject shared hook guidance and Copilot ``mode:`` frontmatter.
"""Inject shared hook guidance into Copilot skill content.
Inserts ``mode: speckit.<stem>`` before the closing ``---`` so
Copilot can associate the skill with its agent mode.
Delegates to :class:`_CopilotSkillsHelper` for shared post-processing.
The ``mode:`` frontmatter field is intentionally omitted: VS Code
Copilot Agent Skills do not support it (see issue #2799).
"""
updated = _CopilotSkillsHelper().post_process_skill_content(content)
lines = updated.splitlines(keepends=True)
# Extract skill name from frontmatter to derive the mode value
dash_count = 0
skill_name = ""
for line in lines:
stripped = line.rstrip("\n\r")
if stripped == "---":
dash_count += 1
if dash_count == 2:
break
continue
if dash_count == 1:
if stripped.startswith("mode:"):
return updated # already present
if stripped.startswith("name:"):
# Parse: name: "speckit-plan" → speckit.plan
val = stripped.split(":", 1)[1].strip().strip('"').strip("'")
# Convert speckit-plan → speckit.plan
if val.startswith("speckit-"):
skill_name = "speckit." + val[len("speckit-"):]
else:
skill_name = val
if not skill_name:
return updated
# Inject mode: before the closing --- of frontmatter
out: list[str] = []
dash_count = 0
injected = False
for line in lines:
stripped = line.rstrip("\n\r")
if stripped == "---":
dash_count += 1
if dash_count == 2 and not injected:
if line.endswith("\r\n"):
eol = "\r\n"
elif line.endswith("\n"):
eol = "\n"
else:
eol = ""
out.append(f"mode: {skill_name}{eol}")
injected = True
out.append(line)
return "".join(out)
return _CopilotSkillsHelper().post_process_skill_content(content)
def setup(
self,

View File

@@ -2,6 +2,12 @@
Cursor Agent uses the ``.cursor/skills/speckit-<name>/SKILL.md`` layout.
Commands are deprecated; ``--skills`` defaults to ``True``.
The IDE/skills flow is the primary path and works without the
``cursor-agent`` CLI being installed (``requires_cli=False``). Workflow
dispatch via ``cursor-agent -p --trust --approve-mcps --force <prompt>``
is offered as an opt-in capability — the presence of ``build_exec_args()``
is what indicates dispatch support, mirroring ``CopilotIntegration``.
"""
from __future__ import annotations
@@ -15,7 +21,12 @@ class CursorAgentIntegration(SkillsIntegration):
"name": "Cursor",
"folder": ".cursor/",
"commands_subdir": "skills",
"install_url": None,
"install_url": "https://docs.cursor.com/en/cli/overview",
# IDE-first integration: ``specify init --ai cursor-agent`` must
# work without the ``cursor-agent`` CLI installed (the IDE flow
# uses skills directly). Workflow dispatch additionally requires
# the CLI on PATH, but that's enforced at dispatch time via
# ``shutil.which`` rather than as a hard ``specify init`` precheck.
"requires_cli": False,
}
registrar_config = {
@@ -28,6 +39,50 @@ class CursorAgentIntegration(SkillsIntegration):
context_file = ".cursor/rules/specify-rules.mdc"
multi_install_safe = True
def build_exec_args(
self,
prompt: str,
*,
model: str | None = None,
output_json: bool = True,
) -> list[str] | None:
"""Build CLI arguments for non-interactive ``cursor-agent`` execution.
Always returns argv (no ``requires_cli`` guard) so workflow
dispatch is supported even though the integration's ``config``
sets ``requires_cli=False`` to keep the IDE-only flow unblocked.
This mirrors ``CopilotIntegration``: dispatch support is signalled
by overriding ``build_exec_args()``, not by the ``requires_cli``
flag (which is reserved for the ``specify init`` precheck).
Mandatory headless flags:
* ``-p`` — print/headless mode (access to all tools)
* ``--trust`` — bypass Workspace Trust prompt (CLI exits non-zero
otherwise)
* ``--approve-mcps`` — auto-approve MCP server loading (otherwise
MCP servers stay ``not loaded (needs approval)`` and tool calls
to them are silently dropped)
* ``--force`` — auto-approve tool invocations (shell/write/MCP),
matching the implicit "trusted environment" semantics that other
integrations (``claude -p``, ``codex --exec``) get by default
Together these are the minimum set required to make
``specify workflow run speckit --input integration=cursor-agent``
behave the same way as it does for ``claude`` / ``codex``.
Verified locally: with ``--approve-mcps --force`` the agent can
call any configured MCP server (e.g. ``dingtalk-doc``) and write
files during ``/speckit-*`` skill execution; without them the run
either drops tool calls or exits non-zero on the first approval
prompt.
"""
args = [self.key, "-p", "--trust", "--approve-mcps", "--force", prompt]
if model:
args.extend(["--model", model])
if output_json:
args.extend(["--output-format", "json"])
return args
@classmethod
def options(cls) -> list[IntegrationOption]:
return [

View File

@@ -1,5 +1,7 @@
"""Kiro CLI integration."""
import shutil
from ..base import MarkdownIntegration
@@ -27,3 +29,17 @@ class KiroCliIntegration(MarkdownIntegration):
"extension": ".md",
}
context_file = "AGENTS.md"
def is_cli_available(self) -> bool:
"""Return ``True`` if the Kiro CLI is installed.
Kiro ships under two binary names: ``kiro-cli`` (preferred) and
the legacy ``kiro`` alias. Either name satisfies the availability
check so existing installations continue to work.
See issue #2597.
"""
return (
shutil.which("kiro-cli") is not None
or shutil.which("kiro") is not None
)

View File

@@ -115,6 +115,7 @@ class IntegrationManifest:
self.project_root = project_root.resolve()
self.version = version
self._files: dict[str, str] = {} # rel_path → sha256 hex
self._recovered_files: set[str] = set()
self._installed_at: str = ""
# -- Manifest file location -------------------------------------------
@@ -131,6 +132,9 @@ class IntegrationManifest:
Creates parent directories as needed. Returns the absolute path
of the written file.
If the path was previously marked as recovered via
``record_existing(recovered=True)``, the recovered marker is
cleared because the bytes are now produced, not merely observed.
Raises ``ValueError`` if *rel_path* resolves outside the project root.
"""
@@ -144,17 +148,77 @@ class IntegrationManifest:
normalized = abs_path.relative_to(self.project_root).as_posix()
self._files[normalized] = hashlib.sha256(content).hexdigest()
# ``record_file`` writes *produced* content, so any prior
# recovered marker for this path is no longer accurate.
self._recovered_files.discard(normalized)
return abs_path
def record_existing(self, rel_path: str | Path) -> None:
"""Record the hash of an already-existing file at *rel_path*.
def record_existing(self, rel_path: str | Path, *, recovered: bool = False) -> None:
"""Record the hash of an already-existing regular file at *rel_path*.
Raises ``ValueError`` if *rel_path* resolves outside the project root.
When ``recovered=True``, the path is also marked in the manifest's
``recovered_files`` list to signal that the file's on-disk hash was
*observed* during install (because the file already existed and was not
overwritten), not *produced* by the install. Future ``refresh_managed``
runs should consult ``is_recovered`` before treating the recorded hash
as a managed baseline.
Raises:
ValueError: if *rel_path* resolves outside the project root, is
a symlink, or is not a regular file. A directory or other
non-file path cannot be silently recorded — its hash would
be meaningless and ``check_modified``/``uninstall`` would
treat the entry as permanently broken.
OSError: if the underlying filesystem call (``is_symlink``,
``is_file``, or the file-read used to compute the hash)
fails — for example a ``PermissionError`` on the path.
Callers should be prepared to handle ``OSError`` (and its
subclasses such as ``PermissionError``) in addition to
``ValueError``.
"""
rel = Path(rel_path)
# Cheap lexical pre-check first so absolute / parent-traversal paths
# don't trigger a filesystem stat outside the project root before
# ``_validate_rel_path`` raises. ``_validate_rel_path`` produces the
# canonical error messages used elsewhere.
if rel.is_absolute() or ".." in rel.parts:
_validate_rel_path(rel, self.project_root)
# _validate_rel_path raised for any actually-escaping path. If we reach
# here the path normalizes inside root (e.g. ``dir/../file.txt``).
# Reject anyway: manifest keys must be canonical so ``check_modified``
# and ``uninstall`` cannot key the same file under two paths.
raise ValueError(
f"Manifest paths must be canonical; '..' segments are not "
f"allowed (got {rel})"
)
# Walk each path component before resolution so a symlinked ancestor
# (e.g. ``linked_dir/file.txt`` where ``linked_dir`` is a symlink)
# cannot be silently followed by ``_validate_rel_path().resolve()``
# down to a target outside the project root. ``_ensure_safe_manifest_directory``
# uses the same pattern.
_walk = self.project_root
for part in rel.parts:
_walk = _walk / part
if _walk.is_symlink():
raise ValueError(
f"Refusing to record symlinked manifest path: {rel} "
f"(symlinked at {_walk.relative_to(self.project_root).as_posix()})"
)
abs_path = _validate_rel_path(rel, self.project_root)
if not abs_path.is_file():
raise ValueError(
f"Manifest path is not a regular file: {rel}"
)
normalized = abs_path.relative_to(self.project_root).as_posix()
self._files[normalized] = _sha256(abs_path)
if recovered:
self._recovered_files.add(normalized)
else:
# ``recovered=False`` means the caller is asserting this path is
# managed-baseline now, not merely observed; drop any stale
# recovered marker so future is_recovered() queries reflect the
# transition. ``discard`` is a no-op when the key is absent.
self._recovered_files.discard(normalized)
# -- Querying ---------------------------------------------------------
@@ -163,6 +227,37 @@ class IntegrationManifest:
"""Return a copy of the ``{rel_path: sha256}`` mapping."""
return dict(self._files)
@property
def recovered_files(self) -> set[str]:
"""Return a copy of the set of paths recorded with ``recovered=True``.
These entries had their hashes observed (not produced) during install
because the file already existed on disk and the install skipped it.
Their on-disk bytes may be user customizations — callers that would
overwrite based on hash equality (e.g. ``refresh_managed``) MUST check
``is_recovered`` first.
"""
return set(self._recovered_files)
def is_recovered(self, rel_path: str | Path) -> bool:
"""Return True if *rel_path* was recorded via ``record_existing(recovered=True)``.
Input is normalized through the same pipeline as ``record_existing``:
absolute paths, paths escaping the project root, AND paths containing
``'..'`` segments are rejected (returned as ``False``). This mirrors
``record_existing``'s canonicalization guard — such paths can never
appear as stored keys, so the answer is always ``False``.
"""
rel = Path(rel_path)
if rel.is_absolute() or ".." in rel.parts:
return False
try:
abs_path = _validate_rel_path(rel, self.project_root)
normalized = abs_path.relative_to(self.project_root).as_posix()
except ValueError:
return False
return normalized in self._recovered_files
def check_modified(self) -> list[str]:
"""Return relative paths of tracked files whose content changed on disk."""
modified: list[str] = []
@@ -269,6 +364,11 @@ class IntegrationManifest:
"version": self.version,
"installed_at": self._installed_at,
"files": self._files,
**(
{"recovered_files": sorted(self._recovered_files)}
if self._recovered_files
else {}
),
}
path = self.manifest_path
content = json.dumps(data, indent=2) + "\n"
@@ -320,6 +420,20 @@ class IntegrationManifest:
inst._installed_at = data.get("installed_at", "")
inst._files = files
recovered = data.get("recovered_files", [])
if not isinstance(recovered, list) or not all(
isinstance(p, str) for p in recovered
):
raise ValueError(
f"Integration manifest 'recovered_files' at {path} must be a "
"list of string paths"
)
inst._recovered_files = set(recovered)
# Drop any recovered_files entries that don't correspond to tracked
# files — defensive against externally-edited or partially-corrupted
# manifests. Inconsistent state self-corrects on next save().
inst._recovered_files &= set(inst._files.keys())
stored_key = data.get("integration", "")
if stored_key and stored_key != key:
raise ValueError(

View File

@@ -0,0 +1,263 @@
"""RovoDev integration — Atlassian Rovo Dev via ``acli rovodev``.
Extends ``SkillsIntegration`` to generate skill files under
``.rovodev/skills/`` and additionally generates prompt wrappers
under ``.rovodev/prompts/`` and a ``prompts.yml`` manifest.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
import yaml
from ..base import SkillsIntegration
from ..manifest import IntegrationManifest
class RovodevIntegration(SkillsIntegration):
"""Integration for Atlassian Rovo Dev.
Uses the skills layout (``speckit-<name>/SKILL.md``) and adds
prompt wrappers plus a ``prompts.yml`` manifest on top.
Runtime execution dispatches through ``acli rovodev``.
"""
key = "rovodev"
config = {
"name": "RovoDev ACLI",
"folder": ".rovodev/",
"commands_subdir": "skills",
"install_url": "https://www.atlassian.com/software/rovo-dev",
"requires_cli": True,
}
registrar_config = {
"dir": ".rovodev/skills",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = "AGENTS.md"
# -- CLI dispatch ------------------------------------------------------
@property
def cli_executable(self) -> str:
"""Executable name for CLI availability detection (``acli``).
RovoDev is invoked as ``acli rovodev …`` — ``acli`` is the
host binary; ``rovodev`` is a sub-command. The integration key
is ``"rovodev"``, but the binary to detect on ``PATH`` is
``"acli"``.
See issue #2597.
"""
return "acli"
def _resolve_executable(self) -> str:
"""Return the binary to invoke (``acli``).
RovoDev is invoked as ``acli rovodev …`` — ``acli`` is the executable
and ``rovodev`` is a subcommand. The base implementation falls back
to ``self.key`` (``"rovodev"``), which is the wrong binary, so we
override the fallback to ``"acli"`` while still honouring the
standard ``SPECKIT_INTEGRATION_ROVODEV_EXECUTABLE`` env-var override.
"""
env_name = (
f"SPECKIT_INTEGRATION_{self.key.upper().replace('-', '_')}_EXECUTABLE"
)
override = os.environ.get(env_name, "").strip()
return override if override else "acli"
def build_exec_args(
self,
prompt: str,
*,
model: str | None = None,
output_json: bool = True,
) -> list[str] | None:
"""Build non-interactive ACLI args for RovoDev.
RovoDev supports a positional ``message`` for non-interactive runs.
``output_json`` maps to ``--output-schema`` so dispatch callers can
request structured output.
The integration currently does not apply ``model`` overrides because
the expected config shape for ``--config-override`` is not yet wired
in this adapter.
Honours the standard env-var contract:
- ``SPECKIT_INTEGRATION_ROVODEV_EXECUTABLE`` overrides ``acli``
- ``SPECKIT_INTEGRATION_ROVODEV_EXTRA_ARGS`` injects extra CLI flags
"""
_ = model
args = [self._resolve_executable(), "rovodev", "run", prompt]
self._apply_extra_args_env_var(args)
if output_json:
args.extend([
"--output-schema",
'{"type": "object", "properties": {"result": {"type": "string"}}}',
])
return args
# -- Prompt wrapper + manifest generation ------------------------------
@staticmethod
def _render_prompt_wrapper(skill_name: str) -> str:
return f"use skill {skill_name} $ARGUMENTS\n"
def _generate_prompt_files(
self,
project_root: Path,
manifest: IntegrationManifest,
skill_paths: list[Path],
) -> tuple[list[Path], list[dict[str, str]]]:
"""Create thin prompt wrappers for each SKILL.md.
Skill name is derived from the parent directory name
(e.g. ``.rovodev/skills/speckit-plan/SKILL.md`` → ``speckit-plan``).
Returns (created_files, prompt_entries) where prompt_entries are
dicts suitable for inclusion in ``prompts.yml``.
"""
prompts_dir = project_root / ".rovodev" / "prompts"
prompts_dir.mkdir(parents=True, exist_ok=True)
created: list[Path] = []
prompt_entries: list[dict[str, str]] = []
for skill_path in skill_paths:
if skill_path.name != "SKILL.md":
continue
skill_name = skill_path.parent.name
if not skill_name:
continue
prompt_filename = f"{skill_name}.prompt.md"
prompt_file = self.write_file_and_record(
self._render_prompt_wrapper(skill_name),
prompts_dir / prompt_filename,
project_root,
manifest,
)
created.append(prompt_file)
prompt_entries.append({
"name": skill_name,
"description": f"Invoke {skill_name} skill",
"content_file": f"prompts/{prompt_filename}",
})
return created, prompt_entries
@staticmethod
def _read_prompts_yml(path: Path) -> list[dict[str, Any]]:
"""Read prompt entries from an existing ``prompts.yml``.
Returns an empty list if the file is missing, malformed, or
contains no valid prompt entries.
"""
if not path.exists():
return []
try:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
except (yaml.YAMLError, OSError, UnicodeError):
return []
if not isinstance(data, dict):
return []
prompts = data.get("prompts")
if not isinstance(prompts, list):
return []
return [dict(item) for item in prompts if isinstance(item, dict)]
@staticmethod
def _merge_prompt_entries(
existing: list[dict[str, Any]],
generated: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Merge *generated* entries into *existing*, preserving user additions.
- Existing entries whose ``name`` matches a generated entry are
replaced in-place (preserving the user's ordering).
- Generated entries not already present are appended at the end.
- User-added entries (no matching generated name) are kept as-is.
"""
generated_by_name = {e["name"]: e for e in generated if e.get("name")}
merged: list[dict[str, Any]] = []
seen: set[str] = set()
for entry in existing:
name = entry.get("name", "")
if name in generated_by_name:
merged.append(generated_by_name[name])
seen.add(name)
else:
merged.append(entry)
for entry in generated:
if entry.get("name", "") not in seen:
merged.append(entry)
return merged
def _merge_prompts_manifest(
self,
project_root: Path,
manifest: IntegrationManifest,
prompt_entries: list[dict[str, str]],
) -> Path | None:
"""Write ``prompts.yml``, merging with any existing user entries."""
if not prompt_entries:
return None
prompts_yml = project_root / ".rovodev" / "prompts.yml"
existing = self._read_prompts_yml(prompts_yml)
merged = self._merge_prompt_entries(existing, prompt_entries)
content = yaml.safe_dump(
{"prompts": merged},
default_flow_style=False,
sort_keys=False,
allow_unicode=True,
width=10_000,
)
return self.write_file_and_record(
content, prompts_yml, project_root, manifest,
)
# -- setup() -----------------------------------------------------------
def setup(
self,
project_root: Path,
manifest: IntegrationManifest,
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
"""Install RovoDev skills, then generate prompt wrappers and manifest.
1. ``SkillsIntegration.setup()`` generates skill files and
upserts the context section.
2. Generates prompt wrappers and ``prompts.yml`` for each skill
created in step 1.
"""
created = super().setup(project_root, manifest, parsed_options, **opts)
# Generate prompt wrappers + merge prompts.yml
prompt_files, prompt_entries = self._generate_prompt_files(
project_root, manifest, created
)
created.extend(prompt_files)
manifest_file = self._merge_prompts_manifest(
project_root, manifest, prompt_entries
)
if manifest_file:
created.append(manifest_file)
return created

View File

@@ -29,6 +29,7 @@ from packaging.specifiers import SpecifierSet, InvalidSpecifier
from .extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority
from .integrations.base import IntegrationBase
from ._init_options import is_ai_skills_enabled
def _substitute_core_template(
@@ -1262,7 +1263,7 @@ class PresetManager:
selected_ai = init_opts.get("ai")
if not isinstance(selected_ai, str):
return []
ai_skills_enabled = bool(init_opts.get("ai_skills"))
ai_skills_enabled = is_ai_skills_enabled(init_opts)
registrar = CommandRegistrar()
integration = get_integration(selected_ai)
agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {})

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import os
import re
import tempfile
from pathlib import Path
from typing import Any
@@ -194,6 +195,37 @@ def _write_shared_bytes(
temp_path.unlink()
_BASH_FORMAT_COMMAND_RE = re.compile(
r"\$\(\s*format_speckit_command\s+(['\"]?)([A-Za-z0-9_.-]+)\1(?:\s+[^)]*)?\)"
)
_POWERSHELL_FORMAT_COMMAND_RE = re.compile(
r"Format-SpecKitCommand\s+-CommandName\s+(['\"])([A-Za-z0-9_.-]+)\1(?:\s+-RepoRoot\s+[^\r\n]+)?"
)
def _format_speckit_command(command_name: str, separator: str) -> str:
name = command_name.strip().lstrip("/")
if name.startswith("speckit."):
name = name[len("speckit.") :]
elif name.startswith("speckit-"):
name = name[len("speckit-") :]
name = name.replace(".", separator)
return f"/speckit{separator}{name}"
def _resolve_dynamic_command_refs(content: str, separator: str) -> str:
"""Render script runtime command helpers for managed shared infra copies."""
content = _BASH_FORMAT_COMMAND_RE.sub(
lambda match: _format_speckit_command(match.group(2), separator),
content,
)
return _POWERSHELL_FORMAT_COMMAND_RE.sub(
lambda match: f"'{_format_speckit_command(match.group(2), separator)}'",
content,
)
def refresh_shared_templates(
project_path: Path,
*,
@@ -365,12 +397,30 @@ def install_shared_infra(
preserved_user_files.append(rel)
else:
skipped_files.append(rel)
# Record the existing-on-disk file in the manifest so a
# fresh manifest run against an already-populated
# ``.specify/`` tree does not silently drop it (#2107).
# ``prior_hashes`` is the function-scope snapshot taken
# at entry, so this membership check is O(1) and avoids
# the repeated ``dict(self._files)`` copy that
# ``manifest.files`` performs on every access.
if dst_path.is_file() and rel not in prior_hashes:
try:
manifest.record_existing(rel, recovered=True)
except (OSError, ValueError) as exc:
# Tolerate races / permission issues / non-file
# collisions so one weird path does not abort
# the whole install.
console.print(
f"[yellow]⚠[/yellow] could not record {rel} in manifest: {exc}"
)
continue
if not _ensure_or_bucket_dir(dst_path.parent):
continue
content = src_path.read_text(encoding="utf-8")
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
content = _resolve_dynamic_command_refs(content, invoke_separator)
planned_copies.append(
(
dst_path,
@@ -398,6 +448,23 @@ def install_shared_infra(
preserved_user_files.append(rel)
else:
skipped_files.append(rel)
# Record the existing-on-disk template in the manifest so a
# fresh manifest run against an already-populated
# ``.specify/`` tree does not silently drop it (#2107).
# ``prior_hashes`` is the function-scope snapshot taken at
# entry, so this membership check is O(1) and avoids the
# repeated ``dict(self._files)`` copy that ``manifest.files``
# performs on every access.
if dst.is_file() and rel not in prior_hashes:
try:
manifest.record_existing(rel, recovered=True)
except (OSError, ValueError) as exc:
# Tolerate races / permission issues / non-file
# collisions so one weird path does not abort
# the whole install.
console.print(
f"[yellow]⚠[/yellow] could not record {rel} in manifest: {exc}"
)
continue
content = src.read_text(encoding="utf-8")
@@ -416,7 +483,7 @@ def install_shared_infra(
if skipped_files:
console.print(
f"[yellow]⚠[/yellow] {len(skipped_files)} shared infrastructure file(s) already exist and were not updated:"
f"[yellow]⚠[/yellow] {len(skipped_files)} shared infrastructure path(s) already exist and were not updated:"
)
for path in skipped_files:
console.print(f" {path}")

View File

@@ -232,6 +232,22 @@ def _validate_steps(
step_errors = step_impl.validate(step_config)
errors.extend(step_errors)
# Validate optional `continue_on_error` field. The engine honours
# this on any step that returns StepStatus.FAILED so the pipeline can route
# around the failure via a downstream `if` or `switch` (or a
# `gate` that surfaces the failure to the operator via message
# interpolation). The field must be a literal boolean —
# coercion from truthy strings is deliberately not supported so
# authoring mistakes surface at validation time rather than
# silently changing run semantics.
if "continue_on_error" in step_config:
coe = step_config["continue_on_error"]
if not isinstance(coe, bool):
errors.append(
f"Step {step_id!r}: 'continue_on_error' must be a "
f"boolean, got {type(coe).__name__}."
)
# Recursively validate nested steps
for nested_key in ("then", "else", "steps"):
nested = step_config.get(nested_key)
@@ -265,16 +281,49 @@ def _validate_steps(
class RunState:
"""Manages workflow run state for persistence and resume."""
# ``run_id`` is interpolated into a filesystem path (``runs/<run_id>``)
# by both ``save()`` and ``load()``. Constrain it to a charset that
# cannot contain path separators (``/`` ``\``), parent-directory
# segments (``..``), or NULs — anything that could escape the
# ``.specify/workflows/runs/`` directory or be mis-interpreted by the
# filesystem. The first-character anchor blocks IDs that start with
# ``-`` (which would be mistaken for a CLI flag in error messages
# and shell completions).
_RUN_ID_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$")
@classmethod
def _validate_run_id(cls, run_id: str) -> None:
"""Raise ``ValueError`` if ``run_id`` is not a safe path component.
This is the single source of truth for what counts as a valid
``run_id``. ``__init__`` calls it to reject malformed IDs at
construction time; ``load`` calls it *before* interpolating the
ID into a path so a malicious value cannot probe or read files
outside ``.specify/workflows/runs/<run_id>/``.
"""
if not isinstance(run_id, str) or not cls._RUN_ID_PATTERN.match(run_id):
raise ValueError(
f"Invalid run_id {run_id!r}: must be alphanumeric with "
"hyphens/underscores only (and must start with an "
"alphanumeric character)."
)
def __init__(
self,
run_id: str | None = None,
workflow_id: str = "",
project_root: Path | None = None,
) -> None:
self.run_id = run_id or str(uuid.uuid4())[:8]
if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_-]*$', self.run_id):
msg = f"Invalid run_id {self.run_id!r}: must be alphanumeric with hyphens/underscores only."
raise ValueError(msg)
# ``run_id is None`` (omitted) → auto-generate. An explicit empty
# string is *not* the same as "omitted" and must be validated like
# any other caller-provided value — otherwise ``__init__("")``
# would silently substitute a UUID while ``load("")`` rejects, and
# the two entry points would diverge on the empty-string vector.
if run_id is None:
self.run_id = str(uuid.uuid4())[:8]
else:
self.run_id = run_id
self._validate_run_id(self.run_id)
self.workflow_id = workflow_id
self.project_root = project_root or Path(".")
self.status = RunStatus.CREATED
@@ -315,7 +364,20 @@ class RunState:
@classmethod
def load(cls, run_id: str, project_root: Path) -> RunState:
"""Load a run state from disk."""
"""Load a run state from disk.
Validates ``run_id`` against ``_RUN_ID_PATTERN`` *before* building
the lookup path. Without this guard, a caller passing a value like
``../escape`` (e.g. via ``specify workflow resume`` CLI argument)
would interpolate path-traversal segments into
``runs_dir`` below, letting ``state_path.exists()`` probe arbitrary
paths and ``json.load`` read attacker-planted JSON from outside
the project's ``runs/`` directory. ``__init__`` already runs this
check on the stored ``state_data["run_id"]``, but that fires
*after* the file lookup — too late to prevent the disclosure.
Mirrors the precedent in ``agents._ensure_within_directory``.
"""
cls._validate_run_id(run_id)
runs_dir = project_root / ".specify" / "workflows" / "runs" / run_id
state_path = runs_dir / "state.json"
if not state_path.exists():
@@ -387,10 +449,10 @@ class WorkflowEngine:
ValueError:
If the workflow YAML is invalid.
"""
path = Path(source)
path = Path(source).expanduser()
# Try as a direct file path first
if path.suffix in (".yml", ".yaml") and path.exists():
if path.suffix.lower() in (".yml", ".yaml") and path.is_file():
return WorkflowDefinition.from_yaml(path)
# Try as an installed workflow ID
@@ -491,8 +553,19 @@ class WorkflowEngine:
state.save()
return state
def resume(self, run_id: str) -> RunState:
"""Resume a paused or failed workflow run."""
def resume(
self,
run_id: str,
inputs: dict[str, Any] | None = None,
) -> RunState:
"""Resume a paused or failed workflow run.
When ``inputs`` is provided, the values are merged over the run's
persisted inputs and re-resolved through the same typed validation
path used by :meth:`execute`, so the resumed step sees updated
workflow inputs. Keys not supplied keep their persisted values; an
empty/``None`` ``inputs`` leaves the run's inputs unchanged.
"""
state = RunState.load(run_id, self.project_root)
if state.status not in (RunStatus.PAUSED, RunStatus.FAILED):
msg = f"Cannot resume run {run_id!r} with status {state.status.value!r}."
@@ -508,6 +581,12 @@ class WorkflowEngine:
else:
definition = self.load_workflow(state.workflow_id)
# Merge any newly-supplied inputs over the persisted ones and
# re-validate through the same typing path as the initial run.
if inputs:
merged = {**state.inputs, **inputs}
state.inputs = self._resolve_inputs(definition, merged)
# Restore context
context = StepContext(
inputs=state.inputs,
@@ -629,7 +708,10 @@ class WorkflowEngine:
# Handle failures
if result.status == StepStatus.FAILED:
# Gate abort (output.aborted) maps to ABORTED status
# Gate abort (output.aborted) maps to ABORTED status.
# Aborts are deliberate operator decisions, so
# `continue_on_error` does NOT override them — that flag
# is for transient/expected step failures only.
if result.output.get("aborted"):
state.status = RunStatus.ABORTED
state.append_log(
@@ -638,15 +720,49 @@ class WorkflowEngine:
"step_id": step_id,
}
)
else:
state.status = RunStatus.FAILED
state.save()
return
# `continue_on_error: true` lets the pipeline route
# around the failure instead of halting. The step
# result (including exit_code, stderr, status) is
# still recorded so a downstream `if` or `switch`
# can branch on it (or a `gate` can surface it to the
# operator via message interpolation). Log a single,
# unambiguous event per failure resolution — either
# the run continued past it, or it halted.
#
# Use identity comparison (`is True`) rather than
# truthiness so that only a literal boolean enables
# the behaviour, even if validation was skipped.
# Validation rejects non-bool values at parse time,
# but `WorkflowEngine.execute()` does not auto-validate
# (see `WorkflowEngine.load_workflow`, whose docstring
# explicitly notes "not yet validated; call
# `validate_workflow()` or `engine.validate()`
# separately"), so a caller passing an unvalidated
# definition could otherwise see truthy non-bool
# values like the string `"true"` silently change
# run semantics.
if step_config.get("continue_on_error") is True:
state.append_log(
{
"event": "step_failed",
"event": "step_continue_on_error",
"step_id": step_id,
"error": result.error,
}
)
state.save()
continue
state.status = RunStatus.FAILED
state.append_log(
{
"event": "step_failed",
"step_id": step_id,
"error": result.error,
}
)
state.save()
return

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
import shutil
from pathlib import Path
from typing import Any
@@ -126,12 +125,10 @@ class CommandStep(StepBase):
if impl is None:
return None
# Check if the integration supports CLI dispatch
if impl.build_exec_args("test") is None:
return None
# Check if the CLI tool is actually installed
if not shutil.which(impl.key):
# Check if the CLI tool is actually installed via the integration's
# own availability check (honours custom executables, dual binaries,
# and non-PATH install paths). See issue #2597.
if not impl.is_cli_available():
return None
project_root = Path(context.project_root) if context.project_root else None

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
import shutil
from pathlib import Path
from typing import Any
@@ -115,10 +114,15 @@ class PromptStep(StepBase):
return None
exec_args = impl.build_exec_args(prompt, model=model, output_json=False)
if exec_args is None:
# Check if the CLI tool is actually installed via the integration's
# own availability check (honours custom executables, dual binaries,
# and non-PATH install paths). See issue #2597.
if not impl.is_cli_available():
return None
if not shutil.which(impl.key):
# Prompt dispatch executes exec_args directly; require a non-empty argv.
if not exec_args:
return None
import subprocess

View File

@@ -147,7 +147,14 @@ Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generate
- Examples: public APIs for libraries, command schemas for CLI tools, endpoints for web services, grammars for parsers, UI contracts for applications
- Skip if project is purely internal (build scripts, one-off tools, etc.)
3. **Agent context update**:
3. **Create quickstart validation guide** → `quickstart.md`:
- Document runnable validation scenarios that prove the feature works end-to-end
- Include prerequisites, setup commands, test/run commands, and expected outcomes
- Use links or references to contracts and data model details instead of duplicating them
- Do not include full implementation code, model/service/controller bodies, migrations, or complete test suites
- Keep this artifact as a validation/run guide; implementation details belong in `tasks.md` and the implementation phase
4. **Agent context update**:
- Update the plan reference between the `<!-- SPECKIT START -->` and `<!-- SPECKIT END -->` markers in `__CONTEXT_FILE__` to point to the plan file created in step 1 (the IMPL_PLAN path)
**Output**: data-model.md, /contracts/*, quickstart.md, updated agent context file

View File

@@ -81,3 +81,72 @@ def _isolate_auth_config(monkeypatch):
# Also clear the per-process cache so tests that unset _config_override
# won't see a previously cached real-file result.
monkeypatch.setattr(_auth_http, "_config_cache", None)
@pytest.fixture
def clean_environ(monkeypatch):
"""Strip any real GH_TOKEN / GITHUB_TOKEN from the test environment."""
monkeypatch.delenv("GH_TOKEN", raising=False)
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
def _fake_self_upgrade_argv0(monkeypatch, tmp_path, env_name, path_parts):
"""Create a fake executable under tmp_path and point sys.argv[0] at it."""
monkeypatch.setenv(env_name, str(tmp_path))
fake_dir = tmp_path.joinpath(*path_parts)
fake_dir.mkdir(parents=True)
fake_specify = fake_dir / ("specify.exe" if os.name == "nt" else "specify")
fake_specify.write_text("#!/usr/bin/env python\n")
fake_specify.chmod(0o755)
monkeypatch.setattr("sys.argv", [str(fake_specify)])
return fake_specify
@pytest.fixture
def uv_tool_argv0(monkeypatch, tmp_path):
"""Point sys.argv[0] at a simulated `uv tool` install path under tmp HOME."""
if os.name == "nt":
return _fake_self_upgrade_argv0(
monkeypatch, tmp_path, "LOCALAPPDATA", ("uv", "tools", "specify-cli", "bin")
)
return _fake_self_upgrade_argv0(
monkeypatch,
tmp_path,
"HOME",
(".local", "share", "uv", "tools", "specify-cli", "bin"),
)
@pytest.fixture
def pipx_argv0(monkeypatch, tmp_path):
"""Point sys.argv[0] at a simulated pipx install path under tmp HOME."""
if os.name == "nt":
return _fake_self_upgrade_argv0(
monkeypatch, tmp_path, "LOCALAPPDATA", ("pipx", "venvs", "specify-cli", "bin")
)
return _fake_self_upgrade_argv0(
monkeypatch, tmp_path, "HOME", (".local", "pipx", "venvs", "specify-cli", "bin")
)
@pytest.fixture
def uvx_ephemeral_argv0(monkeypatch, tmp_path):
"""Point sys.argv[0] at a simulated uvx ephemeral-cache path under tmp HOME."""
if os.name == "nt":
return _fake_self_upgrade_argv0(
monkeypatch,
tmp_path,
"LOCALAPPDATA",
("uv", "cache", "archive-v0", "abc123", "bin"),
)
return _fake_self_upgrade_argv0(
monkeypatch, tmp_path, "HOME", (".cache", "uv", "archive-v0", "abc123", "bin")
)
@pytest.fixture
def unsupported_argv0(monkeypatch, tmp_path):
"""Point sys.argv[0] at a path that does not match any installer prefix."""
return _fake_self_upgrade_argv0(
monkeypatch, tmp_path, "HOME", ("random", "location", "bin")
)

View File

@@ -371,7 +371,7 @@ class TestCreateFeaturePowerShell:
)
assert result.returncode == 0, result.stderr
# pwsh may prefix warnings to stdout; find the JSON line
json_line = [l for l in result.stdout.splitlines() if l.strip().startswith("{")]
json_line = [ln for ln in result.stdout.splitlines() if ln.strip().startswith("{")]
assert json_line, f"No JSON in output: {result.stdout}"
data = json.loads(json_line[-1])
assert "BRANCH_NAME" in data

15
tests/http_helpers.py Normal file
View File

@@ -0,0 +1,15 @@
"""HTTP test helpers shared by version-related CLI tests."""
import json
from unittest.mock import MagicMock
def mock_urlopen_response(payload: dict) -> MagicMock:
"""Build a urlopen context-manager mock whose read returns JSON."""
body = json.dumps(payload).encode("utf-8")
resp = MagicMock()
resp.read.return_value = body
cm = MagicMock()
cm.__enter__.return_value = resp
cm.__exit__.return_value = False
return cm

View File

@@ -121,6 +121,11 @@ class TestBasePrimitives:
assert len(templates) > 0
assert all(t.suffix == ".md" for t in templates)
def test_list_command_templates_keeps_checklist_after_plan(self):
i = StubIntegration()
stems = [template.stem for template in i.list_command_templates()]
assert stems.index("plan") < stems.index("checklist")
def test_command_filename_default(self):
i = StubIntegration()
assert i.command_filename("plan") == "speckit.plan.md"

View File

@@ -131,5 +131,5 @@ class TestAgyHookCommandNote:
)
result = AgyIntegration._inject_hook_command_note(content)
lines = result.splitlines()
note_line = [l for l in lines if "replace dots" in l][0]
note_line = [ln for ln in lines if "replace dots" in ln][0]
assert note_line.startswith(" "), "Note should preserve indentation"

View File

@@ -254,8 +254,8 @@ class MarkdownIntegrationTests:
COMMAND_STEMS = [
"agent-context.update",
"analyze", "checklist", "clarify", "constitution",
"implement", "plan", "specify", "tasks", "taskstoissues",
"analyze", "clarify", "constitution", "implement",
"plan", "checklist", "specify", "tasks", "taskstoissues",
]
def _expected_files(self, script_variant: str) -> list[str]:
@@ -269,10 +269,10 @@ class MarkdownIntegrationTests:
files.append(f"{cmd_dir}/speckit.{stem}.md")
# Framework files
files.append(f".specify/integration.json")
files.append(f".specify/init-options.json")
files.append(".specify/integration.json")
files.append(".specify/init-options.json")
files.append(f".specify/integrations/{self.KEY}.manifest.json")
files.append(f".specify/integrations/speckit.manifest.json")
files.append(".specify/integrations/speckit.manifest.json")
if script_variant == "sh":
for name in ["check-prerequisites.sh", "common.sh", "create-new-feature.sh",

View File

@@ -100,8 +100,8 @@ class SkillsIntegrationTests:
skill_files = [f for f in created if "scripts" not in f.parts]
expected_commands = {
"analyze", "checklist", "clarify", "constitution",
"implement", "plan", "specify", "tasks", "taskstoissues",
"analyze", "clarify", "constitution", "implement",
"plan", "checklist", "specify", "tasks", "taskstoissues",
}
# Derive command names from the skill directory names
@@ -393,8 +393,8 @@ class SkillsIntegrationTests:
# -- Complete file inventory ------------------------------------------
_SKILL_COMMANDS = [
"analyze", "checklist", "clarify", "constitution",
"implement", "plan", "specify", "tasks", "taskstoissues",
"analyze", "clarify", "constitution", "implement",
"plan", "checklist", "specify", "tasks", "taskstoissues",
]
def _expected_files(self, script_variant: str) -> list[str]:

View File

@@ -486,11 +486,11 @@ class TomlIntegrationTests:
COMMAND_STEMS = [
"agent-context.update",
"analyze",
"checklist",
"clarify",
"constitution",
"implement",
"plan",
"checklist",
"specify",
"tasks",
"taskstoissues",

View File

@@ -152,7 +152,7 @@ class YamlIntegrationTests:
content = f.read_text(encoding="utf-8")
# Strip trailing source comment before parsing
lines = content.split("\n")
yaml_lines = [l for l in lines if not l.startswith("# Source:")]
yaml_lines = [ln for ln in lines if not ln.startswith("# Source:")]
try:
parsed = yaml.safe_load("\n".join(yaml_lines))
except Exception as exc:
@@ -183,7 +183,7 @@ class YamlIntegrationTests:
content = cmd_files[0].read_text(encoding="utf-8")
# Strip source comment for parsing
lines = content.split("\n")
yaml_lines = [l for l in lines if not l.startswith("# Source:")]
yaml_lines = [ln for ln in lines if not ln.startswith("# Source:")]
parsed = yaml.safe_load("\n".join(yaml_lines))
assert "description:" not in parsed["prompt"]
@@ -365,11 +365,11 @@ class YamlIntegrationTests:
COMMAND_STEMS = [
"agent-context.update",
"analyze",
"checklist",
"clarify",
"constitution",
"implement",
"plan",
"checklist",
"specify",
"tasks",
"taskstoissues",

View File

@@ -3,6 +3,7 @@
import codecs
import json
import os
from pathlib import Path
from unittest.mock import patch
import yaml
@@ -577,3 +578,204 @@ class TestClaudeHookCommandNote:
assert "user-invocable: true" in result
assert "disable-model-invocation: false" in result
assert "replace dots" in result
class TestSpeckitManifestRecordsSkippedFiles:
"""Regression test for issue #2107.
``install_shared_infra`` must record every shared-infrastructure file
under ``.specify/`` in ``speckit.manifest.json``, including files that
were *skipped* because they already existed on disk and ``force=False``.
Before the fix, the skip branches in the scripts and templates loops
appended to ``skipped_files`` without calling ``manifest.record_existing``.
So when ``install_shared_infra`` ran with a fresh (or lost) manifest
against an already-populated ``.specify/`` tree, every file went down the
skip path, ``planned_copies`` and ``planned_templates`` stayed empty, and
``manifest.save()`` wrote an empty ``files`` field — leaving the
integration believing nothing was installed.
Reproduction (without the fix) using ``install_shared_infra`` directly:
install_shared_infra(p, "sh", ..., force=False) # 1st run → 10 files
(p / ".specify/integrations/speckit.manifest.json").unlink()
install_shared_infra(p, "sh", ..., force=False) # 2nd run → 0 files
# ^^ BUG: empty
"""
def _read_manifest_files(self, project_path: Path) -> dict:
manifest_path = (
project_path / ".specify" / "integrations" / "speckit.manifest.json"
)
assert manifest_path.exists(), (
f"speckit.manifest.json not written at {manifest_path}"
)
data = json.loads(manifest_path.read_text(encoding="utf-8"))
# ``IntegrationManifest.save`` serialises a ``files`` dict — assert
# the schema explicitly so a regression to a different key (e.g.
# the internal ``_files`` attribute name) fails loudly instead of
# being masked by a silent fallback.
assert isinstance(data, dict), (
f"manifest root is not a dict, got {type(data).__name__}"
)
assert "files" in data, (
f"manifest missing 'files' key, got keys: {sorted(data.keys())}"
)
files = data["files"]
assert isinstance(files, dict), (
f"manifest 'files' is not a dict, got {type(files).__name__}"
)
return files
def test_install_shared_infra_records_skipped_files(self, tmp_path):
"""With ``force=False`` and ``.specify/`` already populated, the
manifest must still record every file — the skip branches are not
allowed to drop files from the manifest."""
from rich.console import Console
from specify_cli.shared_infra import install_shared_infra
# Resolve the project's own packaged sources by walking up from this
# test file to the repo root (which contains ``scripts/`` and
# ``templates/`` that ``shared_scripts_source`` looks for).
repo_root = Path(__file__).resolve().parents[2]
console = Console(quiet=True)
# First run — fresh project, manifest gets populated normally.
install_shared_infra(
tmp_path,
"sh",
version="0.0.0",
core_pack=None,
repo_root=repo_root,
console=console,
force=False,
)
first_files = self._read_manifest_files(tmp_path)
assert first_files, "first install produced an empty manifest"
# Simulate a lost manifest while ``.specify/`` is still on disk
# (e.g. the manifest was deleted, corrupted, or the layout was
# extracted out-of-band).
manifest_path = (
tmp_path / ".specify" / "integrations" / "speckit.manifest.json"
)
manifest_path.unlink()
# Second run — every file already exists, so every iteration takes
# the skip branch. With the fix, those files are still recorded.
install_shared_infra(
tmp_path,
"sh",
version="0.0.0",
core_pack=None,
repo_root=repo_root,
console=console,
force=False,
)
second_files = self._read_manifest_files(tmp_path)
assert second_files, (
"speckit.manifest.json files dict is empty after install with "
"skipped files (issue #2107) — every file went down the skip "
"branch but none were recorded"
)
# The recovered manifest must cover everything the first run tracked.
missing = set(first_files) - set(second_files)
assert not missing, (
f"these files were tracked on the first install but missing after "
f"the skipped-files re-install: {sorted(missing)[:5]}"
)
def test_install_shared_infra_handles_directory_at_script_destination(
self, tmp_path
):
"""A non-file (directory) at a script's destination must NOT crash
``install_shared_infra`` and must NOT be recorded in the manifest —
the path still appears in the user-visible skipped-paths warning.
"""
from io import StringIO
from rich.console import Console
from specify_cli.shared_infra import install_shared_infra
repo_root = Path(__file__).resolve().parents[2]
output = StringIO()
console = Console(file=output, force_terminal=False, width=200)
# Pre-create the .specify/scripts/bash tree, then plant a directory
# where a script file is expected so the skip branch hits a
# non-regular-file path.
bash_dir = tmp_path / ".specify" / "scripts" / "bash"
bash_dir.mkdir(parents=True)
(bash_dir / "common.sh").mkdir() # collision: dir where file expected
# Must not crash.
install_shared_infra(
tmp_path,
"sh",
version="0.0.0",
core_pack=None,
repo_root=repo_root,
console=console,
force=False,
)
files = self._read_manifest_files(tmp_path)
assert ".specify/scripts/bash/common.sh" not in files, (
"directory at script dst must not be recorded in the manifest"
)
text = output.getvalue()
assert "common.sh" in text, (
"directory-at-script-dst path must surface in the skipped warning"
)
def test_install_shared_infra_handles_directory_at_template_destination(
self, tmp_path
):
"""Symmetric coverage for the templates loop: a directory at a
template's destination must NOT crash install nor be recorded."""
from io import StringIO
from rich.console import Console
from specify_cli.shared_infra import install_shared_infra
repo_root = Path(__file__).resolve().parents[2]
output = StringIO()
console = Console(file=output, force_terminal=False, width=200)
templates_dir = tmp_path / ".specify" / "templates"
templates_dir.mkdir(parents=True)
src_templates = repo_root / "templates"
real_template = next(
(
p.name
for p in src_templates.iterdir()
if p.is_file()
and not p.name.startswith(".")
and p.name != "vscode-settings.json"
),
None,
)
assert real_template, (
"no real template found in repo to collide against"
)
(templates_dir / real_template).mkdir() # collision
install_shared_infra(
tmp_path,
"sh",
version="0.0.0",
core_pack=None,
repo_root=repo_root,
console=console,
force=False,
)
files = self._read_manifest_files(tmp_path)
template_rel = f".specify/templates/{real_template}"
assert template_rel not in files, (
"directory at template dst must not be recorded in manifest"
)
text = output.getvalue()
assert real_template in text, (
"directory-at-template-dst path must surface in the skipped warning"
)

View File

@@ -127,8 +127,8 @@ class TestCopilotIntegration:
agent_files = sorted(agents_dir.glob("speckit.*.agent.md"))
assert len(agent_files) == 9
expected_commands = {
"analyze", "checklist", "clarify", "constitution",
"implement", "plan", "specify", "tasks", "taskstoissues",
"analyze", "clarify", "constitution", "implement",
"plan", "checklist", "specify", "tasks", "taskstoissues",
}
actual_commands = {f.name.removeprefix("speckit.").removesuffix(".agent.md") for f in agent_files}
assert actual_commands == expected_commands
@@ -321,8 +321,8 @@ class TestCopilotSkillsMode:
"""Tests for Copilot integration in --skills mode."""
_SKILL_COMMANDS = [
"analyze", "checklist", "clarify", "constitution",
"implement", "plan", "specify", "tasks", "taskstoissues",
"analyze", "clarify", "constitution", "implement",
"plan", "checklist", "specify", "tasks", "taskstoissues",
]
def _make_copilot(self):
@@ -426,8 +426,8 @@ class TestCopilotSkillsMode:
# -- Copilot-specific post-processing ---------------------------------
def test_post_process_skill_content_injects_mode(self):
"""post_process_skill_content() should inject mode: field."""
def test_post_process_skill_content_does_not_inject_mode(self):
"""post_process_skill_content() must NOT inject mode: — VS Code Copilot does not support it."""
copilot = self._make_copilot()
content = (
"---\n"
@@ -437,10 +437,10 @@ class TestCopilotSkillsMode:
"\nBody content\n"
)
updated = copilot.post_process_skill_content(content)
assert "mode: speckit.plan" in updated
assert "mode:" not in updated
def test_post_process_skill_content_injects_hook_note(self):
"""post_process_skill_content() should inject shared hook guidance."""
"""post_process_skill_content() should inject shared hook guidance but not mode:."""
copilot = self._make_copilot()
content = (
"---\n"
@@ -451,7 +451,7 @@ class TestCopilotSkillsMode:
)
updated = copilot.post_process_skill_content(content)
assert "replace dots" in updated
assert "mode: speckit.specify" in updated
assert "mode:" not in updated
def test_post_process_idempotent(self):
"""post_process_skill_content() must be idempotent."""
@@ -467,8 +467,8 @@ class TestCopilotSkillsMode:
second = copilot.post_process_skill_content(first)
assert first == second
def test_skills_have_mode_in_frontmatter(self, tmp_path):
"""Generated SKILL.md files should have mode: field from post-processing."""
def test_skills_do_not_have_mode_in_frontmatter(self, tmp_path):
"""Generated SKILL.md files must NOT contain mode: — VS Code Copilot does not support it."""
copilot = self._make_copilot()
created, _ = self._setup_skills(copilot, tmp_path)
skill_files = [f for f in created if f.name == "SKILL.md"]
@@ -477,11 +477,7 @@ class TestCopilotSkillsMode:
content = f.read_text(encoding="utf-8")
parts = content.split("---", 2)
fm = yaml.safe_load(parts[1])
assert "mode" in fm, f"{f} frontmatter missing 'mode'"
# mode should be speckit.<stem>
skill_dir_name = f.parent.name
stem = skill_dir_name.removeprefix("speckit-")
assert fm["mode"] == f"speckit.{stem}"
assert "mode" not in fm, f"{f} frontmatter must not contain unsupported 'mode' field"
def test_skills_hook_sections_explain_dotted_command_conversion(self, tmp_path):
"""Generated skills with hook sections should include shared hook guidance."""

View File

@@ -1,6 +1,7 @@
"""Tests for CursorAgentIntegration."""
from pathlib import Path
from urllib.parse import urlparse
from specify_cli.integrations import get_integration
from specify_cli.integrations.manifest import IntegrationManifest
@@ -106,3 +107,157 @@ class TestCursorAgentAutoPromote:
assert result.exit_code == 0, f"init --ai cursor-agent failed: {result.output}"
assert (target / ".cursor" / "skills" / "speckit-plan" / "SKILL.md").exists()
class TestCursorAgentCliDispatch:
"""Verify the CLI dispatch path for cursor-agent (issue #2629).
The ``cursor-agent`` CLI supports headless execution via ``-p`` (with
full tool access including write/shell) and requires ``--trust`` to
bypass the Workspace Trust prompt. These tests pin the exact argv
shape that the workflow runner will use.
"""
def test_requires_cli_is_false_for_ide_first_flow(self):
"""``requires_cli`` must stay False so the IDE-only flow keeps working.
``specify init --ai cursor-agent`` (without ``--ignore-agent-tools``)
treats ``requires_cli=True`` as a hard precheck and fails when the
``cursor-agent`` CLI isn't on PATH — even though the Cursor IDE
/ skills flow can run without it. Workflow dispatch support is
signalled by overriding ``build_exec_args()`` instead, mirroring
``CopilotIntegration``.
"""
i = get_integration("cursor-agent")
assert i.config.get("requires_cli") is False
def test_install_url_is_set(self):
i = get_integration("cursor-agent")
url = i.config.get("install_url")
assert url is not None
# CodeQL: use a hostname comparison instead of a substring check
# to avoid the "Incomplete URL substring sanitization" warning
# (substring "cursor.com" can also appear in attacker-controlled
# positions of an arbitrary URL).
host = (urlparse(url).hostname or "").lower()
assert host == "cursor.com" or host.endswith(".cursor.com")
def test_build_exec_args_default_includes_headless_flags_and_json(self):
"""Default argv emits the full headless flag set: -p --trust
--approve-mcps --force, then prompt, then --output-format json.
"""
i = get_integration("cursor-agent")
args = i.build_exec_args("/speckit-specify some-feature")
assert args == [
"cursor-agent", "-p", "--trust", "--approve-mcps", "--force",
"/speckit-specify some-feature",
"--output-format", "json",
]
def test_build_exec_args_text_output_omits_format(self):
i = get_integration("cursor-agent")
args = i.build_exec_args("/speckit-plan", output_json=False)
assert args == [
"cursor-agent", "-p", "--trust", "--approve-mcps", "--force",
"/speckit-plan",
]
def test_build_exec_args_with_model(self):
i = get_integration("cursor-agent")
args = i.build_exec_args(
"/speckit-specify", model="sonnet-4-thinking", output_json=False
)
assert args == [
"cursor-agent", "-p", "--trust", "--approve-mcps", "--force",
"/speckit-specify",
"--model", "sonnet-4-thinking",
]
def test_build_exec_args_contains_mandatory_headless_flags(self):
"""The four headless flags must always appear together.
``--approve-mcps`` is required so MCP servers (e.g. dingtalk-doc)
actually load in headless mode; ``--force`` is required so the
agent doesn't block on tool-call approval prompts during the
speckit workflow. Together with ``-p`` and ``--trust`` they
bring cursor-agent's headless behaviour in line with
``claude -p`` / ``codex --exec`` from spec-kit's perspective.
"""
i = get_integration("cursor-agent")
args = i.build_exec_args("/speckit-implement", output_json=False)
for flag in ("-p", "--trust", "--approve-mcps", "--force"):
assert flag in args, f"missing mandatory headless flag: {flag}"
def test_build_exec_args_supports_dispatch_without_requires_cli(self):
"""``build_exec_args`` must return argv even though ``requires_cli``
is ``False``.
``CursorAgentIntegration`` opts out of the ``requires_cli`` hard
precheck (so ``specify init`` doesn't fail when the CLI isn't on
PATH) but still supports workflow dispatch. The presence of a
non-``None`` argv from ``build_exec_args()`` is what the engine
keys off — pin that invariant.
"""
i = get_integration("cursor-agent")
assert i.config.get("requires_cli") is False
argv = i.build_exec_args("/speckit-plan", output_json=False)
assert argv is not None
assert argv[0] == "cursor-agent"
def test_build_command_invocation_uses_hyphenated_skill_name(self):
"""SkillsIntegration: /speckit-plan (not /speckit.plan)."""
i = get_integration("cursor-agent")
assert i.build_command_invocation("speckit.plan", "feature-x") == "/speckit-plan feature-x"
assert i.build_command_invocation("plan") == "/speckit-plan"
def test_dispatch_command_resolves_cmd_shim_for_subprocess(self):
"""``.cmd`` shims must be resolved to their full path before ``subprocess.run``.
``cursor-agent`` (and other npm-installed CLIs on Windows) ship as
``cursor-agent.cmd`` wrappers. ``shutil.which`` honors ``PATHEXT``
and finds them, but Python's ``subprocess.run`` calls
``CreateProcess`` which does **not** consult ``PATHEXT`` and fails
with ``WinError 2`` on a bare ``["cursor-agent", ...]`` argv. The
fix in ``base.py::dispatch_command`` resolves ``exec_args[0]`` via
``shutil.which`` so the full ``.cmd`` path is what reaches
``CreateProcess``.
"""
from unittest.mock import patch, MagicMock
i = get_integration("cursor-agent")
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = "ok"
mock_result.stderr = ""
fake_path = r"C:\Users\foo\AppData\Local\cursor-agent\cursor-agent.CMD"
with patch(
"specify_cli.integrations.base.shutil.which", return_value=fake_path
), patch("subprocess.run", return_value=mock_result) as mock_run:
result = i.dispatch_command(
"speckit.plan", args="feature-x", stream=False, timeout=5
)
assert result["exit_code"] == 0
argv = mock_run.call_args[0][0]
assert argv[0] == fake_path, f"expected resolved .CMD path, got: {argv[0]!r}"
assert argv[1:6] == ["-p", "--trust", "--approve-mcps", "--force", "/speckit-plan feature-x"]
def test_dispatch_command_passthrough_when_shutil_which_finds_nothing(self):
"""If ``shutil.which`` returns ``None``, leave argv unchanged so the
existing ``FileNotFoundError`` path remains observable to callers."""
from unittest.mock import patch, MagicMock
i = get_integration("cursor-agent")
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = ""
mock_result.stderr = ""
with patch(
"specify_cli.integrations.base.shutil.which", return_value=None
), patch("subprocess.run", return_value=mock_result) as mock_run:
i.dispatch_command("speckit.plan", stream=False, timeout=5)
argv = mock_run.call_args[0][0]
assert argv[0] == "cursor-agent"

View File

@@ -185,6 +185,20 @@ class TestGenericIntegration:
)
assert "__CONTEXT_FILE__" not in content
def test_plan_defines_quickstart_as_validation_guide(self, tmp_path):
"""The generated plan command should keep quickstart.md out of implementation scope."""
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
plan_file = tmp_path / ".custom" / "cmds" / "speckit.plan.md"
assert plan_file.exists()
content = plan_file.read_text(encoding="utf-8")
assert "Create quickstart validation guide" in content
assert "runnable validation scenarios" in content
assert "Do not include full implementation code" in content
assert "implementation details belong in `tasks.md` and the implementation phase" in content
def test_implement_loads_constitution_context(self, tmp_path):
"""The generated implement command should load constitution governance context."""
i = get_integration("generic")
@@ -199,10 +213,10 @@ class TestGenericIntegration:
"command_stem",
[
"analyze",
"checklist",
"clarify",
"implement",
"plan",
"checklist",
"specify",
"tasks",
"taskstoissues",

View File

@@ -0,0 +1,305 @@
"""Tests for RovodevIntegration."""
from __future__ import annotations
import os
import pytest
import yaml
from click.testing import Result
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.integrations import get_integration
from specify_cli.integrations.manifest import IntegrationManifest
def _run_init(project, *flags: str) -> Result:
"""Run ``specify init --here`` in *project* with the given extra flags.
Centralises the cwd-management boilerplate so individual tests just
declare the flags they care about.
"""
old_cwd = os.getcwd()
try:
os.chdir(project)
return CliRunner().invoke(
app,
["init", "--here", *flags, "--script", "sh",
"--no-git", "--ignore-agent-tools"],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
@pytest.fixture
def rovodev_init_project(tmp_path):
"""Run ``specify init --integration rovodev`` once and return the project root.
Shared across the slow init-inventory tests so we pay the full-CLI cost
only once instead of three times.
"""
project = tmp_path / "rovodev-init"
project.mkdir()
result = _run_init(project, "--integration", "rovodev")
assert result.exit_code == 0, result.output
return project
class TestRovodevIntegration:
"""Rovodev-specific tests (not inherited from SkillsIntegrationTests because
rovodev's setup() emits prompt wrappers + prompts.yml in addition to skills,
which violates the base mixin's pure-skills assumptions)."""
KEY = "rovodev"
CONTEXT_FILE = "AGENTS.md"
# -- ACLI dispatch -----------------------------------------------------
def test_build_exec_args(self):
impl = get_integration(self.KEY)
args = impl.build_exec_args("/speckit.plan add OAuth")
assert args[0:3] == ["acli", "rovodev", "run"]
assert args[3] == "/speckit.plan add OAuth"
assert "--output-schema" in args
def test_build_exec_args_without_json(self):
impl = get_integration(self.KEY)
args = impl.build_exec_args("/speckit.plan add OAuth", output_json=False)
assert args == ["acli", "rovodev", "run", "/speckit.plan add OAuth"]
def test_build_exec_args_executable_env_override(self, monkeypatch):
"""SPECKIT_INTEGRATION_ROVODEV_EXECUTABLE overrides the binary path.
Lets operators pin a specific ``acli`` build or relocate the binary
without modifying the integration. Mirrors codex/devin/claude/etc.
"""
monkeypatch.setenv("SPECKIT_INTEGRATION_ROVODEV_EXECUTABLE", "/opt/atl/bin/acli")
impl = get_integration(self.KEY)
args = impl.build_exec_args("hello", output_json=False)
assert args == ["/opt/atl/bin/acli", "rovodev", "run", "hello"]
def test_build_exec_args_executable_env_blank_falls_back(self, monkeypatch):
"""Whitespace/empty env override is treated as unset → default ``acli``."""
monkeypatch.setenv("SPECKIT_INTEGRATION_ROVODEV_EXECUTABLE", " ")
impl = get_integration(self.KEY)
args = impl.build_exec_args("hello", output_json=False)
assert args[0] == "acli"
def test_build_exec_args_extra_args_env_injection(self, monkeypatch):
"""SPECKIT_INTEGRATION_ROVODEV_EXTRA_ARGS injects extra CLI flags.
Useful for CI or non-interactive contexts that need to pass flags
the integration doesn't expose. Mirrors the contract on every other
CLI integration (claude, codex, devin, …).
"""
monkeypatch.setenv("SPECKIT_INTEGRATION_ROVODEV_EXTRA_ARGS", "--quiet --no-color")
impl = get_integration(self.KEY)
args = impl.build_exec_args("hello", output_json=False)
assert args == [
"acli", "rovodev", "run", "hello", "--quiet", "--no-color",
]
# -- Setup-level: prompt wrappers + prompts.yml ------------------------
def test_setup_creates_prompts_and_manifest(self, tmp_path):
impl = get_integration(self.KEY)
manifest = IntegrationManifest(self.KEY, tmp_path)
created = impl.setup(tmp_path, manifest)
prompts_manifest = tmp_path / ".rovodev" / "prompts.yml"
assert prompts_manifest in created
assert prompts_manifest.exists()
prompts_dir = tmp_path / ".rovodev" / "prompts"
skills_dir = tmp_path / ".rovodev" / "skills"
assert prompts_dir.is_dir()
assert skills_dir.is_dir()
templates = impl.list_command_templates()
prompt_files = sorted(prompts_dir.glob("speckit-*.prompt.md"))
skill_dirs = sorted(d for d in skills_dir.iterdir() if d.is_dir() and d.name.startswith("speckit-"))
assert len(prompt_files) == len(templates)
assert len(skill_dirs) == len(templates)
for skill_dir in skill_dirs:
assert (skill_dir / "SKILL.md").exists()
def test_prompts_manifest_entries_well_formed(self, tmp_path):
impl = get_integration(self.KEY)
manifest = IntegrationManifest(self.KEY, tmp_path)
impl.setup(tmp_path, manifest)
prompts_manifest = tmp_path / ".rovodev" / "prompts.yml"
data = yaml.safe_load(prompts_manifest.read_text(encoding="utf-8"))
assert list(data) == ["prompts"]
entries = data["prompts"]
assert entries
for entry in entries:
assert entry["name"].startswith("speckit-")
assert entry["description"]
content_file = tmp_path / ".rovodev" / entry["content_file"]
assert content_file.exists(), f"Missing prompt file {content_file}"
def test_prompt_wrapper_format(self, tmp_path):
"""Every prompt wrapper delegates to its paired skill via 'use skill ...'."""
impl = get_integration(self.KEY)
manifest = IntegrationManifest(self.KEY, tmp_path)
impl.setup(tmp_path, manifest)
prompts_dir = tmp_path / ".rovodev" / "prompts"
prompt_files = sorted(prompts_dir.glob("speckit-*.prompt.md"))
assert prompt_files
for prompt_file in prompt_files:
skill_name = prompt_file.name.removesuffix(".prompt.md")
content = prompt_file.read_text(encoding="utf-8")
assert content == f"use skill {skill_name} $ARGUMENTS\n", (
f"{prompt_file} has unexpected wrapper format"
)
def test_prompts_manifest_merge_preserves_user_entries(self, tmp_path):
impl = get_integration(self.KEY)
manifest = IntegrationManifest(self.KEY, tmp_path)
prompts_manifest = tmp_path / ".rovodev" / "prompts.yml"
prompts_manifest.parent.mkdir(parents=True, exist_ok=True)
user_entry = {
"name": "my-custom-prompt",
"description": "User-added prompt",
"content_file": "prompts/my-custom-prompt.md",
}
prompts_manifest.write_text(
yaml.safe_dump({"prompts": [user_entry]}, sort_keys=False),
encoding="utf-8",
)
impl.setup(tmp_path, manifest)
data = yaml.safe_load(prompts_manifest.read_text(encoding="utf-8"))
names = {entry.get("name") for entry in data.get("prompts", [])}
assert "my-custom-prompt" in names
assert "speckit-plan" in names
def test_modified_prompts_yml_survives_uninstall(self, tmp_path):
impl = get_integration(self.KEY)
manifest = IntegrationManifest(self.KEY, tmp_path)
impl.install(tmp_path, manifest)
manifest.save()
modified = tmp_path / ".rovodev" / "prompts.yml"
modified.write_text("user modified this", encoding="utf-8")
_, skipped = impl.uninstall(tmp_path, manifest)
assert modified.exists()
assert modified in skipped
# -- Full-CLI init: skills + prompts integration with extensions -------
def test_init_inventory(self, rovodev_init_project):
"""Rovodev + extensions produce the expected skill / prompt set.
Contract:
- Rovodev.setup() emits one SKILL.md + one .prompt.md per core template.
- Extensions install additional SKILL.md directories with NO prompt wrapper.
"""
project = rovodev_init_project
impl = get_integration(self.KEY)
core_skill_names = {
f"speckit-{t.stem.replace('.', '-')}"
for t in impl.list_command_templates()
}
prompt_files = sorted((project / ".rovodev" / "prompts").glob("speckit-*.prompt.md"))
prompt_stems = {p.name.removesuffix(".prompt.md") for p in prompt_files}
skills_dir = project / ".rovodev" / "skills"
skill_names = {
d.name for d in skills_dir.iterdir()
if d.is_dir() and d.name.startswith("speckit-")
}
# Prompts: exactly the core template set.
assert prompt_stems == core_skill_names
# Skills: core extension-installed.
assert core_skill_names.issubset(skill_names)
extension_skills = skill_names - core_skill_names
assert extension_skills, (
"Expected at least one extension-installed skill (e.g. agent-context)"
)
# prompts.yml mirrors the prompt files exactly.
prompts_manifest = project / ".rovodev" / "prompts.yml"
data = yaml.safe_load(prompts_manifest.read_text(encoding="utf-8"))
assert {e["name"] for e in data["prompts"]} == core_skill_names
def test_init_skill_files_well_formed(self, rovodev_init_project):
"""Every speckit-* SKILL.md from full init has valid frontmatter +
processed body, including extension-installed skills."""
project = rovodev_init_project
skills_dir = project / ".rovodev" / "skills"
skill_dirs = sorted(
d for d in skills_dir.iterdir()
if d.is_dir() and d.name.startswith("speckit-")
)
assert skill_dirs
for skill_dir in skill_dirs:
skill_file = skill_dir / "SKILL.md"
assert skill_file.exists(), f"Missing {skill_file}"
content = skill_file.read_text(encoding="utf-8")
# Frontmatter delimited by leading '---\n' ... '\n---\n'
assert content.startswith("---\n"), f"{skill_file} missing frontmatter"
fm_end = content.find("\n---\n", 4)
assert fm_end != -1, f"{skill_file} has unterminated frontmatter"
fm = yaml.safe_load(content[4:fm_end])
body = content[fm_end + len("\n---\n"):]
assert fm.get("name") == skill_dir.name
assert fm.get("description")
assert body.strip(), f"{skill_file} has empty body"
for placeholder in ("{SCRIPT}", "__AGENT__", "__CONTEXT_FILE__", "__SPECKIT_COMMAND_"):
assert placeholder not in body, (
f"{skill_file} body contains unprocessed placeholder {placeholder!r}"
)
# Skills agents must use hyphen-style refs in body.
assert "/speckit." not in body, (
f"{skill_file} body contains dot-notation /speckit. reference"
)
# The plan skill must reference the agent's context file.
plan_content = (skills_dir / "speckit-plan" / "SKILL.md").read_text(encoding="utf-8")
assert self.CONTEXT_FILE in plan_content
# -- Full-CLI init: integration metadata -------------------------------
def test_init_writes_integration_manifest_and_options(self, rovodev_init_project):
"""Full init must produce an integration manifest and well-formed
init-options.json — used by extensions, presets, and uninstall."""
import json
project = rovodev_init_project
manifest_path = project / ".specify" / "integrations" / "rovodev.manifest.json"
speckit_manifest = project / ".specify" / "integrations" / "speckit.manifest.json"
assert manifest_path.exists(), "rovodev integration manifest missing"
assert speckit_manifest.exists(), "speckit shared manifest missing"
init_options = json.loads(
(project / ".specify" / "init-options.json").read_text(encoding="utf-8")
)
assert init_options["integration"] == self.KEY
assert init_options["ai"] == self.KEY
# Rovodev is a SkillsIntegration, so ai_skills is auto-set.
assert init_options.get("ai_skills") is True
assert init_options.get("script") == "sh"
def test_ai_flag_auto_promotes_to_integration(self, tmp_path):
"""``--ai rovodev`` should reach the same end-state as ``--integration rovodev``."""
project = tmp_path / "rovodev-ai"
project.mkdir()
result = _run_init(project, "--ai", "rovodev")
assert result.exit_code == 0, result.output
assert (project / ".rovodev" / "skills" / "speckit-plan" / "SKILL.md").exists()
assert (project / ".rovodev" / "prompts.yml").exists()
assert (project / ".specify" / "integrations" / "rovodev.manifest.json").exists()

View File

@@ -3,7 +3,6 @@
import json
import os
import pytest
from typer.testing import CliRunner
from specify_cli import app
@@ -242,9 +241,9 @@ class TestIntegrationInstall:
opts["speckit_version"] = "0.6.1"
init_options.write_text(json.dumps(opts), encoding="utf-8")
import specify_cli
import specify_cli.integrations._commands as _int_cmds
monkeypatch.setattr(specify_cli, "get_speckit_version", lambda: "0.8.11")
monkeypatch.setattr(_int_cmds, "get_speckit_version", lambda: "0.8.11")
result = _run_in_project(project, [
"integration", "install", "codex",
@@ -898,11 +897,10 @@ class TestIntegrationSwitch:
assert copilot_git_feature.exists(), "Git extension skill should exist for Copilot skills mode"
assert not copilot_agent_file.exists(), "Copilot skills mode should not create extension .agent.md files"
# Verify Copilot-specific frontmatter: mode field should map from
# skill name (speckit-git-feature) back to dot notation (speckit.git-feature)
# Verify Copilot skill frontmatter does NOT contain mode: — VS Code Copilot does not support it
skill_content = copilot_git_feature.read_text(encoding="utf-8")
assert "mode: speckit.git-feature" in skill_content, (
"Copilot skill frontmatter should contain mode mapped from skill name"
assert "mode:" not in skill_content, (
"Copilot skill frontmatter must not contain unsupported 'mode' field"
)
registry = json.loads(
@@ -1210,9 +1208,9 @@ class TestIntegrationUpgrade:
opts["speckit_version"] = "0.6.1"
init_options.write_text(json.dumps(opts), encoding="utf-8")
import specify_cli
import specify_cli.integrations._commands as _int_cmds
monkeypatch.setattr(specify_cli, "get_speckit_version", lambda: "0.8.11")
monkeypatch.setattr(_int_cmds, "get_speckit_version", lambda: "0.8.11")
result = _run_in_project(project, [
"integration", "upgrade", "claude",
@@ -1236,9 +1234,9 @@ class TestIntegrationUpgrade:
opts["speckit_version"] = "0.6.1"
init_options.write_text(json.dumps(opts), encoding="utf-8")
import specify_cli
import specify_cli.integrations._commands as _int_cmds
monkeypatch.setattr(specify_cli, "get_speckit_version", lambda: "0.8.11")
monkeypatch.setattr(_int_cmds, "get_speckit_version", lambda: "0.8.11")
result = _run_in_project(project, [
"integration", "upgrade", "claude",
@@ -1472,7 +1470,7 @@ class TestScriptTypeValidation:
class TestParseIntegrationOptionsEqualsForm:
def test_equals_form_parsed(self):
"""--commands-dir=./x should be parsed the same as --commands-dir ./x."""
from specify_cli import _parse_integration_options
from specify_cli.integrations._commands import _parse_integration_options
from specify_cli.integrations import get_integration
integration = get_integration("generic")

View File

@@ -34,6 +34,57 @@ class TestManifestRecordFile:
assert m.files["existing.txt"] == _sha256(f)
class TestManifestRecordExistingErrors:
"""Error-case coverage for ``record_existing`` symlink + non-file guards.
Added in #2483 — Copilot review flagged these as un-tested regressions
after the ``is_symlink``/``is_file`` guards were introduced.
"""
def test_rejects_symlink_target(self, tmp_path):
target = tmp_path / "target.txt"
target.write_text("target content", encoding="utf-8")
link = tmp_path / "link.txt"
link.symlink_to(target)
m = IntegrationManifest("test", tmp_path)
with pytest.raises(ValueError, match="symlinked"):
m.record_existing("link.txt")
def test_rejects_dangling_symlink(self, tmp_path):
# A symlink pointing nowhere should still be rejected before the
# ``is_file()`` check (which would itself be False on a dangler).
link = tmp_path / "dangler.txt"
link.symlink_to(tmp_path / "no-such-target.txt")
m = IntegrationManifest("test", tmp_path)
with pytest.raises(ValueError, match="symlinked"):
m.record_existing("dangler.txt")
def test_rejects_directory_path(self, tmp_path):
(tmp_path / "a_dir").mkdir()
m = IntegrationManifest("test", tmp_path)
with pytest.raises(ValueError, match="not a regular file"):
m.record_existing("a_dir")
def test_rejects_missing_path(self, tmp_path):
# ``is_file()`` is False for non-existent paths too; the same error
# surface keeps callers from having to distinguish "missing" from
# "wrong kind" — both mean "cannot hash this".
m = IntegrationManifest("test", tmp_path)
with pytest.raises(ValueError, match="not a regular file"):
m.record_existing("never-existed.txt")
def test_lexical_prevalidation_for_absolute_path(self, tmp_path):
# ``record_existing`` must reject absolute paths via the lexical
# pre-check, NOT via the filesystem-touching ``is_symlink()`` call.
# Verified by passing an absolute path that points to a directory
# outside the project root — the canonical "Absolute paths" error
# must surface before any stat on the absolute path.
m = IntegrationManifest("test", tmp_path)
abs_path = "C:\\tmp\\escape.txt" if sys.platform == "win32" else "/tmp/escape.txt"
with pytest.raises(ValueError, match="Absolute paths"):
m.record_existing(abs_path)
class TestManifestPathTraversal:
def test_record_file_rejects_parent_traversal(self, tmp_path):
m = IntegrationManifest("test", tmp_path)
@@ -245,3 +296,160 @@ class TestManifestLoadValidation:
path.write_text("{not valid json", encoding="utf-8")
with pytest.raises(ValueError, match="invalid JSON"):
IntegrationManifest.load("bad", tmp_path)
def test_load_filters_recovered_files_not_in_files(self, tmp_path):
# Finding B (Round-9): a recovered_files entry referencing a path
# not present in files indicates an internally-inconsistent manifest
# (e.g. external edit). load() filters those entries silently so the
# manifest self-heals on next save(); is_recovered then returns the
# truthful False for the orphan.
path = tmp_path / ".specify" / "integrations" / "test.manifest.json"
path.parent.mkdir(parents=True)
path.write_text(json.dumps({
"integration": "test",
"files": {"kept.txt": "abc123"},
"recovered_files": ["kept.txt", "orphan.txt"],
}), encoding="utf-8")
m = IntegrationManifest.load("test", tmp_path)
assert m.recovered_files == {"kept.txt"}
assert m.is_recovered("kept.txt") is True
assert m.is_recovered("orphan.txt") is False
class TestManifestRecoveredFiles:
"""Coverage for the ``recovered_files`` channel added in #2483.
When ``shared_infra`` skips an existing file (because the user already has
it on disk) it now records the file with ``recovered=True``. The path
appears in ``manifest.recovered_files`` and ``is_recovered(path)`` returns
True. ``refresh_managed`` (out of scope for this PR) consults this list
before treating the recorded hash as a managed baseline, defending against
silent overwrite of user customizations after manifest loss.
"""
def test_record_existing_default_is_not_recovered(self, tmp_path):
(tmp_path / "f.txt").write_text("x", encoding="utf-8")
m = IntegrationManifest("test", tmp_path)
m.record_existing("f.txt")
assert m.is_recovered("f.txt") is False
assert m.recovered_files == set()
def test_record_existing_with_recovered_flag(self, tmp_path):
(tmp_path / "f.txt").write_text("x", encoding="utf-8")
m = IntegrationManifest("test", tmp_path)
m.record_existing("f.txt", recovered=True)
assert m.is_recovered("f.txt") is True
assert m.recovered_files == {"f.txt"}
# File still hashed normally so check_modified/uninstall keep working
assert m.files["f.txt"] == _sha256(tmp_path / "f.txt")
def test_recovered_files_round_trips_through_save_load(self, tmp_path):
(tmp_path / "a.txt").write_text("aaa", encoding="utf-8")
(tmp_path / "b.txt").write_text("bbb", encoding="utf-8")
m = IntegrationManifest("test", tmp_path, version="9.9")
m.record_existing("a.txt", recovered=True)
m.record_existing("b.txt") # not recovered
m.save()
loaded = IntegrationManifest.load("test", tmp_path)
assert loaded.is_recovered("a.txt") is True
assert loaded.is_recovered("b.txt") is False
assert loaded.recovered_files == {"a.txt"}
def test_save_omits_empty_recovered_files(self, tmp_path):
m = IntegrationManifest("test", tmp_path)
m.record_file("f.txt", "x")
path = m.save()
data = json.loads(path.read_text(encoding="utf-8"))
assert "recovered_files" not in data
def test_load_rejects_non_list_recovered_files(self, tmp_path):
path = tmp_path / ".specify" / "integrations" / "bad.manifest.json"
path.parent.mkdir(parents=True)
path.write_text(
json.dumps({"files": {}, "recovered_files": "not-a-list"}),
encoding="utf-8",
)
with pytest.raises(ValueError, match="recovered_files"):
IntegrationManifest.load("bad", tmp_path)
def test_is_recovered_absolute_path_returns_false(self, tmp_path):
# Copilot round-5 finding: passing an absolute path silently returned
# False because the stored keys are relative POSIX strings. Round-7
# made this explicit: ``is_recovered`` now rejects absolute paths
# up front via a lexical ``rel.is_absolute()`` guard and returns
# False without calling ``_validate_rel_path`` at all — matching
# ``record_existing``'s canonical-key guard so the two methods
# agree on which inputs can ever be stored keys.
(tmp_path / "f.txt").write_text("x", encoding="utf-8")
m = IntegrationManifest("test", tmp_path)
m.record_existing("f.txt", recovered=True)
import sys
abs_input = "C:\\tmp\\f.txt" if sys.platform == "win32" else "/tmp/f.txt"
assert m.is_recovered(abs_input) is False
def test_is_recovered_escaping_path_returns_false(self, tmp_path):
# A relative path containing ``..`` segments cannot be a stored key:
# Round-7 added the same lexical ``".." in rel.parts`` guard to
# ``is_recovered`` that ``record_existing`` already enforces, so the
# method returns False immediately without reaching
# ``_validate_rel_path``. The try/except around ``_validate_rel_path``
# remains as defense-in-depth for paths that pass the lexical guard
# but still resolve outside the project root via a symlinked
# ancestor.
m = IntegrationManifest("test", tmp_path)
# Don't record anything — the path is impossible to record anyway.
assert m.is_recovered("../escape.txt") is False
def test_record_existing_clears_recovered_when_false(self, tmp_path):
# Finding A: re-recording the same path with recovered=False must
# drop the prior recovered marker (transition to managed baseline).
f = tmp_path / "x.txt"
f.write_text("v1", encoding="utf-8")
m = IntegrationManifest("test", tmp_path)
m.record_existing("x.txt", recovered=True)
assert m.is_recovered("x.txt") is True
m.record_existing("x.txt", recovered=False)
assert m.is_recovered("x.txt") is False
def test_record_file_clears_recovered(self, tmp_path):
# Finding A: record_file writes produced content; the path can no
# longer be considered "merely observed" once we wrote bytes.
(tmp_path / "y.txt").write_text("observed", encoding="utf-8")
m = IntegrationManifest("test", tmp_path)
m.record_existing("y.txt", recovered=True)
assert m.is_recovered("y.txt") is True
m.record_file("y.txt", "produced")
assert m.is_recovered("y.txt") is False
def test_is_recovered_rejects_dotdot_segment(self, tmp_path):
# Finding B: record_existing rejects ``..`` segments via the lexical
# pre-check; is_recovered must match that behavior and return False
# without raising, mirroring the canonicalization guard.
(tmp_path / "z.txt").write_text("v1", encoding="utf-8")
m = IntegrationManifest("test", tmp_path)
m.record_existing("z.txt", recovered=True)
# Same file via dotdot-normalizing path — must be False, not raise.
assert m.is_recovered("subdir/../z.txt") is False
class TestRecordExistingNewGuards:
"""Coverage for the two new guards added by Copilot's 2026-05-18 review."""
def test_rejects_symlinked_ancestor(self, tmp_path):
real_dir = tmp_path / "real_dir"
real_dir.mkdir()
(real_dir / "file.txt").write_text("payload", encoding="utf-8")
(tmp_path / "linked_dir").symlink_to(real_dir, target_is_directory=True)
m = IntegrationManifest("test", tmp_path)
with pytest.raises(ValueError, match="symlinked"):
m.record_existing("linked_dir/file.txt")
def test_rejects_inside_root_dotdot_with_explicit_message(self, tmp_path):
# ``dir/../file.txt`` normalizes inside root, so the old "escapes
# project root" message was misleading. The new message names the
# actual reason: canonicalization.
(tmp_path / "dir").mkdir()
(tmp_path / "file.txt").write_text("x", encoding="utf-8")
m = IntegrationManifest("test", tmp_path)
with pytest.raises(ValueError, match=r"canonical|'\.\.' segments"):
m.record_existing("dir/../file.txt")

View File

@@ -22,7 +22,7 @@ ALL_INTEGRATION_KEYS = [
"copilot",
# Stage 3 — standard markdown integrations
"claude", "qwen", "opencode", "junie", "kilocode", "auggie",
"roo", "codebuddy", "qodercli", "amp", "shai", "bob", "trae",
"roo", "rovodev", "codebuddy", "qodercli", "amp", "shai", "bob", "trae",
"pi", "iflow", "kiro-cli", "windsurf", "vibe", "cursor-agent",
# Stage 4 — TOML integrations
"gemini", "tabnine",

View File

@@ -0,0 +1,64 @@
"""Shared fixtures and helpers for `specify self upgrade` tests.
These helpers patch subprocess, PATH lookup, and release-tag resolution so
the focused test modules stay isolated from the real environment.
"""
import os
import subprocess
import pytest
from typer.testing import CliRunner
from specify_cli._version import (
_InstallMethod,
_UpgradePlan,
_assemble_installer_argv,
_detect_install_method,
_verify_upgrade,
)
from tests.conftest import strip_ansi
from tests.http_helpers import mock_urlopen_response
__all__ = (
"SENTINEL_GH_TOKEN",
"SENTINEL_GITHUB_TOKEN",
"_InstallMethod",
"_UpgradePlan",
"_assemble_installer_argv",
"_completed_process",
"_detect_install_method",
"_verify_upgrade",
"mock_urlopen_response",
"requires_posix",
"runner",
"strip_ansi",
)
runner = CliRunner()
# Some installer error-path tests create a relative `./uv` fixture, `chdir`
# into the tmp dir, and assert POSIX executable-bit semantics (chmod / X_OK).
# None of that maps cleanly onto Windows: `os.access(path, X_OK)` ignores the
# mode bits, and pytest cannot rmtree a tmp dir that is still the cwd, so the
# fixtures raise PermissionError during teardown. Skip these on Windows — the
# realistic absolute-path and bare-PATH-command branches stay covered there.
requires_posix = pytest.mark.skipif(
os.name == "nt",
reason="relative-path / executable-bit semantics are POSIX-only",
)
SENTINEL_GH_TOKEN = "SENTINEL-GH-TOKEN-VALUE"
SENTINEL_GITHUB_TOKEN = "SENTINEL-GITHUB-TOKEN-VALUE"
def _completed_process(
returncode: int, stdout: str = "", stderr: str = ""
) -> subprocess.CompletedProcess:
"""Build a subprocess.CompletedProcess for installer / verification calls."""
return subprocess.CompletedProcess(
args=["mocked"],
returncode=returncode,
stdout=stdout,
stderr=stderr,
)

View File

@@ -283,3 +283,27 @@ class TestAgentConfigConsistency:
"Found dot-notation command ref (/speckit.<cmd>) in generated Claude skill. "
"Skills agents must use hyphen notation."
)
# --- RovoDev consistency checks ---
def test_rovodev_in_agent_config(self):
"""AGENT_CONFIG should include rovodev with skills-based scaffold metadata."""
assert "rovodev" in AGENT_CONFIG
assert AGENT_CONFIG["rovodev"]["folder"] == ".rovodev/"
assert AGENT_CONFIG["rovodev"]["commands_subdir"] == "skills"
assert AGENT_CONFIG["rovodev"]["requires_cli"] is True
def test_rovodev_in_extension_registrar(self):
"""CommandRegistrar.AGENT_CONFIGS should include rovodev skill scaffold metadata."""
cfg = CommandRegistrar.AGENT_CONFIGS
assert "rovodev" in cfg
rovodev_cfg = cfg["rovodev"]
assert rovodev_cfg["dir"] == ".rovodev/skills"
assert rovodev_cfg["format"] == "markdown"
assert rovodev_cfg["args"] == "$ARGUMENTS"
assert rovodev_cfg["extension"] == "/SKILL.md"
def test_ai_help_includes_rovodev(self):
"""CLI help text for --ai should include rovodev."""
assert "rovodev" in AI_ASSISTANT_HELP

View File

@@ -573,7 +573,9 @@ class TestAuthenticatedHttp:
mock_opener = MagicMock()
def fake_open(req, timeout=None):
captured["req"] = req
resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False)
resp = MagicMock()
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
mock_opener.open.side_effect = fake_open
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
@@ -588,7 +590,9 @@ class TestAuthenticatedHttp:
captured = {}
def fake_urlopen(req, timeout=None):
captured["req"] = req
resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False)
resp = MagicMock()
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_urlopen):
open_url("https://example.com/file.json")
@@ -601,7 +605,9 @@ class TestAuthenticatedHttp:
captured = {}
def fake_urlopen(req, timeout=None):
captured["req"] = req
resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False)
resp = MagicMock()
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_urlopen):
open_url("https://github.com/org/repo")
@@ -615,12 +621,16 @@ class TestAuthenticatedHttp:
self._set_config(monkeypatch, [_github_entry()])
call_count = 0
def fake_side_effect(req, timeout=None):
nonlocal call_count; call_count += 1
nonlocal call_count
call_count += 1
if call_count == 1:
raise urllib.error.HTTPError("url", 401, "Unauthorized", {}, None)
resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False)
resp = MagicMock()
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
mock_opener = MagicMock(); mock_opener.open.side_effect = fake_side_effect
mock_opener = MagicMock()
mock_opener.open.side_effect = fake_side_effect
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener), \
patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_side_effect):
open_url("https://github.com/org/repo")
@@ -692,7 +702,6 @@ class TestLoadConfigCaching:
"""_load_config() should call load_auth_config only once per process."""
from unittest.mock import patch
from specify_cli.authentication import http as _mod
from specify_cli.authentication.config import AuthConfigEntry
# Allow the real load path (no override)
monkeypatch.setattr(_mod, "_config_override", None)
monkeypatch.setattr(_mod, "_config_cache", None)
@@ -825,8 +834,11 @@ class TestFetchLatestReleaseTagDelegation:
def side_effect(req, timeout=None):
captured["request"] = req
body = _json.dumps({"tag_name": "v9.9.9"}).encode()
resp = MagicMock(); resp.read.return_value = body
cm = MagicMock(); cm.__enter__.return_value = resp; cm.__exit__.return_value = False
resp = MagicMock()
resp.read.return_value = body
cm = MagicMock()
cm.__enter__.return_value = resp
cm.__exit__.return_value = False
return cm
return captured, side_effect
@@ -836,7 +848,8 @@ class TestFetchLatestReleaseTagDelegation:
monkeypatch.setenv("GH_TOKEN", "forwarded-sentinel")
self._set_config(monkeypatch, [_github_entry()])
captured, side_effect = self._capture_request()
mock_opener = MagicMock(); mock_opener.open.side_effect = side_effect
mock_opener = MagicMock()
mock_opener.open.side_effect = side_effect
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
_fetch_latest_release_tag()
assert captured["request"].get_header("Authorization") == "Bearer forwarded-sentinel"

View File

@@ -10,6 +10,7 @@ from unittest.mock import patch, MagicMock
from typer.testing import CliRunner
from specify_cli import app, check_tool
from specify_cli.integrations import get_integration
from tests.conftest import strip_ansi
@@ -111,6 +112,107 @@ class TestCheckToolOther:
with patch("shutil.which", side_effect=fake_which):
assert check_tool("kiro-cli") is True
def test_rovodev_uses_acli_executable(self):
"""rovodev should resolve through the shared acli executable."""
def fake_which(name):
return "/usr/bin/acli" if name == "acli" else None
with patch("shutil.which", side_effect=fake_which):
assert check_tool("rovodev") is True
class TestIsCliAvailable:
"""Integration.is_cli_available() must encode correct detection logic."""
def test_rovodev_cli_executable_is_acli(self):
"""RovodevIntegration.cli_executable should return 'acli'."""
impl = get_integration("rovodev")
assert impl.cli_executable == "acli"
def test_rovodev_is_cli_available_uses_acli(self):
"""RovodevIntegration.is_cli_available() checks for 'acli', not 'rovodev'."""
impl = get_integration("rovodev")
with patch("shutil.which", side_effect=lambda name: "/usr/bin/acli" if name == "acli" else None):
assert impl.is_cli_available() is True
with patch("shutil.which", return_value=None):
assert impl.is_cli_available() is False
def test_kiro_is_cli_available_accepts_kiro_cli(self):
"""KiroCliIntegration.is_cli_available() returns True for 'kiro-cli' binary."""
impl = get_integration("kiro-cli")
with patch("shutil.which", side_effect=lambda name: "/usr/bin/kiro-cli" if name == "kiro-cli" else None):
assert impl.is_cli_available() is True
def test_kiro_is_cli_available_accepts_legacy_kiro(self):
"""KiroCliIntegration.is_cli_available() accepts the legacy 'kiro' binary."""
impl = get_integration("kiro-cli")
with patch("shutil.which", side_effect=lambda name: "/usr/bin/kiro" if name == "kiro" else None):
assert impl.is_cli_available() is True
def test_kiro_is_cli_available_false_when_neither(self):
"""KiroCliIntegration.is_cli_available() returns False when neither binary exists."""
impl = get_integration("kiro-cli")
with patch("shutil.which", return_value=None):
assert impl.is_cli_available() is False
def test_claude_is_cli_available_local_path(self, tmp_path):
"""ClaudeIntegration.is_cli_available() finds claude via local path."""
impl = get_integration("claude")
fake_claude = tmp_path / "claude"
fake_claude.touch()
fake_missing = tmp_path / "nonexistent" / "claude"
with patch("specify_cli._utils.CLAUDE_LOCAL_PATH", fake_claude), \
patch("specify_cli._utils.CLAUDE_NPM_LOCAL_PATH", fake_missing), \
patch("shutil.which", return_value=None):
assert impl.is_cli_available() is True
def test_claude_is_cli_available_npm_local_path(self, tmp_path):
"""ClaudeIntegration.is_cli_available() finds claude via npm-local path."""
impl = get_integration("claude")
fake_npm = tmp_path / "node_modules" / ".bin" / "claude"
fake_npm.parent.mkdir(parents=True)
fake_npm.touch()
fake_missing = tmp_path / "nonexistent" / "claude"
with patch("specify_cli._utils.CLAUDE_LOCAL_PATH", fake_missing), \
patch("specify_cli._utils.CLAUDE_NPM_LOCAL_PATH", fake_npm), \
patch("shutil.which", return_value=None):
assert impl.is_cli_available() is True
def test_claude_is_cli_available_path(self, tmp_path):
"""ClaudeIntegration.is_cli_available() finds claude via PATH."""
impl = get_integration("claude")
fake_missing = tmp_path / "nonexistent" / "claude"
with patch("specify_cli._utils.CLAUDE_LOCAL_PATH", fake_missing), \
patch("specify_cli._utils.CLAUDE_NPM_LOCAL_PATH", fake_missing), \
patch("shutil.which", return_value="/usr/local/bin/claude"):
assert impl.is_cli_available() is True
def test_claude_is_cli_available_not_found(self, tmp_path):
"""ClaudeIntegration.is_cli_available() returns False when not installed."""
impl = get_integration("claude")
fake_missing = tmp_path / "nonexistent" / "claude"
with patch("specify_cli._utils.CLAUDE_LOCAL_PATH", fake_missing), \
patch("specify_cli._utils.CLAUDE_NPM_LOCAL_PATH", fake_missing), \
patch("shutil.which", return_value=None):
assert impl.is_cli_available() is False
def test_default_integration_uses_key(self):
"""Integrations without an override use key as cli_executable."""
# Use a non-CLI integration to test the default; any MarkdownIntegration
# without a cli_executable override works.
impl = get_integration("gemini")
assert impl.cli_executable == impl.key
class TestCheckTip:
"""`specify check` should point users to the existing version check."""

View File

@@ -13,12 +13,6 @@ def test_commands_init_importable():
assert callable(mod.register)
def test_commands_stubs_importable():
for name in ("integration", "preset", "extension", "workflow"):
mod = importlib.import_module(f"specify_cli.commands.{name}")
assert mod is not None
def test_agent_config_importable():
from specify_cli._agent_config import (
AGENT_CONFIG,
@@ -35,7 +29,7 @@ def test_agent_config_importable():
def test_agent_config_re_exported_from_init():
from specify_cli import AGENT_CONFIG, AI_ASSISTANT_ALIASES, AI_ASSISTANT_HELP, SCRIPT_TYPE_CHOICES
from specify_cli import AGENT_CONFIG, SCRIPT_TYPE_CHOICES
assert isinstance(AGENT_CONFIG, dict)
assert "sh" in SCRIPT_TYPE_CHOICES

View File

@@ -2,12 +2,7 @@
from specify_cli import (
console,
StepTracker,
get_key,
select_with_arrows,
BannerGroup,
show_banner,
BANNER,
TAGLINE,
)

View File

@@ -17,17 +17,19 @@ import tempfile
import shutil
import yaml
from pathlib import Path
from typing import Any
from specify_cli.extensions import (
ExtensionManifest,
ExtensionManager,
ExtensionError,
)
# ===== Helpers =====
def _create_init_options(project_root: Path, ai: str = "claude", ai_skills: bool = True):
def _create_init_options(
project_root: Path, ai: str = "claude", ai_skills: Any = True
):
"""Write a .specify/init-options.json file."""
opts_dir = project_root / ".specify"
opts_dir.mkdir(parents=True, exist_ok=True)
@@ -36,7 +38,7 @@ def _create_init_options(project_root: Path, ai: str = "claude", ai_skills: bool
"ai": ai,
"ai_skills": ai_skills,
"script": "sh",
}))
}), encoding="utf-8")
def _create_skills_dir(project_root: Path, ai: str = "claude") -> Path:
@@ -221,11 +223,20 @@ class TestExtensionManagerGetSkillsDir:
result = manager._get_skills_dir()
assert result == skills_dir
def test_returns_none_when_ai_skills_is_non_boolean_truthy(self, project_dir):
"""Corrupted truthy ai_skills values should not enable skills mode."""
_create_init_options(project_dir, ai="claude", ai_skills="false")
manager = ExtensionManager(project_dir)
result = manager._get_skills_dir()
assert result is None
assert not (project_dir / ".claude" / "skills").exists()
def test_returns_none_for_non_dict_init_options(self, project_dir):
"""Corrupted-but-parseable init-options should not crash skill-dir lookup."""
opts_file = project_dir / ".specify" / "init-options.json"
opts_file.parent.mkdir(parents=True, exist_ok=True)
opts_file.write_text("[]")
opts_file.write_text("[]", encoding="utf-8")
_create_skills_dir(project_dir, ai="claude")
manager = ExtensionManager(project_dir)
result = manager._get_skills_dir()
@@ -241,7 +252,7 @@ class TestExtensionSkillRegistration:
"""Skills should be created when ai_skills is enabled."""
project_dir, skills_dir = skills_project
manager = ExtensionManager(project_dir)
manifest = manager.install_from_directory(
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
@@ -656,6 +667,393 @@ class TestExtensionSkillRegistration:
assert "speckit-early-ext-hello" in metadata["registered_skills"]
assert "speckit-early-ext-world" in metadata["registered_skills"]
def test_commands_registered_when_claude_skills_dir_missing(self, project_dir, temp_dir):
"""Extension install should not silently skip Claude when skills dir is missing."""
_create_init_options(project_dir, ai="claude", ai_skills=True)
(project_dir / ".claude").mkdir()
# Deliberately do NOT create .claude/skills
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
manager = ExtensionManager(project_dir)
manifest = manager.install_from_directory(
ext_dir, "0.1.0", register_commands=True
)
skills_dir = project_dir / ".claude" / "skills"
assert skills_dir.is_dir()
metadata = manager.registry.get(manifest.id)
assert metadata["registered_commands"] == {
"claude": [
"speckit.early-ext.hello",
"speckit.early-ext.world",
]
}
assert metadata["registered_skills"] == []
skill_file = skills_dir / "speckit-early-ext-hello" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text(encoding="utf-8")
assert "source: early-ext:commands/hello.md" in content
def test_hermes_global_skills_dir_used_when_marker_is_recovered(
self, project_dir, temp_dir, monkeypatch
):
"""Hermes recovery must not use the project marker as the output dir."""
home = temp_dir / "home"
home.mkdir()
monkeypatch.setattr(Path, "home", lambda: home)
_create_init_options(project_dir, ai="hermes", ai_skills=True)
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
manager = ExtensionManager(project_dir)
manifest = manager.install_from_directory(
ext_dir, "0.1.0", register_commands=True
)
metadata = manager.registry.get(manifest.id)
assert metadata is not None
assert metadata["registered_commands"] == {
"hermes": [
"speckit.early-ext.hello",
"speckit.early-ext.world",
]
}
assert metadata["registered_skills"] == []
global_skills_dir = home / ".hermes" / "skills"
assert (
global_skills_dir / "speckit-early-ext-hello" / "SKILL.md"
).exists()
assert (
global_skills_dir / "speckit-early-ext-world" / "SKILL.md"
).exists()
marker = project_dir / ".hermes" / "skills"
assert marker.is_dir()
assert list(marker.glob("speckit-*/SKILL.md")) == []
def test_hermes_get_skills_dir_creates_global_output_dir(
self, project_dir, temp_dir, monkeypatch
):
"""ExtensionManager should create the agent-specific output dir it returns."""
home = temp_dir / "home"
home.mkdir()
monkeypatch.setattr(Path, "home", lambda: home)
_create_init_options(project_dir, ai="hermes", ai_skills=True)
manager = ExtensionManager(project_dir)
skills_dir = manager._get_skills_dir()
assert skills_dir == home / ".hermes" / "skills"
assert skills_dir.is_dir()
assert (project_dir / ".hermes" / "skills").is_dir()
def test_unusable_hermes_global_skills_dir_skips_skill_registration(
self, project_dir, temp_dir, monkeypatch, capsys
):
"""An unusable agent-specific output dir should warn and skip skills."""
home = temp_dir / "home"
hermes_dir = home / ".hermes"
hermes_dir.mkdir(parents=True)
(hermes_dir / "skills").write_text("not a directory", encoding="utf-8")
monkeypatch.setattr(Path, "home", lambda: home)
_create_init_options(project_dir, ai="hermes", ai_skills=True)
ext_dir = _create_extension_dir(temp_dir, ext_id="blocked-ext")
manager = ExtensionManager(project_dir)
manifest = manager.install_from_directory(
ext_dir, "0.1.0", register_commands=False
)
metadata = manager.registry.get(manifest.id)
assert metadata is not None
assert metadata["registered_skills"] == []
captured = capsys.readouterr()
assert "Warning:" in captured.out
assert "Continuing without skill registration." in captured.out
def test_detect_dir_marker_file_does_not_register_hermes_commands(
self, project_dir, temp_dir, monkeypatch
):
"""Regular files at detect_dir marker paths should not detect agents."""
home = temp_dir / "home"
global_skills_dir = home / ".hermes" / "skills"
global_skills_dir.mkdir(parents=True)
monkeypatch.setattr(Path, "home", lambda: home)
_create_init_options(project_dir, ai="hermes", ai_skills=True)
marker_parent = project_dir / ".hermes"
marker_parent.mkdir()
marker_file = marker_parent / "skills"
marker_file.write_text("not a directory", encoding="utf-8")
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
manager = ExtensionManager(project_dir)
manifest = manager.install_from_directory(
ext_dir, "0.1.0", register_commands=True
)
assert marker_file.is_file()
assert marker_file.read_text(encoding="utf-8") == "not a directory"
assert not (
global_skills_dir / "speckit-early-ext-hello" / "SKILL.md"
).exists()
assert not (
global_skills_dir / "speckit-early-ext-world" / "SKILL.md"
).exists()
metadata = manager.registry.get(manifest.id)
assert metadata is not None
assert metadata["registered_commands"] == {}
assert metadata["registered_skills"] == []
def test_non_boolean_ai_skills_does_not_recover_missing_skills_dir(
self, project_dir, temp_dir
):
"""Corrupted truthy ai_skills values should not recover skills dirs."""
_create_init_options(project_dir, ai="claude", ai_skills="false")
(project_dir / ".claude").mkdir()
# Deliberately do NOT create .claude/skills.
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
manager = ExtensionManager(project_dir)
manifest = manager.install_from_directory(
ext_dir, "0.1.0", register_commands=True
)
metadata = manager.registry.get(manifest.id)
assert metadata is not None
assert metadata["registered_commands"] == {}
assert metadata["registered_skills"] == []
assert not (project_dir / ".claude" / "skills").exists()
def test_non_boolean_ai_skills_does_not_skip_default_agent_reregistration(
self, project_dir, temp_dir
):
"""Corrupted ai_skills values should not trigger skills-mode skips."""
_create_init_options(project_dir, ai="copilot", ai_skills="false")
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
manager = ExtensionManager(project_dir)
manifest = manager.install_from_directory(
ext_dir, "0.1.0", register_commands=False
)
manager.register_enabled_extensions_for_agent("copilot")
metadata = manager.registry.get(manifest.id)
assert metadata is not None
assert metadata["registered_commands"] == {
"copilot": [
"speckit.early-ext.hello",
"speckit.early-ext.world",
]
}
assert metadata["registered_skills"] == []
assert (project_dir / ".github" / "agents").is_dir()
def test_existing_agent_command_path_file_is_not_detected(
self, project_dir, temp_dir
):
"""Existing files at command-dir paths should not count as detected agents."""
_create_init_options(project_dir, ai="claude", ai_skills=False)
claude_dir = project_dir / ".claude"
claude_dir.mkdir()
skills_file = claude_dir / "skills"
skills_file.write_text("not a directory", encoding="utf-8")
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
manager = ExtensionManager(project_dir)
manifest = manager.install_from_directory(
ext_dir, "0.1.0", register_commands=True
)
assert skills_file.read_text(encoding="utf-8") == "not a directory"
metadata = manager.registry.get(manifest.id)
assert metadata is not None
assert metadata["registered_commands"] == {}
assert metadata["registered_skills"] == []
def test_missing_shared_skills_dir_registers_only_active_agent(self, project_dir, temp_dir):
"""Recreating shared skills dirs should not activate unrelated agents."""
_create_init_options(project_dir, ai="agy", ai_skills=True)
(project_dir / ".agents").mkdir()
# Deliberately do NOT create .agents/skills, shared by agy and codex.
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
manager = ExtensionManager(project_dir)
manifest = manager.install_from_directory(
ext_dir, "0.1.0", register_commands=True
)
skills_dir = project_dir / ".agents" / "skills"
assert skills_dir.is_dir()
metadata = manager.registry.get(manifest.id)
assert metadata["registered_commands"] == {
"agy": [
"speckit.early-ext.hello",
"speckit.early-ext.world",
]
}
assert metadata["registered_skills"] == []
def test_missing_shared_skills_dir_uses_normalized_guard_for_later_agents(
self, project_dir, temp_dir, monkeypatch
):
"""Shared-dir suppression should tolerate lexical path differences."""
_create_init_options(project_dir, ai="agy", ai_skills=True)
(project_dir / ".agents").mkdir()
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
from specify_cli.agents import CommandRegistrar as AgentRegistrar
original_resolve_agent_dir = AgentRegistrar._resolve_agent_dir
original_register_commands = AgentRegistrar.register_commands
attempted_agents = []
def resolve_codex_with_parent_segment(self, agent_name, agent_config, root):
if agent_name == "codex":
return root / ".agents" / ".." / ".agents" / "skills"
return original_resolve_agent_dir(agent_name, agent_config, root)
def record_registration(self, agent_name, *args, **kwargs):
attempted_agents.append(agent_name)
return original_register_commands(self, agent_name, *args, **kwargs)
monkeypatch.setattr(
AgentRegistrar, "_resolve_agent_dir", resolve_codex_with_parent_segment
)
monkeypatch.setattr(AgentRegistrar, "register_commands", record_registration)
manager = ExtensionManager(project_dir)
manifest = manager.install_from_directory(
ext_dir, "0.1.0", register_commands=True
)
assert attempted_agents == ["agy"]
metadata = manager.registry.get(manifest.id)
assert metadata is not None
assert metadata["registered_commands"] == {
"agy": [
"speckit.early-ext.hello",
"speckit.early-ext.world",
]
}
assert metadata["registered_skills"] == []
def test_missing_shared_skills_dir_write_oserror_does_not_register_other_agents(
self, project_dir, temp_dir, monkeypatch
):
"""Failed active registration must not make shared skills dirs detected."""
_create_init_options(project_dir, ai="agy", ai_skills=True)
(project_dir / ".agents").mkdir()
# Deliberately do NOT create .agents/skills, shared by agy and codex.
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
from specify_cli.agents import CommandRegistrar as AgentRegistrar
original_register_commands = AgentRegistrar.register_commands
attempted_agents = []
def fail_recovered_agy_registration(self, agent_name, *args, **kwargs):
attempted_agents.append(agent_name)
if agent_name == "agy":
raise PermissionError("denied")
return original_register_commands(self, agent_name, *args, **kwargs)
monkeypatch.setattr(
AgentRegistrar, "register_commands", fail_recovered_agy_registration
)
manager = ExtensionManager(project_dir)
manifest = manager.install_from_directory(
ext_dir, "0.1.0", register_commands=True
)
skills_dir = project_dir / ".agents" / "skills"
assert skills_dir.is_dir()
assert attempted_agents == ["agy"]
metadata = manager.registry.get(manifest.id)
assert metadata is not None
assert metadata["registered_commands"] == {}
assert "speckit-early-ext-hello" in metadata["registered_skills"]
assert "speckit-early-ext-world" in metadata["registered_skills"]
def test_missing_active_skills_dir_does_not_follow_symlinked_parent(
self, project_dir, temp_dir
):
"""Recovered command registration must reuse active skills-dir safety checks."""
if not hasattr(os, "symlink"):
pytest.skip("symlinks are unavailable")
_create_init_options(project_dir, ai="claude", ai_skills=True)
outside = temp_dir / "outside-claude"
outside.mkdir()
try:
os.symlink(outside, project_dir / ".claude", target_is_directory=True)
except OSError:
pytest.skip("Current platform/user cannot create directory symlinks")
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
manager = ExtensionManager(project_dir)
manifest = manager.install_from_directory(
ext_dir, "0.1.0", register_commands=True
)
metadata = manager.registry.get(manifest.id)
assert metadata["registered_commands"] == {}
assert metadata["registered_skills"] == []
assert not (outside / "skills").exists()
def test_missing_active_skills_dir_invalid_parent_skips_without_aborting(
self, project_dir, temp_dir
):
"""Invalid active skill parents should not abort extension installation."""
_create_init_options(project_dir, ai="claude", ai_skills=True)
(project_dir / ".claude").write_text("not a directory", encoding="utf-8")
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
manager = ExtensionManager(project_dir)
manifest = manager.install_from_directory(
ext_dir, "0.1.0", register_commands=True
)
metadata = manager.registry.get(manifest.id)
assert metadata["registered_commands"] == {}
assert metadata["registered_skills"] == []
def test_missing_active_skills_dir_write_oserror_skips_without_aborting(
self, project_dir, temp_dir, monkeypatch
):
"""Filesystem failures in recovered command registration should skip safely."""
_create_init_options(project_dir, ai="claude", ai_skills=True)
(project_dir / ".claude").mkdir()
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
from specify_cli.agents import CommandRegistrar as AgentRegistrar
original_register_commands = AgentRegistrar.register_commands
def fail_recovered_claude_registration(self, agent_name, *args, **kwargs):
if agent_name == "claude":
raise PermissionError("denied")
return original_register_commands(self, agent_name, *args, **kwargs)
monkeypatch.setattr(
AgentRegistrar, "register_commands", fail_recovered_claude_registration
)
manager = ExtensionManager(project_dir)
manifest = manager.install_from_directory(
ext_dir, "0.1.0", register_commands=True
)
metadata = manager.registry.get(manifest.id)
assert metadata["registered_commands"] == {}
assert "speckit-early-ext-hello" in metadata["registered_skills"]
assert "speckit-early-ext-world" in metadata["registered_skills"]
# ===== Extension Skill Unregistration Tests =====
@@ -739,7 +1137,7 @@ class TestExtensionSkillEdgeCases:
"""Corrupted init-options payloads should disable skill registration, not crash install."""
opts_file = project_dir / ".specify" / "init-options.json"
opts_file.parent.mkdir(parents=True, exist_ok=True)
opts_file.write_text("[]")
opts_file.write_text("[]", encoding="utf-8")
_create_skills_dir(project_dir, ai="claude")
manager = ExtensionManager(project_dir)
@@ -784,7 +1182,7 @@ class TestExtensionSkillEdgeCases:
)
manager = ExtensionManager(project_dir)
manifest = manager.install_from_directory(
manager.install_from_directory(
ext_dir, "0.1.0", register_commands=False
)
@@ -803,7 +1201,7 @@ class TestExtensionSkillEdgeCases:
ext_dir = _create_extension_dir(temp_dir, ext_id="test-ext")
manager = ExtensionManager(project_dir)
manifest = manager.install_from_directory(
manager.install_from_directory(
ext_dir, "0.1.0", register_commands=False
)
@@ -819,10 +1217,10 @@ class TestExtensionSkillEdgeCases:
ext_dir_b = _create_extension_dir(temp_dir, ext_id="ext-b")
manager = ExtensionManager(project_dir)
manifest_a = manager.install_from_directory(
manager.install_from_directory(
ext_dir_a, "0.1.0", register_commands=False
)
manifest_b = manager.install_from_directory(
manager.install_from_directory(
ext_dir_b, "0.1.0", register_commands=False
)
@@ -880,7 +1278,7 @@ class TestExtensionSkillEdgeCases:
manager = ExtensionManager(project_dir)
# Should not raise
manifest = manager.install_from_directory(
manager.install_from_directory(
ext_dir, "0.1.0", register_commands=False
)

View File

@@ -782,6 +782,71 @@ class TestExtensionManager:
assert (ext_dir / "extension.yml").exists()
assert (ext_dir / "commands" / "hello.md").exists()
def test_install_from_directory_explicitly_recovers_active_skills_dir(
self, extension_dir, project_dir, monkeypatch
):
"""Extension install should explicitly request active skills-dir recovery."""
captured = {}
def fake_register_all(
self,
manifest,
extension_dir,
project_root,
link_outputs=False,
create_missing_active_skills_dir=False,
):
captured["create_missing_active_skills_dir"] = (
create_missing_active_skills_dir
)
return {}
monkeypatch.setattr(
CommandRegistrar,
"register_commands_for_all_agents",
fake_register_all,
)
manager = ExtensionManager(project_dir)
manager.install_from_directory(extension_dir, "0.1.0", register_commands=True)
assert captured["create_missing_active_skills_dir"] is True
def test_command_registrar_default_does_not_recover_active_skills_dir(
self, extension_dir, project_dir, monkeypatch
):
"""The extension wrapper should preserve the core registrar's conservative default."""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
captured = {}
def fake_register_all(
self,
commands,
source_id,
source_dir,
project_root,
context_note=None,
link_outputs=False,
create_missing_active_skills_dir=False,
):
captured["create_missing_active_skills_dir"] = (
create_missing_active_skills_dir
)
return {}
monkeypatch.setattr(
AgentCommandRegistrar,
"register_commands_for_all_agents",
fake_register_all,
)
manifest = ExtensionManifest(extension_dir / "extension.yml")
registrar = CommandRegistrar()
registrar.register_commands_for_all_agents(manifest, extension_dir, project_dir)
assert captured["create_missing_active_skills_dir"] is False
def test_install_duplicate(self, extension_dir, project_dir):
"""Test installing already installed extension."""
manager = ExtensionManager(project_dir)
@@ -793,6 +858,102 @@ class TestExtensionManager:
with pytest.raises(ExtensionError, match="already installed"):
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
def test_install_force_reinstall(self, extension_dir, project_dir):
"""Test force-reinstalling an already-installed extension."""
manager = ExtensionManager(project_dir)
# Install once
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
assert manager.registry.is_installed("test-ext")
# Force-reinstall
manifest2 = manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False, force=True
)
assert manifest2.id == "test-ext"
assert manager.registry.is_installed("test-ext")
# Check extension directory was recreated
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
assert ext_dir.exists()
assert (ext_dir / "extension.yml").exists()
assert (ext_dir / "commands" / "hello.md").exists()
def test_install_force_config_preserved(self, extension_dir, project_dir):
"""Test that config files are preserved when force-reinstalling."""
manager = ExtensionManager(project_dir)
# Install once
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
# Create a config file in the installed extension directory
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
config_file = ext_dir / "test-ext-config.yml"
config_file.write_text("test: config")
# Force-reinstall
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False, force=True
)
# Config file should still exist after reinstall
new_config = ext_dir / "test-ext-config.yml"
assert new_config.exists()
assert new_config.read_text() == "test: config"
def test_install_force_without_existing(self, extension_dir, project_dir):
"""Test force-install when extension is NOT already installed (works normally)."""
manager = ExtensionManager(project_dir)
manifest = manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False, force=True
)
assert manifest.id == "test-ext"
assert manager.registry.is_installed("test-ext")
def test_install_zip_force_reinstall(self, extension_dir, project_dir):
"""Test force-reinstalling from ZIP when already installed."""
import zipfile
import tempfile
manager = ExtensionManager(project_dir)
# Install once from directory
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
# Create a ZIP of the extension in a temp directory (not NamedTemporaryFile,
# which can fail on Windows due to file locking).
with tempfile.TemporaryDirectory() as tmpdir:
zip_path = Path(tmpdir) / "test-ext.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
for f in extension_dir.rglob("*"):
if f.is_file():
zf.write(f, f.relative_to(extension_dir))
# Force-reinstall from ZIP
manifest = manager.install_from_zip(
zip_path, "0.1.0", force=True
)
assert manifest.id == "test-ext"
assert manager.registry.is_installed("test-ext")
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
assert ext_dir.exists()
def test_install_duplicate_error_mentions_force(self, extension_dir, project_dir):
"""Test that duplicate install error message suggests --force."""
manager = ExtensionManager(project_dir)
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
with pytest.raises(ExtensionError, match="--force"):
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
def test_install_rejects_extension_id_in_core_namespace(self, temp_dir, project_dir):
"""Install should reject extension IDs that shadow core commands."""
import yaml
@@ -2804,17 +2965,33 @@ class TestExtensionCatalog:
zf.writestr("extension.yml", "id: test-ext\nname: Test\nversion: 1.0.0\n")
zip_bytes = zip_buf.getvalue()
mock_response = MagicMock()
mock_response.read.return_value = zip_bytes
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
release_response = MagicMock()
release_response.read.return_value = json.dumps(
{
"assets": [
{
"name": "test-ext.zip",
"url": "https://api.github.com/repos/org/repo/releases/assets/1",
}
]
}
).encode()
release_response.__enter__ = lambda s: s
release_response.__exit__ = MagicMock(return_value=False)
captured = {}
asset_response = MagicMock()
asset_response.read.return_value = zip_bytes
asset_response.__enter__ = lambda s: s
asset_response.__exit__ = MagicMock(return_value=False)
captured = []
mock_opener = MagicMock()
def fake_open(req, timeout=None):
captured["req"] = req
return mock_response
captured.append(req)
if req.full_url.endswith("/releases/tags/v1"):
return release_response
return asset_response
mock_opener.open.side_effect = fake_open
@@ -2829,7 +3006,56 @@ class TestExtensionCatalog:
patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
catalog.download_extension("test-ext", target_dir=temp_dir)
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
assert captured[0].full_url == "https://api.github.com/repos/org/repo/releases/tags/v1"
assert captured[0].get_header("Authorization") == "Bearer ghp_testtoken"
assert captured[1].full_url == "https://api.github.com/repos/org/repo/releases/assets/1"
assert captured[1].get_header("Authorization") == "Bearer ghp_testtoken"
assert captured[1].get_header("Accept") == "application/octet-stream"
def test_download_extension_accepts_direct_github_rest_asset_url(self, temp_dir, monkeypatch):
"""download_extension can use a GitHub REST release asset URL directly."""
from unittest.mock import patch, MagicMock
import zipfile
import io
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
catalog = self._make_catalog(temp_dir)
zip_buf = io.BytesIO()
with zipfile.ZipFile(zip_buf, "w") as zf:
zf.writestr("extension.yml", "id: test-ext\nname: Test\nversion: 1.0.0\n")
zip_bytes = zip_buf.getvalue()
asset_response = MagicMock()
asset_response.read.return_value = zip_bytes
asset_response.__enter__ = lambda s: s
asset_response.__exit__ = MagicMock(return_value=False)
captured = []
mock_opener = MagicMock()
def fake_open(req, timeout=None):
captured.append(req)
return asset_response
mock_opener.open.side_effect = fake_open
ext_info = {
"id": "test-ext",
"name": "Test Extension",
"version": "1.0.0",
"download_url": "https://api.github.com/repos/org/repo/releases/assets/1",
}
with patch.object(catalog, "get_extension_info", return_value=ext_info), \
patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
catalog.download_extension("test-ext", target_dir=temp_dir)
assert len(captured) == 1
assert captured[0].full_url == "https://api.github.com/repos/org/repo/releases/assets/1"
assert captured[0].get_header("Authorization") == "Bearer ghp_testtoken"
assert captured[0].get_header("Accept") == "application/octet-stream"
@@ -4723,6 +4949,26 @@ class TestHookInvocationRendering:
assert execution["command"] == "speckit.tasks"
assert execution["invocation"] == "$speckit-tasks"
def test_non_boolean_ai_skills_keeps_default_hook_invocation(self, project_dir):
"""Corrupted truthy ai_skills values should not enable skill invocation."""
init_options = project_dir / ".specify" / "init-options.json"
init_options.parent.mkdir(parents=True, exist_ok=True)
init_options.write_text(
json.dumps({"ai": "codex", "ai_skills": "false"}), encoding="utf-8"
)
hook_executor = HookExecutor(project_dir)
execution = hook_executor.execute_hook(
{
"extension": "test-ext",
"command": "speckit.tasks",
"optional": False,
}
)
assert execution["command"] == "speckit.tasks"
assert execution["invocation"] == "/speckit.tasks"
def test_cline_hooks_render_hyphenated_invocation(self, project_dir):
"""Cline projects should render /speckit-* invocations."""
init_options = project_dir / ".specify" / "init-options.json"
@@ -5049,3 +5295,69 @@ $ARGUMENTS
# Verify body references are still dotted for non-Cline
assert "speckit.mock-ext.greet" in hello_body
assert "speckit-mock-ext-greet" not in hello_body
class TestExtensionForceCLI:
"""CLI tests for `specify extension add --dev --force`."""
def _create_minimal_extension(self, base_dir: str | Path, ext_id: str = "test-ext") -> Path:
"""Create a minimal extension directory with manifest."""
import yaml
ext_dir = Path(base_dir) / ext_id
ext_dir.mkdir(parents=True, exist_ok=True)
(ext_dir / "commands").mkdir()
manifest = {
"schema_version": "1.0",
"extension": {
"id": ext_id,
"name": "Test Extension",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": f"speckit.{ext_id}.hello",
"file": "commands/hello.md",
"description": "Test command",
}
]
},
}
(ext_dir / "extension.yml").write_text(yaml.dump(manifest))
(ext_dir / "commands" / "hello.md").write_text(
"---\ndescription: Test\n---\n\nHello $ARGUMENTS\n"
)
return ext_dir
def test_add_dev_force_reinstall(self, tmp_path):
"""extension add --dev --force should reinstall without error."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
project_dir = tmp_path / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
ext_src = self._create_minimal_extension(tmp_path)
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
# First install
result1 = runner.invoke(
app, ["extension", "add", str(ext_src), "--dev"], catch_exceptions=False
)
assert result1.exit_code == 0, strip_ansi(result1.output)
assert "installed" in strip_ansi(result1.output)
# Force reinstall
result2 = runner.invoke(
app, ["extension", "add", str(ext_src), "--dev", "--force"], catch_exceptions=False
)
assert result2.exit_code == 0, strip_ansi(result2.output)
assert "installed" in strip_ansi(result2.output)

View File

@@ -2255,6 +2255,51 @@ class TestInitOptions:
assert loaded["ai"] == "claude"
assert loaded["ai_skills"] is True
def test_save_and_load_available_from_init_options_module(self, project_dir):
from specify_cli._init_options import load_init_options, save_init_options
opts = {"ai": "codex", "ai_skills": True, "script": "sh"}
save_init_options(project_dir, opts)
assert load_init_options(project_dir) == opts
def test_save_uses_utf8_encoding(self, project_dir, monkeypatch):
from specify_cli import save_init_options
original_write_text = Path.write_text
seen: dict[str, str | None] = {}
def spy_write_text(path, data, *args, **kwargs):
if path == project_dir / ".specify" / "init-options.json":
seen["encoding"] = kwargs.get("encoding")
return original_write_text(path, data, *args, **kwargs)
monkeypatch.setattr(Path, "write_text", spy_write_text)
save_init_options(project_dir, {"label": "中文测试"})
assert seen["encoding"] == "utf-8"
def test_load_uses_utf8_encoding(self, project_dir, monkeypatch):
from specify_cli import load_init_options
opts_file = project_dir / ".specify" / "init-options.json"
opts_file.parent.mkdir(parents=True, exist_ok=True)
opts_file.write_text('{"ai": "codex"}', encoding="utf-8")
original_read_text = Path.read_text
seen: dict[str, str | None] = {}
def spy_read_text(path, *args, **kwargs):
if path == opts_file:
seen["encoding"] = kwargs.get("encoding")
return original_read_text(path, *args, **kwargs)
monkeypatch.setattr(Path, "read_text", spy_read_text)
assert load_init_options(project_dir) == {"ai": "codex"}
assert seen["encoding"] == "utf-8"
def test_load_returns_empty_when_missing(self, project_dir):
from specify_cli import load_init_options
@@ -2348,6 +2393,51 @@ class TestInitOptions:
assert load_init_options(project_dir) == {}
@pytest.mark.parametrize("payload", ["[]", '"value"', "42", "true", "null"])
def test_load_returns_empty_on_non_object_json(self, project_dir, payload):
from specify_cli import load_init_options
opts_file = project_dir / ".specify" / "init-options.json"
opts_file.parent.mkdir(parents=True, exist_ok=True)
opts_file.write_text(payload, encoding="utf-8")
assert load_init_options(project_dir) == {}
def test_load_returns_empty_on_unicode_decode_error(self, project_dir, monkeypatch):
from specify_cli import load_init_options
opts_file = project_dir / ".specify" / "init-options.json"
opts_file.parent.mkdir(parents=True, exist_ok=True)
opts_file.write_bytes(b"{}")
original_read_text = Path.read_text
def raise_decode_error(path, *args, **kwargs):
if path == opts_file:
raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte")
return original_read_text(path, *args, **kwargs)
monkeypatch.setattr(Path, "read_text", raise_decode_error)
assert load_init_options(project_dir) == {}
@pytest.mark.parametrize(
("value", "expected"),
[
(True, True),
(False, False),
("true", False),
("false", False),
(1, False),
(0, False),
(None, False),
],
)
def test_is_ai_skills_enabled_requires_boolean_true(self, value, expected):
from specify_cli._init_options import is_ai_skills_enabled
assert is_ai_skills_enabled({"ai_skills": value}) is expected
class TestPresetSkills:
"""Tests for preset skill registration and unregistration.

View File

@@ -0,0 +1,887 @@
"""Detection, argv assembly, and dry-run tests for `specify self upgrade`."""
import importlib.metadata
import json
import os
import subprocess
from pathlib import Path
from unittest.mock import patch
import pytest
import specify_cli
from specify_cli import app
from tests.self_upgrade_helpers import (
_InstallMethod,
_assemble_installer_argv,
_completed_process,
_detect_install_method,
mock_urlopen_response,
runner,
strip_ansi,
)
class TestDetectionUvTool:
"""Tier-1 path-prefix detection for uv-tool installs."""
def test_posix_uv_tool_prefix_matches(self, uv_tool_argv0):
method, signals = _detect_install_method(include_signals=True)
assert method == _InstallMethod.UV_TOOL
assert signals.matched_tier == 1
assert "uv/tools/specify-cli" in signals.matched_prefix.replace("\\", "/")
def test_detection_is_deterministic(self, uv_tool_argv0):
a = _detect_install_method()
b = _detect_install_method()
assert a == b == _InstallMethod.UV_TOOL
def test_no_argv_match_falls_through_to_unsupported(self, unsupported_argv0):
with patch("specify_cli._version.shutil.which", return_value=None), patch(
"specify_cli._version._editable_marker_seen", return_value=False
):
method = _detect_install_method()
assert method == _InstallMethod.UNSUPPORTED
def test_include_signals_false_returns_bare_enum(self, uv_tool_argv0):
result = _detect_install_method(include_signals=False)
assert isinstance(result, _InstallMethod)
def test_bare_argv0_is_resolved_via_path_lookup(self, monkeypatch, tmp_path):
if os.name == "nt":
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
fake_dir = tmp_path / "uv" / "tools" / "specify-cli" / "bin"
else:
monkeypatch.setenv("HOME", str(tmp_path))
fake_dir = (
tmp_path / ".local" / "share" / "uv" / "tools" / "specify-cli" / "bin"
)
fake_dir.mkdir(parents=True)
fake_specify = fake_dir / "specify"
fake_specify.write_text("#!/usr/bin/env python\n")
monkeypatch.setattr("sys.argv", ["specify"])
with patch(
"specify_cli._version.shutil.which",
side_effect=lambda name: str(fake_specify) if name == "specify" else None,
):
method = _detect_install_method()
assert method == _InstallMethod.UV_TOOL
def test_prefix_match_does_not_accept_sibling_directory(self, monkeypatch, tmp_path):
monkeypatch.setenv("HOME", str(tmp_path))
fake_dir = tmp_path / ".local" / "share" / "uv" / "tools" / "specify-cli2" / "bin"
fake_dir.mkdir(parents=True)
fake_specify = fake_dir / "specify"
fake_specify.write_text("#!/usr/bin/env python\n")
monkeypatch.setattr("sys.argv", [str(fake_specify)])
with patch("specify_cli._version.shutil.which", return_value=None), patch(
"specify_cli._version._editable_marker_seen", return_value=False
):
method = _detect_install_method()
assert method == _InstallMethod.UNSUPPORTED
def test_tier3_uv_tool_when_registry_lists_exact_name(
self,
monkeypatch,
tmp_path,
):
monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")])
def fake_which(name):
return "uv" if name == "uv" else None
def fake_run(argv, *args, **kwargs):
if argv[:3] == ["uv", "tool", "list"]:
return subprocess.CompletedProcess(
args=argv,
returncode=0,
stdout="specify-cli v0.7.6\nother-tool v1.2.3\n",
stderr="",
)
return subprocess.CompletedProcess(
args=argv, returncode=1, stdout="", stderr=""
)
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
"specify_cli._version.subprocess.run", side_effect=fake_run
), patch("specify_cli._version._editable_marker_seen", return_value=False):
method, signals = _detect_install_method(include_signals=True)
assert method == _InstallMethod.UV_TOOL
assert signals.matched_tier == 3
assert "uv tool list" in signals.installer_registries_consulted
def test_unresolved_bare_argv0_skips_tier3_registry_detection(self, monkeypatch):
monkeypatch.setattr("sys.argv", ["specify"])
def fake_which(name):
return "uv" if name == "uv" else None
def fake_run(argv, *args, **kwargs):
return subprocess.CompletedProcess(
args=argv,
returncode=0,
stdout="specify-cli v0.7.6\n",
stderr="",
)
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
"specify_cli._version.subprocess.run", side_effect=fake_run
), patch("specify_cli._version._editable_marker_seen", return_value=False):
method, signals = _detect_install_method(include_signals=True)
assert method == _InstallMethod.UNSUPPORTED
assert signals.installer_registries_consulted == ()
def test_bare_argv0_missing_path_resolution_allows_tier3_registry_detection(
self, monkeypatch, tmp_path
):
missing_specify = tmp_path / "missing" / "specify"
monkeypatch.setattr("sys.argv", ["specify"])
def fake_which(name):
if name == "specify":
return str(missing_specify)
if name == "uv":
return "uv"
return None
def fake_run(argv, *args, **kwargs):
if argv[:3] == ["uv", "tool", "list"]:
return subprocess.CompletedProcess(
args=argv,
returncode=0,
stdout="specify-cli v0.7.6\n",
stderr="",
)
return subprocess.CompletedProcess(
args=argv, returncode=1, stdout="", stderr=""
)
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
"specify_cli._version.subprocess.run", side_effect=fake_run
), patch("specify_cli._version._editable_marker_seen", return_value=False):
method, signals = _detect_install_method(include_signals=True)
assert method == _InstallMethod.UV_TOOL
assert signals.matched_tier == 3
assert "uv tool list" in signals.installer_registries_consulted
def test_missing_relative_argv0_falls_back_to_entrypoint_name_lookup(
self, monkeypatch, tmp_path
):
if os.name == "nt":
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
fake_dir = tmp_path / "uv" / "tools" / "specify-cli" / "bin"
else:
monkeypatch.setenv("HOME", str(tmp_path))
fake_dir = (
tmp_path / ".local" / "share" / "uv" / "tools" / "specify-cli" / "bin"
)
fake_dir.mkdir(parents=True)
fake_specify = fake_dir / "specify"
fake_specify.write_text("#!/usr/bin/env python\n")
monkeypatch.setattr("sys.argv", ["./bin/specify"])
def fake_which(name):
return str(fake_specify) if name == "specify" else None
with patch("specify_cli._version.shutil.which", side_effect=fake_which):
method = _detect_install_method()
assert method == _InstallMethod.UV_TOOL
def test_tier3_uv_tool_ignores_substring_false_positive(
self,
unsupported_argv0,
):
def fake_which(name):
return "uv" if name == "uv" else None
def fake_run(argv, *args, **kwargs):
if argv[:3] == ["uv", "tool", "list"]:
return subprocess.CompletedProcess(
args=argv,
returncode=0,
stdout="my-specify-cli-helper v0.1.0\n",
stderr="",
)
return subprocess.CompletedProcess(
args=argv, returncode=1, stdout="", stderr=""
)
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
"specify_cli._version.subprocess.run", side_effect=fake_run
), patch("specify_cli._version._editable_marker_seen", return_value=False):
method = _detect_install_method()
assert method == _InstallMethod.UNSUPPORTED
def test_tier3_uv_tool_does_not_override_absolute_unsupported_entrypoint(
self,
unsupported_argv0,
):
def fake_which(name):
return "uv" if name == "uv" else None
def fake_run(argv, *args, **kwargs):
if argv[:3] == ["uv", "tool", "list"]:
return subprocess.CompletedProcess(
args=argv,
returncode=0,
stdout="specify-cli v0.7.6\n",
stderr="",
)
return subprocess.CompletedProcess(
args=argv, returncode=1, stdout="", stderr=""
)
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
"specify_cli._version.subprocess.run", side_effect=fake_run
), patch("specify_cli._version._editable_marker_seen", return_value=False):
method = _detect_install_method()
assert method == _InstallMethod.UNSUPPORTED
def test_tier3_uv_tool_does_not_override_resolved_bare_unsupported_entrypoint(
self,
monkeypatch,
tmp_path,
):
venv_bin = tmp_path / "venv" / "bin"
venv_bin.mkdir(parents=True)
fake_specify = venv_bin / "specify"
fake_specify.write_text("#!/usr/bin/env python\n")
fake_specify.chmod(0o755)
monkeypatch.setattr("sys.argv", ["specify"])
def fake_which(name):
if name == "specify":
return str(fake_specify)
if name == "uv":
return "uv"
return None
def fake_run(argv, *args, **kwargs):
if argv[:3] == ["uv", "tool", "list"]:
return subprocess.CompletedProcess(
args=argv,
returncode=0,
stdout="specify-cli v0.7.6\n",
stderr="",
)
return subprocess.CompletedProcess(
args=argv, returncode=1, stdout="", stderr=""
)
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
"specify_cli._version.subprocess.run", side_effect=fake_run
), patch("specify_cli._version._editable_marker_seen", return_value=False):
method, signals = _detect_install_method(include_signals=True)
assert method == _InstallMethod.UNSUPPORTED
assert signals.matched_tier is None
assert signals.installer_registries_consulted == ()
class TestPrefixExpansion:
"""Path-prefix expansion edge cases."""
def test_literal_dollar_without_variable_name_is_preserved(self, tmp_path):
prefix_path = tmp_path / "specify-$-cache" / "tools" / "specify-cli"
prefix = str(prefix_path)
expanded = specify_cli._version._expand_prefix(prefix)
assert expanded == prefix_path.resolve()
def test_unresolved_posix_variable_is_rejected(self):
assert specify_cli._version._expand_prefix("$SPECIFY_MISSING/specify-cli/") is None
def test_absolute_prefix_resolve_oserror_is_rejected(self, tmp_path):
prefix = str(tmp_path / "specify-cli")
with patch("pathlib.Path.resolve", side_effect=OSError("bad path")):
assert specify_cli._version._expand_prefix(prefix) is None
class TestArgv0Resolution:
"""Entrypoint path resolution edge cases."""
def test_absolute_argv0_resolve_oserror_returns_original_path(self, tmp_path):
argv0 = tmp_path / "specify"
with patch("pathlib.Path.resolve", side_effect=OSError("bad path")):
assert specify_cli._version._resolved_argv0_path(str(argv0)) == argv0
def test_path_lookup_resolve_oserror_returns_unresolved_lookup_path(self):
with patch(
"specify_cli._version.shutil.which", return_value="/broken/specify"
), patch("pathlib.Path.resolve", side_effect=OSError("bad path")):
result = specify_cli._version._resolved_argv0_path("specify")
# Compare as Path objects: on Windows the same logical path renders
# with backslashes, so a raw string compare against the POSIX form
# would spuriously fail.
assert result == Path("/broken/specify")
class TestArgvAssemblyUvTool:
"""uv-tool installer argv shape."""
def test_stable_tag_produces_expected_argv(self):
with patch("specify_cli._version.shutil.which", return_value="uv"):
argv = _assemble_installer_argv(_InstallMethod.UV_TOOL, "v0.7.6")
assert argv == [
"uv",
"tool",
"install",
"specify-cli",
"--force",
"--from",
"git+https://github.com/github/spec-kit.git@v0.7.6",
]
def test_dev_suffix_tag_embedded_literally(self):
with patch("specify_cli._version.shutil.which", return_value="uv"):
argv = _assemble_installer_argv(_InstallMethod.UV_TOOL, "v0.8.0.dev0")
assert "git+https://github.com/github/spec-kit.git@v0.8.0.dev0" in argv
assert (
"upgrade" not in argv
) # never `uv tool upgrade` — does not accept --tag pinning
def test_missing_uv_returns_no_installer_argv(self):
with patch("specify_cli._version.shutil.which", return_value=None):
assert _assemble_installer_argv(_InstallMethod.UV_TOOL, "v0.7.6") is None
class TestBareUpgradeUvTool:
"""uv-tool happy path, bare invocation."""
def test_happy_path_end_to_end(self, uv_tool_argv0, clean_environ):
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", return_value="uv"
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
"specify_cli._version._get_installed_version", return_value="0.7.5"
):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
mock_run.side_effect = [
_completed_process(0), # installer
_completed_process(0, stdout="specify 0.7.6\n"), # verify
]
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 0
out = strip_ansi(result.output)
assert "Upgrading specify-cli 0.7.5 → v0.7.6 via uv tool:" in out
assert "Upgraded specify-cli: 0.7.5 → 0.7.6" in out
assert mock_run.call_count == 2
for call in mock_run.call_args_list:
assert call.kwargs.get("shell", False) is False
def test_one_user_action_no_prompt(self, uv_tool_argv0, clean_environ):
# The single `invoke` represents the single user action — no prompt.
# If a prompt existed, runner.invoke would hang waiting for input.
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", return_value="uv"
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
"specify_cli._version._get_installed_version", return_value="0.7.5"
):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
mock_run.side_effect = [
_completed_process(0),
_completed_process(0, stdout="specify 0.7.6\n"),
]
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 0
class TestAlreadyLatestUvTool:
"""already on latest, no installer launched."""
def test_already_latest_exits_zero_no_subprocess(
self, uv_tool_argv0, clean_environ
):
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.subprocess.run"
) as mock_run, patch(
"specify_cli._version.shutil.which", return_value="uv"
), patch("specify_cli._version._get_installed_version", return_value="0.7.6"):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 0
assert "Already on latest release: v0.7.6" in strip_ansi(result.output)
assert mock_run.call_count == 0
def test_trailing_zero_equivalent_version_reports_latest_not_newer(
self, uv_tool_argv0, clean_environ
):
# Version("1.0") == Version("1.0.0") under packaging even though their
# canonical strings differ. The no-op message must use Version equality
# so this prints "Already on latest release", not "... or newer".
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.subprocess.run"
) as mock_run, patch(
"specify_cli._version.shutil.which", return_value="uv"
), patch("specify_cli._version._get_installed_version", return_value="1.0"):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.0.0"})
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 0
out = strip_ansi(result.output)
assert "Already on latest release: v1.0.0" in out
assert "or newer" not in out
assert mock_run.call_count == 0
def test_dev_build_ahead_of_release_reports_newer_noop(
self, uv_tool_argv0, clean_environ
):
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.subprocess.run"
) as mock_run, patch(
"specify_cli._version.shutil.which", return_value="uv"
), patch("specify_cli._version._get_installed_version", return_value="0.7.7.dev0"):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 0
assert "Already on latest release or newer: 0.7.7.dev0" in strip_ansi(result.output)
assert mock_run.call_count == 0
def test_unparseable_current_version_does_not_false_noop(
self, uv_tool_argv0, clean_environ
):
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.subprocess.run"
) as mock_run, patch(
"specify_cli._version.shutil.which", return_value="uv"
), patch("specify_cli._version._get_installed_version", return_value="release-main"):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
mock_run.side_effect = [
_completed_process(0),
_completed_process(0, stdout="specify 0.7.6\n"),
]
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 0
out = strip_ansi(result.output)
assert "Already on latest release" not in out
assert "Upgrading specify-cli release-main → v0.7.6 via uv tool:" in out
assert mock_run.call_count == 2
def test_unparseable_resolved_target_fails_before_literal_noop(
self, uv_tool_argv0, clean_environ
):
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.subprocess.run"
) as mock_run, patch(
"specify_cli._version.shutil.which", return_value="uv"
), patch("specify_cli._version._get_installed_version", return_value="release-main"):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "release-main"})
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 1
out = strip_ansi(result.output)
assert "not a comparable version" in out
assert "release-main" not in out
assert "Already on latest release" not in out
assert mock_run.call_count == 0
def test_pinned_older_tag_still_runs_installer(
self, uv_tool_argv0, clean_environ
):
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
"specify_cli._version.subprocess.run"
) as mock_run, patch(
"specify_cli._version._get_installed_version", return_value="0.7.6"
):
mock_run.side_effect = [
_completed_process(0),
_completed_process(0, stdout="specify 0.7.5\n"),
]
result = runner.invoke(app, ["self", "upgrade", "--tag", "v0.7.5"])
assert result.exit_code == 0
out = strip_ansi(result.output)
assert "Already on latest release" not in out
# A pinned older tag is a downgrade and must be labelled as such.
assert "Downgrading specify-cli 0.7.6 → v0.7.5 via uv tool:" in out
assert "Upgrading specify-cli" not in out
assert mock_run.call_count == 2
def test_pinned_rc_tag_uses_canonical_version_equality_for_noop(
self, uv_tool_argv0, clean_environ
):
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
"specify_cli._version._get_installed_version", return_value="1.0.0rc1"
):
result = runner.invoke(app, ["self", "upgrade", "--tag", "v1.0.0-rc1"])
assert result.exit_code == 0
assert "Already on requested release: v1.0.0-rc1" in strip_ansi(result.output)
class TestDryRunUvTool:
"""--dry-run preview path + --dry-run combined with --tag."""
def test_dry_run_without_tag_resolves_network_but_no_subprocess(
self,
uv_tool_argv0,
clean_environ,
):
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.subprocess.run"
) as mock_run, patch(
"specify_cli._version.shutil.which", return_value="uv"
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
result = runner.invoke(app, ["self", "upgrade", "--dry-run"])
assert result.exit_code == 0
out = strip_ansi(result.output)
assert "Dry run — no changes will be made." in out
assert "Detected install method: uv tool" in out
assert "Current version: 0.7.5" in out
assert "Target version: v0.7.6" in out
assert "Command that would be executed:" in out
assert mock_run.call_count == 0
def test_dry_run_with_tag_skips_network(self, uv_tool_argv0, clean_environ):
# --dry-run with --tag must NOT hit the network.
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.subprocess.run"
), patch("specify_cli._version.shutil.which", return_value="uv"), patch(
"specify_cli._version._get_installed_version", return_value="0.7.5"
):
result = runner.invoke(
app,
["self", "upgrade", "--dry-run", "--tag", "v0.8.0"],
)
assert result.exit_code == 0
assert "Target version: v0.8.0" in strip_ansi(result.output)
mock_urlopen.assert_not_called()
def test_dry_run_rejects_unparseable_network_tag_before_preview(
self, uv_tool_argv0, clean_environ
):
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.subprocess.run"
) as mock_run, patch(
"specify_cli._version.shutil.which", return_value="uv"
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"):
mock_urlopen.return_value = mock_urlopen_response(
{"tag_name": "v0.9.0;echo unsafe"}
)
result = runner.invoke(app, ["self", "upgrade", "--dry-run"])
out = strip_ansi(result.output)
assert result.exit_code == 1
assert "not a comparable version" in out
assert "v0.9.0;echo unsafe" not in out
assert "Command that would be executed:" not in out
assert mock_run.call_count == 0
def test_dry_run_with_missing_uv_flags_unresolved_installer(
self, uv_tool_argv0, clean_environ
):
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.subprocess.run"
) as mock_run, patch(
"specify_cli._version.shutil.which", return_value=None
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
result = runner.invoke(app, ["self", "upgrade", "--dry-run"])
assert result.exit_code == 0
out = strip_ansi(result.output)
assert "Command that would be executed: (installer uv not found on PATH)" in out
assert "uv tool install" not in out
assert mock_run.call_count == 0
# ===========================================================================
# Phase 4 — User Story 2: `pipx` immediate upgrade (P2)
# ===========================================================================
class TestDetectionPipx:
"""Pipx detection — tier 1 (path) and tier 3 (registry)."""
def test_posix_pipx_prefix_matches(self, pipx_argv0):
method, signals = _detect_install_method(include_signals=True)
assert method == _InstallMethod.PIPX
assert signals.matched_tier == 1
def test_tier3_pipx_when_no_prefix_match_but_registry_lists_it(
self,
monkeypatch,
tmp_path,
):
monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")])
def fake_which(name):
return "pipx" if name == "pipx" else None
def fake_run(argv, *args, **kwargs):
if argv[:3] == ["pipx", "list", "--json"]:
return subprocess.CompletedProcess(
args=argv,
returncode=0,
stdout='{"venvs":{"specify-cli":{}}}',
stderr="",
)
return subprocess.CompletedProcess(
args=argv, returncode=1, stdout="", stderr=""
)
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
"specify_cli._version.subprocess.run", side_effect=fake_run
), patch("specify_cli._version._editable_marker_seen", return_value=False):
method, signals = _detect_install_method(include_signals=True)
assert method == _InstallMethod.PIPX
assert signals.matched_tier == 3
assert "pipx list --json" in signals.installer_registries_consulted
def test_tier3_pipx_does_not_override_absolute_unsupported_entrypoint(
self,
unsupported_argv0,
):
def fake_which(name):
return "pipx" if name == "pipx" else None
def fake_run(argv, *args, **kwargs):
if argv[:3] == ["pipx", "list", "--json"]:
return subprocess.CompletedProcess(
args=argv,
returncode=0,
stdout='{"venvs":{"specify-cli":{}}}',
stderr="",
)
return subprocess.CompletedProcess(
args=argv, returncode=1, stdout="", stderr=""
)
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
"specify_cli._version.subprocess.run", side_effect=fake_run
), patch("specify_cli._version._editable_marker_seen", return_value=False):
method = _detect_install_method()
assert method == _InstallMethod.UNSUPPORTED
def test_tier3_pipx_ignores_malformed_json_output(
self,
unsupported_argv0,
):
def fake_which(name):
return "pipx" if name == "pipx" else None
def fake_run(argv, *args, **kwargs):
if argv[:3] == ["pipx", "list", "--json"]:
return subprocess.CompletedProcess(
args=argv,
returncode=0,
stdout="not json but mentions specify-cli",
stderr="",
)
return subprocess.CompletedProcess(
args=argv, returncode=1, stdout="", stderr=""
)
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
"specify_cli._version.subprocess.run", side_effect=fake_run
), patch("specify_cli._version._editable_marker_seen", return_value=False):
method = _detect_install_method()
assert method == _InstallMethod.UNSUPPORTED
def test_tier3_both_uv_tool_and_pipx_match_is_treated_as_unsupported(
self,
monkeypatch,
tmp_path,
):
monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")])
def fake_which(name):
if name == "uv":
return "uv"
if name == "pipx":
return "pipx"
return None
def fake_run(argv, *args, **kwargs):
if argv[:3] == ["uv", "tool", "list"]:
return subprocess.CompletedProcess(
args=argv,
returncode=0,
stdout="specify-cli v0.7.6\n",
stderr="",
)
if argv[:3] == ["pipx", "list", "--json"]:
return subprocess.CompletedProcess(
args=argv,
returncode=0,
stdout='{"venvs":{"specify-cli":{}}}',
stderr="",
)
return subprocess.CompletedProcess(
args=argv, returncode=1, stdout="", stderr=""
)
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
"specify_cli._version.subprocess.run", side_effect=fake_run
), patch("specify_cli._version._editable_marker_seen", return_value=False):
method, signals = _detect_install_method(include_signals=True)
assert method == _InstallMethod.UNSUPPORTED
assert signals.matched_tier is None
assert "uv tool list" in signals.installer_registries_consulted
assert "pipx list --json" in signals.installer_registries_consulted
class TestEditableInstallMetadata:
@pytest.mark.skipif(
not hasattr(importlib.metadata, "InvalidMetadataError"),
reason=(
"importlib.metadata.InvalidMetadataError does not exist on this "
"Python; _editable_direct_url_path only catches it when present, so "
"fabricating it would exercise a path that cannot fire in production"
),
)
def test_editable_marker_false_when_metadata_is_invalid(self):
invalid_metadata_error = importlib.metadata.InvalidMetadataError
with patch(
"importlib.metadata.distribution",
side_effect=invalid_metadata_error("bad metadata"),
):
assert specify_cli._version._editable_marker_seen() is False
assert specify_cli._version._source_checkout_path() is None
def test_direct_url_editable_install_marks_source_checkout(self, tmp_path):
project_root = tmp_path / "spec-kit"
project_root.mkdir()
(project_root / ".git").mkdir()
class FakeDist:
files = []
def read_text(self, name):
if name == "direct_url.json":
return json.dumps(
{
"dir_info": {"editable": True},
"url": project_root.as_uri(),
}
)
return None
def locate_file(self, file):
return file
with patch("importlib.metadata.distribution", return_value=FakeDist()):
assert specify_cli._version._editable_marker_seen() is True
assert specify_cli._version._source_checkout_path() == project_root.resolve()
def test_editable_marker_false_without_explicit_editable_metadata(self, tmp_path):
repo_root = tmp_path / "repo"
repo_root.mkdir()
(repo_root / ".git").mkdir()
venv_file = repo_root / ".venv" / "lib" / "python3.13" / "site-packages" / "specify_cli.py"
venv_file.parent.mkdir(parents=True)
venv_file.write_text("# installed module\n")
class FakeDist:
files = ["specify_cli.py"]
def read_text(self, name):
return None
def locate_file(self, file):
return venv_file
with patch("importlib.metadata.distribution", return_value=FakeDist()):
assert specify_cli._version._editable_marker_seen() is False
class TestTagValidationWhitespace:
def test_tag_whitespace_is_trimmed_before_validation(self, uv_tool_argv0, clean_environ):
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", return_value="uv"
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
"specify_cli._version._get_installed_version", return_value="0.7.5"
):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v9.9.9"})
mock_run.side_effect = [
_completed_process(0),
_completed_process(0, stdout="specify 0.8.0\n"),
]
result = runner.invoke(app, ["self", "upgrade", "--tag", " v0.8.0 "])
assert result.exit_code == 0
assert "v0.8.0" in strip_ansi(result.output)
class TestArgvAssemblyPipx:
"""pipx installer argv shape — pipx 1.5+ uses positional PACKAGE_SPEC, never `--spec` or `upgrade`."""
def test_pipx_argv_uses_install_force_positional_not_upgrade(self):
with patch("specify_cli._version.shutil.which", return_value="pipx"):
argv = _assemble_installer_argv(_InstallMethod.PIPX, "v0.7.6")
assert argv == [
"pipx",
"install",
"--force",
"git+https://github.com/github/spec-kit.git@v0.7.6",
]
assert "upgrade" not in argv # pipx upgrade does not accept arbitrary refs
assert "--spec" not in argv # pipx 1.5+ dropped the --spec flag
def test_missing_pipx_returns_no_installer_argv(self):
with patch("specify_cli._version.shutil.which", return_value=None):
assert _assemble_installer_argv(_InstallMethod.PIPX, "v0.7.6") is None
class TestBareUpgradePipx:
"""pipx happy path."""
def test_happy_path(self, pipx_argv0, clean_environ):
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", return_value="pipx"
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
"specify_cli._version._get_installed_version", return_value="0.7.5"
):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
mock_run.side_effect = [
_completed_process(0),
_completed_process(0, stdout="specify 0.7.6\n"),
]
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 0
out = strip_ansi(result.output)
assert "via pipx:" in out
assert "Upgraded specify-cli: 0.7.5 → 0.7.6" in out
class TestDetectionShortCircuit:
"""Tier-1 path-prefix matches short-circuit before registry checks."""
def test_pipx_argv0_prefix_short_circuits_before_registry_checks(
self,
pipx_argv0,
clean_environ,
):
with patch("specify_cli._version.shutil.which", return_value="/usr/bin/X"), patch(
"specify_cli._version.subprocess.run"
) as mock_run:
method = _detect_install_method()
assert method == _InstallMethod.PIPX
mock_run.assert_not_called()
class TestDryRunPipx:
def test_dry_run_preview_names_pipx(self, pipx_argv0, clean_environ):
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", return_value="pipx"
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
"specify_cli._version._get_installed_version", return_value="0.7.5"
):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
result = runner.invoke(app, ["self", "upgrade", "--dry-run"])
assert result.exit_code == 0
assert "Detected install method: pipx" in strip_ansi(result.output)
assert mock_run.call_count == 0

View File

@@ -0,0 +1,542 @@
"""Installer execution, verification, and error-path tests for `specify self upgrade`."""
import errno
import subprocess
from unittest.mock import patch
from specify_cli import app
from tests.self_upgrade_helpers import (
_completed_process,
mock_urlopen_response,
requires_posix,
runner,
strip_ansi,
)
# ===========================================================================
# Phase 6 — User Story 4: failure recovery (P2)
# ===========================================================================
class TestInstallerMissing:
"""Installer disappeared between detection and run → exit 3."""
def test_uv_missing_exits_3(self, uv_tool_argv0, clean_environ):
which_results = {"specify": "/usr/local/bin/specify"}
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", side_effect=lambda n: which_results.get(n)
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 3
out = strip_ansi(result.output)
assert "Installer uv not found on PATH; reinstall it and retry." in out
assert "Upgrading specify-cli" not in out
def test_pipx_missing_exits_3(self, pipx_argv0, clean_environ):
which_results = {}
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", side_effect=lambda n: which_results.get(n)
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 3
assert "Installer pipx not found on PATH" in strip_ansi(result.output)
def test_absolute_installer_path_does_not_require_path_lookup(
self, uv_tool_argv0, clean_environ, tmp_path
):
fake_uv = tmp_path / "installer-bin" / "uv"
fake_uv.parent.mkdir()
fake_uv.write_text("#!/bin/sh\n")
fake_uv.chmod(0o755)
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", side_effect=lambda name: None
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
"specify_cli._version._get_installed_version", return_value="0.7.5"
), patch(
"specify_cli._version._verify_upgrade", return_value="0.7.6"
), patch(
"specify_cli._version._assemble_installer_argv",
return_value=[
str(fake_uv),
"tool",
"install",
"specify-cli",
"--force",
"--from",
"git+https://github.com/github/spec-kit.git@v0.7.6",
],
):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
mock_run.side_effect = [_completed_process(0)]
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 0
@requires_posix
def test_relative_installer_path_does_not_require_path_lookup(
self, monkeypatch, uv_tool_argv0, clean_environ, tmp_path
):
fake_uv = tmp_path / "uv"
fake_uv.write_text("#!/bin/sh\n")
fake_uv.chmod(0o755)
monkeypatch.chdir(tmp_path)
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", side_effect=lambda name: None
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
"specify_cli._version._get_installed_version", return_value="0.7.5"
), patch(
"specify_cli._version._verify_upgrade", return_value="0.7.6"
), patch(
"specify_cli._version._assemble_installer_argv",
return_value=[
"./uv",
"tool",
"install",
"specify-cli",
"--force",
"--from",
"git+https://github.com/github/spec-kit.git@v0.7.6",
],
):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
mock_run.side_effect = [_completed_process(0)]
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 0
assert mock_run.call_args.args[0][0] == "./uv"
@requires_posix
def test_relative_installer_path_missing_gets_path_specific_message(
self, monkeypatch, uv_tool_argv0, clean_environ, tmp_path
):
monkeypatch.chdir(tmp_path)
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", side_effect=lambda name: None
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch(
"specify_cli._version._assemble_installer_argv",
return_value=[
"./uv",
"tool",
"install",
"specify-cli",
"--force",
"--from",
"git+https://github.com/github/spec-kit.git@v0.7.6",
],
):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 3
assert (
"Installer path ./uv no longer exists; reinstall it and retry."
in strip_ansi(result.output)
)
assert "not found on PATH" not in strip_ansi(result.output)
def test_resolved_absolute_installer_removed_before_exec_gets_missing_path_message(
self, uv_tool_argv0, clean_environ, tmp_path
):
fake_uv = tmp_path / "installer-bin" / "uv"
fake_uv.parent.mkdir()
fake_uv.write_text("#!/bin/sh\n")
fake_uv.chmod(0o755)
def fake_run(argv, *args, **kwargs):
fake_uv.unlink()
raise FileNotFoundError(str(fake_uv))
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which",
side_effect=lambda name: str(fake_uv) if name == "uv" else None,
), patch("specify_cli._version.subprocess.run", side_effect=fake_run), patch(
"specify_cli._version._get_installed_version", return_value="0.7.5"
):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 3
assert (
f"Installer path {fake_uv} no longer exists; reinstall it and retry."
in strip_ansi(result.output)
)
def test_absolute_installer_path_not_executable_gets_specific_message(
self, uv_tool_argv0, clean_environ, tmp_path
):
fake_uv = tmp_path / "installer-bin" / "uv"
fake_uv.parent.mkdir()
fake_uv.write_text("#!/bin/sh\n")
fake_uv.chmod(0o644)
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", side_effect=lambda name: None
), patch("specify_cli._version.os.access", return_value=False), patch(
"specify_cli._version._get_installed_version", return_value="0.7.5"
), patch(
"specify_cli._version._assemble_installer_argv",
return_value=[
str(fake_uv),
"tool",
"install",
"specify-cli",
"--force",
"--from",
"git+https://github.com/github/spec-kit.git@v0.7.6",
],
):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 3
assert (
f"Installer path {fake_uv} is not an executable file; fix the path or reinstall it and retry."
in strip_ansi(result.output)
)
@requires_posix
def test_relative_installer_path_not_executable_gets_path_specific_message(
self, monkeypatch, uv_tool_argv0, clean_environ, tmp_path
):
fake_uv = tmp_path / "uv"
fake_uv.write_text("#!/bin/sh\n")
fake_uv.chmod(0o644)
monkeypatch.chdir(tmp_path)
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", side_effect=lambda name: None
), patch("specify_cli._version.os.access", return_value=False), patch(
"specify_cli._version._get_installed_version", return_value="0.7.5"
), patch(
"specify_cli._version._assemble_installer_argv",
return_value=[
"./uv",
"tool",
"install",
"specify-cli",
"--force",
"--from",
"git+https://github.com/github/spec-kit.git@v0.7.6",
],
):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
result = runner.invoke(app, ["self", "upgrade"])
out = strip_ansi(result.output)
assert result.exit_code == 3
assert (
"Installer path ./uv is not an executable file; fix the path or reinstall it and retry."
in out
)
assert "Installer ./uv is not executable" not in out
def test_real_installer_exit_126_is_not_treated_as_invalid_path(
self, uv_tool_argv0, clean_environ
):
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", return_value="uv"
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
"specify_cli._version._get_installed_version", return_value="0.7.5"
):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
mock_run.side_effect = [_completed_process(126)]
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 126
out = strip_ansi(result.output)
assert "Upgrade failed. Installer exit code: 126." in out
assert "not an executable file" not in out
def test_absolute_installer_path_missing_gets_path_specific_message(
self, uv_tool_argv0, clean_environ, tmp_path
):
fake_uv = tmp_path / "missing-installer" / "uv"
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", side_effect=lambda name: None
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
"specify_cli._version._get_installed_version", return_value="0.7.5"
), patch(
"specify_cli._version._assemble_installer_argv",
return_value=[
str(fake_uv),
"tool",
"install",
"specify-cli",
"--force",
"--from",
"git+https://github.com/github/spec-kit.git@v0.7.6",
],
):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 3
assert (
f"Installer path {fake_uv} no longer exists; reinstall it and retry."
in strip_ansi(result.output)
)
mock_run.assert_not_called()
def test_exec_oserror_is_treated_as_invalid_installer(
self, uv_tool_argv0, clean_environ, tmp_path
):
fake_uv = tmp_path / "installer-bin" / "uv"
fake_uv.parent.mkdir()
fake_uv.write_text("#!/usr/bin/env bash\n", encoding="utf-8")
fake_uv.chmod(0o755)
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", side_effect=lambda name: None
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch(
"specify_cli._version._assemble_installer_argv",
return_value=[
str(fake_uv),
"tool",
"install",
"specify-cli",
"--force",
"--from",
"git+https://github.com/github/spec-kit.git@v0.7.6",
],
), patch(
"specify_cli._version.subprocess.run",
side_effect=PermissionError("Permission denied"),
):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 3
out = strip_ansi(result.output)
assert f"Installer path {fake_uv} is not an executable file" in out
assert "not found on PATH" not in out
def test_bare_invalid_installer_message_does_not_call_it_a_path(
self, uv_tool_argv0, clean_environ
):
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", return_value="uv"
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch(
"specify_cli._version._assemble_installer_argv",
return_value=[
"uv",
"tool",
"install",
"specify-cli",
"--force",
"--from",
"git+https://github.com/github/spec-kit.git@v0.7.6",
],
), patch(
"specify_cli._version.subprocess.run",
side_effect=PermissionError("Permission denied"),
):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 3
out = strip_ansi(result.output)
assert "Installer uv is not executable" in out
assert "Installer path uv" not in out
def test_exec_oserror_errno_is_treated_as_invalid_installer(
self, uv_tool_argv0, clean_environ, tmp_path
):
fake_uv = tmp_path / "installer-bin" / "uv"
fake_uv.parent.mkdir()
fake_uv.write_text("#!/usr/bin/env bash\n", encoding="utf-8")
fake_uv.chmod(0o755)
invalid_error = OSError(errno.ENOEXEC, "Exec format error")
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", side_effect=lambda name: None
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch(
"specify_cli._version._assemble_installer_argv",
return_value=[
str(fake_uv),
"tool",
"install",
"specify-cli",
"--force",
"--from",
"git+https://github.com/github/spec-kit.git@v0.7.6",
],
), patch("specify_cli._version.subprocess.run", side_effect=invalid_error):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 3
out = strip_ansi(result.output)
assert f"Installer path {fake_uv} is not an executable file" in out
assert "not found on PATH" not in out
def test_transient_exec_oserror_is_not_treated_as_invalid_installer(
self, uv_tool_argv0, clean_environ, tmp_path
):
fake_uv = tmp_path / "installer-bin" / "uv"
fake_uv.parent.mkdir()
fake_uv.write_text("#!/usr/bin/env bash\n", encoding="utf-8")
fake_uv.chmod(0o755)
transient_error = OSError(errno.EMFILE, "Too many open files")
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", side_effect=lambda name: None
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch(
"specify_cli._version._assemble_installer_argv",
return_value=[
str(fake_uv),
"tool",
"install",
"specify-cli",
"--force",
"--from",
"git+https://github.com/github/spec-kit.git@v0.7.6",
],
), patch("specify_cli._version.subprocess.run", side_effect=transient_error):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
result = runner.invoke(app, ["self", "upgrade"])
# Transient/unknown OSErrors are re-raised rather than mapped to the
# invalid-installer exit 3, so the CLI surfaces them as an uncaught
# error: exit code 1 with the original OSError preserved.
assert result.exit_code == 1
assert isinstance(result.exception, OSError)
class TestInstallerFailed:
"""Installer non-zero exit → propagate code, print rollback hint."""
def test_installer_exit_2_propagates(self, uv_tool_argv0, clean_environ):
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", return_value="uv"
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
"specify_cli._version._get_installed_version", return_value="0.7.5"
):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
mock_run.side_effect = [_completed_process(2)] # installer fails
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 2
out = strip_ansi(result.output)
assert "Upgrade failed. Installer exit code: 2." in out
assert "Try again or run the command manually:" in out
assert "git+https://github.com/github/spec-kit.git@v0.7.6" in out
assert (
"To pin back to the previous version: "
"uv tool install specify-cli --force --from "
"git+https://github.com/github/spec-kit.git@v0.7.5"
) in out
# No verification attempted after a failed installer run.
assert mock_run.call_count == 1
def test_installer_exit_127_propagates(self, uv_tool_argv0, clean_environ):
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", return_value="uv"
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
"specify_cli._version._get_installed_version", return_value="0.7.5"
):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
mock_run.side_effect = [_completed_process(127)]
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 127
def test_installer_timeout_prints_timeout_specific_message(
self, uv_tool_argv0, clean_environ, monkeypatch
):
monkeypatch.setenv("SPECIFY_UPGRADE_TIMEOUT_SECS", "12")
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", return_value="uv"
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
"specify_cli._version._get_installed_version", return_value="0.7.5"
):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
mock_run.side_effect = [
subprocess.TimeoutExpired(cmd=["uv"], timeout=12)
]
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 124
out = strip_ansi(result.output)
assert "Upgrade timed out while waiting for the installer subprocess." in out
assert "SPECIFY_UPGRADE_TIMEOUT_SECS=12" in out
def test_non_finite_timeout_warns_and_runs_without_timeout(
self, uv_tool_argv0, clean_environ, monkeypatch
):
monkeypatch.setenv("SPECIFY_UPGRADE_TIMEOUT_SECS", "nan")
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", return_value="uv"
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
"specify_cli._version._get_installed_version", return_value="0.7.5"
):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
mock_run.side_effect = [
_completed_process(0),
_completed_process(0, stdout="specify 0.7.6\n"),
]
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 0
assert "Ignoring invalid SPECIFY_UPGRADE_TIMEOUT_SECS='nan'" in strip_ansi(
result.output
)
assert mock_run.call_args_list[0].kwargs["timeout"] is None
def test_real_installer_exit_124_is_not_treated_as_timeout(
self, uv_tool_argv0, clean_environ
):
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", return_value="uv"
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
"specify_cli._version._get_installed_version", return_value="0.7.5"
):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
mock_run.side_effect = [_completed_process(124)]
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 124
out = strip_ansi(result.output)
assert "Upgrade failed. Installer exit code: 124." in out
assert "Upgrade timed out while waiting for the installer subprocess." not in out
def test_pipx_failure_prints_pipx_rollback_hint(self, pipx_argv0, clean_environ):
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", return_value="pipx"
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
"specify_cli._version._get_installed_version", return_value="0.7.5"
):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
mock_run.side_effect = [_completed_process(2)]
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 2
out = strip_ansi(result.output)
assert (
"To pin back to the previous version: pipx install --force "
"git+https://github.com/github/spec-kit.git@v0.7.5"
) in out
def test_rollback_hint_accepts_normalizable_stable_snapshot(
self, uv_tool_argv0, clean_environ
):
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", return_value="uv"
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
"specify_cli._version._get_installed_version", return_value="v0.7.5"
):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
mock_run.side_effect = [_completed_process(2)]
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 2
out = strip_ansi(result.output)
assert (
"To pin back to the previous version: uv tool install specify-cli --force "
"--from git+https://github.com/github/spec-kit.git@v0.7.5"
) in out
assert "Previous version was not an exact stable release tag" not in out
def test_prerelease_failure_degrades_rollback_hint_to_releases_page(
self, uv_tool_argv0, clean_environ
):
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", return_value="uv"
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
"specify_cli._version._get_installed_version", return_value="1.0.0rc1"
):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.0.0"})
mock_run.side_effect = [_completed_process(2)]
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 2
out = strip_ansi(result.output)
assert "Previous version was not an exact stable release tag" in out
assert "https://github.com/github/spec-kit/releases" in out
assert "git+https://github.com/github/spec-kit.git@v1.0.0rc1" not in out

View File

@@ -0,0 +1,184 @@
"""Non-upgradable path guidance tests for `specify self upgrade`."""
from unittest.mock import patch
from specify_cli import app
from tests.self_upgrade_helpers import (
mock_urlopen_response,
runner,
strip_ansi,
)
# ===========================================================================
# Phase 5 — User Story 3: non-upgradable path guidance (P3)
# ===========================================================================
class TestUvxEphemeral:
"""uvx ephemeral path emits exact one-liner, no installer call."""
def test_uvx_argv0_prints_exact_one_liner_and_exits_zero(
self,
uvx_ephemeral_argv0,
clean_environ,
):
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.subprocess.run"
) as mock_run:
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 0
expected = (
"Running via uvx (ephemeral); the next uvx invocation already "
"resolves to latest — no upgrade action needed."
)
assert expected in strip_ansi(result.output)
assert mock_run.call_count == 0
def test_offline_still_exits_zero_without_tag_resolution(
self,
uvx_ephemeral_argv0,
clean_environ,
):
with patch(
"specify_cli.authentication.http.urllib.request.urlopen",
side_effect=AssertionError("non-upgradable uvx path must not hit network"),
):
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 0
assert "uvx (ephemeral)" in strip_ansi(result.output)
class TestSourceCheckout:
"""Editable install path emits git pull guidance."""
def test_source_checkout_prints_git_pull_guidance(
self,
unsupported_argv0,
tmp_path,
clean_environ,
):
fake_tree = tmp_path / "worktree"
fake_tree.mkdir()
(fake_tree / ".git").mkdir()
with patch("specify_cli._version._editable_marker_seen", return_value=True), patch(
"specify_cli._version._source_checkout_path", return_value=fake_tree
), patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.subprocess.run"
) as mock_run:
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 0
out = strip_ansi(result.output)
assert f"Running from a source checkout at {fake_tree}" in out
assert "git pull" in out
assert "pip install -e ." in out
assert mock_run.call_count == 0
def test_source_checkout_without_path_mentions_checkout_directory(
self,
unsupported_argv0,
clean_environ,
):
with patch("specify_cli._version._editable_marker_seen", return_value=True), patch(
"specify_cli._version._source_checkout_path", return_value=None
), patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.subprocess.run"
) as mock_run:
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
result = runner.invoke(app, ["self", "upgrade"])
out = strip_ansi(result.output)
assert result.exit_code == 0
assert "checkout path could not be detected" in out
assert "from your checkout directory" in out
assert "(path unavailable)" not in out
assert mock_run.call_count == 0
class TestUnsupported:
"""Unsupported path enumerates manual reinstall commands."""
def test_unsupported_prints_both_reinstall_commands(
self,
unsupported_argv0,
clean_environ,
):
with patch("specify_cli._version._editable_marker_seen", return_value=False), patch(
"specify_cli._version.shutil.which", return_value=None
), patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.subprocess.run"
) as mock_run:
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 0
out = strip_ansi(result.output)
assert "Could not identify your install method automatically" in out
assert (
"uv tool install specify-cli --force --from "
"git+https://github.com/github/spec-kit.git@vX.Y.Z"
) in out
assert (
"pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z"
in out
)
assert mock_run.call_count == 0
def test_unsupported_offline_degrades_to_placeholder_manual_commands(
self,
unsupported_argv0,
clean_environ,
):
with patch("specify_cli._version._editable_marker_seen", return_value=False), patch(
"specify_cli._version.shutil.which", return_value=None
), patch(
"specify_cli.authentication.http.urllib.request.urlopen",
side_effect=AssertionError("unsupported guidance should not require network"),
):
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 0
out = strip_ansi(result.output)
assert "Could not identify your install method automatically" in out
assert (
"uv tool install specify-cli --force --from "
"git+https://github.com/github/spec-kit.git@vX.Y.Z"
) in out
assert (
"pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z"
in out
)
class TestDryRunNonUpgradablePaths:
"""--dry-run on non-upgradable paths emits guidance, not preview."""
def test_dry_run_on_uvx_ephemeral_emits_guidance_not_preview(
self,
uvx_ephemeral_argv0,
clean_environ,
):
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen:
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
result = runner.invoke(app, ["self", "upgrade", "--dry-run"])
assert result.exit_code == 0
out = strip_ansi(result.output)
assert "Dry run — no changes will be made." not in out
assert "uvx (ephemeral)" in out
def test_dry_run_on_unsupported_emits_manual_commands(
self,
unsupported_argv0,
clean_environ,
):
with patch("specify_cli._version._editable_marker_seen", return_value=False), patch(
"specify_cli._version.shutil.which", return_value=None
), patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen:
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
result = runner.invoke(app, ["self", "upgrade", "--dry-run"])
assert result.exit_code == 0
assert "Could not identify your install method" in strip_ansi(result.output)

View File

@@ -0,0 +1,649 @@
"""Verification, resolution, and validation tests for `specify self upgrade`."""
import urllib.error
from unittest.mock import patch
import pytest
import specify_cli
from specify_cli import app
from tests.self_upgrade_helpers import (
SENTINEL_GH_TOKEN,
SENTINEL_GITHUB_TOKEN,
_InstallMethod,
_UpgradePlan,
_completed_process,
_verify_upgrade,
mock_urlopen_response,
runner,
strip_ansi,
)
# ===========================================================================
# Phase 6 — User Story 4: failure recovery (P2)
# ===========================================================================
class TestVerificationMismatch:
"""Installer says 0 but the binary is still the old version → exit 2."""
def test_installer_ok_but_verify_returns_old_version(
self,
uv_tool_argv0,
clean_environ,
):
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", return_value="uv"
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
"specify_cli._version._get_installed_version", return_value="0.7.5"
):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
mock_run.side_effect = [
_completed_process(0), # installer OK
_completed_process(0, stdout="specify 0.7.5\n"), # verify: OLD!
]
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 2
out = strip_ansi(result.output)
assert "Verification failed" in out
assert "resolves to 0.7.5 (expected v0.7.6)" in out
assert "The new version may take effect on your next invocation." in out
def test_verify_nonzero_exit_is_not_treated_as_success(
self,
uv_tool_argv0,
clean_environ,
):
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", return_value="uv"
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
"specify_cli._version._get_installed_version", return_value="0.7.5"
):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
mock_run.side_effect = [
_completed_process(0),
_completed_process(1, stdout="specify 0.7.6\n"),
]
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 2
out = strip_ansi(result.output)
assert "Verification failed" in out
assert "(unknown) (expected v0.7.6)" in out
def test_verify_accepts_pep440_equivalent_rc_version(
self,
uv_tool_argv0,
clean_environ,
):
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", return_value="uv"
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
"specify_cli._version._get_installed_version", return_value="0.9.0"
):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v9.9.9"})
mock_run.side_effect = [
_completed_process(0),
_completed_process(0, stdout="specify 1.0.0rc1\n"),
]
result = runner.invoke(app, ["self", "upgrade", "--tag", "v1.0.0-rc1"])
assert result.exit_code == 0
assert "Upgraded specify-cli: 0.9.0 → 1.0.0rc1" in strip_ansi(result.output)
def test_verify_accepts_specify_cli_binary_name_in_version_output(
self,
uv_tool_argv0,
clean_environ,
):
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", return_value="uv"
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
"specify_cli._version._get_installed_version", return_value="0.7.5"
):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
mock_run.side_effect = [
_completed_process(0),
_completed_process(0, stdout="specify-cli version 0.7.6\n"),
]
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 0
assert "Upgraded specify-cli: 0.7.5 → 0.7.6" in strip_ansi(result.output)
def test_verify_accepts_capitalized_binary_name_in_version_output(
self,
uv_tool_argv0,
clean_environ,
):
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", return_value="uv"
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
"specify_cli._version._get_installed_version", return_value="0.7.5"
):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
mock_run.side_effect = [
_completed_process(0),
_completed_process(0, stdout="Specify, version 0.7.6\n"),
]
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 0
assert "Upgraded specify-cli: 0.7.5 → 0.7.6" in strip_ansi(result.output)
def test_verify_rejects_output_without_parseable_version(
self,
uv_tool_argv0,
clean_environ,
):
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", return_value="uv"
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
"specify_cli._version._get_installed_version", return_value="0.7.5"
):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
mock_run.side_effect = [
_completed_process(0),
_completed_process(0, stdout="specify version unknown\n"),
]
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 2
out = strip_ansi(result.output)
assert "Verification failed" in out
assert "(unknown) (expected v0.7.6)" in out
def test_verify_uses_current_entrypoint_when_not_on_path(
self,
uv_tool_argv0,
clean_environ,
):
assert uv_tool_argv0.exists()
assert uv_tool_argv0.is_file()
plan = _UpgradePlan(
method=_InstallMethod.UV_TOOL,
current_version="0.7.5",
target_tag="v0.7.6",
installer_argv=["/usr/bin/uv", "tool", "install", "specify-cli"],
preview_summary="",
pre_upgrade_snapshot="0.7.5",
)
with patch(
"specify_cli._version.shutil.which", side_effect=lambda name: None
), patch(
"specify_cli._version.subprocess.run"
) as mock_run, patch(
"specify_cli._version.os.access", return_value=True
):
mock_run.return_value = _completed_process(0, stdout="specify 0.7.6\n")
verified = _verify_upgrade(plan)
assert verified == "0.7.6"
assert mock_run.call_args.args[0][0] == str(uv_tool_argv0)
assert mock_run.call_args.kwargs["timeout"] == specify_cli._version._VERIFY_TIMEOUT_SECS
def test_verify_falls_back_to_path_when_current_entrypoint_is_not_executable(
self,
uv_tool_argv0,
clean_environ,
):
plan = _UpgradePlan(
method=_InstallMethod.UV_TOOL,
current_version="0.7.5",
target_tag="v0.7.6",
installer_argv=["/usr/bin/uv", "tool", "install", "specify-cli"],
preview_summary="",
pre_upgrade_snapshot="0.7.5",
)
with patch(
"specify_cli._version.shutil.which",
side_effect=lambda name: "/usr/local/bin/specify" if name == "specify" else None,
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
"specify_cli._version.os.access", return_value=False
):
mock_run.return_value = _completed_process(0, stdout="specify 0.7.6\n")
verified = _verify_upgrade(plan)
assert verified == "0.7.6"
assert mock_run.call_args.args[0][0] == "/usr/local/bin/specify"
def test_verify_ignores_python_entrypoint_and_falls_back_to_specify(
self,
clean_environ,
tmp_path,
):
fake_python = tmp_path / "python3"
fake_python.write_text("#!/bin/sh\n")
fake_python.chmod(0o755)
plan = _UpgradePlan(
method=_InstallMethod.UV_TOOL,
current_version="0.7.5",
target_tag="v0.7.6",
installer_argv=["/usr/bin/uv", "tool", "install", "specify-cli"],
preview_summary="",
pre_upgrade_snapshot="0.7.5",
)
with patch(
"specify_cli._version.shutil.which", side_effect=lambda name: "/usr/local/bin/specify" if name == "specify" else None
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
"specify_cli._version.sys.argv", [str(fake_python)]
), patch(
"specify_cli._version.os.access", return_value=True
):
mock_run.return_value = _completed_process(0, stdout="specify 0.7.6\n")
verified = _verify_upgrade(plan)
assert verified == "0.7.6"
assert mock_run.call_args.args[0][0] == "/usr/local/bin/specify"
def test_verify_accepts_specify_cli_named_current_entrypoint(
self,
clean_environ,
tmp_path,
):
fake_specify_cli = tmp_path / "specify-cli"
fake_specify_cli.write_text("#!/bin/sh\n")
fake_specify_cli.chmod(0o755)
plan = _UpgradePlan(
method=_InstallMethod.UV_TOOL,
current_version="0.7.5",
target_tag="v0.7.6",
installer_argv=["/usr/bin/uv", "tool", "install", "specify-cli"],
preview_summary="",
pre_upgrade_snapshot="0.7.5",
)
with patch("specify_cli._version.shutil.which", return_value=None), patch(
"specify_cli._version.subprocess.run"
) as mock_run, patch("specify_cli._version.sys.argv", [str(fake_specify_cli)]), patch(
"specify_cli._version.os.access", return_value=True
):
mock_run.return_value = _completed_process(0, stdout="specify 0.7.6\n")
verified = _verify_upgrade(plan)
assert verified == "0.7.6"
assert mock_run.call_args.args[0][0] == str(fake_specify_cli)
class TestResolutionFailures:
"""Pre-installer resolution failure → exit 1, reusing the resolver category strings."""
def test_offline_exits_1_with_phase1_string(self, uv_tool_argv0, clean_environ):
with patch(
"specify_cli.authentication.http.urllib.request.urlopen",
side_effect=urllib.error.URLError("nope"),
):
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 1
assert "Upgrade aborted: offline or timeout" in strip_ansi(result.output)
def test_rate_limited_exits_1(self, uv_tool_argv0, clean_environ):
err = urllib.error.HTTPError(
url="https://api.github.com",
code=403,
msg="rate limited",
hdrs={}, # type: ignore[arg-type]
fp=None,
)
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=err):
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 1
assert (
"Upgrade aborted: rate limited (configure ~/.specify/auth.json with a GitHub token)"
in strip_ansi(result.output)
)
def test_http_500_exits_1(self, uv_tool_argv0, clean_environ):
err = urllib.error.HTTPError(
url="https://api.github.com",
code=500,
msg="srv err",
hdrs={}, # type: ignore[arg-type]
fp=None,
)
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=err):
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 1
assert "Upgrade aborted: HTTP 500" in strip_ansi(result.output)
@pytest.mark.parametrize(
"code, expected",
[
# 429 (Too Many Requests / secondary rate limit) gets the same
# actionable token hint as 403; other statuses surface verbatim.
(
429,
"Upgrade aborted: rate limited (configure ~/.specify/auth.json "
"with a GitHub token)",
),
(404, "Upgrade aborted: HTTP 404"),
(502, "Upgrade aborted: HTTP 502"),
],
)
def test_http_error_categorization(
self, code, expected, uv_tool_argv0, clean_environ
):
err = urllib.error.HTTPError(
url="https://api.github.com",
code=code,
msg="err",
hdrs={}, # type: ignore[arg-type]
fp=None,
)
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=err):
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 1
assert expected in strip_ansi(result.output)
def test_unparseable_resolved_release_tag_exits_1_without_traceback(
self, uv_tool_argv0, clean_environ
):
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.subprocess.run"
) as mock_run, patch(
"specify_cli._version.shutil.which", return_value="uv"
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "release-main"})
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 1
out = strip_ansi(result.output)
assert "resolved release tag is not a comparable version" in out
assert "release-main" not in out
assert "Traceback" not in out
assert mock_run.call_count == 0
class TestTagValidation:
"""--tag regex enforcement."""
def test_valid_stable_tag(self, uv_tool_argv0, clean_environ):
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
"specify_cli._version._get_installed_version", return_value="0.7.5"
):
result = runner.invoke(
app,
["self", "upgrade", "--dry-run", "--tag", "v0.7.6"],
)
assert result.exit_code == 0
def test_valid_dev_suffix_tag(self, uv_tool_argv0, clean_environ):
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
"specify_cli._version._get_installed_version", return_value="0.7.5"
):
result = runner.invoke(
app,
["self", "upgrade", "--dry-run", "--tag", "v0.8.0.dev0"],
)
assert result.exit_code == 0
assert "Target version: v0.8.0.dev0" in strip_ansi(result.output)
def test_valid_rc_tag(self, uv_tool_argv0, clean_environ):
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
"specify_cli._version._get_installed_version", return_value="0.7.5"
):
result = runner.invoke(
app,
["self", "upgrade", "--dry-run", "--tag", "v1.0.0-rc1"],
)
assert result.exit_code == 0
def test_valid_beta_dot_tag_uses_pep440_equivalent_for_noop(
self, uv_tool_argv0, clean_environ
):
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
"specify_cli._version._get_installed_version", return_value="1.0.0b1"
):
result = runner.invoke(
app,
["self", "upgrade", "--tag", "v1.0.0-beta.1"],
)
assert result.exit_code == 0
assert "Already on requested release: v1.0.0-beta.1" in strip_ansi(
result.output
)
def test_valid_build_metadata_tag(self, uv_tool_argv0, clean_environ):
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
"specify_cli._version._get_installed_version", return_value="0.7.5"
):
result = runner.invoke(
app,
["self", "upgrade", "--dry-run", "--tag", "v0.8.0+build.42"],
)
assert result.exit_code == 0
assert "Target version: v0.8.0+build.42" in strip_ansi(result.output)
def test_uppercase_v_prefix_is_folded_to_lowercase(
self, uv_tool_argv0, clean_environ
):
# A pasted uppercase `V` prefix is accepted and normalized to `v` so
# the git ref matches the canonical lowercase release tag.
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
"specify_cli._version._get_installed_version", return_value="0.7.5"
):
result = runner.invoke(
app,
["self", "upgrade", "--dry-run", "--tag", "V0.7.6"],
)
assert result.exit_code == 0
assert "Target version: v0.7.6" in strip_ansi(result.output)
def test_valid_prerelease_with_build_metadata_tag(
self, uv_tool_argv0, clean_environ
):
# Prerelease and build-metadata suffixes compose (PEP 440 / semver).
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
"specify_cli._version._get_installed_version", return_value="0.7.5"
):
result = runner.invoke(
app,
["self", "upgrade", "--dry-run", "--tag", "v1.0.0-rc1+build.42"],
)
assert result.exit_code == 0
assert "Target version: v1.0.0-rc1+build.42" in strip_ansi(result.output)
@pytest.mark.parametrize(
"bad_tag",
[
"latest",
"0.7.5",
"main",
"v7",
"",
"v1.2.3abc",
"v1.2.3...",
"v1.2.3++",
"v\uff11.2.3",
"v1.\u0662.3",
],
)
def test_invalid_tags_rejected(self, bad_tag, uv_tool_argv0, clean_environ):
result = runner.invoke(app, ["self", "upgrade", "--tag", bad_tag])
assert result.exit_code == 1
output = strip_ansi(result.output)
assert "Invalid --tag" in output or "expected vMAJOR.MINOR.PATCH" in output
class TestUnknownCurrent:
"""'unknown' current version renders literally in notice and success message."""
def test_unknown_current_renders_literal_in_notice(
self,
uv_tool_argv0,
clean_environ,
):
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", return_value="uv"
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
"specify_cli._version._get_installed_version", return_value="unknown"
):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
mock_run.side_effect = [
_completed_process(0),
_completed_process(0, stdout="specify 0.7.6\n"),
]
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 0
out = strip_ansi(result.output)
assert "Upgrading specify-cli unknown → v0.7.6 via uv tool:" in out
assert "Upgraded specify-cli: unknown → 0.7.6" in out
def test_unknown_current_rollback_hint_degrades(
self,
uv_tool_argv0,
clean_environ,
):
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.shutil.which", return_value="uv"
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
"specify_cli._version._get_installed_version", return_value="unknown"
):
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
mock_run.side_effect = [_completed_process(2)] # installer fails
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 2
out = strip_ansi(result.output)
assert "Could not determine the previous version" in out
assert "https://github.com/github/spec-kit/releases" in out
class TestTokenScrubbing:
"""GH_TOKEN / GITHUB_TOKEN are stripped from every child env."""
def test_env_passed_to_subprocess_has_no_github_tokens(
self,
uv_tool_argv0,
monkeypatch,
):
monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN)
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
response = mock_urlopen_response({"tag_name": "v0.7.6"})
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli.authentication.http.urllib.request.build_opener"
) as mock_build_opener, patch(
"specify_cli._version.shutil.which", return_value="uv"
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
"specify_cli._version._get_installed_version", return_value="0.7.5"
):
mock_urlopen.return_value = response
mock_build_opener.return_value.open.return_value = response
mock_run.side_effect = [
_completed_process(0),
_completed_process(0, stdout="specify 0.7.6\n"),
]
runner.invoke(app, ["self", "upgrade"])
assert mock_run.call_count >= 1
for call in mock_run.call_args_list:
env_kwarg = call.kwargs.get("env") or {}
assert "GH_TOKEN" not in env_kwarg, f"env leaked GH_TOKEN: {env_kwarg!r}"
assert "GITHUB_TOKEN" not in env_kwarg
for v in env_kwarg.values():
assert SENTINEL_GH_TOKEN not in v
assert SENTINEL_GITHUB_TOKEN not in v
def test_env_scrubbing_is_case_insensitive(
self,
uv_tool_argv0,
monkeypatch,
):
monkeypatch.setenv("gh_token", SENTINEL_GH_TOKEN)
monkeypatch.setenv("GitHub_Token", SENTINEL_GITHUB_TOKEN)
response = mock_urlopen_response({"tag_name": "v0.7.6"})
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli.authentication.http.urllib.request.build_opener"
) as mock_build_opener, patch(
"specify_cli._version.shutil.which", return_value="uv"
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
"specify_cli._version._get_installed_version", return_value="0.7.5"
):
mock_urlopen.return_value = response
mock_build_opener.return_value.open.return_value = response
mock_run.side_effect = [
_completed_process(0),
_completed_process(0, stdout="specify 0.7.6\n"),
]
runner.invoke(app, ["self", "upgrade"])
assert mock_run.call_count >= 1
for call in mock_run.call_args_list:
env_kwarg = call.kwargs.get("env") or {}
assert "gh_token" not in env_kwarg
assert "GitHub_Token" not in env_kwarg
for v in env_kwarg.values():
assert SENTINEL_GH_TOKEN not in v
assert SENTINEL_GITHUB_TOKEN not in v
def test_env_scrubbing_removes_github_token_variants(self, monkeypatch):
monkeypatch.setenv("GH_PAT", "gh-pat")
monkeypatch.setenv("GH_TOKEN_FILE", "gh-token-file")
monkeypatch.setenv("GH_ENTERPRISE_TOKEN", "enterprise-gh")
monkeypatch.setenv("GH_ENTERPRISE_SECRET", "enterprise-secret")
monkeypatch.setenv("GH_ENTERPRISE_PRIVATE_KEY", "enterprise-key")
monkeypatch.setenv("GITHUB_PAT", "github-pat")
monkeypatch.setenv("GITHUB_TOKEN_PATH", "github-token-path")
monkeypatch.setenv("GITHUB_ENTERPRISE_TOKEN", "enterprise-github")
monkeypatch.setenv("GITHUB_API_TOKEN", "api-token")
monkeypatch.setenv("GITHUB_APP_PRIVATE_KEY", "app-private-key")
monkeypatch.setenv("GITHUB_OAUTH_CLIENT_SECRET", "oauth-secret")
monkeypatch.setenv("HOMEBREW_GITHUB_API_TOKEN", "homebrew-token")
monkeypatch.setenv("NOTGITHUB_TOKEN", "not-github-kept")
monkeypatch.setenv("GHOST_API_TOKEN", "ghost-kept")
monkeypatch.setenv("GHIDRA_API_KEY", "ghidra-kept")
monkeypatch.setenv("UNRELATED_TOKEN", "kept")
env = specify_cli._version._scrubbed_env()
assert "GH_PAT" not in env
assert "GH_TOKEN_FILE" not in env
assert "GH_ENTERPRISE_TOKEN" not in env
assert "GH_ENTERPRISE_SECRET" not in env
assert "GH_ENTERPRISE_PRIVATE_KEY" not in env
assert "GITHUB_PAT" not in env
assert "GITHUB_TOKEN_PATH" not in env
assert "GITHUB_ENTERPRISE_TOKEN" not in env
assert "GITHUB_API_TOKEN" not in env
assert "GITHUB_APP_PRIVATE_KEY" not in env
assert "GITHUB_OAUTH_CLIENT_SECRET" not in env
assert "HOMEBREW_GITHUB_API_TOKEN" not in env
assert env["NOTGITHUB_TOKEN"] == "not-github-kept"
assert env["GHOST_API_TOKEN"] == "ghost-kept"
assert env["GHIDRA_API_KEY"] == "ghidra-kept"
assert env["UNRELATED_TOKEN"] == "kept"
def test_env_scrubbing_strips_noncredential_github_vars_by_design(
self, monkeypatch
):
# The scrub is intentionally broad: every GH_/GITHUB_-prefixed name is
# removed from the installer subprocess env, including non-credential
# context vars. This is a deliberate fail-safe so credential-adjacent
# names that lack a recognized suffix (e.g. GH_TOKEN_FILE,
# GITHUB_TOKEN_PATH, asserted above) can never leak. The installer
# (`uv tool install` / `pipx install` of a public package) does not
# consume routing/context vars like GITHUB_REPOSITORY, so nothing the
# subprocess needs is lost by stripping them.
monkeypatch.setenv("GH_HOST", "github.example.com")
monkeypatch.setenv("GH_CONFIG_DIR", "/home/u/.config/gh")
monkeypatch.setenv("GITHUB_REPOSITORY", "github/spec-kit")
monkeypatch.setenv("GITHUB_WORKSPACE", "/home/runner/work")
monkeypatch.setenv("GITHUB_USER", "octocat")
env = specify_cli._version._scrubbed_env()
assert "GH_HOST" not in env
assert "GH_CONFIG_DIR" not in env
assert "GITHUB_REPOSITORY" not in env
assert "GITHUB_WORKSPACE" not in env
assert "GITHUB_USER" not in env

View File

@@ -13,8 +13,10 @@ from tests.conftest import requires_bash
PROJECT_ROOT = Path(__file__).resolve().parent.parent
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
SETUP_TASKS_SH = PROJECT_ROOT / "scripts" / "bash" / "setup-tasks.sh"
CHECK_PREREQ_SH = PROJECT_ROOT / "scripts" / "bash" / "check-prerequisites.sh"
COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
SETUP_TASKS_PS = PROJECT_ROOT / "scripts" / "powershell" / "setup-tasks.ps1"
CHECK_PREREQ_PS = PROJECT_ROOT / "scripts" / "powershell" / "check-prerequisites.ps1"
TASKS_TEMPLATE = PROJECT_ROOT / "templates" / "tasks-template.md"
HAS_PWSH = shutil.which("pwsh") is not None
@@ -30,6 +32,7 @@ def _install_bash_scripts(repo: Path) -> None:
d.mkdir(parents=True, exist_ok=True)
shutil.copy(COMMON_SH, d / "common.sh")
shutil.copy(SETUP_TASKS_SH, d / "setup-tasks.sh")
shutil.copy(CHECK_PREREQ_SH, d / "check-prerequisites.sh")
def _install_ps_scripts(repo: Path) -> None:
@@ -37,6 +40,7 @@ def _install_ps_scripts(repo: Path) -> None:
d.mkdir(parents=True, exist_ok=True)
shutil.copy(COMMON_PS, d / "common.ps1")
shutil.copy(SETUP_TASKS_PS, d / "setup-tasks.ps1")
shutil.copy(CHECK_PREREQ_PS, d / "check-prerequisites.ps1")
def _install_core_tasks_template(repo: Path) -> None:
@@ -57,6 +61,25 @@ def _minimal_feature(repo: Path) -> Path:
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
return feat
def _write_integration_state(repo: Path, integration: str = "claude", separator: str = "-") -> None:
specify_dir = repo / ".specify"
specify_dir.mkdir(parents=True, exist_ok=True)
state = {
"integration": integration,
"default_integration": integration,
"installed_integrations": [integration],
"integration_settings": {
integration: {
"invoke_separator": separator,
},
},
}
(specify_dir / "integration.json").write_text(
json.dumps(state),
encoding="utf-8",
)
def _clean_env() -> dict[str, str]:
@@ -71,6 +94,38 @@ def _clean_env() -> dict[str, str]:
return env
def _run_bash_format_command(repo: Path, command_name: str) -> subprocess.CompletedProcess:
script = repo / ".specify" / "scripts" / "bash" / "common.sh"
return subprocess.run(
["bash", "-c", 'source "$1"; format_speckit_command "$2" "$PWD"', "bash", str(script), command_name],
cwd=repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
def _run_powershell_format_command(repo: Path, command_name: str) -> subprocess.CompletedProcess:
script = repo / ".specify" / "scripts" / "powershell" / "common.ps1"
exe = "pwsh" if HAS_PWSH else _POWERSHELL
return subprocess.run(
[
exe,
"-NoProfile",
"-Command",
'& { param($common, $commandName) . $common; Format-SpecKitCommand -CommandName $commandName -RepoRoot (Get-Location).Path }',
str(script),
command_name,
],
cwd=repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
def _git_init(repo: Path) -> None:
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
subprocess.run(
@@ -123,7 +178,7 @@ def test_setup_tasks_bash_core_template_resolved(tasks_repo: Path) -> None:
setup-tasks.sh --json should exit 0 and return an absolute, existing
TASKS_TEMPLATE path pointing to the core template.
"""
feat = _minimal_feature(tasks_repo)
_minimal_feature(tasks_repo)
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
result = subprocess.run(
@@ -150,7 +205,7 @@ def test_setup_tasks_bash_override_wins(tasks_repo: Path) -> None:
When an override exists at .specify/templates/overrides/tasks-template.md,
setup-tasks.sh --json must return the override path, not the core path.
"""
feat = _minimal_feature(tasks_repo)
_minimal_feature(tasks_repo)
# Create the override
overrides_dir = tasks_repo / ".specify" / "templates" / "overrides"
@@ -187,7 +242,7 @@ def test_setup_tasks_bash_extension_wins_over_core(tasks_repo: Path) -> None:
When an extension template exists, setup-tasks.sh --json must resolve
tasks-template.md from the extension before falling back to the core path.
"""
feat = _minimal_feature(tasks_repo)
_minimal_feature(tasks_repo)
# FIX: real extension layout is .specify/extensions/<id>/templates/<name>.md
extension_dir = (
@@ -225,7 +280,7 @@ def test_setup_tasks_bash_preset_wins_over_extension(tasks_repo: Path) -> None:
When both preset and extension templates exist, setup-tasks.sh --json must
resolve the preset path because presets outrank extensions.
"""
feat = _minimal_feature(tasks_repo)
_minimal_feature(tasks_repo)
# FIX: real extension layout is .specify/extensions/<id>/templates/<name>.md
extension_dir = (
@@ -269,7 +324,7 @@ def test_setup_tasks_bash_preset_priority_order(tasks_repo: Path) -> None:
When two presets both provide tasks-template.md, the one listed first in
.specify/presets/.registry wins.
"""
feat = _minimal_feature(tasks_repo)
_minimal_feature(tasks_repo)
# resolve_template reads .specify/presets/.registry as a JSON object with a
# "presets" map where each entry has a numeric "priority" (lower = higher
@@ -329,7 +384,7 @@ def test_setup_tasks_bash_missing_template_errors(tasks_repo: Path) -> None:
When tasks-template.md is absent from all locations, setup-tasks.sh must
exit non-zero and print a helpful ERROR message to stderr.
"""
feat = _minimal_feature(tasks_repo)
_minimal_feature(tasks_repo)
# Remove the core template so no template exists anywhere
core = tasks_repo / ".specify" / "templates" / "tasks-template.md"
@@ -345,12 +400,138 @@ def test_setup_tasks_bash_missing_template_errors(tasks_repo: Path) -> None:
check=False,
env=_clean_env(),
)
assert result.returncode != 0
assert "ERROR" in result.stderr
assert "tasks-template" in result.stderr
@requires_bash
def test_bash_command_hint_defaults_to_dot_without_integration_json(tasks_repo: Path) -> None:
integration_json = tasks_repo / ".specify" / "integration.json"
if integration_json.exists():
integration_json.unlink()
result = _run_bash_format_command(tasks_repo, "plan")
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "/speckit.plan"
@requires_bash
def test_bash_command_hint_rejects_invalid_invoke_separator(tasks_repo: Path) -> None:
_write_integration_state(tasks_repo, "claude", "/")
result = _run_bash_format_command(tasks_repo, "plan")
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "/speckit.plan"
@requires_bash
def test_bash_command_hint_normalizes_mixed_separators(tasks_repo: Path) -> None:
_write_integration_state(tasks_repo, "copilot", ".")
result = _run_bash_format_command(tasks_repo, "/speckit-git.commit")
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "/speckit.git.commit"
_write_integration_state(tasks_repo, "claude", "-")
result = _run_bash_format_command(tasks_repo, "speckit.git-commit")
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "/speckit-git-commit"
@requires_bash
def test_bash_command_hint_preserves_hyphens_inside_segments(tasks_repo: Path) -> None:
_write_integration_state(tasks_repo, "copilot", ".")
result = _run_bash_format_command(tasks_repo, "speckit.jira.sync-status")
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "/speckit.jira.sync-status"
@requires_bash
def test_bash_command_hint_caches_invoke_separator_per_process(tasks_repo: Path) -> None:
_write_integration_state(tasks_repo, "claude", "-")
script = tasks_repo / ".specify" / "scripts" / "bash" / "common.sh"
dot_state = {
"integration": "copilot",
"default_integration": "copilot",
"installed_integrations": ["copilot"],
"integration_settings": {"copilot": {"invoke_separator": "."}},
}
result = subprocess.run(
[
"bash",
"-c",
'source "$1"; format_speckit_command plan "$PWD"; printf "%s" "$2" > .specify/integration.json; format_speckit_command tasks "$PWD"',
"bash",
str(script),
json.dumps(dot_state),
],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr
assert result.stdout.splitlines() == ["/speckit-plan", "/speckit-tasks"]
@requires_bash
def test_setup_tasks_bash_uses_invoke_separator_in_plan_hint(tasks_repo: Path) -> None:
_write_integration_state(tasks_repo, "claude", "-")
feat = tasks_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode != 0
assert "Run /speckit-plan first" in result.stderr
assert "/speckit.plan" not in result.stderr
@requires_bash
def test_check_prerequisites_bash_uses_invoke_separator_in_tasks_hint(
tasks_repo: Path,
) -> None:
_write_integration_state(tasks_repo, "claude", "-")
_minimal_feature(tasks_repo)
script = tasks_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
result = subprocess.run(
["bash", str(script), "--require-tasks"],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode != 0
assert "Run /speckit-tasks first" in result.stderr
assert "/speckit.tasks" not in result.stderr
@requires_bash
def test_setup_tasks_bash_passes_custom_branch_when_feature_json_valid(
tasks_repo: Path,
@@ -413,11 +594,10 @@ def test_setup_tasks_bash_fails_custom_branch_without_feature_json(
check=False,
env=_clean_env(),
)
assert result.returncode != 0
assert "Not on a feature branch" in result.stderr
# ===========================================================================
# POWERSHELL TESTS
# ===========================================================================
@@ -429,7 +609,7 @@ def test_setup_tasks_ps_core_template_resolved(tasks_repo: Path) -> None:
setup-tasks.ps1 -Json should exit 0 and return an absolute, existing
TASKS_TEMPLATE path.
"""
feat = _minimal_feature(tasks_repo)
_minimal_feature(tasks_repo)
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
exe = "pwsh" if HAS_PWSH else _POWERSHELL
@@ -457,7 +637,7 @@ def test_setup_tasks_ps_override_wins(tasks_repo: Path) -> None:
When an override exists at .specify/templates/overrides/tasks-template.md,
setup-tasks.ps1 -Json must return the override path, not the core path.
"""
feat = _minimal_feature(tasks_repo)
_minimal_feature(tasks_repo)
overrides_dir = tasks_repo / ".specify" / "templates" / "overrides"
overrides_dir.mkdir(parents=True, exist_ok=True)
@@ -493,7 +673,7 @@ def test_setup_tasks_ps_missing_template_errors(tasks_repo: Path) -> None:
When tasks-template.md is absent from all locations, setup-tasks.ps1 must
exit non-zero and write a helpful error to stderr.
"""
feat = _minimal_feature(tasks_repo)
_minimal_feature(tasks_repo)
core = tasks_repo / ".specify" / "templates" / "tasks-template.md"
core.unlink()
@@ -514,6 +694,87 @@ def test_setup_tasks_ps_missing_template_errors(tasks_repo: Path) -> None:
assert "tasks-template" in result.stderr.lower() or "tasks-template" in result.stdout.lower()
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_powershell_command_hint_normalizes_mixed_separators(
tasks_repo: Path,
) -> None:
_write_integration_state(tasks_repo, "copilot", ".")
result = _run_powershell_format_command(tasks_repo, "/speckit-git.commit")
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "/speckit.git.commit"
_write_integration_state(tasks_repo, "claude", "-")
result = _run_powershell_format_command(tasks_repo, "speckit.git-commit")
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "/speckit-git-commit"
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_powershell_command_hint_preserves_hyphens_inside_segments(
tasks_repo: Path,
) -> None:
_write_integration_state(tasks_repo, "copilot", ".")
result = _run_powershell_format_command(tasks_repo, "speckit.jira.sync-status")
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "/speckit.jira.sync-status"
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_setup_tasks_ps_uses_invoke_separator_in_plan_hint(tasks_repo: Path) -> None:
_write_integration_state(tasks_repo, "claude", "-")
feat = tasks_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
exe = "pwsh" if HAS_PWSH else _POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
output = result.stderr + result.stdout
assert result.returncode != 0
assert "Run /speckit-plan first" in output
assert "/speckit.plan" not in output
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_check_prerequisites_ps_uses_invoke_separator_in_tasks_hint(
tasks_repo: Path,
) -> None:
_write_integration_state(tasks_repo, "claude", "-")
_minimal_feature(tasks_repo)
script = tasks_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
exe = "pwsh" if HAS_PWSH else _POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-RequireTasks"],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
output = result.stderr + result.stdout
assert result.returncode != 0
assert "Run /speckit-tasks first" in output
assert "/speckit.tasks" not in output
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_setup_tasks_ps_passes_custom_branch_when_feature_json_valid(
tasks_repo: Path,
@@ -581,4 +842,3 @@ def test_setup_tasks_ps_fails_custom_branch_without_feature_json(
assert result.returncode != 0
assert "Not on a feature branch" in result.stderr

View File

@@ -923,7 +923,7 @@ class TestDryRun:
assert re.match(r"^\d{8}-\d{6}-ts-feat$", branch), f"unexpected: {branch}"
# Verify no side effects
branches = subprocess.run(
["git", "branch", "--list", f"*ts-feat*"],
["git", "branch", "--list", "*ts-feat*"],
cwd=git_repo,
capture_output=True,
text=True,

View File

@@ -1,14 +1,14 @@
"""Tests for the `specify self` sub-app (`self check` and `self upgrade`).
Network isolation contract (SC-004 / FR-014): every test that exercises
`specify self check` or `_fetch_latest_release_tag()` MUST mock
`urllib.request.urlopen` so no real outbound call ever reaches
api.github.com. The `self upgrade` stub tests do not need that patch because
the stub is contractually network-free. Run this module under `pytest-socket`
(if installed) with `--disable-socket` as an extra safety net.
`specify self check` or `_fetch_latest_release_tag()` MUST mock the outbound
urllib path it expects (`urlopen` for unauthenticated requests, `build_opener`
for authenticated requests) so no real outbound call ever reaches api.github.com.
Tests for non-network `self upgrade` behavior should keep that contract explicit
with local mocks. Run this module under `pytest-socket` (if installed) with
`--disable-socket` as an extra safety net.
"""
import json
import urllib.error
import importlib.metadata
from unittest.mock import MagicMock, patch
@@ -24,6 +24,7 @@ from specify_cli._version import (
_normalize_tag,
)
from tests.conftest import strip_ansi
from tests.http_helpers import mock_urlopen_response
runner = CliRunner()
@@ -35,16 +36,6 @@ _RATE_LIMITED_REASON = (
)
def _mock_urlopen_response(payload: dict) -> MagicMock:
body = json.dumps(payload).encode("utf-8")
resp = MagicMock()
resp.read.return_value = body
cm = MagicMock()
cm.__enter__.return_value = resp
cm.__exit__.return_value = False
return cm
def _http_error(code: int, message: str = "error") -> urllib.error.HTTPError:
return urllib.error.HTTPError(
url="https://api.github.com/repos/github/spec-kit/releases/latest",
@@ -55,39 +46,6 @@ def _http_error(code: int, message: str = "error") -> urllib.error.HTTPError:
)
class TestSelfUpgradeStub:
"""Pins the `specify self upgrade` stub output + exit code (contract §3.5, FR-016)."""
def test_prints_exactly_three_lines_and_exits_zero(self):
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 0
lines = strip_ansi(result.output).strip().splitlines()
assert lines == [
"specify self upgrade is not implemented yet.",
"Run 'specify self check' to see whether a newer release is available.",
"Actual self-upgrade is planned as follow-up work.",
]
def test_stub_makes_no_network_call(self):
# The stub must not hit the network via either urllib path:
# unauthenticated requests use urlopen() directly; authenticated ones
# go through build_opener(...).open(). Both are patched so that any
# accidental network call raises immediately.
network_error = AssertionError("stub must not hit the network")
with (
patch(
"specify_cli.authentication.http.urllib.request.urlopen",
side_effect=network_error,
),
patch(
"specify_cli.authentication.http.urllib.request.build_opener",
side_effect=network_error,
),
):
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 0
class TestIsNewer:
def test_latest_strictly_greater_returns_true(self):
assert _is_newer("0.8.0", "0.7.4") is True
@@ -151,7 +109,7 @@ class TestUserStory1:
def test_newer_available_prints_update_and_install_command(self):
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
"specify_cli.authentication.http.urllib.request.urlopen",
return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}),
return_value=mock_urlopen_response({"tag_name": "v0.9.0"}),
):
result = runner.invoke(app, ["self", "check"])
output = strip_ansi(result.output)
@@ -164,7 +122,7 @@ class TestUserStory1:
def test_up_to_date_prints_current_only(self):
with patch("specify_cli._version._get_installed_version", return_value="0.9.0"), patch(
"specify_cli.authentication.http.urllib.request.urlopen",
return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}),
return_value=mock_urlopen_response({"tag_name": "v0.9.0"}),
):
result = runner.invoke(app, ["self", "check"])
output = strip_ansi(result.output)
@@ -176,7 +134,7 @@ class TestUserStory1:
def test_dev_build_ahead_of_release_is_up_to_date(self):
with patch("specify_cli._version._get_installed_version", return_value="0.7.5.dev0"), patch(
"specify_cli.authentication.http.urllib.request.urlopen",
return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}),
return_value=mock_urlopen_response({"tag_name": "v0.7.4"}),
):
result = runner.invoke(app, ["self", "check"])
output = strip_ansi(result.output)
@@ -187,26 +145,46 @@ class TestUserStory1:
def test_unknown_installed_still_prints_latest_and_reinstall(self):
with patch("specify_cli._version._get_installed_version", return_value="unknown"), patch(
"specify_cli.authentication.http.urllib.request.urlopen",
return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}),
return_value=mock_urlopen_response({"tag_name": "v0.7.4"}),
):
result = runner.invoke(app, ["self", "check"])
output = strip_ansi(result.output)
assert result.exit_code == 0
assert "Current version could not be determined" in output
assert "Latest release: v0.7.4" in output
assert "0.7.4" in output
assert "git+https://github.com/github/spec-kit.git@v0.7.4" in output
assert "specify self upgrade" in output
assert "pipx install --force git+https://github.com/github/spec-kit.git@v0.7.4" in output
def test_unparseable_tag_routes_to_indeterminate(self):
def test_unknown_installed_uses_placeholder_when_latest_tag_is_invalid(self):
with patch("specify_cli._version._get_installed_version", return_value="unknown"), patch(
"specify_cli.authentication.http.urllib.request.urlopen",
return_value=mock_urlopen_response({"tag_name": "v0.9.0;echo unsafe"}),
):
result = runner.invoke(app, ["self", "check"])
output = strip_ansi(result.output)
assert result.exit_code == 0
assert "Latest release: vX.Y.Z" in output
assert "Could not validate latest release tag from GitHub." in output
assert "git+https://github.com/github/spec-kit.git@vX.Y.Z" in output
assert "v0.9.0;echo unsafe" not in output
def test_unparseable_tag_reports_validation_failure_without_raw_tag(self):
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
"specify_cli.authentication.http.urllib.request.urlopen",
return_value=_mock_urlopen_response({"tag_name": "not-a-version"}),
return_value=mock_urlopen_response({"tag_name": "not-a-version"}),
):
result = runner.invoke(app, ["self", "check"])
output = strip_ansi(result.output)
assert result.exit_code == 0
assert "Update available" not in output
assert "Up to date" in output
assert "Up to date" not in output
assert "Could not validate latest release tag from GitHub." in output
assert "Latest release: vX.Y.Z" in output
assert "0.7.4" in output
assert "not-a-version" not in output
assert "git+https://github.com/github/spec-kit.git@vX.Y.Z" in output
class TestFailureCategorization:
@@ -306,13 +284,25 @@ class TestUserStory2:
def _capture_request_via_urlopen():
captured = {}
def _side_effect(req, timeout=None):
def _side_effect(req, *args, **kwargs):
captured["request"] = req
return _mock_urlopen_response({"tag_name": "v0.7.4"})
return mock_urlopen_response({"tag_name": "v0.7.4"})
return captured, _side_effect
def _capture_request_via_auth_opener():
captured = {}
def _side_effect(req, *args, **kwargs):
captured["request"] = req
return mock_urlopen_response({"tag_name": "v0.7.4"})
opener = MagicMock()
opener.open.side_effect = _side_effect
return captured, opener
def _inject_github_config(monkeypatch, token_env="GH_TOKEN"):
from tests.auth_helpers import inject_github_config
inject_github_config(monkeypatch, token_env)
@@ -323,10 +313,11 @@ class TestUserStory3:
monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN)
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
_inject_github_config(monkeypatch, token_env="GH_TOKEN")
captured, side_effect = _capture_request_via_urlopen()
mock_opener = MagicMock()
mock_opener.open.side_effect = side_effect
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
captured, opener = _capture_request_via_auth_opener()
with patch(
"specify_cli.authentication.http.urllib.request.build_opener",
return_value=opener,
):
_fetch_latest_release_tag()
req = captured["request"]
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GH_TOKEN}"
@@ -335,10 +326,11 @@ class TestUserStory3:
monkeypatch.delenv("GH_TOKEN", raising=False)
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
_inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
captured, side_effect = _capture_request_via_urlopen()
mock_opener = MagicMock()
mock_opener.open.side_effect = side_effect
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
captured, opener = _capture_request_via_auth_opener()
with patch(
"specify_cli.authentication.http.urllib.request.build_opener",
return_value=opener,
):
_fetch_latest_release_tag()
req = captured["request"]
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}"
@@ -376,10 +368,11 @@ class TestUserStory3:
monkeypatch.setenv("GH_TOKEN", " ")
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
_inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
captured, side_effect = _capture_request_via_urlopen()
mock_opener = MagicMock()
mock_opener.open.side_effect = side_effect
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
captured, opener = _capture_request_via_auth_opener()
with patch(
"specify_cli.authentication.http.urllib.request.build_opener",
return_value=opener,
):
_fetch_latest_release_tag()
req = captured["request"]
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}"

View File

@@ -1,7 +1,6 @@
"""Regression guard: utility and asset symbols importable from specify_cli."""
from specify_cli import (
run_command, check_tool, is_git_repo, init_git_repo,
handle_vscode_settings, merge_json_files,
check_tool, is_git_repo, merge_json_files,
get_speckit_version,
CLAUDE_LOCAL_PATH, CLAUDE_NPM_LOCAL_PATH,
)

View File

@@ -23,7 +23,6 @@ def test_version_symbols_available_from_star_import():
def test_version_module_symbols_directly_importable():
from specify_cli._version import (
GITHUB_API_LATEST,
_fetch_latest_release_tag,
_get_installed_version,
_is_newer,

View File

@@ -0,0 +1,238 @@
"""Tests for running workflow YAML files without a project."""
import os
import pytest
import yaml
class TestWorkflowRunWithoutProject:
"""Tests that specify workflow run works with YAML files without .specify/ dir."""
def test_workflow_run_yaml_without_project(self, tmp_path):
"""Running a .yml file should work without a .specify/ directory."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
# Create a minimal workflow YAML with a shell step
workflow_file = tmp_path / "test-workflow.yml"
workflow_content = {
"schema_version": "1.0",
"workflow": {
"id": "standalone-test",
"name": "Standalone Test",
"version": "1.0.0",
"description": "A workflow that runs without a project",
},
"steps": [
{
"id": "create-marker",
"type": "shell",
"run": "echo done > marker.txt",
},
],
}
workflow_file.write_text(yaml.dump(workflow_content), encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(app, [
"workflow", "run", str(workflow_file),
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"workflow run failed: {result.output}"
assert "completed" in result.output
assert (tmp_path / "marker.txt").exists()
assert (tmp_path / ".specify" / "workflows" / "runs").is_dir()
def test_workflow_run_yaml_with_tilde_and_uppercase_suffix(self, tmp_path, monkeypatch):
"""Running ~/file.YML should work without a .specify/ directory."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
home_dir = tmp_path / "home"
home_dir.mkdir()
monkeypatch.setenv("HOME", str(home_dir))
monkeypatch.setenv("USERPROFILE", str(home_dir))
workflow_file = home_dir / "test-workflow.YML"
workflow_content = {
"schema_version": "1.0",
"workflow": {
"id": "standalone-test-uppercase",
"name": "Standalone Test Uppercase",
"version": "1.0.0",
"description": "A workflow that runs from ~/ with an uppercase suffix",
},
"steps": [
{
"id": "create-marker",
"type": "shell",
"run": "echo done > marker.txt",
},
],
}
workflow_file.write_text(yaml.dump(workflow_content), encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(app, [
"workflow", "run", "~/test-workflow.YML",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"workflow run failed: {result.output}"
assert "Status: completed" in result.output
assert (tmp_path / "marker.txt").exists()
def test_workflow_run_id_still_requires_project(self, tmp_path):
"""Running a workflow by ID should still require a .specify/ directory."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
old_cwd = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(app, [
"workflow", "run", "some-workflow-id",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
assert "Not a spec-kit project" in result.output
def test_workflow_run_missing_yaml_file(self, tmp_path):
"""Running a non-existent .yml file should still require a project."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
old_cwd = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(app, [
"workflow", "run", "nonexistent.yml",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
# non-existent .yml files fall through to project check or file-not-found
assert result.exit_code != 0
def test_workflow_run_failing_yaml_without_project(self, tmp_path):
"""A failing workflow YAML should report failure status."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
workflow_file = tmp_path / "fail-workflow.yml"
workflow_content = {
"schema_version": "1.0",
"workflow": {
"id": "fail-test",
"name": "Fail Test",
"version": "1.0.0",
"description": "A workflow that fails",
},
"steps": [
{
"id": "fail-step",
"type": "shell",
"run": "exit 1",
},
],
}
workflow_file.write_text(yaml.dump(workflow_content), encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(app, [
"workflow", "run", str(workflow_file),
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"workflow run failed unexpectedly: {result.output}"
assert "Status: failed" in result.output
def test_workflow_run_yaml_rejects_symlinked_specify_dir(self, tmp_path):
"""Running local YAML should fail when .specify is a symlink."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
workflow_file = tmp_path / "test-workflow.yml"
workflow_content = {
"schema_version": "1.0",
"workflow": {
"id": "symlink-test",
"name": "Symlink Test",
"version": "1.0.0",
"description": "A workflow for symlink guard testing",
},
"steps": [{"id": "noop", "type": "shell", "run": "echo done"}],
}
workflow_file.write_text(yaml.dump(workflow_content), encoding="utf-8")
target_dir = tmp_path / "real-specify-dir"
target_dir.mkdir()
try:
(tmp_path / ".specify").symlink_to(target_dir, target_is_directory=True)
except (OSError, NotImplementedError):
pytest.skip("Symlinks are not available in this environment")
old_cwd = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(app, [
"workflow", "run", str(workflow_file),
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
assert "Refusing to use symlinked .specify path in current directory" in result.output
def test_workflow_run_yaml_rejects_non_directory_specify_path(self, tmp_path):
"""Running local YAML should fail when .specify is not a directory."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
workflow_file = tmp_path / "test-workflow.yml"
workflow_content = {
"schema_version": "1.0",
"workflow": {
"id": "nondir-test",
"name": "Non-directory Test",
"version": "1.0.0",
"description": "A workflow for non-directory guard testing",
},
"steps": [{"id": "noop", "type": "shell", "run": "echo done"}],
}
workflow_file.write_text(yaml.dump(workflow_content), encoding="utf-8")
(tmp_path / ".specify").write_text("not a directory", encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(app, [
"workflow", "run", str(workflow_file),
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
assert ".specify path exists but is not a directory" in result.output

View File

@@ -467,6 +467,15 @@ class TestBuildExecArgs:
args = impl.build_exec_args("do stuff", output_json=False)
assert "--output-format" not in args
def test_rovodev_exec_args(self):
from specify_cli.integrations.rovodev import RovodevIntegration
impl = RovodevIntegration()
args = impl.build_exec_args("/speckit.plan add OAuth")
assert args[0:3] == ["acli", "rovodev", "run"]
assert args[3] == "/speckit.plan add OAuth"
assert "--output-schema" in args
# ===== Step Type Tests =====
@@ -495,6 +504,37 @@ class TestCommandStep:
assert result.output["integration"] == "claude"
assert result.output["input"]["args"] == "login"
def test_try_dispatch_resolves_rovodev_via_acli(self, tmp_path):
"""When acli is installed, rovodev dispatch succeeds via acli."""
from unittest.mock import patch, MagicMock
from specify_cli.workflows.steps.command import CommandStep
from specify_cli.workflows.base import StepContext, StepStatus
step = CommandStep()
ctx = StepContext(
default_integration="rovodev",
project_root=str(tmp_path),
)
config = {
"id": "test",
"command": "speckit.plan",
"input": {"args": "add OAuth"},
}
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = ""
mock_result.stderr = ""
with patch("specify_cli.workflows.steps.command.shutil.which",
lambda name: "/usr/bin/acli" if name == "acli" else None), \
patch("subprocess.run", return_value=mock_result):
result = step.execute(config, ctx)
assert result.status == StepStatus.COMPLETED
assert result.output["dispatched"] is True
assert result.output["exit_code"] == 0
def test_validate_missing_command(self):
from specify_cli.workflows.steps.command import CommandStep
@@ -601,15 +641,18 @@ class TestCommandStep:
mock_result.stderr = ""
with patch("specify_cli.workflows.steps.command.shutil.which", return_value="/usr/local/bin/claude"), \
patch("specify_cli.integrations.base.shutil.which", return_value="/usr/local/bin/claude"), \
patch("subprocess.run", return_value=mock_result) as mock_run:
result = step.execute(config, ctx)
assert result.status == StepStatus.COMPLETED
assert result.output["dispatched"] is True
assert result.output["exit_code"] == 0
# Verify the CLI was called with -p and the skill invocation
# Verify the CLI was called with the resolved path (via shutil.which,
# which honors PATHEXT for ``.cmd``/``.bat`` shims on Windows), then
# ``-p`` and the skill invocation.
call_args = mock_run.call_args
assert call_args[0][0][0] == "claude"
assert call_args[0][0][0] == "/usr/local/bin/claude"
assert call_args[0][0][1] == "-p"
# Claude is a SkillsIntegration so uses /speckit-specify
assert "/speckit-specify login" in call_args[0][0][2]
@@ -638,6 +681,7 @@ class TestCommandStep:
mock_result.stderr = "API error"
with patch("specify_cli.workflows.steps.command.shutil.which", return_value="/usr/local/bin/claude"), \
patch("specify_cli.integrations.base.shutil.which", return_value="/usr/local/bin/claude"), \
patch("subprocess.run", return_value=mock_result):
result = step.execute(config, ctx)
@@ -705,6 +749,37 @@ class TestPromptStep:
result = step.execute(config, ctx)
assert result.output["model"] == "opus-4"
def test_try_dispatch_resolves_rovodev_via_acli(self, tmp_path):
"""When acli is installed, rovodev prompt dispatch succeeds via acli."""
from unittest.mock import patch, MagicMock
from specify_cli.workflows.steps.prompt import PromptStep
from specify_cli.workflows.base import StepContext, StepStatus
step = PromptStep()
ctx = StepContext(
default_integration="rovodev",
project_root=str(tmp_path),
)
config = {
"id": "test",
"type": "prompt",
"prompt": "Explain this code",
}
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = ""
mock_result.stderr = ""
with patch("specify_cli.workflows.steps.prompt.shutil.which",
lambda name: "/usr/bin/acli" if name == "acli" else None), \
patch("subprocess.run", return_value=mock_result):
result = step.execute(config, ctx)
assert result.status == StepStatus.COMPLETED
assert result.output["dispatched"] is True
assert result.output["exit_code"] == 0
def test_dispatch_with_mock_cli(self, tmp_path):
from unittest.mock import patch, MagicMock
from specify_cli.workflows.steps.prompt import PromptStep
@@ -2379,6 +2454,306 @@ steps:
assert state.step_results["stamp"]["output"]["stdout"].strip() == "explicit-456"
# ===== continue_on_error Tests =====
#
# Locks the contract documented in workflows/README.md "Error Handling"
# section: when a step returns `StepResult(status=StepStatus.FAILED, ...)` and
# `continue_on_error: true` is declared, the engine records the step's
# `output` (with `exit_code` and `stderr` from the failure) and its
# `status` (sibling key on `steps.<id>`, not nested under `output`)
# and continues to the next sibling step instead of halting the run.
# Gate aborts (`output.aborted`) still halt regardless of the flag.
# Unhandled exceptions raised out of `step_impl.execute()` are out of
# scope for this flag — they propagate to `WorkflowEngine.execute()`
# and abort the run.
class TestContinueOnError:
"""Test the `continue_on_error` step-level field."""
def test_undeclared_failure_halts_run(self, project_dir):
"""Default behaviour (no `continue_on_error`): a failing step
halts the workflow run with `status == StepStatus.FAILED`.
Locks the byte-equivalent default — workflows that do not
declare the flag must behave exactly as before this feature.
"""
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
from specify_cli.workflows.base import RunStatus
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "halt-on-fail"
name: "Halt On Fail"
version: "1.0.0"
steps:
- id: fail-step
type: shell
run: "exit 7"
- id: after
type: shell
run: "echo should-not-run"
""")
engine = WorkflowEngine(project_dir)
state = engine.execute(definition)
assert state.status == RunStatus.FAILED
assert "fail-step" in state.step_results
assert state.step_results["fail-step"]["output"]["exit_code"] == 7
# Subsequent step never executes when the flag is absent.
assert "after" not in state.step_results
def test_declared_and_fired_continues_run(self, project_dir):
"""`continue_on_error: true` + failing step: the run keeps
going, the failed step's result is recorded, and the
downstream step runs.
"""
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
from specify_cli.workflows.base import RunStatus
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "continue-past-fail"
name: "Continue Past Fail"
version: "1.0.0"
steps:
- id: flaky-step
type: shell
run: "exit 42"
continue_on_error: true
- id: after
type: shell
run: "echo did-run"
""")
engine = WorkflowEngine(project_dir)
state = engine.execute(definition)
assert state.status == RunStatus.COMPLETED
# Failed step's exit_code is preserved so downstream branching
# can inspect it.
assert state.step_results["flaky-step"]["output"]["exit_code"] == 42
assert state.step_results["flaky-step"]["status"] == "failed"
# Downstream step ran successfully.
assert state.step_results["after"]["output"]["exit_code"] == 0
def test_declared_but_step_succeeded_is_noop(self, project_dir):
"""`continue_on_error: true` on a step that succeeds is a
no-op — the flag only changes behaviour on StepStatus.FAILED status.
"""
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
from specify_cli.workflows.base import RunStatus
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "flag-but-success"
name: "Flag But Success"
version: "1.0.0"
steps:
- id: ok-step
type: shell
run: "echo ok"
continue_on_error: true
- id: after
type: shell
run: "echo done"
""")
engine = WorkflowEngine(project_dir)
state = engine.execute(definition)
assert state.status == RunStatus.COMPLETED
assert state.step_results["ok-step"]["status"] == "completed"
assert state.step_results["ok-step"]["output"]["exit_code"] == 0
assert state.step_results["after"]["output"]["exit_code"] == 0
def test_if_branch_routes_around_failure(self, project_dir):
"""End-to-end: `continue_on_error` + `if` cleanly routes around
a failure. The recovery branch runs; the success branch does
not.
Mirrors the canonical usage pattern from the original feature
discussion in issue #2591.
"""
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
from specify_cli.workflows.base import RunStatus
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "route-around"
name: "Route Around Failure"
version: "1.0.0"
steps:
- id: heavy-thing
type: shell
run: "exit 1"
continue_on_error: true
- id: check-result
type: if
condition: "{{ steps.heavy-thing.output.exit_code != 0 }}"
then:
- id: recovery
type: shell
run: "echo recovery-ran"
else:
- id: happy-path
type: shell
run: "echo happy-path-ran"
""")
engine = WorkflowEngine(project_dir)
state = engine.execute(definition)
assert state.status == RunStatus.COMPLETED
assert "recovery" in state.step_results
assert "happy-path" not in state.step_results
def test_gate_abort_still_halts_with_continue_on_error(
self, project_dir, monkeypatch
):
"""`continue_on_error` does NOT override a deliberate gate
abort. `output.aborted` always halts the run with
`status == ABORTED`.
Aborts are explicit operator decisions; continue_on_error
is for transient/expected step failures only.
"""
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
from specify_cli.workflows.base import RunStatus
from specify_cli.workflows.steps.gate import GateStep
from specify_cli.workflows.steps import gate as gate_module
# Force the gate step into interactive mode and feed a "reject"
# choice so the abort path actually runs in the test env
# (default behaviour returns StepStatus.PAUSED when stdin is not a TTY).
# Swap sys.stdin itself for a stub: setattr on the real
# TextIOWrapper's `isatty` method is not assignable under some
# runners (e.g. pytest with capture disabled).
class _TTYStdin:
def isatty(self) -> bool:
return True
monkeypatch.setattr(gate_module.sys, "stdin", _TTYStdin())
monkeypatch.setattr(
GateStep, "_prompt", staticmethod(lambda _msg, _opts: "reject")
)
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "gate-abort-halts"
name: "Gate Abort Halts"
version: "1.0.0"
steps:
- id: gate-step
type: gate
message: "Approve?"
options: [approve, reject]
on_reject: abort
continue_on_error: true
- id: should-not-run
type: shell
run: "echo nope"
""")
engine = WorkflowEngine(project_dir)
state = engine.execute(definition)
assert state.status == RunStatus.ABORTED
assert "should-not-run" not in state.step_results
def test_validation_rejects_non_bool_continue_on_error(self):
"""`continue_on_error` must be a literal boolean; coerced
strings like `"true"` are rejected at validation time so
authoring mistakes surface before execution.
"""
from specify_cli.workflows.engine import (
WorkflowDefinition,
validate_workflow,
)
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "bad-coe"
name: "Bad COE"
version: "1.0.0"
steps:
- id: step-one
type: shell
run: "true"
continue_on_error: "true"
""")
errors = validate_workflow(definition)
assert any(
"continue_on_error" in e and "boolean" in e for e in errors
), errors
def test_validation_accepts_bool_continue_on_error(self):
"""Boolean values pass validation cleanly."""
from specify_cli.workflows.engine import (
WorkflowDefinition,
validate_workflow,
)
for value in (True, False):
yaml_value = "true" if value else "false"
definition = WorkflowDefinition.from_string(f"""
schema_version: "1.0"
workflow:
id: "good-coe"
name: "Good COE"
version: "1.0.0"
steps:
- id: step-one
type: shell
run: "true"
continue_on_error: {yaml_value}
""")
errors = validate_workflow(definition)
assert errors == [], errors
def test_engine_ignores_truthy_non_bool_continue_on_error(self, project_dir):
"""Defense-in-depth: even if a caller bypasses
`validate_workflow()` and feeds the engine a definition with
`continue_on_error: "true"` (a string), the engine must NOT
honour the flag — only a literal boolean enables the
behaviour. `WorkflowEngine.execute()` does not auto-validate
(the `WorkflowEngine.load_workflow` docstring explicitly
notes the definition is "not yet validated; call
`validate_workflow()` or `engine.validate()` separately"),
so the engine guards against truthy non-bool values itself
via an identity check rather than truthiness.
"""
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
from specify_cli.workflows.base import RunStatus
# Bypass `validate_workflow()` — execute() is what would
# be called by a caller that skipped validation.
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "string-coe"
name: "String COE"
version: "1.0.0"
steps:
- id: fail-step
type: shell
run: "exit 1"
continue_on_error: "true"
- id: should-not-run
type: shell
run: "echo should-not-run"
""")
engine = WorkflowEngine(project_dir)
state = engine.execute(definition)
# String "true" is truthy but not a literal boolean, so the
# engine must treat the step as a halting failure.
assert state.status == RunStatus.FAILED
assert "should-not-run" not in state.step_results
# ===== State Persistence Tests =====
class TestRunState:
@@ -2416,6 +2791,112 @@ class TestRunState:
with pytest.raises(FileNotFoundError):
RunState.load("nonexistent", project_dir)
@pytest.mark.parametrize(
"malicious_run_id",
[
# Parent-directory traversal — the classic path-escape vector.
"../escape",
"..",
"../../etc/passwd",
# Embedded path separators — both POSIX and Windows.
"foo/bar",
"foo\\bar",
# Leading non-alphanumeric characters that the existing
# pattern's anchor blocks (would be mistaken for CLI flags
# or hidden files in shell completions / error messages).
".hidden",
"-flag",
# NUL byte — some filesystems treat the prefix as a valid
# path and silently truncate at the NUL.
"foo\x00bar",
# Empty string — degenerate case, matches no file but the
# validator should reject it before any I/O.
"",
],
)
def test_load_rejects_path_traversal(self, project_dir, malicious_run_id):
"""``RunState.load`` validates ``run_id`` before touching the
filesystem.
Without this guard, a value like ``../escape`` passed via
``specify workflow resume`` would interpolate path-traversal
segments into the lookup path. ``state_path.exists()`` would
probe arbitrary paths the process can read (a file-existence
oracle) and ``json.load`` would happily parse attacker-planted
JSON from outside ``.specify/workflows/runs/``. The check must
fire *before* the path is built — ``__init__``'s identical
regex on ``state_data["run_id"]`` fires too late.
"""
from specify_cli.workflows.engine import RunState
# Plant a state.json *outside* the legitimate ``runs/`` directory
# at the location ``../escape`` would traverse to, so a missing
# guard would surface as a successful load rather than a
# ``FileNotFoundError`` (which would be ambiguous with the
# not-found case).
runs_dir = project_dir / ".specify" / "workflows" / "runs"
runs_dir.mkdir(parents=True, exist_ok=True)
attacker_dir = project_dir / ".specify" / "workflows" / "escape"
attacker_dir.mkdir(exist_ok=True)
(attacker_dir / "state.json").write_text(
json.dumps(
{
"run_id": "pwned",
"workflow_id": "attacker-owned",
"status": "created",
}
),
encoding="utf-8",
)
with pytest.raises(ValueError, match="Invalid run_id"):
RunState.load(malicious_run_id, project_dir)
@pytest.mark.parametrize(
"bad_run_id",
[
# One vector per category from ``test_load_rejects_path_traversal``
# — enough to prove both entry points agree without re-running
# the full attack matrix here.
"../escape", # parent-directory traversal
"foo/bar", # embedded path separator
".hidden", # leading non-alphanumeric
"", # empty / degenerate
],
)
def test_init_and_load_share_validation(self, project_dir, bad_run_id):
"""``__init__`` *and* ``load`` reject the same malformed IDs.
The two entry points must stay in sync — drift would let an ID
slip in via one path that the other would reject, producing
confusing crashes mid-workflow. The previous version of this
test only exercised ``__init__`` and ``_validate_run_id`` (the
shared helper), so a regression in ``load`` — e.g. someone
deleting the ``cls._validate_run_id(run_id)`` call there — could
slip through despite ``__init__`` and the helper staying
aligned. We now hit ``load`` directly with the same vector so
any drift between the two call sites is caught by this test.
"""
from specify_cli.workflows.engine import RunState
# ``__init__`` rejects up front.
with pytest.raises(ValueError, match="Invalid run_id"):
RunState(run_id=bad_run_id)
# The shared helper rejects the value too (sanity check that the
# ``__init__`` rejection came from the validator, not some
# unrelated constructor failure).
with pytest.raises(ValueError, match="Invalid run_id"):
RunState._validate_run_id(bad_run_id)
# And ``load`` rejects it *before* touching the filesystem. This
# is the assertion the previous version was missing: without it,
# a regression in ``load`` (e.g. forgetting to call the
# validator before building the path) would not be caught even
# though ``__init__`` and the helper still agreed.
with pytest.raises(ValueError, match="Invalid run_id"):
RunState.load(bad_run_id, project_dir)
def test_append_log(self, project_dir):
from specify_cli.workflows.engine import RunState
@@ -2726,3 +3207,270 @@ steps:
assert state.status == RunStatus.COMPLETED
assert "do-plan" in state.step_results
assert "do-specify" not in state.step_results
class TestWorkflowJsonOutput:
"""Test the --json machine-readable output for run/resume/status."""
_WF = """
schema_version: "1.0"
workflow:
id: "json-wf"
name: "JSON WF"
version: "1.0.0"
steps:
- id: ask
type: gate
message: "Review"
options: [approve, reject]
- id: after
type: shell
run: "echo done"
"""
_WF_DONE = """
schema_version: "1.0"
workflow:
id: "json-done"
name: "JSON Done"
version: "1.0.0"
steps:
- id: only
type: shell
run: "echo done"
"""
def _write_wf(self, project_dir, text, name):
path = project_dir / f"{name}.yml"
path.write_text(text, encoding="utf-8")
return path
def _invoke(self, project_dir, args):
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
return runner.invoke(app, args, catch_exceptions=False)
def test_run_json_completed(self, project_dir):
wf = self._write_wf(project_dir, self._WF_DONE, "done")
result = self._invoke(project_dir, ["workflow", "run", str(wf), "--json"])
assert result.exit_code == 0
payload = json.loads(result.stdout)
assert payload["workflow_id"] == "json-done"
assert payload["status"] == "completed"
assert "run_id" in payload
def test_run_json_paused(self, project_dir):
wf = self._write_wf(project_dir, self._WF, "gated")
result = self._invoke(project_dir, ["workflow", "run", str(wf), "--json"])
assert result.exit_code == 0
payload = json.loads(result.stdout)
assert payload["status"] == "paused"
assert payload["current_step_id"] == "ask"
assert payload["current_step_index"] == 0
def test_run_json_output_has_no_markup_or_ansi(self, project_dir):
wf = self._write_wf(project_dir, self._WF_DONE, "clean")
out = self._invoke(
project_dir, ["workflow", "run", str(wf), "--json"]
).stdout
# Machine output must be exactly the JSON object: no Rich markup
# tags and no ANSI escape sequences leaking in.
assert "\x1b[" not in out
assert "[/" not in out
assert out.strip() == json.dumps(json.loads(out), indent=2)
def test_run_default_output_is_human_not_json(self, project_dir):
wf = self._write_wf(project_dir, self._WF_DONE, "done2")
result = self._invoke(project_dir, ["workflow", "run", str(wf)])
assert result.exit_code == 0
assert "Running workflow" in result.stdout
with pytest.raises(json.JSONDecodeError):
json.loads(result.stdout)
def test_status_json_single_and_list(self, project_dir):
wf = self._write_wf(project_dir, self._WF, "gated2")
run = json.loads(
self._invoke(project_dir, ["workflow", "run", str(wf), "--json"]).stdout
)
rid = run["run_id"]
single = json.loads(
self._invoke(project_dir, ["workflow", "status", rid, "--json"]).stdout
)
assert single["run_id"] == rid
assert single["status"] == "paused"
assert single["steps"]["ask"] == "paused"
# status --json carries the same step-position fields as run/resume
# so automation never has to branch on which command produced it.
assert single["current_step_id"] == run["current_step_id"]
assert single["current_step_index"] == run["current_step_index"]
listing = json.loads(
self._invoke(project_dir, ["workflow", "status", "--json"]).stdout
)
assert any(r["run_id"] == rid for r in listing["runs"])
def test_resume_json(self, project_dir):
wf = self._write_wf(project_dir, self._WF, "gated3")
rid = json.loads(
self._invoke(project_dir, ["workflow", "run", str(wf), "--json"]).stdout
)["run_id"]
# Non-interactive resume re-runs the gate, which pauses again.
resumed = json.loads(
self._invoke(project_dir, ["workflow", "resume", rid, "--json"]).stdout
)
assert resumed["run_id"] == rid
assert resumed["status"] == "paused"
def test_json_redirect_keeps_stdout_clean(self, capfd):
# While a workflow runs under --json, steps can still write to stdout:
# the gate step prints its prompt and the prompt step runs a
# subprocess that inherits the stdout fd. Both must be redirected to
# stderr so the JSON object on stdout stays parseable. capfd captures
# at the file-descriptor level, so it sees the subprocess output too.
import subprocess
import sys as _sys
from specify_cli import _stdout_to_stderr_when
print("STDOUT_BEFORE")
with _stdout_to_stderr_when(True):
print("PY_LEAK") # Python-level write (gate-style)
subprocess.run( # inherited-fd write (prompt-style)
[_sys.executable, "-c", "print('SUBPROC_LEAK')"],
check=True,
)
print("STDOUT_AFTER")
out, err = capfd.readouterr()
# stdout keeps only what was written outside the guarded block.
assert "STDOUT_BEFORE" in out and "STDOUT_AFTER" in out
assert "PY_LEAK" not in out and "SUBPROC_LEAK" not in out
# The step output is preserved on stderr, not discarded.
assert "PY_LEAK" in err and "SUBPROC_LEAK" in err
def test_json_redirect_inactive_is_noop(self, capfd):
from specify_cli import _stdout_to_stderr_when
with _stdout_to_stderr_when(False):
print("VISIBLE_ON_STDOUT")
out, _ = capfd.readouterr()
assert "VISIBLE_ON_STDOUT" in out
class TestResumeWithInputs:
"""Test that `workflow resume` can accept updated workflow inputs."""
_WF_CMD = """
schema_version: "1.0"
workflow:
id: "resume-cmd-wf"
name: "Resume Cmd WF"
version: "1.0.0"
inputs:
cmd:
type: string
default: "exit 1"
steps:
- id: s
type: shell
run: "{{ inputs.cmd }}"
"""
_WF_NUM = """
schema_version: "1.0"
workflow:
id: "resume-num-wf"
name: "Resume Num WF"
version: "1.0.0"
inputs:
count:
type: number
default: 1
steps:
- id: gate
type: gate
message: "Review"
options: [approve, reject]
"""
def _engine(self, project_dir):
from specify_cli.workflows.engine import WorkflowEngine
return WorkflowEngine(project_dir)
def test_resume_with_input_reruns_step_with_new_value(self, project_dir):
from specify_cli.workflows.engine import WorkflowDefinition
from specify_cli.workflows.base import RunStatus
definition = WorkflowDefinition.from_string(self._WF_CMD)
engine = self._engine(project_dir)
state = engine.execute(definition)
assert state.status == RunStatus.FAILED # "exit 1" fails
resumed = engine.resume(state.run_id, {"cmd": "exit 0"})
assert resumed.status == RunStatus.COMPLETED
assert resumed.inputs["cmd"] == "exit 0"
def test_resume_without_input_preserves_inputs(self, project_dir):
from specify_cli.workflows.engine import WorkflowDefinition
from specify_cli.workflows.base import RunStatus
definition = WorkflowDefinition.from_string(self._WF_CMD)
engine = self._engine(project_dir)
state = engine.execute(definition)
assert state.status == RunStatus.FAILED
resumed = engine.resume(state.run_id)
assert resumed.status == RunStatus.FAILED # still "exit 1"
assert resumed.inputs["cmd"] == "exit 1"
def test_resume_merges_and_coerces_typed_input(self, project_dir):
import json as _json
from specify_cli.workflows.engine import WorkflowDefinition
from specify_cli.workflows.base import RunStatus
definition = WorkflowDefinition.from_string(self._WF_NUM)
engine = self._engine(project_dir)
state = engine.execute(definition)
assert state.status == RunStatus.PAUSED
resumed = engine.resume(state.run_id, {"count": "5"})
assert resumed.inputs["count"] == 5 # coerced string -> number
inputs_file = (
project_dir / ".specify" / "workflows" / "runs" / state.run_id / "inputs.json"
)
assert _json.loads(inputs_file.read_text())["inputs"]["count"] == 5
def test_resume_invalid_typed_input_raises(self, project_dir):
from specify_cli.workflows.engine import WorkflowDefinition
definition = WorkflowDefinition.from_string(self._WF_NUM)
engine = self._engine(project_dir)
state = engine.execute(definition)
with pytest.raises(ValueError):
engine.resume(state.run_id, {"count": "not-a-number"})
def test_cli_resume_input_invalid_format_errors(self, project_dir):
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
from specify_cli.workflows.engine import WorkflowDefinition
definition = WorkflowDefinition.from_string(self._WF_NUM)
state = self._engine(project_dir).execute(definition)
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app, ["workflow", "resume", state.run_id, "--input", "bogus"]
)
assert result.exit_code == 1
assert "Invalid input format" in result.stdout

View File

@@ -219,6 +219,83 @@ Aggregate results from fan-out steps:
output: {}
```
## Error Handling
By default, any step that returns `StepResult(status=StepStatus.FAILED, ...)`
at runtime halts the entire run — most commonly a `shell` or
`command` step exiting non-zero. Set `continue_on_error: true` on
a step to record its result and continue to the next sibling step
instead. When the failure was a non-zero exit, the exit code
remains available on `steps.<id>.output.exit_code` so a downstream
`if` or `switch` can branch on it (or a `gate` can surface it to
the operator via `{{ }}` interpolation in `message`):
```yaml
- id: heavy-thing
type: command
integration: claude
command: speckit.heavy-thing
continue_on_error: true
- id: check-result
type: if
condition: "{{ steps.heavy-thing.output.exit_code != 0 }}"
then:
- id: review
type: gate
message: "Step failed (exit {{ steps.heavy-thing.output.exit_code }}). Approve to run the recovery path, or reject to leave the failure recorded and move on."
on_reject: skip
- id: recover
type: if
condition: "{{ steps.review.output.choice == 'approve' }}"
then:
- id: rerun
command: speckit.recovery
else:
- id: next-thing
command: speckit.next-thing
```
A few things worth knowing about that example:
- Both gate options (`approve`, `reject`) return `StepStatus.COMPLETED`;
`on_reject: skip` controls only whether the engine aborts on reject
(it doesn't, with `skip`) — it does **not** auto-skip subsequent
sibling steps in the `then:` list. Downstream branching is the
workflow author's responsibility: read
`{{ steps.<gate-id>.output.choice }}` in a follow-up `if`, `switch`,
or expression, as the `recover` step above does.
- `on_reject` has three values: `abort` (default — reject → `StepStatus.FAILED`
with `output.aborted = True`, halts the run), `skip` (reject →
`StepStatus.COMPLETED`, author handles branching as shown), and `retry`
(reject → `StepStatus.PAUSED` so the next `specify workflow resume` re-runs
the gate).
- Gates do not automatically re-run the failed step. To express a
retry path, either define custom gate options and branch on the
choice downstream, or wrap the failing step in your own loop.
**Notes:**
- The field must be a literal boolean (`true` / `false`); coerced
strings like `"true"` are rejected at validation time.
- **Scope: returned failures only.** The flag applies to step results
with `status=StepStatus.FAILED`. Unhandled exceptions raised out of a step's
`execute()` method are caught one level up by `WorkflowEngine.execute()`,
logged as `workflow_failed`, and abort the run regardless of
`continue_on_error`. If a step author wants the flag to cover an
exceptional path, the step must catch the exception internally and
return `StepResult(status=StepStatus.FAILED, ...)` with the failure encoded in
`output` (e.g. `exit_code`, `stderr`, or a custom field).
- Gate aborts (`on_reject: abort` chosen by the operator) always halt
the run — `continue_on_error` does not override them. The flag is
for transient/expected step failures, not for overriding deliberate
operator decisions.
- Structural validation runs up-front: `specify workflow run` rejects
invalid workflow definitions before the run is created, so
validation failures never reach this code path.
- When the flag is omitted, behaviour is byte-equivalent to before
this feature.
## Expressions
Workflow definitions use `{{ expression }}` syntax for dynamic values: