Compare commits

..

23 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
71 changed files with 7494 additions and 375 deletions

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,34 @@
<!-- 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

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

@@ -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

@@ -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-02T00: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": {
@@ -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-31T00: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": {
@@ -542,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",
@@ -595,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",
@@ -618,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.2"
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
}

View File

@@ -26,6 +26,7 @@ Or install globally:
specify init --here
"""
import contextlib
import os
import sys
import zipfile
@@ -86,6 +87,12 @@ from ._agent_config import (
DEFAULT_INIT_INTEGRATION as DEFAULT_INIT_INTEGRATION,
SCRIPT_TYPE_CHOICES as SCRIPT_TYPE_CHOICES,
)
from ._init_options import (
INIT_OPTIONS_FILE as INIT_OPTIONS_FILE,
is_ai_skills_enabled as _is_ai_skills_enabled,
load_init_options as load_init_options,
save_init_options as save_init_options,
)
app = typer.Typer(
name="specify",
@@ -259,65 +266,6 @@ def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None =
for f in failures:
console.print(f" - {f}")
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``.
Writes a small JSON file to ``.specify/init-options.json`` so that
later operations (e.g. preset install) can adapt their behaviour
without scanning the filesystem.
"""
dest = project_path / INIT_OPTIONS_FILE
dest.parent.mkdir(parents=True, exist_ok=True)
# Write JSON as real UTF-8 instead of ``\uXXXX`` escape sequences
# (``ensure_ascii=False``) and pin the file encoding to match.
#
# The default ``json.dumps`` output is ASCII-only — any non-ASCII
# character is encoded as a ``\uXXXX`` escape — so without the
# ``ensure_ascii=False`` flip below the encoding pin alone would be
# a no-op for any payload we plausibly write today. We pair the two
# so the on-disk bytes match a human's expectation of "this file is
# UTF-8" (greppable, readable in editors that don't decode JSON
# escapes, friendly to peers running ``cat`` or ``Get-Content``) and
# so the encoding pin is a real contract instead of a future hedge.
#
# ``Path.write_text`` without ``encoding=`` falls back to the system
# locale codec (cp1252 / gb2312 / cp932 on Windows), which would
# mis-encode non-ASCII bytes locally and produce a file a peer with
# a different locale couldn't decode. The sibling integration-
# catalog writer in ``integrations/catalog.py`` pins
# ``encoding="utf-8"`` for the same reason.
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 the init options previously saved by ``specify init``.
Returns an empty dict if the file does not exist or cannot be parsed.
"""
path = project_path / INIT_OPTIONS_FILE
if not path.exists():
return {}
try:
# Match the explicit UTF-8 used by ``save_init_options``; without
# it ``read_text`` falls back to the system codec on Windows and
# raises ``UnicodeDecodeError`` on any file containing the
# multi-byte UTF-8 sequences ``save_init_options`` now writes
# directly. ``UnicodeDecodeError`` is a subclass of
# ``ValueError``, not ``OSError`` / ``json.JSONDecodeError``, so
# it must be listed explicitly here to preserve the existing
# "fall back to empty dict" contract for corrupted / foreign-
# codec files.
return json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
return {}
# ---------------------------------------------------------------------------
# Agent-context extension config helpers
# ---------------------------------------------------------------------------
@@ -401,10 +349,10 @@ def resolve_active_skills_dir(project_root: Path) -> Path | None:
"""Return the active skills directory, creating it on demand when enabled.
Reads ``.specify/init-options.json`` to determine whether skills are
enabled and which agent was selected. When ``ai_skills`` is true the
directory is created safely (symlink/containment checks); when false
only Kimi's native-skills fallback is honoured (directory must already
exist).
enabled and which agent was selected. Only ``ai_skills`` set to boolean
``True`` creates the directory safely (symlink/containment checks); when
``ai_skills`` is not boolean ``True``, only Kimi's native-skills fallback
is honoured, and the native skills directory must already exist.
Returns:
The skills directory ``Path``, or ``None`` if skills are not active.
@@ -425,14 +373,15 @@ def resolve_active_skills_dir(project_root: Path) -> Path | None:
if not isinstance(agent, str) or not agent:
return None
ai_skills_enabled = bool(opts.get("ai_skills"))
ai_skills_enabled = _is_ai_skills_enabled(opts)
if not ai_skills_enabled and agent != "kimi":
return None
skills_dir = _get_skills_dir(project_root, agent)
if not ai_skills_enabled:
# Kimi native-skills fallback: use the directory only if it exists.
# Kimi native-skills fallback when ai_skills is not boolean True:
# use the native skills directory only if it already exists.
if not skills_dir.is_dir():
return None
_ensure_safe_shared_directory(
@@ -441,7 +390,7 @@ def resolve_active_skills_dir(project_root: Path) -> Path | None:
)
return skills_dir
# ai_skills is explicitly enabled — create the directory safely.
# ai_skills is boolean True: create the directory safely.
_ensure_safe_shared_directory(
project_root, skills_dir, context="agent skills directory",
)
@@ -1611,6 +1560,7 @@ def extension_add(
extension: str = typer.Argument(help="Extension name or path"),
dev: bool = typer.Option(False, "--dev", help="Install from local directory"),
from_url: Optional[str] = typer.Option(None, "--from", help="Install from custom URL"),
force: bool = typer.Option(False, "--force", help="Overwrite if already installed"),
priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"),
):
"""Install an extension."""
@@ -1625,6 +1575,9 @@ def extension_add(
manager = ExtensionManager(project_root)
speckit_version = get_speckit_version()
if force:
console.print("[yellow]--force:[/yellow] Will overwrite if already installed")
# Prompt for URL-based installs BEFORE the spinner so the user can
# actually see and respond to the confirmation (the Rich status
# spinner overwrites the typer.confirm prompt line, making it appear
@@ -1675,11 +1628,15 @@ def extension_add(
console.print(f"[red]Error:[/red] No extension.yml found in {source_path}")
raise typer.Exit(1)
if force:
console.print(f"[yellow]--force:[/yellow] Installing from [cyan]{source_path}[/cyan] (will overwrite if already installed)...")
manifest = manager.install_from_directory(
source_path,
speckit_version,
priority=priority,
link_commands=True,
force=force
)
elif from_url:
@@ -1701,7 +1658,7 @@ def extension_add(
zip_path.write_bytes(zip_data)
# Install from downloaded ZIP
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority)
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority, force=force)
except urllib.error.URLError as e:
console.print(f"[red]Error:[/red] Failed to download from {safe_url}: {e}")
raise typer.Exit(1)
@@ -1714,7 +1671,9 @@ def extension_add(
# Try bundled extensions first (shipped with spec-kit)
bundled_path = _locate_bundled_extension(extension)
if bundled_path is not None:
manifest = manager.install_from_directory(bundled_path, speckit_version, priority=priority)
manifest = manager.install_from_directory(
bundled_path, speckit_version, priority=priority, force=force
)
else:
# Install from catalog (also resolves display names to IDs)
catalog = ExtensionCatalog(project_root)
@@ -1735,7 +1694,9 @@ def extension_add(
if resolved_id != extension:
bundled_path = _locate_bundled_extension(resolved_id)
if bundled_path is not None:
manifest = manager.install_from_directory(bundled_path, speckit_version, priority=priority)
manifest = manager.install_from_directory(
bundled_path, speckit_version, priority=priority, force=force
)
if bundled_path is None:
# Bundled extensions without a download URL must come from the local package
@@ -1771,7 +1732,7 @@ def extension_add(
try:
# Install from downloaded ZIP
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority)
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority, force=force)
finally:
# Clean up downloaded ZIP
if zip_path.exists():
@@ -2717,22 +2678,111 @@ workflow_catalog_app = typer.Typer(
workflow_app.add_typer(workflow_catalog_app, name="catalog")
def _parse_input_values(input_values: list[str] | None) -> dict[str, Any]:
"""Parse repeated ``key=value`` CLI inputs into a dict.
Shared by ``workflow run`` and ``workflow resume``. Exits with an error
on any entry missing ``=``.
"""
inputs: dict[str, Any] = {}
for kv in input_values or []:
if "=" not in kv:
console.print(f"[red]Error:[/red] Invalid input format: {kv!r} (expected key=value)")
raise typer.Exit(1)
key, _, value = kv.partition("=")
inputs[key.strip()] = value.strip()
return inputs
def _workflow_run_payload(state: Any) -> dict[str, Any]:
"""Machine-readable summary of a run/resume outcome."""
return {
"run_id": state.run_id,
"workflow_id": state.workflow_id,
"status": state.status.value,
"current_step_id": state.current_step_id,
"current_step_index": state.current_step_index,
}
def _emit_workflow_json(payload: dict[str, Any]) -> None:
"""Write a workflow payload as machine-readable JSON to stdout.
Uses the builtin ``print`` rather than ``console.print`` so Rich
markup interpretation, syntax highlighting, and line-wrapping can
never alter the emitted JSON.
"""
print(json.dumps(payload, indent=2))
@contextlib.contextmanager
def _stdout_to_stderr_when(active: bool):
"""Redirect everything written to stdout onto stderr while *active*.
Suppressing the banner and the step-start callback is not enough to
keep a ``--json`` stream clean: individual steps may still write to
stdout while the engine runs — the gate step prints its prompt,
and the prompt step runs a subprocess that inherits the process's
stdout file descriptor. Either would corrupt the single JSON object.
Redirecting at the file-descriptor level (``dup2``) captures both
Python-level writes and inherited-fd subprocess output, so step
progress lands on stderr (still visible to a human) while stdout
carries only the emitted JSON. A no-op when *active* is false.
"""
if not active:
yield
return
sys.stdout.flush()
saved_stdout_fd = os.dup(1)
try:
os.dup2(2, 1) # fd 1 (stdout) now points at fd 2 (stderr)
with contextlib.redirect_stdout(sys.stderr):
yield
finally:
sys.stdout.flush()
os.dup2(saved_stdout_fd, 1) # restore the real stdout
os.close(saved_stdout_fd)
@workflow_app.command("run")
def workflow_run(
source: str = typer.Argument(..., help="Workflow ID or YAML file path"),
input_values: list[str] | None = typer.Option(
None, "--input", "-i", help="Input values as key=value pairs"
),
json_output: bool = typer.Option(
False,
"--json",
help="Emit the run outcome as a single JSON object instead of formatted text.",
),
):
"""Run a workflow from an installed ID or local YAML path."""
from .workflows.engine import WorkflowEngine
project_root = _require_specify_project()
source_path = Path(source).expanduser()
is_file_source = source_path.suffix.lower() in (".yml", ".yaml") and source_path.is_file()
if is_file_source:
# When running a YAML file directly, use cwd as project root
# without requiring a .specify/ project directory.
project_root = Path.cwd()
specify_dir = project_root / ".specify"
if specify_dir.is_symlink():
console.print("[red]Error:[/red] Refusing to use symlinked .specify path in current directory")
raise typer.Exit(1)
if specify_dir.exists() and not specify_dir.is_dir():
console.print("[red]Error:[/red] .specify path exists but is not a directory")
raise typer.Exit(1)
else:
project_root = _require_specify_project()
engine = WorkflowEngine(project_root)
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
if not json_output:
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
try:
definition = engine.load_workflow(source)
definition = engine.load_workflow(source_path if is_file_source else source)
except FileNotFoundError:
console.print(f"[red]Error:[/red] Workflow not found: {source}")
raise typer.Exit(1)
@@ -2749,20 +2799,15 @@ def workflow_run(
raise typer.Exit(1)
# Parse inputs
inputs: dict[str, Any] = {}
if input_values:
for kv in input_values:
if "=" not in kv:
console.print(f"[red]Error:[/red] Invalid input format: {kv!r} (expected key=value)")
raise typer.Exit(1)
key, _, value = kv.partition("=")
inputs[key.strip()] = value.strip()
inputs = _parse_input_values(input_values)
console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})")
console.print(f"[dim]Version: {definition.version}[/dim]\n")
if not json_output:
console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})")
console.print(f"[dim]Version: {definition.version}[/dim]\n")
try:
state = engine.execute(definition, inputs)
with _stdout_to_stderr_when(json_output):
state = engine.execute(definition, inputs)
except ValueError as exc:
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)
@@ -2770,6 +2815,10 @@ def workflow_run(
console.print(f"[red]Workflow failed:[/red] {exc}")
raise typer.Exit(1)
if json_output:
_emit_workflow_json(_workflow_run_payload(state))
return
status_colors = {
"completed": "green",
"paused": "yellow",
@@ -2787,16 +2836,28 @@ def workflow_run(
@workflow_app.command("resume")
def workflow_resume(
run_id: str = typer.Argument(..., help="Run ID to resume"),
input_values: list[str] | None = typer.Option(
None, "--input", "-i", help="Updated input values as key=value pairs"
),
json_output: bool = typer.Option(
False,
"--json",
help="Emit the resume outcome as a single JSON object instead of formatted text.",
),
):
"""Resume a paused or failed workflow run."""
from .workflows.engine import WorkflowEngine
project_root = _require_specify_project()
engine = WorkflowEngine(project_root)
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
if not json_output:
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
inputs = _parse_input_values(input_values)
try:
state = engine.resume(run_id)
with _stdout_to_stderr_when(json_output):
state = engine.resume(run_id, inputs or None)
except FileNotFoundError:
console.print(f"[red]Error:[/red] Run not found: {run_id}")
raise typer.Exit(1)
@@ -2807,6 +2868,10 @@ def workflow_resume(
console.print(f"[red]Resume failed:[/red] {exc}")
raise typer.Exit(1)
if json_output:
_emit_workflow_json(_workflow_run_payload(state))
return
status_colors = {
"completed": "green",
"paused": "yellow",
@@ -2820,6 +2885,11 @@ def workflow_resume(
@workflow_app.command("status")
def workflow_status(
run_id: str | None = typer.Argument(None, help="Run ID to inspect (shows all if omitted)"),
json_output: bool = typer.Option(
False,
"--json",
help="Emit run status as a single JSON object instead of formatted text.",
),
):
"""Show workflow run status."""
from .workflows.engine import WorkflowEngine
@@ -2835,6 +2905,21 @@ def workflow_status(
console.print(f"[red]Error:[/red] Run not found: {run_id}")
raise typer.Exit(1)
if json_output:
# Build on the shared run/resume payload so the common fields
# (including current_step_index) stay identical across commands.
payload = {
**_workflow_run_payload(state),
"created_at": state.created_at,
"updated_at": state.updated_at,
"steps": {
sid: sd.get("status", "unknown")
for sid, sd in state.step_results.items()
},
}
_emit_workflow_json(payload)
return
status_colors = {
"completed": "green",
"paused": "yellow",
@@ -2862,6 +2947,22 @@ def workflow_status(
console.print(f" [{sc}]●[/{sc}] {step_id}: {s}")
else:
runs = engine.list_runs()
if json_output:
payload = {
"runs": [
{
"run_id": r["run_id"],
"workflow_id": r.get("workflow_id"),
"status": r.get("status", "unknown"),
"updated_at": r.get("updated_at"),
}
for r in runs
]
}
_emit_workflow_json(payload)
return
if not runs:
console.print("[yellow]No workflow runs found.[/yellow]")
return
@@ -3323,6 +3424,17 @@ def workflow_catalog_remove(
def main():
# On Windows the default stdout/stderr code page (e.g. cp1252) cannot encode
# the Rich banner and box-drawing glyphs, so the CLI crashes with
# UnicodeEncodeError whenever output is not a UTF-8 TTY (piped, redirected to
# a file, or running under a legacy code page). Force UTF-8 with graceful
# replacement so output degrades instead of aborting. No-op on POSIX.
if sys.platform == "win32":
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, ValueError, OSError):
pass
app()
if __name__ == "__main__":

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

@@ -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(
@@ -2482,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)
@@ -2742,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

@@ -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

@@ -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

@@ -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,
*,
@@ -388,6 +420,7 @@ def install_shared_infra(
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,

View File

@@ -281,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
@@ -331,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():
@@ -403,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
@@ -507,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}."
@@ -524,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,

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

@@ -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):

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

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

@@ -29,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
@@ -4788,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"
@@ -5114,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
@@ -2716,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
@@ -3026,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