Compare commits

...

19 Commits

Author SHA1 Message Date
github-actions[bot]
800fe33f59 chore: bump version to 0.9.3 2026-06-03 21:25:59 +00:00
Pascal THUET
4028c50af8 fix: render script command hints with active agent separator (#2649)
* fix script command hints for agent separators

* Address command hint review feedback

* chore: remove whitespace-only PR churn

* test: fix PowerShell command hint invocation

* fix: preserve hyphens in script command hints

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

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

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

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

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

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

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

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

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

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

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

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

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

Changes
-------

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

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

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

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

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

* fix(cli): tighten self-upgrade diagnostics

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

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

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

* fix(cli): validate resolved upgrade tags

* fix(cli): tolerate invalid install metadata

* test(cli): align upgrade network mocks

* fix(cli): respect relative installer paths

* fix(cli): tighten upgrade failure handling

* fix(cli): align installer path diagnostics

* fix(cli): validate release and version output

* fix(cli): clarify source checkout guidance

* fix(cli): harden upgrade detection helpers

* fix(cli): avoid echoing invalid release tags

* fix(cli): tolerate argv path resolve failures

* chore: remove self-upgrade formatting-only diffs

* fix: address self-upgrade review feedback

* fix: address self-upgrade review followups

* fix: address self-upgrade review edge cases

* fix: address self-upgrade review docs

* fix: refine self-upgrade review followups

* fix: address self-upgrade review cleanup

* fix: handle self-upgrade review edge cases

* fix: address self-upgrade review nits

* fix: address follow-up self-upgrade review

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

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

* fix: address second self-upgrade review round

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

* fix: address third self-upgrade review round

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

* fix: address fourth self-upgrade review round

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

* fix: accept uppercase V prefix in --tag

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

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

Closes #2812.

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

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

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

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

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

* chore: begin 0.9.3.dev0 development

---------

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

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

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

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

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

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

---------

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

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

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

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

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

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

* Potential fix for pull request finding

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

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

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

---------

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

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

* Fix Product Forge typography in catalog/docs

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

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

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

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

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

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

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

Closes #2591.

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

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

Two Copilot findings on d0b9e00:

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

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

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

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

Copilot finding on d0b9e00:

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

Audited the whole PR diff for the same overclaim:

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

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

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

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

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

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

Copilot finding on b8982a7:

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

Two fixes:

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

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

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

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

161/161 tests/test_workflows.py pass locally.

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

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

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

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

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

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

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

161/161 tests/test_workflows.py pass locally.

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

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

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

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

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

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

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

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

161/161 tests/test_workflows.py pass locally.

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

---------

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

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

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

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

Fixes #2107

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

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

Three coordinated fixes:

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

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

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

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

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

Refs #2107

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(manifest): normalize is_recovered input through _validate_rel_path

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Round 9 — addresses Copilot review on PR #2483:

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

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

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

* chore: begin 0.9.2.dev0 development

---------

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

28
.editorconfig Normal file
View File

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

View File

@@ -2,6 +2,48 @@
<!-- insert new changelog below this comment -->
## [0.9.3] - 2026-06-03
### Changed
- fix: render script command hints with active agent separator (#2649)
- chore(tests): fix ruff lint violations in tests/ (#2827)
- fix(workflows): validate run_id in RunState.load before touching the … (#2813)
- feat(cli): implement specify self upgrade (#2475)
- feat(workflows): allow resume to accept updated workflow inputs (#2815)
- catalog: rename "superpowers-bridge" to "superspec" (v1.0.1) (#2772)
- fix(cli): force UTF-8 stdout/stderr on Windows to prevent UnicodeEncodeError (#2817)
- fix(plan): clarify quickstart validation guide scope (#2805)
- chore: release 0.9.2, begin 0.9.3.dev0 development (#2823)
## [0.9.2] - 2026-06-02
### Changed
- Update agent parity governance preset catalog entry (#2777)
- fix: resolve GitHub release asset API URL for private repo extension downloads (#2792)
- fix: remove unsupported mode: frontmatter from Copilot skills mode (fixes #2799) (#2819)
- refactor(integrations): co-locate integration commands in integrations/ domain dir (PR-5/8) (#2720)
- Update Product Forge extension to v1.6.0 (#2820)
- feat(workflows): add continue_on_error step field for non-halting failures (#2663)
- chore: add .editorconfig for consistent code formatting (#2366)
- fix(shared-infra): record skipped files in speckit.manifest.json (#2483)
- chore: release 0.9.1, begin 0.9.2.dev0 development (#2818)
## [0.9.1] - 2026-06-02
### Changed
- fix(cli): pin UTF-8 encoding on init-options and .extensionignore I/O (#2686)
- docs: list Hermes in supported integrations table (#2768)
- fix(copilot): resolve active spec template (#2765)
- fix: add missing agent-context extension entries to Cline _expected_files (#2797)
- Add spec-kit-linear extension to community catalog (#2795)
- feat: add native Cline integration (#2508)
- Update workflow-preset community catalog entry (#2756)
- chore: release 0.9.0, begin 0.9.1.dev0 development (#2794)
- Add RAG Azure Builder extension to community catalog (#2793)
## [0.9.0] - 2026-06-01
### Changed

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,8 +28,18 @@ 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) |
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

View File

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

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-06-01T00:00:00Z",
"updated_at": "2026-06-02T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"aide": {
@@ -2055,10 +2055,10 @@
"product-forge": {
"name": "Product Forge",
"id": "product-forge",
"description": "Full product lifecycle from research to release — portfolio, lite mode, monorepo, optional V-Model",
"description": "Full product lifecycle from research to release — express/lite/standard/v-model tracks, living spec + traceability, structured journeys → E2E, monorepo, and selectable doc-structure strategies",
"author": "VaiYav",
"version": "1.5.1",
"download_url": "https://github.com/VaiYav/speckit-product-forge/archive/refs/tags/v1.5.1.zip",
"version": "1.6.0",
"download_url": "https://github.com/VaiYav/speckit-product-forge/archive/refs/tags/v1.6.0.zip",
"repository": "https://github.com/VaiYav/speckit-product-forge",
"homepage": "https://github.com/VaiYav/speckit-product-forge",
"documentation": "https://github.com/VaiYav/speckit-product-forge/blob/main/README.md",
@@ -2068,7 +2068,7 @@
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 29,
"commands": 31,
"hooks": 0
},
"tags": [
@@ -2082,7 +2082,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-03-28T00:00:00Z",
"updated_at": "2026-04-24T15:52:00Z"
"updated_at": "2026-06-02T00:00:00Z"
},
"qa": {
"name": "QA Testing Extension",
@@ -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-28T00:00:00Z",
"updated_at": "2026-05-31T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
"presets": {
"a11y-governance": {
@@ -34,11 +34,11 @@
"agent-parity-governance": {
"name": "Agent Parity Governance",
"id": "agent-parity-governance",
"version": "0.1.0",
"description": "Keeps shared AI-agent guidance aligned across a project-defined set of agent instruction surfaces.",
"version": "0.2.0",
"description": "Keeps shared AI-agent guidance aligned and adds agent-neutral Spec Kit model-routing guidance across declared agent instruction surfaces.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/archive/refs/tags/v0.1.0.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/archive/refs/tags/v0.2.0.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/blob/main/README.md",
"license": "MIT",
@@ -46,18 +46,20 @@
"speckit_version": ">=0.8.0"
},
"provides": {
"templates": 6,
"templates": 9,
"commands": 3
},
"tags": [
"agents",
"governance",
"parity",
"agent-md",
"agent-guidance",
"model-routing",
"multi-agent"
],
"created_at": "2026-04-27T00:00:00Z",
"updated_at": "2026-04-27T00:00:00Z"
"updated_at": "2026-05-31T00:00:00Z"
},
"aide-in-place": {
"name": "AIDE In-Place Migration",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1749,13 +1749,59 @@ class ExtensionCatalog(CatalogStackBase):
from specify_cli.authentication.http import build_request
return build_request(url)
def _open_url(self, url: str, timeout: int = 10):
def _open_url(
self,
url: str,
timeout: int = 10,
extra_headers: Optional[Dict[str, str]] = None,
):
"""Open a URL with provider-based auth, trying each configured provider.
Delegates to :func:`specify_cli.authentication.http.open_url`.
"""
from specify_cli.authentication.http import open_url
return open_url(url, timeout)
return open_url(url, timeout, extra_headers=extra_headers)
def _resolve_github_release_asset_api_url(
self,
download_url: str,
timeout: int = 60,
) -> Optional[str]:
"""Resolve a GitHub release asset URL to its API asset URL."""
import urllib.error
from urllib.parse import unquote, urlparse
parsed = urlparse(download_url)
parts = [unquote(part) for part in parsed.path.strip("/").split("/")]
if (
parsed.hostname == "api.github.com"
and len(parts) >= 6
and parts[:1] == ["repos"]
and parts[3:5] == ["releases", "assets"]
):
return download_url
if parsed.hostname != "github.com":
return None
if len(parts) < 6 or parts[2:4] != ["releases", "download"]:
return None
owner, repo, tag = parts[0], parts[1], parts[4]
asset_name = "/".join(parts[5:])
release_url = f"https://api.github.com/repos/{owner}/{repo}/releases/tags/{tag}"
try:
with self._open_url(release_url, timeout=timeout) as response:
release_data = json.loads(response.read())
except (urllib.error.URLError, json.JSONDecodeError):
return None
for asset in release_data.get("assets", []):
if asset.get("name") == asset_name and asset.get("url"):
return str(asset["url"])
return None
def get_active_catalogs(self) -> List[CatalogEntry]:
"""Get the ordered list of active catalogs.
@@ -2155,9 +2201,15 @@ class ExtensionCatalog(CatalogStackBase):
zip_filename = f"{extension_id}-{version}.zip"
zip_path = target_dir / zip_filename
extra_headers = None
resolved_download_url = self._resolve_github_release_asset_api_url(download_url)
if resolved_download_url:
download_url = resolved_download_url
extra_headers = {"Accept": "application/octet-stream"}
# Download the ZIP file
try:
with self._open_url(download_url, timeout=60) as response:
with self._open_url(download_url, timeout=60, extra_headers=extra_headers) as response:
zip_data = response.read()
zip_path.write_bytes(zip_data)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -21,7 +21,6 @@ from pathlib import Path
from specify_cli.extensions import (
ExtensionManifest,
ExtensionManager,
ExtensionError,
)
@@ -241,7 +240,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
)
@@ -784,7 +783,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 +802,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 +818,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 +879,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

@@ -2804,17 +2804,33 @@ class TestExtensionCatalog:
zf.writestr("extension.yml", "id: test-ext\nname: Test\nversion: 1.0.0\n")
zip_bytes = zip_buf.getvalue()
mock_response = MagicMock()
mock_response.read.return_value = zip_bytes
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
release_response = MagicMock()
release_response.read.return_value = json.dumps(
{
"assets": [
{
"name": "test-ext.zip",
"url": "https://api.github.com/repos/org/repo/releases/assets/1",
}
]
}
).encode()
release_response.__enter__ = lambda s: s
release_response.__exit__ = MagicMock(return_value=False)
captured = {}
asset_response = MagicMock()
asset_response.read.return_value = zip_bytes
asset_response.__enter__ = lambda s: s
asset_response.__exit__ = MagicMock(return_value=False)
captured = []
mock_opener = MagicMock()
def fake_open(req, timeout=None):
captured["req"] = req
return mock_response
captured.append(req)
if req.full_url.endswith("/releases/tags/v1"):
return release_response
return asset_response
mock_opener.open.side_effect = fake_open
@@ -2829,7 +2845,56 @@ class TestExtensionCatalog:
patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
catalog.download_extension("test-ext", target_dir=temp_dir)
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
assert captured[0].full_url == "https://api.github.com/repos/org/repo/releases/tags/v1"
assert captured[0].get_header("Authorization") == "Bearer ghp_testtoken"
assert captured[1].full_url == "https://api.github.com/repos/org/repo/releases/assets/1"
assert captured[1].get_header("Authorization") == "Bearer ghp_testtoken"
assert captured[1].get_header("Accept") == "application/octet-stream"
def test_download_extension_accepts_direct_github_rest_asset_url(self, temp_dir, monkeypatch):
"""download_extension can use a GitHub REST release asset URL directly."""
from unittest.mock import patch, MagicMock
import zipfile
import io
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
catalog = self._make_catalog(temp_dir)
zip_buf = io.BytesIO()
with zipfile.ZipFile(zip_buf, "w") as zf:
zf.writestr("extension.yml", "id: test-ext\nname: Test\nversion: 1.0.0\n")
zip_bytes = zip_buf.getvalue()
asset_response = MagicMock()
asset_response.read.return_value = zip_bytes
asset_response.__enter__ = lambda s: s
asset_response.__exit__ = MagicMock(return_value=False)
captured = []
mock_opener = MagicMock()
def fake_open(req, timeout=None):
captured.append(req)
return asset_response
mock_opener.open.side_effect = fake_open
ext_info = {
"id": "test-ext",
"name": "Test Extension",
"version": "1.0.0",
"download_url": "https://api.github.com/repos/org/repo/releases/assets/1",
}
with patch.object(catalog, "get_extension_info", return_value=ext_info), \
patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
catalog.download_extension("test-ext", target_dir=temp_dir)
assert len(captured) == 1
assert captured[0].full_url == "https://api.github.com/repos/org/repo/releases/assets/1"
assert captured[0].get_header("Authorization") == "Bearer ghp_testtoken"
assert captured[0].get_header("Accept") == "application/octet-stream"

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

@@ -2379,6 +2379,306 @@ steps:
assert state.step_results["stamp"]["output"]["stdout"].strip() == "explicit-456"
# ===== continue_on_error Tests =====
#
# Locks the contract documented in workflows/README.md "Error Handling"
# section: when a step returns `StepResult(status=StepStatus.FAILED, ...)` and
# `continue_on_error: true` is declared, the engine records the step's
# `output` (with `exit_code` and `stderr` from the failure) and its
# `status` (sibling key on `steps.<id>`, not nested under `output`)
# and continues to the next sibling step instead of halting the run.
# Gate aborts (`output.aborted`) still halt regardless of the flag.
# Unhandled exceptions raised out of `step_impl.execute()` are out of
# scope for this flag — they propagate to `WorkflowEngine.execute()`
# and abort the run.
class TestContinueOnError:
"""Test the `continue_on_error` step-level field."""
def test_undeclared_failure_halts_run(self, project_dir):
"""Default behaviour (no `continue_on_error`): a failing step
halts the workflow run with `status == StepStatus.FAILED`.
Locks the byte-equivalent default — workflows that do not
declare the flag must behave exactly as before this feature.
"""
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
from specify_cli.workflows.base import RunStatus
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "halt-on-fail"
name: "Halt On Fail"
version: "1.0.0"
steps:
- id: fail-step
type: shell
run: "exit 7"
- id: after
type: shell
run: "echo should-not-run"
""")
engine = WorkflowEngine(project_dir)
state = engine.execute(definition)
assert state.status == RunStatus.FAILED
assert "fail-step" in state.step_results
assert state.step_results["fail-step"]["output"]["exit_code"] == 7
# Subsequent step never executes when the flag is absent.
assert "after" not in state.step_results
def test_declared_and_fired_continues_run(self, project_dir):
"""`continue_on_error: true` + failing step: the run keeps
going, the failed step's result is recorded, and the
downstream step runs.
"""
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
from specify_cli.workflows.base import RunStatus
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "continue-past-fail"
name: "Continue Past Fail"
version: "1.0.0"
steps:
- id: flaky-step
type: shell
run: "exit 42"
continue_on_error: true
- id: after
type: shell
run: "echo did-run"
""")
engine = WorkflowEngine(project_dir)
state = engine.execute(definition)
assert state.status == RunStatus.COMPLETED
# Failed step's exit_code is preserved so downstream branching
# can inspect it.
assert state.step_results["flaky-step"]["output"]["exit_code"] == 42
assert state.step_results["flaky-step"]["status"] == "failed"
# Downstream step ran successfully.
assert state.step_results["after"]["output"]["exit_code"] == 0
def test_declared_but_step_succeeded_is_noop(self, project_dir):
"""`continue_on_error: true` on a step that succeeds is a
no-op — the flag only changes behaviour on StepStatus.FAILED status.
"""
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
from specify_cli.workflows.base import RunStatus
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "flag-but-success"
name: "Flag But Success"
version: "1.0.0"
steps:
- id: ok-step
type: shell
run: "echo ok"
continue_on_error: true
- id: after
type: shell
run: "echo done"
""")
engine = WorkflowEngine(project_dir)
state = engine.execute(definition)
assert state.status == RunStatus.COMPLETED
assert state.step_results["ok-step"]["status"] == "completed"
assert state.step_results["ok-step"]["output"]["exit_code"] == 0
assert state.step_results["after"]["output"]["exit_code"] == 0
def test_if_branch_routes_around_failure(self, project_dir):
"""End-to-end: `continue_on_error` + `if` cleanly routes around
a failure. The recovery branch runs; the success branch does
not.
Mirrors the canonical usage pattern from the original feature
discussion in issue #2591.
"""
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
from specify_cli.workflows.base import RunStatus
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "route-around"
name: "Route Around Failure"
version: "1.0.0"
steps:
- id: heavy-thing
type: shell
run: "exit 1"
continue_on_error: true
- id: check-result
type: if
condition: "{{ steps.heavy-thing.output.exit_code != 0 }}"
then:
- id: recovery
type: shell
run: "echo recovery-ran"
else:
- id: happy-path
type: shell
run: "echo happy-path-ran"
""")
engine = WorkflowEngine(project_dir)
state = engine.execute(definition)
assert state.status == RunStatus.COMPLETED
assert "recovery" in state.step_results
assert "happy-path" not in state.step_results
def test_gate_abort_still_halts_with_continue_on_error(
self, project_dir, monkeypatch
):
"""`continue_on_error` does NOT override a deliberate gate
abort. `output.aborted` always halts the run with
`status == ABORTED`.
Aborts are explicit operator decisions; continue_on_error
is for transient/expected step failures only.
"""
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
from specify_cli.workflows.base import RunStatus
from specify_cli.workflows.steps.gate import GateStep
from specify_cli.workflows.steps import gate as gate_module
# Force the gate step into interactive mode and feed a "reject"
# choice so the abort path actually runs in the test env
# (default behaviour returns StepStatus.PAUSED when stdin is not a TTY).
# Swap sys.stdin itself for a stub: setattr on the real
# TextIOWrapper's `isatty` method is not assignable under some
# runners (e.g. pytest with capture disabled).
class _TTYStdin:
def isatty(self) -> bool:
return True
monkeypatch.setattr(gate_module.sys, "stdin", _TTYStdin())
monkeypatch.setattr(
GateStep, "_prompt", staticmethod(lambda _msg, _opts: "reject")
)
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "gate-abort-halts"
name: "Gate Abort Halts"
version: "1.0.0"
steps:
- id: gate-step
type: gate
message: "Approve?"
options: [approve, reject]
on_reject: abort
continue_on_error: true
- id: should-not-run
type: shell
run: "echo nope"
""")
engine = WorkflowEngine(project_dir)
state = engine.execute(definition)
assert state.status == RunStatus.ABORTED
assert "should-not-run" not in state.step_results
def test_validation_rejects_non_bool_continue_on_error(self):
"""`continue_on_error` must be a literal boolean; coerced
strings like `"true"` are rejected at validation time so
authoring mistakes surface before execution.
"""
from specify_cli.workflows.engine import (
WorkflowDefinition,
validate_workflow,
)
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "bad-coe"
name: "Bad COE"
version: "1.0.0"
steps:
- id: step-one
type: shell
run: "true"
continue_on_error: "true"
""")
errors = validate_workflow(definition)
assert any(
"continue_on_error" in e and "boolean" in e for e in errors
), errors
def test_validation_accepts_bool_continue_on_error(self):
"""Boolean values pass validation cleanly."""
from specify_cli.workflows.engine import (
WorkflowDefinition,
validate_workflow,
)
for value in (True, False):
yaml_value = "true" if value else "false"
definition = WorkflowDefinition.from_string(f"""
schema_version: "1.0"
workflow:
id: "good-coe"
name: "Good COE"
version: "1.0.0"
steps:
- id: step-one
type: shell
run: "true"
continue_on_error: {yaml_value}
""")
errors = validate_workflow(definition)
assert errors == [], errors
def test_engine_ignores_truthy_non_bool_continue_on_error(self, project_dir):
"""Defense-in-depth: even if a caller bypasses
`validate_workflow()` and feeds the engine a definition with
`continue_on_error: "true"` (a string), the engine must NOT
honour the flag — only a literal boolean enables the
behaviour. `WorkflowEngine.execute()` does not auto-validate
(the `WorkflowEngine.load_workflow` docstring explicitly
notes the definition is "not yet validated; call
`validate_workflow()` or `engine.validate()` separately"),
so the engine guards against truthy non-bool values itself
via an identity check rather than truthiness.
"""
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
from specify_cli.workflows.base import RunStatus
# Bypass `validate_workflow()` — execute() is what would
# be called by a caller that skipped validation.
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "string-coe"
name: "String COE"
version: "1.0.0"
steps:
- id: fail-step
type: shell
run: "exit 1"
continue_on_error: "true"
- id: should-not-run
type: shell
run: "echo should-not-run"
""")
engine = WorkflowEngine(project_dir)
state = engine.execute(definition)
# String "true" is truthy but not a literal boolean, so the
# engine must treat the step as a halting failure.
assert state.status == RunStatus.FAILED
assert "should-not-run" not in state.step_results
# ===== State Persistence Tests =====
class TestRunState:
@@ -2416,6 +2716,112 @@ class TestRunState:
with pytest.raises(FileNotFoundError):
RunState.load("nonexistent", project_dir)
@pytest.mark.parametrize(
"malicious_run_id",
[
# Parent-directory traversal — the classic path-escape vector.
"../escape",
"..",
"../../etc/passwd",
# Embedded path separators — both POSIX and Windows.
"foo/bar",
"foo\\bar",
# Leading non-alphanumeric characters that the existing
# pattern's anchor blocks (would be mistaken for CLI flags
# or hidden files in shell completions / error messages).
".hidden",
"-flag",
# NUL byte — some filesystems treat the prefix as a valid
# path and silently truncate at the NUL.
"foo\x00bar",
# Empty string — degenerate case, matches no file but the
# validator should reject it before any I/O.
"",
],
)
def test_load_rejects_path_traversal(self, project_dir, malicious_run_id):
"""``RunState.load`` validates ``run_id`` before touching the
filesystem.
Without this guard, a value like ``../escape`` passed via
``specify workflow resume`` would interpolate path-traversal
segments into the lookup path. ``state_path.exists()`` would
probe arbitrary paths the process can read (a file-existence
oracle) and ``json.load`` would happily parse attacker-planted
JSON from outside ``.specify/workflows/runs/``. The check must
fire *before* the path is built — ``__init__``'s identical
regex on ``state_data["run_id"]`` fires too late.
"""
from specify_cli.workflows.engine import RunState
# Plant a state.json *outside* the legitimate ``runs/`` directory
# at the location ``../escape`` would traverse to, so a missing
# guard would surface as a successful load rather than a
# ``FileNotFoundError`` (which would be ambiguous with the
# not-found case).
runs_dir = project_dir / ".specify" / "workflows" / "runs"
runs_dir.mkdir(parents=True, exist_ok=True)
attacker_dir = project_dir / ".specify" / "workflows" / "escape"
attacker_dir.mkdir(exist_ok=True)
(attacker_dir / "state.json").write_text(
json.dumps(
{
"run_id": "pwned",
"workflow_id": "attacker-owned",
"status": "created",
}
),
encoding="utf-8",
)
with pytest.raises(ValueError, match="Invalid run_id"):
RunState.load(malicious_run_id, project_dir)
@pytest.mark.parametrize(
"bad_run_id",
[
# One vector per category from ``test_load_rejects_path_traversal``
# — enough to prove both entry points agree without re-running
# the full attack matrix here.
"../escape", # parent-directory traversal
"foo/bar", # embedded path separator
".hidden", # leading non-alphanumeric
"", # empty / degenerate
],
)
def test_init_and_load_share_validation(self, project_dir, bad_run_id):
"""``__init__`` *and* ``load`` reject the same malformed IDs.
The two entry points must stay in sync — drift would let an ID
slip in via one path that the other would reject, producing
confusing crashes mid-workflow. The previous version of this
test only exercised ``__init__`` and ``_validate_run_id`` (the
shared helper), so a regression in ``load`` — e.g. someone
deleting the ``cls._validate_run_id(run_id)`` call there — could
slip through despite ``__init__`` and the helper staying
aligned. We now hit ``load`` directly with the same vector so
any drift between the two call sites is caught by this test.
"""
from specify_cli.workflows.engine import RunState
# ``__init__`` rejects up front.
with pytest.raises(ValueError, match="Invalid run_id"):
RunState(run_id=bad_run_id)
# The shared helper rejects the value too (sanity check that the
# ``__init__`` rejection came from the validator, not some
# unrelated constructor failure).
with pytest.raises(ValueError, match="Invalid run_id"):
RunState._validate_run_id(bad_run_id)
# And ``load`` rejects it *before* touching the filesystem. This
# is the assertion the previous version was missing: without it,
# a regression in ``load`` (e.g. forgetting to call the
# validator before building the path) would not be caught even
# though ``__init__`` and the helper still agreed.
with pytest.raises(ValueError, match="Invalid run_id"):
RunState.load(bad_run_id, project_dir)
def test_append_log(self, project_dir):
from specify_cli.workflows.engine import RunState
@@ -2726,3 +3132,118 @@ steps:
assert state.status == RunStatus.COMPLETED
assert "do-plan" in state.step_results
assert "do-specify" not in state.step_results
class TestResumeWithInputs:
"""Test that `workflow resume` can accept updated workflow inputs."""
_WF_CMD = """
schema_version: "1.0"
workflow:
id: "resume-cmd-wf"
name: "Resume Cmd WF"
version: "1.0.0"
inputs:
cmd:
type: string
default: "exit 1"
steps:
- id: s
type: shell
run: "{{ inputs.cmd }}"
"""
_WF_NUM = """
schema_version: "1.0"
workflow:
id: "resume-num-wf"
name: "Resume Num WF"
version: "1.0.0"
inputs:
count:
type: number
default: 1
steps:
- id: gate
type: gate
message: "Review"
options: [approve, reject]
"""
def _engine(self, project_dir):
from specify_cli.workflows.engine import WorkflowEngine
return WorkflowEngine(project_dir)
def test_resume_with_input_reruns_step_with_new_value(self, project_dir):
from specify_cli.workflows.engine import WorkflowDefinition
from specify_cli.workflows.base import RunStatus
definition = WorkflowDefinition.from_string(self._WF_CMD)
engine = self._engine(project_dir)
state = engine.execute(definition)
assert state.status == RunStatus.FAILED # "exit 1" fails
resumed = engine.resume(state.run_id, {"cmd": "exit 0"})
assert resumed.status == RunStatus.COMPLETED
assert resumed.inputs["cmd"] == "exit 0"
def test_resume_without_input_preserves_inputs(self, project_dir):
from specify_cli.workflows.engine import WorkflowDefinition
from specify_cli.workflows.base import RunStatus
definition = WorkflowDefinition.from_string(self._WF_CMD)
engine = self._engine(project_dir)
state = engine.execute(definition)
assert state.status == RunStatus.FAILED
resumed = engine.resume(state.run_id)
assert resumed.status == RunStatus.FAILED # still "exit 1"
assert resumed.inputs["cmd"] == "exit 1"
def test_resume_merges_and_coerces_typed_input(self, project_dir):
import json as _json
from specify_cli.workflows.engine import WorkflowDefinition
from specify_cli.workflows.base import RunStatus
definition = WorkflowDefinition.from_string(self._WF_NUM)
engine = self._engine(project_dir)
state = engine.execute(definition)
assert state.status == RunStatus.PAUSED
resumed = engine.resume(state.run_id, {"count": "5"})
assert resumed.inputs["count"] == 5 # coerced string -> number
inputs_file = (
project_dir / ".specify" / "workflows" / "runs" / state.run_id / "inputs.json"
)
assert _json.loads(inputs_file.read_text())["inputs"]["count"] == 5
def test_resume_invalid_typed_input_raises(self, project_dir):
from specify_cli.workflows.engine import WorkflowDefinition
definition = WorkflowDefinition.from_string(self._WF_NUM)
engine = self._engine(project_dir)
state = engine.execute(definition)
with pytest.raises(ValueError):
engine.resume(state.run_id, {"count": "not-a-number"})
def test_cli_resume_input_invalid_format_errors(self, project_dir):
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
from specify_cli.workflows.engine import WorkflowDefinition
definition = WorkflowDefinition.from_string(self._WF_NUM)
state = self._engine(project_dir).execute(definition)
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app, ["workflow", "resume", state.run_id, "--input", "bogus"]
)
assert result.exit_code == 1
assert "Invalid input format" in result.stdout

View File

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