Files
github-spec-kit/tests/test_extensions.py
kanfil f8e474d6fd feat: first-class agent-native runtime hooks for integrations (#3704)
* feat: first-class agent-native runtime hooks for integrations

* refactor: rework integration events per maintainer review

- Rename hooks terminology to 'events' (events:, --events flag, events.py).
- Use snake_case names for canonical events consistent with spec-kit vocabulary.
- Fold event config adapters into integration classes via class attributes (CANONICAL_TO_NATIVE, events_config_file, events_format).
- Lift event command-script resolution to core 'specify event run' command.
- Split events sourcing from integration config writing.
- Support first-class Copilot CLI events JSON generation under '.github/hooks/speckit.json'.
- Rewrite and expand full test suite under 'tests/integrations/test_events.py'.

Assisted-by: opencode (model: litellm/gemini-3.5-flash, autonomous)

* fix(events): resolve ruff lint errors blocking CI

Address Copilot review finding #18 (src/specify_cli/__init__.py event-command
import missing # noqa: E402), #19 (unused console import in commands/event.py),
and #20 (unused patch/yaml/Path/integration imports in test_events.py). Also
fix two stray F541 f-string prefixes in _build_opencode_plugin that ruff
flagged in the same job.

Bump dev version 0.14.2.dev0 -> 0.14.2.dev1 and add a CHANGELOG entry per the
AGENTS.md convention for Specify CLI __init__.py changes.

Refs: PR #3704 Copilot inline review (findings #18, #19, #20)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): make generated native hooks actually execute

Address Copilot review findings that left generated event hooks inert or
schema-invalid after the rework:

- #2: the resolved events map now carries an ordered list of handlers per
  event (dict[str, list[dict]]) so two extensions declaring the same event
  both run instead of the last one silently winning. collect_extension_events
  accumulates; every adapter emits one native entry per handler.
- #6: Claude/Gemini/Qwen/Devin/Tabnine native schema accepts a single
  'command' string, not command+args. Each adapter now renders one complete
  shell invocation of the dispatcher via _dispatcher_command().
- #7: Gemini measures hook timeouts in milliseconds; add events_timeout_unit
  attr and _native_timeout() so the 60s default becomes 60000ms instead of
  terminating the dispatcher after 60ms.
- #4: _resolve_event_command_argv() replaces _extract_script_path() —
  scripts: values are command strings (e.g. 'scripts/bash/setup-plan.sh --json'),
  not bare paths. Resolves the project's sh/ps/py variant, splits safely into
  argv, and prepends the interpreter for .py.
- #5: bundled-template fallback now uses _locate_core_pack()/_repo_root()
  (core_pack/commands, not the non-existent core_pack/templates/commands).
- #16: all formatters use IntegrationBase.resolve_python_interpreter() so
  generated commands honor the project venv and never hard-code python3
  (absent on Windows). The opencode TS plugin bakes in the same resolved
  interpreter.
- #13: opencode TS plugin runEvent() now throws on failure instead of
  process.exit(2), which killed the OpenCode host process; only the failing
  hook is rejected.
- #21: user YAML override is validated (event names, non-empty command
  strings) before returning; a malformed override is warned about and
  ignored rather than crashing installation on cfg.get().

Bump dev version 0.14.2.dev1 -> 0.14.2.dev2 (gemini/__init__.py change) and
add a CHANGELOG entry.

Refs: PR #3704 Copilot inline review (findings #2, #4, #5, #6, #7, #13, #16, #21)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): merge/teardown idempotency and data safety

Address Copilot review findings on native-config merge and teardown:

- #9: _has_marker now recurses into nested 'hooks' arrays so a matcher-group
  containing Specify-owned inner hooks is recognized and replaced on upgrade
  instead of accumulating duplicates.
- #11: _merge_json_fragment strips ALL Specify-marked entries from every event
  before adding the new set, so an override that drops an event (pre_tool_use
  -> stop) removes the stale marked entry instead of leaving it active.
- #3: an empty resolved map (--events false / disabled override) now runs the
  native-config removal path instead of early-returning, so prior Specify
  hooks are stripped. The shared dispatcher is left untouched (#10).
- #14: teardown deletes a Spec-Kit-created config that is now empty of user
  content (rather than leaving '{}' that confused manifest.uninstall()),
  while preserving pre-existing configs with user hooks/settings.
- #10: the shared .specify/events.py dispatcher is deleted only when no other
  installed event-capable integration's manifest still references it, so
  uninstalling one multi-install integration doesn't break the others.
- #8: Copilot's .github/hooks/speckit.json now merges owned entries (with
  markers) into a pre-existing file instead of overwriting, and teardown
  removes only owned entries (deleting the file when no user hooks remain).
- #22/#23: JSON/JSONC parse failures in native configs (Claude/Cursor/etc.
  and opencode.json) abort the merge with a warning instead of resetting user
  content to '{}'.
- #12: write destinations are validated (symlinked-ancestor rejection +
  containment) before any bytes are written, so a symlinked .specify or
  native config directory can't redirect writes outside the repository.

Refs: PR #3704 Copilot inline review (findings #3, #8, #9, #10, #11, #12, #14, #22, #23)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): honor enabled flag, refresh on extension lifecycle, strict command validation

Address Copilot review findings on sourcing, validation, and lifecycle:

- #1: collect_extension_events now honors the extension registry's 'enabled'
  flag — a disabled extension's events are skipped so disabling an extension
  actually deactivates its runtime hooks. Adds refresh_integration_events(),
  wired into extension add/remove/enable/disable, so installing, removing,
  enabling, or disabling an extension regenerates each installed event-capable
  integration's native event config (the documented install-after-init flow is
  no longer inert, and disabled/removed extension events are stripped).
- #17: validate_events now requires 'command' to be a non-empty string, not
  merely truthy, so a value like 'command: [foo]' is rejected at manifest
  load instead of rendering into invalid native configuration.
- #15: updated PR #3704 description to the implemented events terminology
  (.specify/events.py, events:, --events, integration-events.yml) replacing
  the stale bridge.py / runtime_hooks: / --hooks false / integration-hooks.yml
  references that no longer match the shipped API.

(#21 — user YAML override validation — was addressed in the prior tier.)

Refs: PR #3704 Copilot inline review (findings #1, #15, #17)

Assisted-by: opencode (model: glm-5.2, autonomous)

* revert: drop CHANGELOG.md/pyproject.toml version bumps from events fixes

Per maintainer request, the events PR no longer carries CHANGELOG entries or
pyproject version revs. This restores both files to their pre-PR (da6c20d9)
state: pyproject.toml back to 0.14.2.dev0 and the [Unreleased] block removed
from CHANGELOG.md. The AGENTS.md version-rev convention for __init__.py
changes is intentionally waived for this PR by maintainer decision.

This also clears the pending merge conflicts with upstream/main on these two
files (upstream's 0.14.2 release commit c0fe0e43): our side now makes no
net change to them relative to the merge-base, so a future upstream merge
takes theirs on both without conflict.

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): compose --events into Copilot/Devin options() (#8, #9)

Copilot and Devin are event-capable, but their options() overrides returned
only --skills without calling super(), so the base class never declared
--events. The documented --integration-options "--events false" opt-out was
therefore rejected as unknown for both adapters.

Both now compose with super().options() (mirroring Codex and Cursor) so
--events is declared alongside --skills. Added a TestEventCapableOptionsCompo
sition test class asserting --events appears in Copilot, Devin, Cursor, and
Codex options() output.

Refs: PR #3704 Copilot review 4790195897 (findings #8, #9)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): Cursor version field, matcher grouping, Copilot cross-OS

Address three Copilot review findings on native-config generation:

- #7: Cursor's .cursor/hooks.json schema requires top-level "version": 1,
  but json-flat used _merge_json_fragment() which only writes hooks, so a
  freshly generated file was missing the required schema version. Added a
  version kwarg to _merge_json_fragment (preserving a user's value if
  present) and the Cursor json-flat branch now passes version=1.
- S3: json-nested placed all handlers under the first handler's matcher, so
  two extensions registering the same event with different matchers both ran
  for the first matcher and neither for the later. Handlers are now grouped
  by distinct matcher, emitting one matcher-group per matcher (handlers
  sharing a matcher stay in one group).
- S4: Copilot's bash and powershell fields both received the same
  host-resolved command, so a config generated on Linux wrote a POSIX venv
  path into the PowerShell hook (and vice-versa). _dispatcher_command gains
  a target_os kwarg; Copilot now emits an independent POSIX interpreter
  (python3) for bash and a Windows interpreter (python) for powershell, so
  the checked-in config works on either OS.

Tests: added TestCursorJsonWriting (version present + preserved) and
matcher-grouping regressions (per-distinct-matcher, shared-matcher); updated
the Copilot generation test to assert bash != powershell with OS-appropriate
interpreters.

Refs: PR #3704 Copilot review 4790195897 (findings #7, S3, S4)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): anchor py scripts and prefix ps launcher in command runner

Address two Copilot review findings on the core command runner:

- S2: the py variant called build_python_invocation() on the raw scripts:
  command string, which left 'scripts/...' anchored at the project root
  instead of under .specify/ (or .specify/extensions/<id>/). Every event
  command in a project configured with --script py launched a nonexistent
  project-root path. The py branch now shares the same base-anchoring as
  sh/ps and prepends the resolved interpreter as argv (no shell quoting
  needed for subprocess.run(shell=False)).
- S6: the ps variant returned the .ps1 path as the executable, but Windows
  subprocess.run(shell=False) cannot execute a PowerShell script directly,
  so event dispatch failed on the default Windows script type. The ps branch
  now prefixes argv with 'pwsh -File' (PowerShell 7+), falling back to
  'powershell -File' (Windows PowerShell) when pwsh is absent.

Tests: added test_py_variant_anchored_under_specify and
test_ps_variant_prefixed_with_powershell_launcher covering the new argv
shapes (interpreter + .specify-anchored path; launcher -File + path).

Refs: PR #3704 Copilot review 4790195897 (findings S2, S6)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): skip-tracking on parse fail, drop dispatcher claim on retain, honor --events false in refresh, preserve layers on invalid override

Address four Copilot review findings on merge/teardown/refresh safety:

- S5: _merge_json_fragment/_merge_opencode_plugin_ref/_merge_copilot_json now
  return bool (wrote). Install branches skip manifest.record_existing() and
  created.append() when a merge was skipped on parse failure, so a user's
  JSONC/malformed native config is not tracked and manifest.uninstall() can't
  later delete the untouched file.
- S1: remove_integration_events now drops this integration's manifest claim on
  the shared dispatcher (manifest.remove) even when the file is retained
  because another integration references it. Previously the retained file
  stayed tracked, so the subsequent manifest.uninstall() in teardown() saw
  the matching hash and deleted the file another integration still depended
  on. The unit test now exercises full teardown() (not just
  remove_integration_events) to cover the gap.
- S7: refresh_integration_events reads each integration's stored
  parsed_options via _resolve_integration_options and passes them to
  resolve_events, so a persisted --events false is honored across extension
  add/enable/disable instead of being discarded (which re-enabled events the
  user had disabled).
- #10: an invalid override entry now abandons the entire override and keeps
  the accumulated built-in + extension layers, instead of resetting
  resolved_override to {} and assigning that empty map to events (which
  silently disabled all hooks on a single typo). Only a fully-valid override
  (including an explicit events: {}) replaces the prior layers.

Tests: added TestOverridePreserveLayers (invalid entry keeps layers; explicit
empty disables), TestSkippedMergeNotTracked (JSONC not recorded), and
TestDispatcherManifestClaimDroppedOnRetain (full teardown keeps dispatcher
when another integration references it). Added S7 refresh-honors-events-false
regression.

Refs: PR #3704 Copilot review 4790195897 (findings S5, S1, S7, #10)

Assisted-by: opencode (model: glm-5.2, autonomous)

* test(extensions): update stale validation-message assertion

The 'no commands/hooks/events' validation message changed to
'Extension must provide at least one command, hook, or event' when the
events feature added a third provider kind, but test_no_commands_no_hooks
still matched the old 'must provide at least one command or hook' text and
failed on every CI job. Update the regex to the current message.

Refs: PR #3704 CI failure (test_extensions.py:579)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): forced-teardown data safety, manifest-driven command resolution, toml teardown safe-dest

Address three findings from Copilot review 4791088500:

- S9: _remove_native_event_hooks now unconditionally drops this integration's
  manifest claim on the native config, not only when the file was deleted.
  Previously a config whose owned entries were cleaned but user content
  retained stayed tracked, so teardown(force=True) -> manifest.uninstall(
  force=True) deleted the entire user-owned settings file. This is the
  config-file mirror of the earlier shared-dispatcher fix.
- S8: _find_command_template resolved extension event commands via a broken
  registry lookup (the registry stores per-agent registered_commands
  name-lists, not a {name, file} map) and a file-stem scan that only matched
  when the .md stem equaled the command name. A manifest mapping
  speckit.selftest.extension -> commands/selftest.md resolved as missing. It
  now enumerates installed extensions via ExtensionManager.get_extension()
  and matches provides.commands[].name -> file, with the directory scan and
  core-template lookups kept as fallbacks.
- R3: _remove_toml_entries now validates the destination with
  _ensure_safe_destination before read/write, matching the merge path, so a
  symlink swap of .codex/config.toml after install can't make teardown
  overwrite a file outside the project.

Tests: forced full teardown preserves a user settings file; an extension
command whose file stem differs from its name resolves via the manifest;
TOML teardown rejects a symlinked config destination.

Refs: PR #3704 Copilot review 4791088500 (findings S8, S9, R3)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): subprocess cwd, shell quoting, TOML matcher escaping, Tabnine ms

Address four findings from Copilot review 4791088500:

- R1: the generated dispatcher and resolve_and_run_event_command now run
  their subprocesses with cwd set to the dispatcher-derived project root.
  Previously 'specify event run' (and the resolved script) inherited the
  agent's working directory, but event_run resolves the project via
  Path.cwd(), so a hook fired from a subdirectory targeted the wrong project
  and reported the command missing.
- R2: _dispatcher_command now shell-quotes each component (interpreter,
  command, event) for the target shell (POSIX via shlex.quote; PowerShell via
  single-quoted literals with doubled quotes). An interpreter path containing
  spaces or an extension/override command containing shell metacharacters is
  passed as a single argument instead of being reinterpreted by the native
  hook shell. Claude's  prefix is left unquoted so the
  shell still expands it (prefix + relative path are fixed, safe strings).
- R4: the Codex TOML matcher is now rendered through the shared TOML escaper
  like command, so a matcher containing a quote/backslash/newline/control
  character no longer produces malformed config.toml.
- R5: Tabnine declares events_timeout_unit='ms' (its hook schema mirrors
  Gemini's BeforeTool/AfterTool), so the 60s default becomes 60000ms instead
  of timeout: 60 (60 ms), which would terminate the dispatcher immediately.

Tests: cwd-forced execution from a subdirectory; POSIX/PowerShell quoting of
metacharacter and space-bearing components; TOML matcher with a quote parses
cleanly; Tabnine timeout converts to 60000. Updated the Copilot generation
test for the new quoted args.

Refs: PR #3704 Copilot review 4791088500 (findings R1, R2, R4, R5)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): POSIX dispatcher path constant + platform-agnostic tests

Three Windows test failures, one a real cross-OS bug:

- W1 (bug): EVENTS_DISPATCHER_REL was str(Path('.specify')/'events.py'),
  which yields '.specify\events.py' on Windows. Manifest keys are stored in
  POSIX form (.as_posix()), so 'dispatcher_rel in manifest.files' was always
  False on Windows: the shared-dispatcher manifest-claim drop was skipped and
  manifest.uninstall(force=True) deleted the dispatcher another integration
  still depended on. Make it a POSIX constant (.as_posix()) so it matches
  manifest keys on every platform.
- W2/W3 (tests): the py/ps argv assertions used endswith() and an exact
  launcher-name set that broke on Windows backslash paths and the
  pwsh.EXE/full-path launcher returned by shutil.which. Compare in POSIX form
  and match the launcher by case-insensitive stem.

Refs: PR #3704 Windows CI failures

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): override layer preservation, matcher validation, event command-ref canonicalization

Address four Copilot review findings:

- C4: a malformed override handler (e.g. "stop: []" or "stop: bad-value")
  normalizes to no handlers. Previously the entry was skipped and the override
  still adopted, so an override whose only entry was malformed silently
  disabled every built-in and extension hook. The empty-handler case now
  abandons the whole override (keeps prior layers); an explicit "events: {}"
  (no entries) remains a valid disable.
- C6: a non-mapping integration entry (e.g. "claude: bad") was coerced to
  "events: {}" and treated as a valid explicit disable. It now warns and
  abandons the override, keeping the accumulated layers. Only an explicitly
  present, mapping-valued "events" field replaces the prior layers.
- C10: matcher is now validated as a string (or absent) in both
  validate_events (manifest) and _validate_resolved_event (override). A
  non-string matcher such as "matcher: []" previously passed validation but
  crashed by_matcher.setdefault(matcher, ...) with TypeError: unhashable
  type, aborting init or refresh.
- C11: ExtensionManifest._validate now applies the same rename + alias-lift
  canonicalization to event command references that it already applies to
  hook references. An event referencing an auto-corrected command (e.g.
  my-ext.boot -> speckit.my-ext.boot) previously kept the obsolete name,
  so dispatch reported no command and the event silently no-oped.

Tests: empty-handler/non-mapping override preserves layers; non-string
matcher rejected in manifest and abandoned in override; event command ref
lifted to canonical form with a warning.

Refs: PR #3704 Copilot review (findings C4, C6, C10, C11)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): protect shared dispatcher from stale cleanup, delete Cursor version stub, non-destructive refresh

Address three Copilot review findings:

- C3: the shared .specify/events.py dispatcher is now in
  events_stale_exclusions(). It is written into every event-capable
  integration's manifest but reference-counted across them; an upgrade with
  --events false omits events.py from the new manifest, so the generic stale
  pass would delete it without the refcount check, breaking any other
  installed event-capable integration. Its deletion is left to
  remove_integration_events(), which checks the refcount.
- C5: _remove_json_entries now deletes a Spec-Kit-created Cursor file that
  retains only {"version": 1} after all owned hooks are removed (we added the
  version field), mirroring _remove_copilot_entries. Previously the generic
  remover only deleted a literally-empty object, so clean teardown left a
  generated stub behind.
- C12: refresh_integration_events now resolves first and calls
  install_integration_events once, instead of running the destructive
  _remove_native_event_hooks pre-step before resolution. A later failure
  (invalid destination, write error, formatter error) no longer destroys the
  working native config before the new one is written.
  install_integration_events already removes stale Specify-marked entries and
  handles an empty map (stripping prior hooks), so the pre-step was both
  unsafe and redundant.

Tests: dispatcher in stale exclusions; Cursor version-only stub deleted on
teardown; refresh failure preserves the pre-existing config (no pre-strip).

Refs: PR #3704 Copilot review (findings C3, C5, C12)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): host target uses POSIX quoting, Claude dispatcher double-quoted, & for windows

Address two Copilot review findings on the shell-quoting added in the prior
round (R2):

- C1: _shell_quote("host") now always uses POSIX shlex.quote, not PowerShell
  single-quoting on Windows. The single-command-string formats
  (Claude/Gemini/Qwen/Devin/Tabnine) are run via the agent's POSIX-ish shell
  (Git Bash on Windows), and a single-quoted 'python' is not invoked as a
  command by PowerShell without the call operator — so generated hooks failed
  to launch the dispatcher on Windows. Safe tokens pass through bare
  (python3, speckit.ext.cmd) on every platform. PowerShell single-quoting is
  now used only for the explicit target_os="windows" (Copilot's powershell
  field), where the quoted interpreter is prefixed with "& " so it is
  actually invoked.
- C2: Claude's ${CLAUDE_PROJECT_DIR} dispatcher path is now double-quoted
  ("${CLAUDE_PROJECT_DIR}/.specify/events.py") so the variable still expands
  (double quotes allow expansion in POSIX shells) but a project path
  containing spaces no longer word-splits and breaks dispatcher launch.

Tests: host target never emits PowerShell quotes; windows target carries the
& call operator; Claude dispatcher is double-quoted; updated Copilot
generation assertions for the &-prefixed powershell command.

Refs: PR #3704 Copilot review (findings C1, C2)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): opencode TS plugin resolves dispatcher from directory, execFileSync argv, forwards input+output

Address three Copilot review findings on the opencode TS plugin:

- C8: the dispatcher and interpreter are now resolved per-project at plugin
  load from the `directory` OpenCode passes to the plugin factory, not
  process.cwd(). OpenCode may be launched from a parent directory or host
  another workspace, in which case process.cwd() pointed at the wrong project
  and every event failed. The resolver prefers a project-local venv
  interpreter, then falls back to python3.
- C9: the dispatcher is launched with execFileSync and an argv array
  [interpreter, dispatcher, command, event] instead of a shell command string
  built by interpolating the interpreter/command/event into a template
  literal. Command/event strings are only validated as non-empty, so quotes or
  backticks could previously break the generated TypeScript and shell
  metacharacters could execute outside the dispatcher; an interpreter path
  with spaces also failed. No shell is involved now.
- C7: tool callbacks now forward both `input` and `output` to runEvent
  (combined into one JSON payload), so pre_tool_use can inspect the tool
  arguments and post_tool_use can inspect the result — the primary payload for
  those events. Previously only `input` was forwarded.

Tests: plugin resolves dispatcher/interpreter from `directory` (no
process.cwd() path.join), uses execFileSync (no shell string), and forwards
output to runEvent for both pre/post_tool_use.

Refs: PR #3704 Copilot review (findings C7, C8, C9)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): Qwen ms timeout, Devin root-nested format, Copilot agentStop

Address three Copilot review findings on adapter mappings (verified against
each agent's published hook documentation):

- U1: Qwen Code command hooks measure timeout in milliseconds (default
  60000), per the Qwen Code hooks docs. The adapter previously inherited the
  seconds default, so every generated handler got timeout: 60 (60 ms) and was
  killed before the dispatcher could start. Declare events_timeout_unit="ms".
- U2: Devin's .devin/hooks.v1.json is a root event map ({"PreToolUse": [...]})
  with no top-level "hooks" wrapper (the docs state "the hooks object is the
  entire file"). The adapter reused json-nested, which writes events under a
  "hooks" key Devin never reads. Add a json-root-nested format with a matching
  writer (_merge_json_root) and remover (_remove_json_root_entries) that
  operate on the root event keys, sharing the matcher-grouping, marker, and
  JSONC-abort behavior of the nested variants.
- U3: Copilot CLI supports the canonical per-turn stop lifecycle as native
  agentStop; add "stop": "agentStop" to the mapping so an extension's stop
  handler fires for Copilot.

Tests: Qwen timeout converts to 60000; Devin events written at the root (no
"hooks" wrapper) and teardown preserves user root entries; Copilot stop maps
to agentStop.

Refs: PR #3704 Copilot review (findings U1, U2, U3)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): collect events via validated manifest, surface refresh failures

Address two Copilot review findings:

- R1: collect_extension_events now reads events from a validated
  ExtensionManifest (whose command refs were canonicalized at install
  validation, C11) instead of the raw extension.yml YAML. Previously an
  event command ref like my-ext.boot was normalized to speckit.my-ext.boot
  during install validation, but the on-disk YAML kept the obsolete name;
  refresh then emitted it and _find_command_template could not match it,
  leaving the hook silently inert. Registry-tracked extensions use the
  validated manifest; on-disk extensions not yet in the registry fall back
  to the raw YAML (preserving the partial-staged-install scan behavior).
- R3: refresh_integration_events now accumulates per-integration failures
  and raises EventRefreshError at the end (after refreshing the others) so
  the extension lifecycle commands (add/remove/enable/disable) can't claim
  an extension was fully deactivated while a stale native hook may still be
  active. A new _refresh_events_and_warn helper surfaces the aggregated
  failures as a warning at each call site without aborting the overall
  command (the extension was already added/removed/enabled/disabled).

Tests: event command ref canonicalized via the validated manifest;
refresh failure raises EventRefreshError (aggregated) while still preserving
the pre-existing config.

Refs: PR #3704 Copilot review (findings R1, R3)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): probe venv for specify_cli before selecting it; python on Windows

Address two Copilot review findings on interpreter resolution:

- R2: the dispatcher's _find_specify and the opencode TS resolver both
  selected a project-local venv python and ran `-m specify_cli` without
  checking that specify_cli is importable there. In a typical project where
  Spec Kit is installed globally (or via uv tool) but the project has its own
  unrelated virtualenv, every event invoked that interpreter and failed
  instead of reaching the PATH `specify` fallback. Both now probe the
  candidate interpreter (subprocess `import specify_cli` / execFileSync probe)
  before selecting it, falling through to the fallback when the venv lacks
  Spec Kit.
- S2: the opencode TS PATH fallback was always `python3`, which is commonly
  unavailable on Windows. It is now `python` on Windows
  (process.platform === 'win32') and `python3` on POSIX.

Tests: the generated dispatcher contains the _has_specify_cli probe and the
PATH fallback; the opencode TS plugin probes for specify_cli and uses a
platform-appropriate PATH interpreter.

Refs: PR #3704 Copilot review (findings R2, S2)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): serialize opencode TS plugin string literals as JSON

Address Copilot review finding S1: command and matcher values come from
user/extension YAML but were interpolated into single-quoted TypeScript
literals without escaping. A quote, backslash, or backtick in a command or
matcher produced invalid generated TypeScript and could inject code into the
plugin. _build_opencode_plugin now serializes every interpolated value
(command, event name, native hook key, matcher tool names) as a JSON string
literal via json.dumps, which produces a valid double-quoted, fully-escaped
TS/JS string.

Tests: a command and matcher containing quotes/backticks render inside JSON
double-quoted literals; the dangerous single-quoted form is absent. Updated
the forwards-output test for the new double-quoted literals.

Refs: PR #3704 Copilot review (finding S1)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): thread per-handler timeout through dispatcher, bash launcher for sh on Windows

Address two Copilot review findings:

- S4: the dispatcher and inner runner both hardcoded timeout=120, so a valid
  handler configured with a timeout above 120 seconds could never run for its
  full duration. The resolved per-handler timeout now flows through the chain:
  _dispatcher_command appends it (in the integration's native unit, plus a
  small buffer) as a 4th argument; the generated dispatcher reads sys.argv[3]
  and uses it for its inner subprocess and the `event run` invocation;
  `event run` accepts a timeout argument and passes it to
  resolve_and_run_event_command, which uses it for the script subprocess.
  Defaults to 120s when absent (backward compat with already-deployed
  dispatchers that don't pass the arg).
- S5: for a project configured with the sh script type on Windows,
  subprocess.run(shell=False) cannot execute a .sh file directly (chmod
  doesn't change that). The sh variant now prefixes a bash/sh launcher
  (resolved via shutil.which) on Windows, mirroring the ps branch's
  pwsh -File handling.

Tests: dispatcher reads the timeout arg and uses it; the native command
appends the resolved timeout; the sh variant uses a launcher on Windows.

Refs: PR #3704 Copilot review (findings S4, S5)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): delete shared dispatcher when last event integration disables events

Address Copilot review finding S3: the empty-resolved-map install path
(--events false upgrade, or override disabling events) stripped prior native
hooks but left the shared dispatcher behind. Because the new manifest no
longer claims it and stale cleanup excludes it (C3), .specify/events.py
became permanently orphaned when this was the last event-capable
integration — uninstall could not remove it.

Extracted the dispatcher refcount cleanup into _cleanup_shared_dispatcher
(shared by remove_integration_events and the empty-map install path) and
called it from the empty-map path so the dispatcher is deleted when no other
installed event-capable integration's manifest references it, while still
being retained when another integration does.

Tests: an --events false upgrade of the last event integration deletes the
dispatcher; with another integration still referencing it, the dispatcher is
retained.

Refs: PR #3704 Copilot review (finding S3)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): map user_prompt_submit/stop for Gemini and Tabnine

Address two Copilot review findings on adapter mappings:

- S6: Gemini exposes BeforeAgent for the per-turn prompt-submit lifecycle
  point (verified against Gemini CLI's hooks docs — BeforeAgent fires after
  the user submits a prompt, before planning). The mapping omitted
  user_prompt_submit, so valid extension handlers were skipped. Added
  user_prompt_submit -> BeforeAgent.
- S7: Tabnine's Gemini-compatible schema also provides BeforeAgent and
  AfterAgent, but the mapping omitted user_prompt_submit and stop. Added
  user_prompt_submit -> BeforeAgent and stop -> AfterAgent so those
  extension events fire instead of being warned about and skipped.

Tests: Gemini and Tabnine mappings include BeforeAgent/AfterAgent.

Refs: PR #3704 Copilot review (findings S6, S7)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): correct timeout unit threading through dispatcher and opencode TS

Address two Copilot review findings on the per-handler timeout threading
added in the prior round (S4):

- R2: _dispatcher_command passed _native_timeout(timeout_seconds) as the
  dispatcher's 4th argument, but the dispatcher interprets that argument as
  seconds. For Gemini/Qwen/Tabnine (ms adapters), 60 seconds became 60000
  seconds (~16h). It now passes the raw seconds (no unit conversion). The
  +5s buffer moves to the native hook timeout field
  (_native_timeout(seconds + EVENT_TIMEOUT_BUFFER)) so the agent's outer cap
  fires after the dispatcher's inner subprocess timeout — letting the inner
  kill its child cleanly instead of being killed mid-flight (which orphaned
  the grandchild script process).
- S3: the opencode TS runEvent hardcoded timeout: 60000 (60s) and invoked the
  dispatcher without its timeout argument, so handlers configured above 60s
  were killed early while the inner runner defaulted to 120s. runEvent now
  accepts a timeoutSec parameter (seconds); execFileSync uses
  (timeoutSec + buffer) * 1000 ms and appends String(timeoutSec) to the
  dispatcher argv, so both layers honor the per-handler timeout.

Tests: the dispatcher arg is raw seconds for ms adapters (60, not 60000); the
native timeout field carries the buffer (65 for a 60s Claude handler); opencode
runEvent threads the per-handler timeout as the 5th argument.

Refs: PR #3704 Copilot review (findings R2, S3)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): skip disabled extensions in _find_command_template and disk fallback

Address Copilot review finding S1: _find_command_template resolved event
commands without filtering enabled: false — the registry loop used
registry.keys() and the raw directory fallback could also rediscover
disabled extensions. If native cleanup is skipped (e.g. a JSONC config
cannot be parsed), a stale hook would therefore continue executing a
disabled extension.

Extracted the disabled-ID logic into _disabled_extension_ids (shared with
collect_extension_events) and applied it to both the manifest-resolution
loop and the on-disk fallback scan in _find_command_template, so a disabled
extension's command is never resolved for dispatch.

Tests: a disabled extension's command resolves to None via both the manifest
loop and the disk-fallback path.

Refs: PR #3704 Copilot review (finding S1)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): delete shared dispatcher regardless of fresh manifest claim

Address Copilot review finding S2: _cleanup_shared_dispatcher gated the
no-other-references deletion on `dispatcher_rel in manifest.files`. An
`integration upgrade --integration-options "--events false"` passes a fresh
manifest (created in _migrate_commands) that never recorded the dispatcher,
so the condition was false even though the old on-disk manifest owned the
file — and stale cleanup explicitly excludes it (C3), leaving
.specify/events.py orphaned after the last integration disabled events.

The refcount deletion now runs independently of whether the new manifest
contains the key; manifest.remove() stays conditional (a no-op when the key
is absent).

Tests: an upgrade passing a fresh manifest (no dispatcher claim) still
deletes the shared dispatcher when no other integration references it.

Refs: PR #3704 Copilot review (finding S2)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): refresh native event config after extension update

Address Copilot review finding S4: the _refresh_events_and_warn helper was
wired to extension add/remove/enable/disable, but not to extension_update,
which replaces the installed extension.yml (remove + install_from_zip).
If an update adds, removes, or changes event declarations, native configs
remained stale until a manual integration upgrade.

extension_update now refreshes once after the update loop finalizes its
successful updates (skipped on rollback/failure), mirroring the other
lifecycle commands.

Refs: PR #3704 Copilot review (finding S4)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): make the dispatcher self-contained for one-time/temporary installs

Address Copilot review finding R1: the dispatcher required a persistent
`specify` executable at runtime. The supported one-time flow runs
`specify init` through a temporary `uvx` environment that is discarded, so
generated hooks later reached the PATH fallback with no `specify` on PATH
and every event failed.

The generated .specify/events.py is now self-contained:

- Preferred path: it imports specify_cli.events.resolve_and_run_event_command
  when the package is importable (durable pip/pipx/uv-tool install), which
  handles extension manifests whose file stem differs from the command name
  and the project's custom script selection, staying in sync with the CLI.
- Fallback path: an inline stdlib-only resolver finds the command template,
  parses its scripts: frontmatter, resolves the project's script variant
  (reading .specify/init-options.json directly), and runs the script with
  the correct launcher (pwsh/bash/interpreter), so one-time and temporary
  installs work without a persistent `specify` executable on PATH.

The `event run` CLI command remains available for manual use; the dispatcher
no longer depends on it.

Tests: the dispatcher delegates to specify_cli when importable and falls back
to the inline resolver when it is not; the inline fallback finds the command
template and runs its script end-to-end (shadowing specify_cli with an empty
package to force the fallback); the preferred path also runs end-to-end.

Refs: PR #3704 Copilot review (finding R1)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): validate safe destination on all removers and teardown unlinks

Address Copilot review findings (inline #1, suppressed #2, #3):

- Guard all removers (_remove_json_entries, _remove_copilot_entries,
  _remove_json_root_entries, _remove_opencode_entries, _remove_native_event_hooks),
  _cleanup_shared_dispatcher, and remove_integration_events with
  _ensure_safe_destination(dst) before reading, rewriting, or unlinking.
- Prevents teardown or removal operations from overwriting or unlinking external
  files if a config file, plugin path, or .specify directory is replaced with
  a symlink post-installation.

Tests: added unit tests in TestSafeWriteDestination covering JSON config,
OpenCode plugin, and TOML teardown symlink rejection.

Refs: PR #3704 Copilot review (findings inline #1, suppressed #2, #3)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): manifest-driven resolution and disabled-extension filter in dispatcher template

Address Copilot review finding (suppressed #1):

- In _EVENTS_DISPATCHER_TEMPLATE's _find_command_template, read
  .specify/extensions/.registry to identify disabled extensions (enabled == false).
- Parse provides.commands in each enabled extension's extension.yml to match
  command_name to its declared file, so commands whose file stem differs
  from the command name (e.g. speckit.selftest.extension -> commands/selftest.md)
  resolve correctly when specify_cli is unavailable (one-time uvx installs).
- Skip disabled extensions in both manifest-driven and on-disk fallback scans.

Refs: PR #3704 Copilot review (finding suppressed #1)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): positive integer timeout validation and OpenCode multi-handler error aggregation

Address Copilot review findings (suppressed #4, #6):

- In validate_events and _validate_resolved_event, validate that timeout (when
  present) is a positive integer (isinstance(t, int) and not isinstance(t, bool)
  and t > 0). Rejects string, boolean, zero, or negative timeouts at manifest
  and override validation time instead of crashing during setup/refresh.
- In _build_opencode_plugin, wrap each runEvent invocation inside _ev() in a
  try/catch block, collect error messages, and throw an aggregate error at the
  end if any handler failed. Guarantees that all handlers for an event execute
  to completion even if an earlier handler throws.

Tests: added TestTimeoutValidation testing string, boolean, and zero timeout
rejections; updated OpenCode plugin merging tests for try/catch error collection.

Refs: PR #3704 Copilot review (findings suppressed #4, #6)

Assisted-by: opencode (model: glm-5.2, autonomous)
2026-07-29 08:00:26 -05:00

10256 lines
397 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Unit tests for the extension system.
Tests cover:
- Extension manifest validation
- Extension registry operations
- Extension manager installation/removal
- Command registration
- Catalog stack (multi-catalog support)
"""
import io
import pytest
import json
import os
import platform
import tempfile
import shutil
import tomllib
from contextlib import contextmanager
from pathlib import Path
from datetime import datetime, timezone
from unittest.mock import MagicMock
from tests.conftest import strip_ansi
from tests.http_helpers import route_opener_open_through_urlopen # noqa: F401
from specify_cli import extensions as _ext_module
from specify_cli.extensions import (
CatalogEntry,
CORE_COMMAND_NAMES,
DEFAULT_HOOK_PRIORITY,
VALID_EFFECTS,
ExtensionManifest,
ExtensionRegistry,
ExtensionManager,
CommandRegistrar,
ConfigManager,
HookExecutor,
ExtensionCatalog,
ExtensionError,
ValidationError,
CompatibilityError,
normalize_priority,
)
from specify_cli._utils import version_satisfies
# Minimal valid ZIP (empty end-of-central-directory record). Passes
# zipfile.is_zipfile() so --from download tests exercise the content guard.
_MINIMAL_ZIP_BYTES = b"PK\x05\x06" + b"\x00" * 18
def can_create_symlink(tmp_path: Path) -> bool:
"""Return True when the current platform/user can create file symlinks."""
target = tmp_path / "symlink-target.txt"
link = tmp_path / "symlink-link.txt"
target.write_text("ok", encoding="utf-8")
try:
os.symlink(target, link)
except OSError:
return False
return link.is_symlink()
# ===== Fixtures =====
@pytest.fixture
def temp_dir():
"""Create a temporary directory for tests."""
tmpdir = tempfile.mkdtemp()
yield Path(tmpdir)
shutil.rmtree(tmpdir)
@pytest.fixture
def valid_manifest_data():
"""Valid extension manifest data."""
return {
"schema_version": "1.0",
"extension": {
"id": "test-ext",
"name": "Test Extension",
"version": "1.0.0",
"description": "A test extension",
"author": "Test Author",
"repository": "https://github.com/test/test-ext",
"license": "MIT",
},
"requires": {
"speckit_version": ">=0.1.0",
"commands": ["speckit.tasks"],
},
"provides": {
"commands": [
{
"name": "speckit.test-ext.hello",
"file": "commands/hello.md",
"description": "Test command",
}
]
},
"hooks": {
"after_tasks": {
"command": "speckit.test-ext.hello",
"optional": True,
"prompt": "Run test?",
}
},
"tags": ["testing", "example"],
}
@pytest.fixture
def extension_dir(temp_dir, valid_manifest_data):
"""Create a complete extension directory structure."""
ext_dir = temp_dir / "test-ext"
ext_dir.mkdir()
# Write manifest
import yaml
manifest_path = ext_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
# Create commands directory
commands_dir = ext_dir / "commands"
commands_dir.mkdir()
# Write command file
cmd_file = commands_dir / "hello.md"
cmd_file.write_text("""---
description: "Test hello command"
---
# Test Hello Command
$ARGUMENTS
""")
return ext_dir
@pytest.fixture
def project_dir(temp_dir):
"""Create a mock spec-kit project directory."""
proj_dir = temp_dir / "project"
proj_dir.mkdir()
# Create .specify directory
specify_dir = proj_dir / ".specify"
specify_dir.mkdir()
return proj_dir
# ===== normalize_priority Tests =====
class TestNormalizePriority:
"""Test normalize_priority helper function."""
def test_valid_integer(self):
"""Test with valid integer priority."""
assert normalize_priority(5) == 5
assert normalize_priority(1) == 1
assert normalize_priority(100) == 100
def test_valid_string_number(self):
"""Test with string that can be converted to int."""
assert normalize_priority("5") == 5
assert normalize_priority("10") == 10
def test_zero_returns_default(self):
"""Test that zero priority returns default."""
assert normalize_priority(0) == 10
assert normalize_priority(0, default=5) == 5
def test_negative_returns_default(self):
"""Test that negative priority returns default."""
assert normalize_priority(-1) == 10
assert normalize_priority(-100, default=5) == 5
def test_none_returns_default(self):
"""Test that None returns default."""
assert normalize_priority(None) == 10
assert normalize_priority(None, default=5) == 5
def test_invalid_string_returns_default(self):
"""Test that non-numeric string returns default."""
assert normalize_priority("invalid") == 10
assert normalize_priority("abc", default=5) == 5
def test_float_truncates(self):
"""Test that float is truncated to int."""
assert normalize_priority(5.9) == 5
assert normalize_priority(3.1) == 3
def test_empty_string_returns_default(self):
"""Test that empty string returns default."""
assert normalize_priority("") == 10
def test_custom_default(self):
"""Test custom default value."""
assert normalize_priority(None, default=20) == 20
assert normalize_priority("invalid", default=1) == 1
def test_boolean_returns_default(self):
"""Booleans fall back to the default rather than acting as int 0/1."""
assert normalize_priority(True) == 10
assert normalize_priority(False) == 10
assert normalize_priority(True, default=5) == 5
# ===== ExtensionManifest Tests =====
class TestExtensionManifest:
"""Test ExtensionManifest validation and parsing."""
def test_valid_manifest(self, extension_dir):
"""Test loading a valid manifest."""
manifest_path = extension_dir / "extension.yml"
manifest = ExtensionManifest(manifest_path)
assert manifest.id == "test-ext"
assert manifest.name == "Test Extension"
assert manifest.version == "1.0.0"
assert manifest.description == "A test extension"
assert len(manifest.commands) == 1
assert manifest.commands[0]["name"] == "speckit.test-ext.hello"
def test_core_command_names_match_bundled_templates(self):
"""Core command reservations should stay aligned with bundled templates."""
commands_dir = Path(__file__).resolve().parent.parent / "templates" / "commands"
expected = {
command_file.stem
for command_file in commands_dir.iterdir()
if command_file.is_file() and command_file.suffix == ".md"
}
assert CORE_COMMAND_NAMES == expected
def test_load_core_command_names_discovers_from_source_checkout(self, monkeypatch):
"""Discovery must actually read the repo-root templates, not silently
fall back (#3274).
The fallback set happens to equal the real command stems today, so an
equality check against the live tree cannot tell a working loader apart
from a dead one. Point ``_repo_root`` at a temp tree with *different*
command names: the old off-by-one path math read nothing and returned
the baked-in fallback; the fixed loader returns the temp stems.
"""
from specify_cli.extensions import (
_load_core_command_names,
_FALLBACK_CORE_COMMAND_NAMES,
)
import specify_cli.extensions as ext
with tempfile.TemporaryDirectory() as tmp:
commands = Path(tmp) / "templates" / "commands"
commands.mkdir(parents=True)
(commands / "widget.md").write_text("# widget", encoding="utf-8")
(commands / "gadget.md").write_text("# gadget", encoding="utf-8")
(commands / "notacommand.txt").write_text("skip me", encoding="utf-8")
# No wheel bundle in this scenario; force the source-checkout path.
monkeypatch.setattr(ext, "_locate_core_pack", lambda: None)
monkeypatch.setattr(ext, "_repo_root", lambda: Path(tmp))
result = _load_core_command_names()
assert result == {"widget", "gadget"}
assert result != _FALLBACK_CORE_COMMAND_NAMES
def test_load_core_command_names_prefers_wheel_core_pack(self, monkeypatch):
"""When a wheel ``core_pack`` bundle exists, discovery reads
``core_pack/commands`` (the force-include target) ahead of the source
tree (#3274)."""
from specify_cli.extensions import _load_core_command_names
import specify_cli.extensions as ext
with tempfile.TemporaryDirectory() as tmp:
core_pack = Path(tmp) / "core_pack"
(core_pack / "commands").mkdir(parents=True)
(core_pack / "commands" / "sprocket.md").write_text("# sprocket", encoding="utf-8")
monkeypatch.setattr(ext, "_locate_core_pack", lambda: core_pack)
# Source fallback should be ignored while the bundle resolves.
monkeypatch.setattr(ext, "_repo_root", lambda: Path(tmp) / "nonexistent")
result = _load_core_command_names()
assert result == {"sprocket"}
def test_load_core_command_names_falls_back_when_nothing_found(self, monkeypatch):
"""With neither a bundle nor a source tree, discovery returns the
baked-in fallback so validation still works (#3274)."""
from specify_cli.extensions import (
_load_core_command_names,
_FALLBACK_CORE_COMMAND_NAMES,
)
import specify_cli.extensions as ext
with tempfile.TemporaryDirectory() as tmp:
monkeypatch.setattr(ext, "_locate_core_pack", lambda: None)
monkeypatch.setattr(ext, "_repo_root", lambda: Path(tmp) / "nonexistent")
assert _load_core_command_names() == _FALLBACK_CORE_COMMAND_NAMES
def test_missing_required_field(self, temp_dir):
"""Test manifest missing required field."""
import yaml
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump({"schema_version": "1.0"}, f) # Missing 'extension'
with pytest.raises(ValidationError, match="Missing required field"):
ExtensionManifest(manifest_path)
def test_non_mapping_yaml_raises_validation_error(self, temp_dir):
"""Manifest whose YAML root is a scalar or list raises ValidationError, not TypeError."""
manifest_path = temp_dir / "extension.yml"
for bad_content in ("42\n", "[]\n", "null\n"):
manifest_path.write_text(bad_content)
with pytest.raises(ValidationError, match="YAML mapping"):
ExtensionManifest(manifest_path)
def test_utf8_non_ascii_description_loads(self, temp_dir, valid_manifest_data):
"""Regression for #2325: non-ASCII (UTF-8) description loads on any platform.
On Windows, Python's default text-mode encoding is the locale codepage
(e.g. cp1252/GBK), which raises UnicodeDecodeError on UTF-8 bytes
outside the ASCII range. The loader must open with encoding='utf-8'.
"""
import yaml
valid_manifest_data["extension"]["description"] = "中文测试 — émojis 🚀"
manifest_path = temp_dir / "extension.yml"
# Write UTF-8 bytes explicitly so the test exercises the read path,
# not the (locale-dependent) write path.
manifest_path.write_bytes(
yaml.safe_dump(valid_manifest_data, allow_unicode=True).encode("utf-8")
)
manifest = ExtensionManifest(manifest_path)
assert manifest.description == "中文测试 — émojis 🚀"
def test_invalid_utf8_bytes_raises_validation_error(self, temp_dir):
"""Negative case: file containing invalid UTF-8 bytes raises ValidationError, not raw UnicodeDecodeError."""
manifest_path = temp_dir / "extension.yml"
# 0xFF/0xFE are not valid UTF-8 lead bytes.
manifest_path.write_bytes(b"\xff\xfe not valid utf-8 \xff\n")
with pytest.raises(ValidationError, match="not valid UTF-8"):
ExtensionManifest(manifest_path)
def test_invalid_extension_id(self, temp_dir, valid_manifest_data):
"""Test manifest with invalid extension ID format."""
import yaml
valid_manifest_data["extension"]["id"] = "Invalid_ID" # Uppercase not allowed
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="Invalid extension ID"):
ExtensionManifest(manifest_path)
def test_invalid_version(self, temp_dir, valid_manifest_data):
"""Test manifest with invalid semantic version."""
import yaml
valid_manifest_data["extension"]["version"] = "invalid"
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="Invalid version"):
ExtensionManifest(manifest_path)
def test_valid_category(self, temp_dir, valid_manifest_data):
"""Test manifest with various category values (free-form string)."""
import yaml
for category in ("docs", "code", "process", "integration", "visibility", "custom-category"):
valid_manifest_data["extension"]["category"] = category
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
manifest = ExtensionManifest(manifest_path)
assert manifest.category == category
def test_valid_effect(self, temp_dir, valid_manifest_data):
"""Test manifest with valid effect values."""
import yaml
for effect in sorted(VALID_EFFECTS):
valid_manifest_data["extension"]["effect"] = effect
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
manifest = ExtensionManifest(manifest_path)
assert manifest.effect == effect
def test_invalid_category(self, temp_dir, valid_manifest_data):
"""Test manifest with empty category raises ValidationError."""
import yaml
valid_manifest_data["extension"]["category"] = ""
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="Invalid extension.category"):
ExtensionManifest(manifest_path)
def test_invalid_effect(self, temp_dir, valid_manifest_data):
"""Test manifest with invalid effect raises ValidationError."""
import yaml
valid_manifest_data["extension"]["effect"] = "write-only"
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="Invalid extension.effect"):
ExtensionManifest(manifest_path)
def test_category_and_effect_optional(self, temp_dir, valid_manifest_data):
"""Test that omitting category and effect still passes validation."""
import yaml
# Ensure no category/effect in data
valid_manifest_data["extension"].pop("category", None)
valid_manifest_data["extension"].pop("effect", None)
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
manifest = ExtensionManifest(manifest_path)
assert manifest.category is None
assert manifest.effect is None
def test_invalid_command_name(self, temp_dir, valid_manifest_data):
"""Test manifest with command name that cannot be auto-corrected raises ValidationError."""
import yaml
valid_manifest_data["provides"]["commands"][0]["name"] = "invalid-name"
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="Invalid command name"):
ExtensionManifest(manifest_path)
@pytest.mark.parametrize(
"bad_file",
["../../../outside.md", "../escape.md", "a/../../escape.md", "/abs/outside.md", "C:escape.md", "C:\\Windows\\x.md", "..\\..\\escape.md"],
)
def test_command_file_traversal_rejected(self, temp_dir, valid_manifest_data, bad_file):
"""Manifest 'file' field with traversal/absolute path raises ValidationError.
Defense-in-depth for GHSA-w5fv-7w9x-7fc5.
"""
import yaml
valid_manifest_data["provides"]["commands"][0]["file"] = bad_file
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w', encoding='utf-8') as f:
yaml.safe_dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="Invalid command 'file'"):
ExtensionManifest(manifest_path)
@pytest.mark.parametrize("bad_file", [" commands/hello.md", "commands/hello.md ", "\tcommands/hello.md"])
def test_command_file_whitespace_rejected(self, temp_dir, valid_manifest_data, bad_file):
"""Manifest 'file' with leading/trailing whitespace raises ValidationError."""
import yaml
valid_manifest_data["provides"]["commands"][0]["file"] = bad_file
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w', encoding='utf-8') as f:
yaml.safe_dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="leading or trailing whitespace"):
ExtensionManifest(manifest_path)
def test_command_name_autocorrect_speckit_prefix(self, temp_dir, valid_manifest_data):
"""Test that 'speckit.command' is auto-corrected to 'speckit.{ext_id}.command'."""
import yaml
valid_manifest_data["provides"]["commands"][0]["name"] = "speckit.hello"
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
manifest = ExtensionManifest(manifest_path)
assert manifest.commands[0]["name"] == "speckit.test-ext.hello"
assert len(manifest.warnings) == 1
assert "speckit.hello" in manifest.warnings[0]
assert "speckit.test-ext.hello" in manifest.warnings[0]
def test_command_name_autocorrect_matching_ext_id_prefix(self, temp_dir, valid_manifest_data):
"""Test that '{ext_id}.command' is auto-corrected to 'speckit.{ext_id}.command'."""
import yaml
# Set ext_id to match the legacy namespace so correction is valid
valid_manifest_data["extension"]["id"] = "docguard"
valid_manifest_data["provides"]["commands"][0]["name"] = "docguard.guard"
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
manifest = ExtensionManifest(manifest_path)
assert manifest.commands[0]["name"] == "speckit.docguard.guard"
assert len(manifest.warnings) == 1
assert "docguard.guard" in manifest.warnings[0]
assert "speckit.docguard.guard" in manifest.warnings[0]
def test_command_name_mismatched_namespace_not_corrected(self, temp_dir, valid_manifest_data):
"""Test that 'X.command' is NOT corrected when X doesn't match ext_id."""
import yaml
# ext_id is "test-ext" but command uses a different namespace
valid_manifest_data["provides"]["commands"][0]["name"] = "docguard.guard"
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="Invalid command name"):
ExtensionManifest(manifest_path)
def test_alias_free_form_accepted(self, temp_dir, valid_manifest_data):
"""Aliases are free-form — a 'speckit.command' alias must be accepted unchanged."""
import yaml
valid_manifest_data["provides"]["commands"][0]["aliases"] = ["speckit.hello"]
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
manifest = ExtensionManifest(manifest_path)
assert manifest.commands[0]["aliases"] == ["speckit.hello"]
assert manifest.warnings == []
def test_valid_command_name_has_no_warnings(self, temp_dir, valid_manifest_data):
"""Test that a correctly-named command produces no warnings."""
import yaml
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
manifest = ExtensionManifest(manifest_path)
assert manifest.warnings == []
def test_no_commands_no_hooks(self, temp_dir, valid_manifest_data):
"""Test manifest with no commands and no hooks provided."""
import yaml
valid_manifest_data["provides"]["commands"] = []
valid_manifest_data.pop("hooks", None)
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="must provide at least one command, hook, or event"):
ExtensionManifest(manifest_path)
def test_hooks_only_extension(self, temp_dir, valid_manifest_data):
"""Test manifest with hooks but no commands is valid."""
import yaml
valid_manifest_data["provides"]["commands"] = []
valid_manifest_data["hooks"] = {
"after_specify": {
"command": "speckit.test-ext.notify",
"optional": True,
"prompt": "Run notification?",
}
}
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
manifest = ExtensionManifest(manifest_path)
assert manifest.id == valid_manifest_data["extension"]["id"]
assert len(manifest.commands) == 0
assert len(manifest.hooks) == 1
def test_commands_null_rejected(self, temp_dir, valid_manifest_data):
"""Test manifest with commands: null is rejected."""
import yaml
valid_manifest_data["provides"]["commands"] = None
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="Invalid provides.commands"):
ExtensionManifest(manifest_path)
@pytest.mark.parametrize("section", ["extension", "requires", "provides"])
@pytest.mark.parametrize("bad", [None, [], "text"])
def test_required_section_not_mapping_rejected(
self, temp_dir, valid_manifest_data, section, bad
):
"""A required section that is written but empty or wrongly shaped must
raise ValidationError, not a raw TypeError/AttributeError.
REQUIRED_FIELDS only checks key presence, so `provides:` with no value
passed it and then hit `None.get(...)`. That AttributeError escaped
list_installed()'s ValidationError-only "Corrupted extension" fallback,
so one bad extension made `specify extension list` exit 1 instead of
listing the others.
"""
import yaml
valid_manifest_data[section] = bad
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match=f"Invalid {section}"):
ExtensionManifest(manifest_path)
def test_empty_provides_mapping_is_still_accepted_with_hooks(
self, temp_dir, valid_manifest_data
):
"""Regression guard: `provides: {}` is a well-SHAPED mapping, so the new
shape check must not reject it — an extension may provide only hooks."""
import yaml
valid_manifest_data["provides"] = {}
assert valid_manifest_data.get("hooks"), "fixture is expected to define hooks"
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
ExtensionManifest(manifest_path) # must not raise
def test_empty_provides_and_no_hooks_keeps_its_own_message(
self, temp_dir, valid_manifest_data
):
"""...and with no hooks either, it keeps the pre-existing message rather
than the new shape error."""
import yaml
valid_manifest_data["provides"] = {}
valid_manifest_data.pop("hooks", None)
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="at least one command or hook"):
ExtensionManifest(manifest_path)
def test_hooks_not_dict_rejected(self, temp_dir, valid_manifest_data):
"""Test manifest with hooks as a list is rejected."""
import yaml
valid_manifest_data["hooks"] = ["not", "a", "dict"]
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="Invalid hooks"):
ExtensionManifest(manifest_path)
def test_non_dict_hook_entry_raises_validation_error(self, temp_dir, valid_manifest_data):
"""Non-mapping hook entries must raise ValidationError, not silently skip."""
import yaml
valid_manifest_data["hooks"]["after_tasks"] = "speckit.test-ext.hello"
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="Invalid hook 'after_tasks'"):
ExtensionManifest(manifest_path)
def test_hook_single_mapping_still_accepted(self, extension_dir):
"""Existing single-mapping hook manifests parse unchanged (regression)."""
manifest_path = extension_dir / "extension.yml"
manifest = ExtensionManifest(manifest_path)
assert "after_tasks" in manifest.hooks
assert isinstance(manifest.hooks["after_tasks"], dict)
assert manifest.hooks["after_tasks"]["command"] == "speckit.test-ext.hello"
def test_hook_list_of_mappings_accepted(self, temp_dir, valid_manifest_data):
"""A hook event may be configured as a list of mappings."""
import yaml
valid_manifest_data["provides"]["commands"].append({
"name": "speckit.test-ext.bye",
"file": "commands/bye.md",
"description": "Second test command",
})
valid_manifest_data["hooks"]["after_tasks"] = [
{"command": "speckit.test-ext.hello", "description": "first"},
{"command": "speckit.test-ext.bye", "description": "second"},
]
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w', encoding="utf-8") as f:
yaml.dump(valid_manifest_data, f)
manifest = ExtensionManifest(manifest_path)
entries = manifest.hooks["after_tasks"]
assert isinstance(entries, list)
assert [e["command"] for e in entries] == [
"speckit.test-ext.hello",
"speckit.test-ext.bye",
]
def test_hook_list_with_non_mapping_entry_rejected(self, temp_dir, valid_manifest_data):
"""A list entry that is not a mapping must raise ValidationError."""
import yaml
valid_manifest_data["hooks"]["after_tasks"] = [
{"command": "speckit.test-ext.hello"},
"not-a-mapping",
]
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w', encoding="utf-8") as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(
ValidationError,
match="Invalid hook 'after_tasks': expected a mapping or list of mappings",
):
ExtensionManifest(manifest_path)
def test_hook_list_command_refs_normalized(self, temp_dir, valid_manifest_data):
"""Alias-form command refs are lifted to canonical form for every entry
in a list hook, each emitting a warning."""
import yaml
valid_manifest_data["provides"]["commands"].append({
"name": "speckit.test-ext.bye",
"file": "commands/bye.md",
"description": "Second test command",
})
valid_manifest_data["hooks"]["after_tasks"] = [
{"command": "test-ext.hello"},
{"command": "test-ext.bye"},
]
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w', encoding="utf-8") as f:
yaml.dump(valid_manifest_data, f)
manifest = ExtensionManifest(manifest_path)
assert [e["command"] for e in manifest.hooks["after_tasks"]] == [
"speckit.test-ext.hello",
"speckit.test-ext.bye",
]
lifted = [w for w in manifest.warnings if "updated to canonical form" in w]
assert len(lifted) == 2
def test_hook_empty_list_rejected(self, temp_dir, valid_manifest_data):
"""An empty list for a hook event is rejected rather than silently
registering nothing."""
import yaml
valid_manifest_data["hooks"]["after_tasks"] = []
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w', encoding="utf-8") as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="must contain at least one entry"):
ExtensionManifest(manifest_path)
def test_hook_priority_field_validation(self, temp_dir, valid_manifest_data):
"""Hook entry ``priority`` must be a positive integer when provided."""
import yaml
manifest_path = temp_dir / "extension.yml"
valid_manifest_data["hooks"]["after_tasks"] = {
"command": "speckit.test-ext.hello",
"priority": "high",
}
with open(manifest_path, 'w', encoding="utf-8") as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="invalid 'priority'.*integer"):
ExtensionManifest(manifest_path)
valid_manifest_data["hooks"]["after_tasks"]["priority"] = 0
with open(manifest_path, 'w', encoding="utf-8") as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="invalid 'priority'.*>= 1"):
ExtensionManifest(manifest_path)
# bool is a subclass of int, so it must be rejected explicitly.
valid_manifest_data["hooks"]["after_tasks"]["priority"] = True
with open(manifest_path, 'w', encoding="utf-8") as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="invalid 'priority'.*integer"):
ExtensionManifest(manifest_path)
valid_manifest_data["hooks"]["after_tasks"]["priority"] = 5
with open(manifest_path, 'w', encoding="utf-8") as f:
yaml.dump(valid_manifest_data, f)
manifest = ExtensionManifest(manifest_path)
assert manifest.hooks["after_tasks"]["priority"] == 5
def test_manifest_hash(self, extension_dir):
"""Test manifest hash calculation."""
manifest_path = extension_dir / "extension.yml"
manifest = ExtensionManifest(manifest_path)
hash_value = manifest.get_hash()
assert hash_value.startswith("sha256:")
assert len(hash_value) > 10
# ===== ExtensionRegistry Tests =====
class TestExtensionRegistry:
"""Test ExtensionRegistry operations."""
def test_empty_registry(self, temp_dir):
"""Test creating a new empty registry."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)
assert registry.data["schema_version"] == "1.0"
assert registry.data["extensions"] == {}
assert len(registry.list()) == 0
def test_add_extension(self, temp_dir):
"""Test adding an extension to registry."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)
metadata = {
"version": "1.0.0",
"source": "local",
"enabled": True,
}
registry.add("test-ext", metadata)
assert registry.is_installed("test-ext")
ext_data = registry.get("test-ext")
assert ext_data["version"] == "1.0.0"
assert "installed_at" in ext_data
def test_remove_extension(self, temp_dir):
"""Test removing an extension from registry."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)
registry.add("test-ext", {"version": "1.0.0"})
assert registry.is_installed("test-ext")
registry.remove("test-ext")
assert not registry.is_installed("test-ext")
assert registry.get("test-ext") is None
def test_registry_persistence(self, temp_dir):
"""Test that registry persists to disk."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
# Create registry and add extension
registry1 = ExtensionRegistry(extensions_dir)
registry1.add("test-ext", {"version": "1.0.0"})
# Load new registry instance
registry2 = ExtensionRegistry(extensions_dir)
# Should still have the extension
assert registry2.is_installed("test-ext")
assert registry2.get("test-ext")["version"] == "1.0.0"
def test_update_preserves_installed_at(self, temp_dir):
"""Test that update() preserves the original installed_at timestamp."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)
registry.add("test-ext", {"version": "1.0.0", "enabled": True})
# Get original installed_at
original_data = registry.get("test-ext")
original_installed_at = original_data["installed_at"]
# Update with new metadata
registry.update("test-ext", {"version": "2.0.0", "enabled": False})
# Verify installed_at is preserved
updated_data = registry.get("test-ext")
assert updated_data["installed_at"] == original_installed_at
assert updated_data["version"] == "2.0.0"
assert updated_data["enabled"] is False
def test_update_merges_with_existing(self, temp_dir):
"""Test that update() merges new metadata with existing fields."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)
registry.add("test-ext", {
"version": "1.0.0",
"enabled": True,
"registered_commands": {"claude": ["cmd1", "cmd2"]},
})
# Update with partial metadata (only enabled field)
registry.update("test-ext", {"enabled": False})
# Verify existing fields are preserved
updated_data = registry.get("test-ext")
assert updated_data["enabled"] is False
assert updated_data["version"] == "1.0.0" # Preserved
assert updated_data["registered_commands"] == {"claude": ["cmd1", "cmd2"]} # Preserved
def test_update_raises_for_missing_extension(self, temp_dir):
"""Test that update() raises KeyError for non-installed extension."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)
with pytest.raises(KeyError, match="not installed"):
registry.update("nonexistent-ext", {"enabled": False})
def test_restore_overwrites_completely(self, temp_dir):
"""Test that restore() overwrites the registry entry completely."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)
registry.add("test-ext", {"version": "2.0.0", "enabled": True})
# Restore with complete backup data
backup_data = {
"version": "1.0.0",
"enabled": False,
"installed_at": "2024-01-01T00:00:00+00:00",
"registered_commands": {"claude": ["old-cmd"]},
}
registry.restore("test-ext", backup_data)
# Verify entry is exactly as restored
restored_data = registry.get("test-ext")
assert restored_data == backup_data
def test_restore_can_recreate_removed_entry(self, temp_dir):
"""Test that restore() can recreate an entry after remove()."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)
registry.add("test-ext", {"version": "1.0.0"})
# Save backup and remove
backup = registry.get("test-ext").copy()
registry.remove("test-ext")
assert not registry.is_installed("test-ext")
# Restore should recreate the entry
registry.restore("test-ext", backup)
assert registry.is_installed("test-ext")
assert registry.get("test-ext")["version"] == "1.0.0"
def test_restore_rejects_none_metadata(self, temp_dir):
"""Test restore() raises ValueError for None metadata."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)
with pytest.raises(ValueError, match="metadata must be a dict"):
registry.restore("test-ext", None)
def test_restore_rejects_non_dict_metadata(self, temp_dir):
"""Test restore() raises ValueError for non-dict metadata."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)
with pytest.raises(ValueError, match="metadata must be a dict"):
registry.restore("test-ext", "not-a-dict")
with pytest.raises(ValueError, match="metadata must be a dict"):
registry.restore("test-ext", ["list", "not", "dict"])
def test_restore_uses_deep_copy(self, temp_dir):
"""Test restore() deep copies metadata to prevent mutation."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)
original_metadata = {
"version": "1.0.0",
"nested": {"key": "original"},
}
registry.restore("test-ext", original_metadata)
# Mutate the original metadata after restore
original_metadata["version"] = "MUTATED"
original_metadata["nested"]["key"] = "MUTATED"
# Registry should have the original values
stored = registry.get("test-ext")
assert stored["version"] == "1.0.0"
assert stored["nested"]["key"] == "original"
def test_get_returns_deep_copy(self, temp_dir):
"""Test that get() returns deep copies for nested structures."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)
metadata = {
"version": "1.0.0",
"registered_commands": {"claude": ["cmd1"]},
}
registry.add("test-ext", metadata)
fetched = registry.get("test-ext")
fetched["registered_commands"]["claude"].append("cmd2")
# Internal registry must remain unchanged.
internal = registry.data["extensions"]["test-ext"]
assert internal["registered_commands"] == {"claude": ["cmd1"]}
def test_get_returns_none_for_corrupted_entry(self, temp_dir):
"""Test that get() returns None for corrupted (non-dict) entries."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)
# Directly corrupt the registry with non-dict entries
registry.data["extensions"]["corrupted-string"] = "not a dict"
registry.data["extensions"]["corrupted-list"] = ["not", "a", "dict"]
registry.data["extensions"]["corrupted-int"] = 42
registry._save()
# All corrupted entries should return None
assert registry.get("corrupted-string") is None
assert registry.get("corrupted-list") is None
assert registry.get("corrupted-int") is None
# Non-existent should also return None
assert registry.get("nonexistent") is None
def test_list_returns_deep_copy(self, temp_dir):
"""Test that list() returns deep copies for nested structures."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)
metadata = {
"version": "1.0.0",
"registered_commands": {"claude": ["cmd1"]},
}
registry.add("test-ext", metadata)
listed = registry.list()
listed["test-ext"]["registered_commands"]["claude"].append("cmd2")
# Internal registry must remain unchanged.
internal = registry.data["extensions"]["test-ext"]
assert internal["registered_commands"] == {"claude": ["cmd1"]}
def test_list_returns_empty_dict_for_corrupted_registry(self, temp_dir):
"""Test that list() returns empty dict when extensions is not a dict."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)
# Corrupt the registry - extensions is a list instead of dict
registry.data["extensions"] = ["not", "a", "dict"]
registry._save()
# list() should return empty dict, not crash
result = registry.list()
assert result == {}
# ===== ExtensionManager Tests =====
class TestExtensionManager:
"""Test ExtensionManager installation and removal."""
def test_check_compatibility_valid(self, extension_dir, project_dir):
"""Test compatibility check with valid version."""
manager = ExtensionManager(project_dir)
manifest = ExtensionManifest(extension_dir / "extension.yml")
# Should not raise
result = manager.check_compatibility(manifest, "0.1.0")
assert result is True
def test_check_compatibility_invalid(self, extension_dir, project_dir):
"""Test compatibility check with invalid version."""
manager = ExtensionManager(project_dir)
manifest = ExtensionManifest(extension_dir / "extension.yml")
# Requires >=0.1.0, but we have 0.0.1
with pytest.raises(CompatibilityError, match="Extension requires spec-kit"):
manager.check_compatibility(manifest, "0.0.1")
def test_check_compatibility_allows_prerelease_builds(self, extension_dir, project_dir):
"""Prerelease spec-kit builds should satisfy compatible version ranges."""
manager = ExtensionManager(project_dir)
manifest = ExtensionManifest(extension_dir / "extension.yml")
result = manager.check_compatibility(manifest, "0.8.8.dev0")
assert result is True
def test_install_from_directory(self, extension_dir, project_dir):
"""Test installing extension from directory."""
manager = ExtensionManager(project_dir)
manifest = manager.install_from_directory(
extension_dir,
"0.1.0",
register_commands=False # Skip command registration for now
)
assert manifest.id == "test-ext"
assert manager.registry.is_installed("test-ext")
# Check extension directory was copied
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
assert ext_dir.exists()
assert (ext_dir / "extension.yml").exists()
assert (ext_dir / "commands" / "hello.md").exists()
@pytest.mark.skipif(
os.name == "nt", reason="POSIX execute bits are not meaningful on Windows"
)
def test_install_restores_execute_bit_on_shipped_scripts(
self, extension_dir, project_dir
):
"""Every install route restores execute bits on shipped POSIX scripts.
``install_from_directory`` is the single sink all routes funnel through
(``install_from_zip`` delegates to it; ``extension add``, ``extension
update``, and bundle installs all call these two methods). copytree /
zipfile.extractall do not restore a stripped Unix mode, so a shipped
``*.sh`` would land non-executable and a documented
``.specify/extensions/<id>/scripts/...`` invocation would fail with
"Permission denied". Guards that every route restores the bit; fails
without the ensure_executable_scripts() call in install_from_directory.
"""
import zipfile
import tempfile
scripts = extension_dir / "scripts"
scripts.mkdir()
script = scripts / "gate.sh"
script.write_text("#!/usr/bin/env bash\necho hi\n")
script.chmod(0o644) # non-executable, as extractall/copytree may leave it
installed = (
project_dir / ".specify" / "extensions" / "test-ext" / "scripts" / "gate.sh"
)
manager = ExtensionManager(project_dir)
# Route 1 — install_from_directory (extension add --dev, bundle dir install)
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
assert os.access(installed, os.X_OK), "directory install left script non-executable"
# Route 2 — install_from_zip, force=True: the ZIP path (extension add --from,
# bundle zip) and the remove-then-reinstall shape of `extension update`.
with tempfile.TemporaryDirectory() as tmpdir:
zip_path = Path(tmpdir) / "test-ext.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
for f in extension_dir.rglob("*"):
if f.is_file():
zf.write(f, f.relative_to(extension_dir))
manager.install_from_zip(zip_path, "0.1.0", force=True)
assert os.access(installed, os.X_OK), "zip/update install left script non-executable"
def test_install_from_directory_explicitly_recovers_active_skills_dir(
self, extension_dir, project_dir, monkeypatch
):
"""Extension install should explicitly request active skills-dir recovery."""
captured = {}
def fake_register_all(
self,
manifest,
extension_dir,
project_root,
link_outputs=False,
create_missing_active_skills_dir=False,
):
captured["create_missing_active_skills_dir"] = (
create_missing_active_skills_dir
)
return {}
monkeypatch.setattr(
CommandRegistrar,
"register_commands_for_all_agents",
fake_register_all,
)
manager = ExtensionManager(project_dir)
manager.install_from_directory(extension_dir, "0.1.0", register_commands=True)
assert captured["create_missing_active_skills_dir"] is True
def test_command_registrar_default_does_not_recover_active_skills_dir(
self, extension_dir, project_dir, monkeypatch
):
"""The extension wrapper should preserve the core registrar's conservative default."""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
captured = {}
def fake_register_all(
self,
commands,
source_id,
source_dir,
project_root,
context_note=None,
link_outputs=False,
create_missing_active_skills_dir=False,
extension_id=None,
only_agent=None,
):
captured["create_missing_active_skills_dir"] = (
create_missing_active_skills_dir
)
captured["extension_id"] = extension_id
return {}
monkeypatch.setattr(
AgentCommandRegistrar,
"register_commands_for_all_agents",
fake_register_all,
)
manifest = ExtensionManifest(extension_dir / "extension.yml")
registrar = CommandRegistrar()
registrar.register_commands_for_all_agents(manifest, extension_dir, project_dir)
assert captured["create_missing_active_skills_dir"] is False
assert captured["extension_id"] == manifest.id
def test_install_duplicate(self, extension_dir, project_dir):
"""Test installing already installed extension."""
manager = ExtensionManager(project_dir)
# Install once
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
# Try to install again
with pytest.raises(ExtensionError, match="already installed"):
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
def test_install_force_reinstall(self, extension_dir, project_dir):
"""Test force-reinstalling an already-installed extension."""
manager = ExtensionManager(project_dir)
# Install once
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
assert manager.registry.is_installed("test-ext")
# Force-reinstall
manifest2 = manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False, force=True
)
assert manifest2.id == "test-ext"
assert manager.registry.is_installed("test-ext")
# Check extension directory was recreated
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
assert ext_dir.exists()
assert (ext_dir / "extension.yml").exists()
assert (ext_dir / "commands" / "hello.md").exists()
def test_install_force_config_preserved(self, extension_dir, project_dir):
"""Test that config files are preserved when force-reinstalling."""
manager = ExtensionManager(project_dir)
# Install once
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
# Create a config file in the installed extension directory
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
config_file = ext_dir / "test-ext-config.yml"
config_file.write_text("test: config")
# Force-reinstall
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False, force=True
)
# Config file should still exist after reinstall
new_config = ext_dir / "test-ext-config.yml"
assert new_config.exists()
assert new_config.read_text() == "test: config"
def test_reinstall_after_keep_config_preserves_config(
self, extension_dir, project_dir
):
"""Reinstalling after `remove --keep-config` must not overwrite preserved config."""
manager = ExtensionManager(project_dir)
# Add a packaged default config so the reinstall has a file to overwrite.
# Without the fix, the packaged default would silently win on reinstall.
packaged_config = extension_dir / "test-ext-config.yml"
packaged_config.write_text("model: default-model\nmax_iterations: 1\n")
# Install once (packaged default is copied into the installed directory)
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
# Overwrite the installed config with user-customized values
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
config_file = ext_dir / "test-ext-config.yml"
config_file.write_text("model: custom-model\nmax_iterations: 99\n")
# Remove while preserving config
manager.remove("test-ext", keep_config=True)
assert not manager.registry.is_installed("test-ext")
assert config_file.exists()
assert "custom-model" in config_file.read_text()
# Plain reinstall (no --force) — packaged default is still present in
# extension_dir, so a naive implementation would overwrite the custom values.
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
# Preserved config must survive the reinstall and beat the packaged default
assert config_file.exists()
assert "custom-model" in config_file.read_text()
assert "99" in config_file.read_text()
assert "default-model" not in config_file.read_text()
def test_reinstall_after_keep_config_preserves_local_config(
self, extension_dir, project_dir
):
"""Local config override files (*-config.local.yml) are also rescued on reinstall."""
manager = ExtensionManager(project_dir)
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
local_cfg = ext_dir / "test-ext-config.local.yml"
local_cfg.write_text("local_override: true\n")
manager.remove("test-ext", keep_config=True)
assert local_cfg.exists()
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
assert local_cfg.exists()
assert "local_override: true" in local_cfg.read_text()
def test_reinstall_after_legacy_keep_config_preserves_config(
self, extension_dir, project_dir
):
"""Pre-marker keep-config leftovers are still rescued on reinstall."""
manager = ExtensionManager(project_dir)
packaged_config = extension_dir / "test-ext-config.yml"
packaged_config.write_text("model: default-model\nmax_iterations: 1\n")
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
config_file = ext_dir / "test-ext-config.yml"
config_file.write_text("model: legacy-custom-model\nmax_iterations: 99\n")
manager.remove("test-ext", keep_config=True)
(ext_dir / ".keep-config").unlink()
assert not manager.registry.is_installed("test-ext")
assert config_file.exists()
assert "legacy-custom-model" in config_file.read_text()
packaged_config.write_text("model: upgraded-default-model\nmax_iterations: 2\n")
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
assert config_file.exists()
assert "legacy-custom-model" in config_file.read_text()
assert "99" in config_file.read_text()
assert "upgraded-default-model" not in config_file.read_text()
def test_reinstall_with_symlinked_config_rejects_install(
self, extension_dir, project_dir
):
"""A preserved symlinked config must abort reinstall, not be deleted."""
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
if not can_create_symlink(ext_dir.parent if ext_dir.parent.exists() else project_dir):
pytest.skip("Current platform/user cannot create symlinks")
manager = ExtensionManager(project_dir)
packaged_config = extension_dir / "test-ext-config.yml"
packaged_config.write_text("model: default-model\n")
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
# Replace the installed config with a symlink to a file outside dest_dir.
config_file = ext_dir / "test-ext-config.yml"
external_target = project_dir / "external-config.yml"
external_target.write_text("model: linked-model\n")
config_file.unlink()
os.symlink(external_target, config_file)
assert config_file.is_symlink()
# `remove --keep-config` follows the symlink via is_file() and keeps it.
manager.remove("test-ext", keep_config=True)
assert not manager.registry.is_installed("test-ext")
assert config_file.is_symlink()
# Plain reinstall must reject rather than silently delete the link.
with pytest.raises(ValidationError, match="is a symlink"):
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
# The symlink and its target survive; nothing was silently discarded.
assert config_file.is_symlink()
assert external_target.read_text() == "model: linked-model\n"
assert not manager.registry.is_installed("test-ext")
def test_retry_with_symlinked_live_config_aborts_and_preserves_both(
self, extension_dir, project_dir, monkeypatch
):
"""A live config replaced by a symlink on retry is a conflict, not overwritten."""
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
if not can_create_symlink(project_dir):
pytest.skip("Current platform/user cannot create symlinks")
manager = ExtensionManager(project_dir)
packaged_config = extension_dir / "test-ext-config.yml"
packaged_config.write_text("model: default-model\n")
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
config_file = ext_dir / "test-ext-config.yml"
config_file.write_text("model: custom-model\nmax_iterations: 99\n")
staged_bytes = config_file.read_bytes()
manager.remove("test-ext", keep_config=True)
assert not manager.registry.is_installed("test-ext")
staging_dir = manager._rescue_staging_dir("test-ext")
original_copytree = shutil.copytree
copytree_calls = 0
def flaky_copytree(*args, **kwargs):
nonlocal copytree_calls
copytree_calls += 1
if copytree_calls == 1:
dst = args[1]
Path(dst).mkdir(parents=True, exist_ok=True)
(Path(dst) / "_partial.txt").write_text("partial")
raise OSError("simulated disk full")
return original_copytree(*args, **kwargs)
monkeypatch.setattr(_ext_module.shutil, "copytree", flaky_copytree)
with pytest.raises(OSError, match="simulated disk full"):
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
assert staging_dir.exists()
assert (staging_dir / ".rescue-complete").exists()
# Simulate the user replacing the live config with a symlink before retry.
external_target = project_dir / "external-config.yml"
external_target.write_text("model: newer-linked-model\n")
config_file.unlink()
os.symlink(external_target, config_file)
assert config_file.is_symlink()
with pytest.raises(ValidationError, match="Preserved extension config conflict"):
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
# Both copies survive: the live symlink choice and the staged backup.
assert config_file.is_symlink()
assert external_target.read_text() == "model: newer-linked-model\n"
assert staging_dir.exists()
assert (staging_dir / "test-ext-config.yml").read_bytes() == staged_bytes
assert not manager.registry.is_installed("test-ext")
def test_copytree_failure_restores_stranded_config(
self, extension_dir, project_dir, monkeypatch
):
"""A copytree failure must not permanently lose a preserved config.
When copytree raises after the existing directory has been removed, the
rollback path must write the rescued bytes back to dest_dir and restore
the original file mode, while leaving the extension unregistered.
"""
import stat
manager = ExtensionManager(project_dir)
# Add a packaged default config so copytree would overwrite it on success.
packaged_config = extension_dir / "test-ext-config.yml"
packaged_config.write_text("model: default-model\n")
# Install once so the extension is on disk.
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
config_file = ext_dir / "test-ext-config.yml"
config_file.write_text("model: custom-model\nmax_iterations: 99\n")
# Set a known, non-default mode so we can assert it survives the rollback.
if platform.system() != "Windows":
config_file.chmod(0o640)
original_bytes = config_file.read_bytes()
original_mode = config_file.stat().st_mode
# Remove while preserving the config — it is now a stranded file.
manager.remove("test-ext", keep_config=True)
assert not manager.registry.is_installed("test-ext")
assert config_file.exists()
# Make copytree create a partial destination then raise so the rollback
# path is exercised.
def failing_copytree(src, dst, **kwargs):
Path(dst).mkdir(parents=True, exist_ok=True)
(Path(dst) / "_partial.txt").write_text("partial")
raise OSError("simulated disk full")
monkeypatch.setattr(_ext_module.shutil, "copytree", failing_copytree)
with pytest.raises(OSError, match="simulated disk full"):
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
# The preserved config must have been written back by the rollback path.
assert config_file.exists(), "rollback must recreate the config file"
assert config_file.read_bytes() == original_bytes
# On POSIX, the original file mode must be faithfully restored.
if platform.system() != "Windows":
restored_mode = config_file.stat().st_mode
assert stat.S_IMODE(restored_mode) == stat.S_IMODE(original_mode)
# The extension must remain unregistered after the failed install.
assert not manager.registry.is_installed("test-ext")
def test_extensionignore_load_failure_preserves_kept_config(
self, extension_dir, project_dir
):
"""An .extensionignore load failure must not lose a preserved config.
`.extensionignore` is loaded/validated before the rescue staging
directory is read or created (and thus before dest_dir is deleted), so a
ValidationError raised for invalid UTF-8 must abort the reinstall while
leaving the kept config authoritative in its documented location. It must
NOT publish a stale staging copy that a later retry — after the user
fixes the ignore file and edits the kept config — would reload and use to
silently overwrite the newer bytes.
"""
manager = ExtensionManager(project_dir)
packaged_config = extension_dir / "test-ext-config.yml"
packaged_config.write_text("model: default-model\n")
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
config_file = ext_dir / "test-ext-config.yml"
config_file.write_text("model: custom-model\nmax_iterations: 99\n")
original_bytes = config_file.read_bytes()
manager.remove("test-ext", keep_config=True)
assert not manager.registry.is_installed("test-ext")
assert config_file.exists()
# Author an .extensionignore that is not valid UTF-8 so the loader
# raises before rescue staging is read or created.
(extension_dir / ".extensionignore").write_bytes(b"\xff\xfe invalid\n")
with pytest.raises(ValidationError, match="not valid UTF-8"):
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
# The kept config must remain in its documented location, untouched.
assert config_file.exists(), "config must survive the ignore-load failure"
assert config_file.read_bytes() == original_bytes
assert not manager.registry.is_installed("test-ext")
# No rescue staging may have been published, so a later retry reads the
# live (possibly newly edited) config rather than stale staged bytes.
staging_dir = manager._rescue_staging_dir("test-ext")
assert not staging_dir.exists()
# Simulate the user fixing the ignore file and editing the kept config,
# then retrying: the retry must adopt the newer bytes, never a stale
# staged copy.
(extension_dir / ".extensionignore").write_text("*.log\n")
config_file.write_text("model: newer-model\n")
newer_bytes = config_file.read_bytes()
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
assert manager.registry.is_installed("test-ext")
assert config_file.read_bytes() == newer_bytes
def test_retry_after_staging_backup_restores_stranded_config(
self, extension_dir, project_dir, monkeypatch
):
"""A retry after an interrupted install restores the rescued config.
When the live config is unchanged since the interrupted attempt (it
still matches the staged backup), the retry proceeds and yields the
preserved bytes, and the staging directory is cleaned up on success.
"""
import stat
manager = ExtensionManager(project_dir)
packaged_config = extension_dir / "test-ext-config.yml"
packaged_config.write_text("model: default-model\n")
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
config_file = ext_dir / "test-ext-config.yml"
config_file.write_text("model: custom-model\nmax_iterations: 99\n")
if platform.system() != "Windows":
config_file.chmod(0o640)
original_bytes = config_file.read_bytes()
original_mode = config_file.stat().st_mode
manager.remove("test-ext", keep_config=True)
assert not manager.registry.is_installed("test-ext")
assert config_file.exists()
staging_dir = manager._rescue_staging_dir("test-ext")
assert not staging_dir.exists()
original_copytree = shutil.copytree
copytree_calls = 0
def flaky_copytree(*args, **kwargs):
nonlocal copytree_calls
copytree_calls += 1
if copytree_calls == 1:
dst = args[1]
Path(dst).mkdir(parents=True, exist_ok=True)
(Path(dst) / "_partial.txt").write_text("partial")
raise OSError("simulated disk full")
return original_copytree(*args, **kwargs)
monkeypatch.setattr(_ext_module.shutil, "copytree", flaky_copytree)
with pytest.raises(OSError, match="simulated disk full"):
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
assert staging_dir.exists()
assert (staging_dir / ".rescue-complete").exists()
assert (staging_dir / "test-ext-config.yml").exists()
# The rollback restored the preserved bytes to the live config, so it
# still agrees with the staged backup — the retry proceeds normally.
assert config_file.read_bytes() == original_bytes
manifest = manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
assert manifest.id == "test-ext"
assert manager.registry.is_installed("test-ext")
assert config_file.read_bytes() == original_bytes
assert not (ext_dir / "_partial.txt").exists()
assert not staging_dir.exists()
if platform.system() != "Windows":
restored_mode = config_file.stat().st_mode
assert stat.S_IMODE(restored_mode) == stat.S_IMODE(original_mode)
def test_retry_restores_config_from_staging_when_live_absent(
self, extension_dir, project_dir, monkeypatch
):
"""Retry succeeds using only staging when the live config is absent.
After a copytree failure, staging is complete and the rollback writes
the config back to dest_dir. If a power loss interrupts that rollback
the config may be absent on the next attempt. The retry-from-staging
branch (``if staging_is_complete``) must restore the config from staging
alone so the original bytes and mode are recovered even when no live copy
is present. This is the critical path that distinguishes the staging
branch from the live-dir fallback.
"""
import stat
manager = ExtensionManager(project_dir)
packaged_config = extension_dir / "test-ext-config.yml"
packaged_config.write_text("model: default-model\n")
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
config_file = ext_dir / "test-ext-config.yml"
config_file.write_text("model: custom-model\nmax_iterations: 99\n")
if platform.system() != "Windows":
config_file.chmod(0o640)
original_bytes = config_file.read_bytes()
original_mode = config_file.stat().st_mode
manager.remove("test-ext", keep_config=True)
assert not manager.registry.is_installed("test-ext")
assert config_file.exists()
staging_dir = manager._rescue_staging_dir("test-ext")
assert not staging_dir.exists()
original_copytree = shutil.copytree
copytree_calls = 0
def flaky_copytree(*args, **kwargs):
nonlocal copytree_calls
copytree_calls += 1
if copytree_calls == 1:
dst = args[1]
Path(dst).mkdir(parents=True, exist_ok=True)
(Path(dst) / "_partial.txt").write_text("partial")
raise OSError("simulated disk full")
return original_copytree(*args, **kwargs)
monkeypatch.setattr(_ext_module.shutil, "copytree", flaky_copytree)
with pytest.raises(OSError, match="simulated disk full"):
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
# Staging is complete after the first failure.
assert staging_dir.exists()
assert (staging_dir / ".rescue-complete").exists()
assert (staging_dir / "test-ext-config.yml").exists()
# Simulate a power loss that prevented the rollback from writing the
# config back: delete the live copy so the retry must rely on staging.
config_file.unlink()
assert not config_file.exists()
manifest = manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
# The staging branch must restore the original bytes even though no live
# copy was present — proving staging (not the live-dir fallback) was used.
assert manifest.id == "test-ext"
assert manager.registry.is_installed("test-ext")
assert config_file.read_bytes() == original_bytes
assert not (ext_dir / "_partial.txt").exists()
assert not staging_dir.exists()
if platform.system() != "Windows":
restored_mode = config_file.stat().st_mode
assert stat.S_IMODE(restored_mode) == stat.S_IMODE(original_mode)
def test_retry_ignores_live_only_packaged_config_after_registry_failure(
self, extension_dir, project_dir, monkeypatch
):
"""Retry should keep packaged live-only configs that still match source."""
manager = ExtensionManager(project_dir)
packaged_config = extension_dir / "test-ext-config.yml"
packaged_config.write_text("model: default-model\n")
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
config_file = ext_dir / "test-ext-config.yml"
config_file.write_text("model: custom-model\nmax_iterations: 99\n")
original_bytes = config_file.read_bytes()
manager.remove("test-ext", keep_config=True)
assert not manager.registry.is_installed("test-ext")
live_only_packaged = extension_dir / "test-ext-config.local.yml"
live_only_packaged.write_text("new_default: true\n")
original_add = manager.registry.add
add_calls = 0
def flaky_add(*args, **kwargs):
nonlocal add_calls
add_calls += 1
if add_calls == 1:
raise OSError("simulated registry failure")
return original_add(*args, **kwargs)
monkeypatch.setattr(manager.registry, "add", flaky_add)
with pytest.raises(OSError, match="simulated registry failure"):
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
staging_dir = manager._rescue_staging_dir("test-ext")
assert staging_dir.exists()
assert (staging_dir / ".rescue-complete").exists()
assert config_file.read_bytes() == original_bytes
assert (
ext_dir / "test-ext-config.local.yml"
).read_text() == "new_default: true\n"
manifest = manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
assert manifest.id == "test-ext"
assert manager.registry.is_installed("test-ext")
assert config_file.read_bytes() == original_bytes
assert (
ext_dir / "test-ext-config.local.yml"
).read_text() == "new_default: true\n"
assert not staging_dir.exists()
def test_retry_with_edited_live_config_aborts_and_preserves_both(
self, extension_dir, project_dir, monkeypatch
):
"""A retry must not silently overwrite a config edited after a crash.
A complete staging directory proves only that staging finished, not
that dest_dir was modified. If the user edits the live kept config
before retrying, the retry must detect the divergence, preserve both
copies, and abort rather than blindly restoring the older staged bytes.
"""
manager = ExtensionManager(project_dir)
packaged_config = extension_dir / "test-ext-config.yml"
packaged_config.write_text("model: default-model\n")
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
config_file = ext_dir / "test-ext-config.yml"
config_file.write_text("model: custom-model\nmax_iterations: 99\n")
staged_bytes = config_file.read_bytes()
manager.remove("test-ext", keep_config=True)
assert not manager.registry.is_installed("test-ext")
staging_dir = manager._rescue_staging_dir("test-ext")
original_copytree = shutil.copytree
copytree_calls = 0
def flaky_copytree(*args, **kwargs):
nonlocal copytree_calls
copytree_calls += 1
if copytree_calls == 1:
dst = args[1]
Path(dst).mkdir(parents=True, exist_ok=True)
(Path(dst) / "_partial.txt").write_text("partial")
raise OSError("simulated disk full")
return original_copytree(*args, **kwargs)
monkeypatch.setattr(_ext_module.shutil, "copytree", flaky_copytree)
with pytest.raises(OSError, match="simulated disk full"):
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
assert staging_dir.exists()
assert (staging_dir / ".rescue-complete").exists()
# Simulate the user editing the live config before retrying so it now
# diverges from the staged backup.
config_file.write_text("model: newer-edited-model\n")
edited_bytes = config_file.read_bytes()
assert edited_bytes != staged_bytes
with pytest.raises(ValidationError, match="Preserved extension config conflict"):
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
# Both copies must survive: the edited live config and the staged backup.
assert config_file.read_bytes() == edited_bytes
assert staging_dir.exists()
assert (staging_dir / "test-ext-config.yml").read_bytes() == staged_bytes
assert not manager.registry.is_installed("test-ext")
@pytest.mark.parametrize(
"failure_mode",
[
pytest.param("mkdir", id="mkdir"),
pytest.param("os_open", id="os_open"),
pytest.param("fsync", id="fsync"),
],
)
def test_staging_failure_aborts_before_dest_dir_removal(
self, extension_dir, project_dir, monkeypatch, failure_mode
):
"""Staging failures abort the install before dest_dir is removed.
When mkdir, os.open, or fsync fails while publishing rescue staging,
the install must abort before removing dest_dir so the preserved
config bytes remain authoritative, any partial staging is cleaned up
rather than trusted on retry, and the extension stays unregistered.
"""
import errno as _errno
manager = ExtensionManager(project_dir)
packaged_config = extension_dir / "test-ext-config.yml"
packaged_config.write_text("model: default-model\n")
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
config_file = ext_dir / "test-ext-config.yml"
config_file.write_text("model: custom-model\nmax_iterations: 99\n")
original_bytes = config_file.read_bytes()
manager.remove("test-ext", keep_config=True)
assert not manager.registry.is_installed("test-ext")
assert config_file.exists()
staging_dir = manager._rescue_staging_dir("test-ext")
assert not staging_dir.exists()
if failure_mode == "mkdir":
original_mkdir = Path.mkdir
def failing_mkdir(self_path, *args, **kwargs):
if self_path == staging_dir:
raise OSError("staging mkdir failed")
return original_mkdir(self_path, *args, **kwargs)
monkeypatch.setattr(Path, "mkdir", failing_mkdir)
elif failure_mode == "os_open":
original_os_open = _ext_module.os.open
def failing_os_open(path, flags, mode=0o777, *args, **kwargs):
# Fail only for file creation (O_CREAT) inside the staging
# directory so other os.open calls (e.g. directory fsync) are
# unaffected.
if str(staging_dir) in str(path) and (flags & os.O_CREAT):
raise OSError(_errno.ENOSPC, "No space left on device")
return original_os_open(path, flags, mode, *args, **kwargs)
monkeypatch.setattr(_ext_module.os, "open", failing_os_open)
else: # "fsync"
def failing_fsync_fd(fd: int) -> None:
# Simulate a real storage error (EIO) that the helper propagates.
raise OSError(_errno.EIO, "Input/output error")
monkeypatch.setattr(_ext_module, "_fsync_fd", failing_fsync_fd)
with pytest.raises(OSError):
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
# dest_dir must still exist — the install aborted before rmtree.
assert ext_dir.exists(), "dest_dir must survive a staging failure"
assert config_file.read_bytes() == original_bytes, (
"preserved config must remain authoritative"
)
# Partial staging must have been cleaned up and not left as complete.
assert not staging_dir.exists() or not (
staging_dir / ".rescue-complete"
).exists(), "incomplete staging must not be trusted"
assert not manager.registry.is_installed("test-ext")
def test_rescue_staging_dir_is_fixed_length_for_long_ids(self, project_dir):
"""The rescue staging component length must not grow with the ID length.
Manifest validation caps the ID character set but not its length, so a
very long (but valid) ID must not lengthen the single staging path
component past a filesystem's per-component byte limit.
"""
manager = ExtensionManager(project_dir)
short_dir = manager._rescue_staging_dir("a")
long_id = "a" * 250
long_dir = manager._rescue_staging_dir(long_id)
# Same fixed component length regardless of ID length.
assert len(short_dir.name) == len(long_dir.name)
# Comfortably within the common 255-byte component limit.
assert len(long_dir.name.encode("utf-8")) <= 255
# Distinct IDs still map to distinct staging directories.
assert manager._rescue_staging_dir("b") != short_dir
def test_failed_install_without_keep_config_does_not_rescue_defaults(
self, extension_dir, project_dir, monkeypatch
):
"""A dir left by a partially-failed install must not trigger the rescue path.
Any install that copies files but then fails during command, skill, or
hook registration also leaves a complete dest_dir with no registry entry.
On a later retry from an updated package this branch must not treat the
previous package's default config as user-preserved data and restore it
over the new defaults. Only directories explicitly left by
``remove --keep-config`` (which writes a ``.keep-config`` marker) should
trigger the rescue path.
"""
manager = ExtensionManager(project_dir)
packaged_config = extension_dir / "test-ext-config.yml"
packaged_config.write_text("model: default-model\n")
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
config_file = ext_dir / "test-ext-config.yml"
# Simulate a partially-failed install: the extension directory is present
# with the packaged default config but there is no .keep-config marker and
# the extension is not in the registry. This matches what happens when
# copytree succeeds but command/hook registration raises afterwards.
manager.registry.remove("test-ext")
assert not manager.registry.is_installed("test-ext")
assert ext_dir.exists()
assert not (ext_dir / ".keep-config").exists()
# Update the packaged config so a retry with the new package would use
# different defaults — the old defaults must NOT be rescued.
packaged_config.write_text("model: updated-default-model\n")
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
assert manager.registry.is_installed("test-ext")
# The new packaged default must win; the old default was not user data.
assert config_file.read_text() == "model: updated-default-model\n"
def test_install_force_without_existing(self, extension_dir, project_dir):
"""Test force-install when extension is NOT already installed (works normally)."""
manager = ExtensionManager(project_dir)
manifest = manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False, force=True
)
assert manifest.id == "test-ext"
assert manager.registry.is_installed("test-ext")
def test_install_from_install_dir_is_rejected_without_data_loss(
self, extension_dir, project_dir
):
"""Installing from an extension's own install dir must fail without
deleting it (regression for issue #2990)."""
manager = ExtensionManager(project_dir)
# Install once so the extension lives at its install destination.
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
install_dir = project_dir / ".specify" / "extensions" / "test-ext"
assert install_dir.exists()
# Re-installing from that same directory with --force must be rejected.
with pytest.raises(ValidationError, match="install destination"):
manager.install_from_directory(
install_dir, "0.1.0", register_commands=False, force=True
)
# The directory and its contents must be left intact (no data loss).
assert install_dir.exists()
assert (install_dir / "extension.yml").exists()
assert (install_dir / "commands" / "hello.md").exists()
def test_install_from_install_dir_is_rejected_when_resolve_fails(
self, extension_dir, project_dir, monkeypatch
):
"""Resolution failures must not bypass the self-install guard."""
manager = ExtensionManager(project_dir)
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
install_dir = project_dir / ".specify" / "extensions" / "test-ext"
original_resolve = Path.resolve
def fail_resolve(self, *args, **kwargs):
if self in {install_dir, manager.extensions_dir / "test-ext"}:
raise OSError("cannot resolve path")
return original_resolve(self, *args, **kwargs)
monkeypatch.setattr(Path, "resolve", fail_resolve)
with pytest.raises(ValidationError, match="install destination"):
manager.install_from_directory(
install_dir, "0.1.0", register_commands=False, force=True
)
assert install_dir.exists()
assert (install_dir / "extension.yml").exists()
assert (install_dir / "commands" / "hello.md").exists()
def test_install_zip_force_reinstall(self, extension_dir, project_dir):
"""Test force-reinstalling from ZIP when already installed."""
import zipfile
import tempfile
manager = ExtensionManager(project_dir)
# Install once from directory
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
# Create a ZIP of the extension in a temp directory (not NamedTemporaryFile,
# which can fail on Windows due to file locking).
with tempfile.TemporaryDirectory() as tmpdir:
zip_path = Path(tmpdir) / "test-ext.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
for f in extension_dir.rglob("*"):
if f.is_file():
zf.write(f, f.relative_to(extension_dir))
# Force-reinstall from ZIP
manifest = manager.install_from_zip(
zip_path, "0.1.0", force=True
)
assert manifest.id == "test-ext"
assert manager.registry.is_installed("test-ext")
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
assert ext_dir.exists()
def test_install_from_zip_rejects_symlink_entry(
self, extension_dir, project_dir, temp_dir
):
"""Extension ZIPs delegate to the shared symlink-safe extractor."""
import stat
import zipfile
zip_path = temp_dir / "symlink-extension.zip"
link = zipfile.ZipInfo("templates/escape")
link.create_system = 3
link.external_attr = (stat.S_IFLNK | 0o777) << 16
with zipfile.ZipFile(zip_path, "w") as zf:
for file_path in extension_dir.rglob("*"):
if file_path.is_file():
zf.write(file_path, file_path.relative_to(extension_dir))
zf.writestr(link, "../../outside")
manager = ExtensionManager(project_dir)
with pytest.raises(ValidationError, match="Unsafe symlink"):
manager.install_from_zip(zip_path, "0.1.0")
assert not manager.registry.is_installed("test-ext")
def test_install_duplicate_error_mentions_force(self, extension_dir, project_dir):
"""Test that duplicate install error message suggests --force."""
manager = ExtensionManager(project_dir)
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
with pytest.raises(ExtensionError, match="--force"):
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
def test_install_rejects_extension_id_in_core_namespace(self, temp_dir, project_dir):
"""Install should reject extension IDs that shadow core commands."""
import yaml
ext_dir = temp_dir / "analyze-ext"
ext_dir.mkdir()
(ext_dir / "commands").mkdir()
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "analyze",
"name": "Analyze Extension",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.analyze.extra",
"file": "commands/cmd.md",
}
]
},
}
(ext_dir / "extension.yml").write_text(yaml.dump(manifest_data))
(ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody")
manager = ExtensionManager(project_dir)
with pytest.raises(ValidationError, match="conflicts with core command namespace"):
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
def test_install_accepts_free_form_alias(self, temp_dir, project_dir):
"""Aliases are free-form — a short 'speckit.shortcut' alias must be preserved unchanged."""
import yaml
ext_dir = temp_dir / "alias-shortcut"
ext_dir.mkdir()
(ext_dir / "commands").mkdir()
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "alias-shortcut",
"name": "Alias Shortcut",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.alias-shortcut.cmd",
"file": "commands/cmd.md",
"aliases": ["speckit.shortcut"],
}
]
},
}
(ext_dir / "extension.yml").write_text(yaml.dump(manifest_data))
(ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody")
manager = ExtensionManager(project_dir)
manifest = manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
assert manifest.commands[0]["aliases"] == ["speckit.shortcut"]
assert manifest.warnings == []
def test_install_rejects_namespace_squatting(self, temp_dir, project_dir):
"""Install should reject commands and aliases outside the extension namespace."""
import yaml
ext_dir = temp_dir / "squat-ext"
ext_dir.mkdir()
(ext_dir / "commands").mkdir()
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "squat-ext",
"name": "Squat Extension",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.other-ext.cmd",
"file": "commands/cmd.md",
"aliases": ["speckit.squat-ext.ok"],
}
]
},
}
(ext_dir / "extension.yml").write_text(yaml.dump(manifest_data))
(ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody")
manager = ExtensionManager(project_dir)
with pytest.raises(ValidationError, match="must use extension namespace 'squat-ext'"):
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
def test_install_rejects_command_collision_with_installed_extension(self, temp_dir, project_dir):
"""Install should reject names already claimed by an installed legacy extension."""
import yaml
first_dir = temp_dir / "ext-one"
first_dir.mkdir()
(first_dir / "commands").mkdir()
first_manifest = {
"schema_version": "1.0",
"extension": {
"id": "ext-one",
"name": "Extension One",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.ext-one.sync",
"file": "commands/cmd.md",
"aliases": ["speckit.shared.sync"],
}
]
},
}
(first_dir / "extension.yml").write_text(yaml.dump(first_manifest))
(first_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody")
installed_ext_dir = project_dir / ".specify" / "extensions" / "ext-one"
installed_ext_dir.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(first_dir, installed_ext_dir)
second_dir = temp_dir / "ext-two"
second_dir.mkdir()
(second_dir / "commands").mkdir()
second_manifest = {
"schema_version": "1.0",
"extension": {
"id": "shared",
"name": "Shared Extension",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.shared.sync",
"file": "commands/cmd.md",
}
]
},
}
(second_dir / "extension.yml").write_text(yaml.dump(second_manifest))
(second_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody")
manager = ExtensionManager(project_dir)
manager.registry.add("ext-one", {"version": "1.0.0", "source": "local"})
with pytest.raises(ValidationError, match="already provided by extension 'ext-one'"):
manager.install_from_directory(second_dir, "0.1.0", register_commands=False)
def test_remove_extension(self, extension_dir, project_dir):
"""Test removing an installed extension."""
manager = ExtensionManager(project_dir)
# Install extension
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
assert ext_dir.exists()
# Remove extension
result = manager.remove("test-ext", keep_config=False)
assert result is True
assert not manager.registry.is_installed("test-ext")
assert not ext_dir.exists()
def test_remove_nonexistent(self, project_dir):
"""Test removing non-existent extension."""
manager = ExtensionManager(project_dir)
result = manager.remove("nonexistent")
assert result is False
def test_list_installed(self, extension_dir, project_dir):
"""Test listing installed extensions."""
manager = ExtensionManager(project_dir)
# Initially empty
assert len(manager.list_installed()) == 0
# Install extension
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
# Should have one extension
installed = manager.list_installed()
assert len(installed) == 1
assert installed[0]["id"] == "test-ext"
assert installed[0]["name"] == "Test Extension"
assert installed[0]["version"] == "1.0.0"
assert installed[0]["command_count"] == 1
assert installed[0]["hook_count"] == 1
def test_config_backup_on_remove(self, extension_dir, project_dir):
"""Test that config files are backed up on removal."""
manager = ExtensionManager(project_dir)
# Install extension
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
# Create a config file
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
config_file = ext_dir / "test-ext-config.yml"
config_file.write_text("test: config")
# Remove extension (without keep_config)
manager.remove("test-ext", keep_config=False)
# Check backup was created (now in subdirectory per extension)
backup_dir = project_dir / ".specify" / "extensions" / ".backup" / "test-ext"
backup_file = backup_dir / "test-ext-config.yml"
assert backup_file.exists()
assert backup_file.read_text() == "test: config"
# ===== CommandRegistrar Tests =====
class TestCommandRegistrar:
"""Test CommandRegistrar command registration."""
def test_kiro_cli_agent_config_present(self):
"""Kiro CLI should be mapped to .kiro/prompts and legacy q removed."""
assert "kiro-cli" in CommandRegistrar.AGENT_CONFIGS
assert CommandRegistrar.AGENT_CONFIGS["kiro-cli"]["dir"] == ".kiro/prompts"
assert "q" not in CommandRegistrar.AGENT_CONFIGS
def test_codex_agent_config_present(self):
"""Codex should be mapped to .agents/skills."""
assert "codex" in CommandRegistrar.AGENT_CONFIGS
assert CommandRegistrar.AGENT_CONFIGS["codex"]["dir"] == ".agents/skills"
assert CommandRegistrar.AGENT_CONFIGS["codex"]["extension"] == "/SKILL.md"
def test_pi_agent_config_present(self):
"""Pi should be mapped to .pi/prompts."""
assert "pi" in CommandRegistrar.AGENT_CONFIGS
cfg = CommandRegistrar.AGENT_CONFIGS["pi"]
assert cfg["dir"] == ".pi/prompts"
assert cfg["format"] == "markdown"
assert cfg["args"] == "$ARGUMENTS"
assert cfg["extension"] == ".md"
def test_qwen_agent_config_is_markdown(self):
"""Qwen should use Markdown format with $ARGUMENTS (not TOML)."""
assert "qwen" in CommandRegistrar.AGENT_CONFIGS
cfg = CommandRegistrar.AGENT_CONFIGS["qwen"]
assert cfg["dir"] == ".qwen/commands"
assert cfg["format"] == "markdown"
assert cfg["args"] == "$ARGUMENTS"
assert cfg["extension"] == ".md"
def test_parse_frontmatter_valid(self):
"""Test parsing valid YAML frontmatter."""
content = """---
description: "Test command"
tools:
- tool1
- tool2
---
# Command body
$ARGUMENTS
"""
registrar = CommandRegistrar()
frontmatter, body = registrar.parse_frontmatter(content)
assert frontmatter["description"] == "Test command"
assert frontmatter["tools"] == ["tool1", "tool2"]
assert "Command body" in body
assert "$ARGUMENTS" in body
def test_parse_frontmatter_no_frontmatter(self):
"""Test parsing content without frontmatter."""
content = "# Just a command\n$ARGUMENTS"
registrar = CommandRegistrar()
frontmatter, body = registrar.parse_frontmatter(content)
assert frontmatter == {}
assert body == content
def test_parse_frontmatter_non_mapping_returns_empty_dict(self):
"""Non-mapping YAML frontmatter should not crash downstream renderers."""
content = """---
- item1
- item2
---
# Command body
"""
registrar = CommandRegistrar()
frontmatter, body = registrar.parse_frontmatter(content)
assert frontmatter == {}
assert "Command body" in body
def test_parse_frontmatter_dash_in_value(self):
"""A ``---`` inside a frontmatter value must not close the block early."""
content = """---
description: Separate sections with --- markers
argument-hint: "[name]"
---
Real body starts here.
"""
registrar = CommandRegistrar()
frontmatter, body = registrar.parse_frontmatter(content)
assert frontmatter["description"] == "Separate sections with --- markers"
assert frontmatter["argument-hint"] == "[name]"
assert body == "Real body starts here."
def test_render_frontmatter(self):
"""Test rendering frontmatter to YAML."""
frontmatter = {
"description": "Test command",
"tools": ["tool1", "tool2"]
}
registrar = CommandRegistrar()
output = registrar.render_frontmatter(frontmatter)
assert output.startswith("---\n")
assert output.endswith("---\n")
assert "description: Test command" in output
def test_render_frontmatter_unicode(self):
"""Test rendering frontmatter preserves non-ASCII characters."""
frontmatter = {
"description": "Prüfe Konformität der Implementierung"
}
registrar = CommandRegistrar()
output = registrar.render_frontmatter(frontmatter)
assert "Prüfe Konformität" in output
assert "\\u" not in output
def test_adjust_script_paths_does_not_mutate_input(self):
"""Path adjustments should not mutate caller-owned frontmatter dicts."""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
registrar = AgentCommandRegistrar()
original = {
"scripts": {
"sh": "../../scripts/bash/setup-plan.sh {ARGS}",
"ps": "../../scripts/powershell/setup-plan.ps1 {ARGS}",
}
}
before = json.loads(json.dumps(original))
adjusted = registrar._adjust_script_paths(original)
assert original == before
assert adjusted["scripts"]["sh"] == ".specify/scripts/bash/setup-plan.sh {ARGS}"
assert adjusted["scripts"]["ps"] == ".specify/scripts/powershell/setup-plan.ps1 {ARGS}"
def test_adjust_script_paths_preserves_extension_local_paths(self):
"""Extension-local script paths should not be rewritten into .specify/.specify."""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
registrar = AgentCommandRegistrar()
original = {
"scripts": {
"sh": ".specify/extensions/test-ext/scripts/setup.sh {ARGS}",
"ps": "scripts/powershell/setup-plan.ps1 {ARGS}",
}
}
adjusted = registrar._adjust_script_paths(original)
assert adjusted["scripts"]["sh"] == ".specify/extensions/test-ext/scripts/setup.sh {ARGS}"
assert adjusted["scripts"]["ps"] == ".specify/scripts/powershell/setup-plan.ps1 {ARGS}"
def test_adjust_script_paths_rewrites_extension_top_level_scripts(self):
"""Extension command-local scripts should resolve under the installed extension."""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
registrar = AgentCommandRegistrar()
original = {
"scripts": {
"sh": "scripts/bash/resolve-skill.sh {ARGS}",
"ps": "../../scripts/powershell/setup-plan.ps1 -Json",
}
}
adjusted = registrar._adjust_script_paths(original, extension_id="test-ext")
assert (
adjusted["scripts"]["sh"]
== ".specify/extensions/test-ext/scripts/bash/resolve-skill.sh {ARGS}"
)
assert (
adjusted["scripts"]["ps"]
== ".specify/scripts/powershell/setup-plan.ps1 -Json"
)
def test_rewrite_project_relative_paths_preserves_extension_local_body_paths(self):
"""Body rewrites should preserve extension-local assets while fixing top-level refs."""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
body = (
"Read `.specify/extensions/test-ext/templates/spec.md`\n"
"Run scripts/bash/setup-plan.sh\n"
)
rewritten = AgentCommandRegistrar.rewrite_project_relative_paths(body)
assert ".specify/extensions/test-ext/templates/spec.md" in rewritten
assert ".specify/scripts/bash/setup-plan.sh" in rewritten
def test_rewrite_project_relative_paths_uses_extension_context_for_scripts(self):
"""Extension source bodies treat top-level scripts/ as extension-local."""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
body = (
"Run scripts/bash/ensure-skills.sh\n"
"Fallback ../../scripts/bash/setup-plan.sh\n"
"Read templates/checklist.md\n"
)
rewritten = AgentCommandRegistrar.rewrite_project_relative_paths(
body, extension_id="test-ext"
)
assert ".specify/extensions/test-ext/scripts/bash/ensure-skills.sh" in rewritten
assert ".specify/scripts/bash/setup-plan.sh" in rewritten
assert ".specify/templates/checklist.md" in rewritten
def test_render_toml_command_handles_embedded_triple_double_quotes(self):
"""TOML renderer should stay valid when body includes triple double-quotes."""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
registrar = AgentCommandRegistrar()
output = registrar.render_toml_command(
{"description": "x"},
'line1\n"""danger"""\nline2',
"extension:test-ext",
)
assert "prompt = '''" in output
assert '"""danger"""' in output
def test_render_toml_command_escapes_when_both_triple_quote_styles_exist(self):
"""If body has both triple quote styles, fall back to escaped basic string."""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
registrar = AgentCommandRegistrar()
output = registrar.render_toml_command(
{"description": "x"},
'a """ b\nc \'\'\' d',
"extension:test-ext",
)
assert 'prompt = "' in output
assert "\\n" in output
assert "\\\"\\\"\\\"" in output
def test_render_toml_command_preserves_multiline_description(self):
"""Multiline descriptions should render as parseable TOML with preserved semantics."""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
registrar = AgentCommandRegistrar()
output = registrar.render_toml_command(
{"description": "first line\nsecond line\n"},
"body",
"extension:test-ext",
)
parsed = tomllib.loads(output)
assert parsed["description"] == "first line\nsecond line\n"
def test_render_toml_command_escapes_control_characters(self):
"""Control characters and a lone CR must be escaped so the TOML parses.
TOML forbids literal control characters (U+0000U+001F except tab and
newline, plus U+007F) in any string, and treats a bare CR outside a
CRLF pair as illegal. The renderer used to emit these raw — into a
basic string (single-line) or a ``\"\"\"`` multiline string (for a lone
CR) — producing a command file that fails to parse."""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
registrar = AgentCommandRegistrar()
body = "start\x00null\x01ctrl\x1besc\x7fdel\rlone-cr end"
output = registrar.render_toml_command(
{"description": "d"}, body, "extension:test-ext"
)
parsed = tomllib.loads(output)
assert parsed["prompt"] == body
def test_render_toml_command_preserves_backslashes_in_body(self):
"""A backslash in the body (e.g. a Windows path) must not break TOML.
A multiline basic string ("\"\"\"") processes backslash escapes, so
``C:\\Users`` (``\\U``) would render as invalid TOML; the body must
round-trip with backslashes intact.
"""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
registrar = AgentCommandRegistrar()
output = registrar.render_toml_command(
{"description": "x"},
r"Run C:\Users\dev\tool.exe then report.",
"extension:test-ext",
)
parsed = tomllib.loads(output) # must not raise
assert parsed["prompt"].strip() == r"Run C:\Users\dev\tool.exe then report."
def test_render_toml_command_handles_trailing_backslash(self):
"""A body ending in a backslash must round-trip without corruption."""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
registrar = AgentCommandRegistrar()
output = registrar.render_toml_command(
{"description": "x"},
"path ends with sep\\",
"extension:test-ext",
)
parsed = tomllib.loads(output)
assert parsed["prompt"].strip() == "path ends with sep\\"
def test_render_toml_command_backslash_with_both_triple_quotes_escapes(self):
"""Body with a backslash and both triple-quote styles → escaped basic string."""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
registrar = AgentCommandRegistrar()
body = "a \\ b\nc \"\"\" d\ne ''' f"
output = registrar.render_toml_command({"description": "x"}, body, "extension:test-ext")
parsed = tomllib.loads(output)
assert parsed["prompt"] == body
def test_register_commands_for_claude(self, extension_dir, project_dir):
"""Test registering commands for Claude agent."""
# Create .claude directory
claude_dir = project_dir / ".claude" / "skills"
claude_dir.mkdir(parents=True)
ExtensionManager(project_dir) # Initialize manager (side effects only)
manifest = ExtensionManifest(extension_dir / "extension.yml")
registrar = CommandRegistrar()
registered = registrar.register_commands_for_claude(
manifest,
extension_dir,
project_dir
)
assert len(registered) == 1
assert "speckit.test-ext.hello" in registered
# Check command file was created
cmd_file = claude_dir / "speckit-test-ext-hello" / "SKILL.md"
assert cmd_file.exists()
content = cmd_file.read_text()
assert "description: Test hello command" in content
assert "test-ext" in content
def test_command_with_aliases(self, project_dir, temp_dir):
"""Test registering a command with aliases."""
import yaml
# Create extension with command alias
ext_dir = temp_dir / "ext-alias"
ext_dir.mkdir()
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "ext-alias",
"name": "Extension with Alias",
"version": "1.0.0",
"description": "Test",
},
"requires": {
"speckit_version": ">=0.1.0",
},
"provides": {
"commands": [
{
"name": "speckit.ext-alias.cmd",
"file": "commands/cmd.md",
"aliases": ["speckit.ext-alias.shortcut"],
}
]
},
}
with open(ext_dir / "extension.yml", 'w') as f:
yaml.dump(manifest_data, f)
(ext_dir / "commands").mkdir()
(ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nTest")
claude_dir = project_dir / ".claude" / "skills"
claude_dir.mkdir(parents=True)
manifest = ExtensionManifest(ext_dir / "extension.yml")
registrar = CommandRegistrar()
registered = registrar.register_commands_for_claude(manifest, ext_dir, project_dir)
assert len(registered) == 2
assert "speckit.ext-alias.cmd" in registered
assert "speckit.ext-alias.shortcut" in registered
assert (claude_dir / "speckit-ext-alias-cmd" / "SKILL.md").exists()
assert (claude_dir / "speckit-ext-alias-shortcut" / "SKILL.md").exists()
def test_unregister_commands_for_codex_skills_uses_mapped_names(self, project_dir):
"""Codex skill cleanup should use the same mapped names as registration."""
skills_dir = project_dir / ".agents" / "skills"
(skills_dir / "speckit-specify").mkdir(parents=True)
(skills_dir / "speckit-specify" / "SKILL.md").write_text("body")
(skills_dir / "speckit-shortcut").mkdir(parents=True)
(skills_dir / "speckit-shortcut" / "SKILL.md").write_text("body")
registrar = CommandRegistrar()
registrar.unregister_commands(
{"codex": ["speckit.specify", "speckit.shortcut"]},
project_dir,
)
assert not (skills_dir / "speckit-specify" / "SKILL.md").exists()
assert not (skills_dir / "speckit-shortcut" / "SKILL.md").exists()
def test_unregister_commands_handles_legacy_dot_notated_files(self, project_dir):
"""Unregister should clean up both legacy dot-notated and new hyphenated files."""
# 1. Mock an agent that uses hyphenated/formatted names (e.g. Cline)
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
registrar = AgentCommandRegistrar()
# We'll use "cline" since it has format_name
assert "cline" in registrar.AGENT_CONFIGS
cline_config = registrar.AGENT_CONFIGS["cline"]
cline_dir = project_dir / cline_config["dir"]
cline_dir.mkdir(parents=True, exist_ok=True)
# 2. Create both legacy and new files
# Command name: speckit.git.commit
# Formatted name: speckit-git-commit
cmd_name = "speckit.git.commit"
formatted_name = "speckit-git-commit"
legacy_file = cline_dir / f"{cmd_name}.md"
formatted_file = cline_dir / f"{formatted_name}.md"
legacy_file.write_text("legacy body")
formatted_file.write_text("formatted body")
assert legacy_file.exists()
assert formatted_file.exists()
# 3. Call unregister
registrar.unregister_commands({"cline": [cmd_name]}, project_dir)
# 4. Verify both are gone
assert not legacy_file.exists(), "Legacy dot-notated file should be removed"
assert (
not formatted_file.exists()
), "Formatted hyphenated file should be removed"
def test_register_commands_for_all_agents_distinguishes_codex_from_amp(self, extension_dir, project_dir):
"""A Codex project under .agents/skills should not implicitly activate Amp."""
skills_dir = project_dir / ".agents" / "skills"
skills_dir.mkdir(parents=True)
manifest = ExtensionManifest(extension_dir / "extension.yml")
registrar = CommandRegistrar()
registered = registrar.register_commands_for_all_agents(manifest, extension_dir, project_dir)
assert "codex" in registered
assert "amp" not in registered
assert not (project_dir / ".agents" / "commands").exists()
def test_codex_skill_registration_writes_skill_frontmatter(self, extension_dir, project_dir):
"""Codex SKILL.md output should use skills-oriented frontmatter."""
skills_dir = project_dir / ".agents" / "skills"
skills_dir.mkdir(parents=True)
manifest = ExtensionManifest(extension_dir / "extension.yml")
registrar = CommandRegistrar()
registrar.register_commands_for_agent("codex", manifest, extension_dir, project_dir)
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text()
assert "name: speckit-test-ext-hello" in content
assert "description: Test hello command" in content
assert "compatibility:" in content
assert "metadata:" in content
assert "source: test-ext:commands/hello.md" in content
assert "<!-- Extension:" not in content
def test_codex_skill_registration_uses_dollar_command_refs(
self, extension_dir, project_dir
):
"""Codex extension skills use the native dollar invocation prefix."""
skills_dir = project_dir / ".agents" / "skills"
skills_dir.mkdir(parents=True)
command = extension_dir / "commands" / "hello.md"
command.write_text(
"---\ndescription: Test hello command\n---\n\nRun __SPECKIT_COMMAND_PLAN__.",
encoding="utf-8",
)
manifest = ExtensionManifest(extension_dir / "extension.yml")
registrar = CommandRegistrar()
registrar.register_commands_for_agent(
"codex", manifest, extension_dir, project_dir
)
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
content = skill_file.read_text(encoding="utf-8")
assert "$speckit-plan" in content
assert "/speckit-plan" not in content
def test_codex_skill_registration_resolves_script_placeholders(self, project_dir, temp_dir):
"""Codex SKILL.md overrides should resolve script placeholders."""
import yaml
ext_dir = temp_dir / "ext-scripted"
ext_dir.mkdir()
(ext_dir / "commands").mkdir()
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "ext-scripted",
"name": "Scripted Extension",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.ext-scripted.plan",
"file": "commands/plan.md",
"description": "Scripted command",
}
]
},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.dump(manifest_data, f)
(ext_dir / "commands" / "plan.md").write_text(
"""---
description: "Scripted command"
scripts:
sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}"
ps: ../../scripts/powershell/setup-plan.ps1 -Json
---
Run {SCRIPT}
Agent __AGENT__
"""
)
init_options = project_dir / ".specify" / "init-options.json"
init_options.parent.mkdir(parents=True, exist_ok=True)
init_options.write_text('{"ai":"codex","ai_skills":true,"script":"sh"}')
skills_dir = project_dir / ".agents" / "skills"
skills_dir.mkdir(parents=True)
manifest = ExtensionManifest(ext_dir / "extension.yml")
registrar = CommandRegistrar()
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
skill_file = skills_dir / "speckit-ext-scripted-plan" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text()
assert "{SCRIPT}" not in content
assert "__AGENT__" not in content
assert "{ARGS}" not in content
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
@pytest.mark.parametrize("agent_name,skills_path", [
("codex", ".agents/skills"),
("kimi", ".kimi-code/skills"),
("claude", ".claude/skills"),
("cursor-agent", ".cursor/skills"),
("trae", ".trae/skills"),
("agy", ".agents/skills"),
])
def test_all_skill_agents_register_commands_with_resolved_placeholders(
self, project_dir, temp_dir, agent_name, skills_path
):
"""All SKILL.md agents must produce fully resolved SKILL.md files when commands are registered."""
import yaml
ext_dir = temp_dir / f"ext-{agent_name}"
ext_dir.mkdir()
(ext_dir / "commands").mkdir()
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": f"ext-{agent_name}",
"name": "Scripted Extension",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": f"speckit.ext-{agent_name}.run",
"file": "commands/run.md",
"description": "Scripted command",
}
]
},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.dump(manifest_data, f)
(ext_dir / "commands" / "run.md").write_text(
"---\n"
"description: Scripted command\n"
"scripts:\n"
' sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}"\n'
"---\n\n"
"Run {SCRIPT}\n"
"Agent is __AGENT__.\n"
)
init_options = project_dir / ".specify" / "init-options.json"
init_options.parent.mkdir(parents=True, exist_ok=True)
init_options.write_text(f'{{"ai":"{agent_name}","script":"sh"}}')
skills_dir = project_dir
for part in skills_path.split("/"):
skills_dir = skills_dir / part
skills_dir.mkdir(parents=True)
manifest = ExtensionManifest(ext_dir / "extension.yml")
registrar = CommandRegistrar()
registrar.register_commands_for_agent(agent_name, manifest, ext_dir, project_dir)
skill_dir_name = f"speckit-ext-{agent_name}-run"
skill_file = skills_dir / skill_dir_name / "SKILL.md"
assert skill_file.exists(), f"SKILL.md not created for {agent_name}"
content = skill_file.read_text()
assert "{SCRIPT}" not in content, f"{{SCRIPT}} not resolved for {agent_name}"
assert "__AGENT__" not in content, f"__AGENT__ not resolved for {agent_name}"
assert "{ARGS}" not in content, f"{{ARGS}} not resolved for {agent_name}"
assert '.specify/scripts/bash/setup-plan.sh' in content
def test_codex_skill_alias_frontmatter_matches_alias_name(self, project_dir, temp_dir):
"""Codex alias skills should render their own matching `name:` frontmatter."""
import yaml
ext_dir = temp_dir / "ext-alias-skill"
ext_dir.mkdir()
(ext_dir / "commands").mkdir()
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "ext-alias-skill",
"name": "Alias Skill Extension",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.ext-alias-skill.cmd",
"file": "commands/cmd.md",
"aliases": ["speckit.ext-alias-skill.shortcut"],
}
]
},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.dump(manifest_data, f)
(ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Alias skill\n---\n\nBody\n")
skills_dir = project_dir / ".agents" / "skills"
skills_dir.mkdir(parents=True)
manifest = ExtensionManifest(ext_dir / "extension.yml")
registrar = CommandRegistrar()
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
primary = skills_dir / "speckit-ext-alias-skill-cmd" / "SKILL.md"
alias = skills_dir / "speckit-ext-alias-skill-shortcut" / "SKILL.md"
assert primary.exists()
assert alias.exists()
assert "name: speckit-ext-alias-skill-cmd" in primary.read_text()
assert "name: speckit-ext-alias-skill-shortcut" in alias.read_text()
def test_codex_skill_registration_uses_fallback_script_variant_without_init_options(
self, project_dir, temp_dir
):
"""Codex placeholder substitution should still work without init-options.json."""
import yaml
ext_dir = temp_dir / "ext-script-fallback"
ext_dir.mkdir()
(ext_dir / "commands").mkdir()
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "ext-script-fallback",
"name": "Script fallback",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.ext-script-fallback.plan",
"file": "commands/plan.md",
}
]
},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.dump(manifest_data, f)
(ext_dir / "commands" / "plan.md").write_text(
"""---
description: "Fallback scripted command"
scripts:
sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}"
ps: ../../scripts/powershell/setup-plan.ps1 -Json
---
Run {SCRIPT}
"""
)
# Intentionally do NOT create .specify/init-options.json
skills_dir = project_dir / ".agents" / "skills"
skills_dir.mkdir(parents=True)
manifest = ExtensionManifest(ext_dir / "extension.yml")
registrar = CommandRegistrar()
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
skill_file = skills_dir / "speckit-ext-script-fallback-plan" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text()
assert "{SCRIPT}" not in content
if platform.system().lower().startswith("win"):
assert ".specify/scripts/powershell/setup-plan.ps1 -Json" in content
else:
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
def test_codex_skill_registration_handles_non_dict_init_options(
self, project_dir, temp_dir
):
"""Non-dict init-options payloads should not crash skill placeholder resolution."""
import yaml
ext_dir = temp_dir / "ext-script-list-init"
ext_dir.mkdir()
(ext_dir / "commands").mkdir()
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "ext-script-list-init",
"name": "List init options",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.ext-script-list-init.plan",
"file": "commands/plan.md",
}
]
},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.dump(manifest_data, f)
(ext_dir / "commands" / "plan.md").write_text(
"""---
description: "List init scripted command"
scripts:
sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}"
---
Run {SCRIPT}
"""
)
init_options = project_dir / ".specify" / "init-options.json"
init_options.parent.mkdir(parents=True, exist_ok=True)
init_options.write_text("[]")
skills_dir = project_dir / ".agents" / "skills"
skills_dir.mkdir(parents=True)
manifest = ExtensionManifest(ext_dir / "extension.yml")
registrar = CommandRegistrar()
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
content = (skills_dir / "speckit-ext-script-list-init-plan" / "SKILL.md").read_text()
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
def test_codex_skill_registration_fallback_prefers_powershell_on_windows(
self, project_dir, temp_dir, monkeypatch
):
"""Without init metadata, Windows fallback should prefer ps scripts over sh."""
import yaml
monkeypatch.setattr(
"specify_cli.integrations.base.platform.system", lambda: "Windows"
)
ext_dir = temp_dir / "ext-script-windows-fallback"
ext_dir.mkdir()
(ext_dir / "commands").mkdir()
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "ext-script-windows-fallback",
"name": "Script fallback windows",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.ext-script-windows-fallback.plan",
"file": "commands/plan.md",
}
]
},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.dump(manifest_data, f)
(ext_dir / "commands" / "plan.md").write_text(
"""---
description: "Windows fallback scripted command"
scripts:
sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}"
ps: ../../scripts/powershell/setup-plan.ps1 -Json
---
Run {SCRIPT}
"""
)
skills_dir = project_dir / ".agents" / "skills"
skills_dir.mkdir(parents=True)
manifest = ExtensionManifest(ext_dir / "extension.yml")
registrar = CommandRegistrar()
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
skill_file = skills_dir / "speckit-ext-script-windows-fallback-plan" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text()
assert ".specify/scripts/powershell/setup-plan.ps1 -Json" in content
assert ".specify/scripts/bash/setup-plan.sh" not in content
@staticmethod
def _make_subdir_extension(temp_dir, ext_id="echelon", aliases=None):
"""Create an extension whose command body references bundled subdirs."""
import yaml
ext_dir = temp_dir / ext_id
ext_dir.mkdir()
(ext_dir / "commands").mkdir()
(ext_dir / "agents" / "control").mkdir(parents=True)
(ext_dir / "knowledge-base").mkdir()
(ext_dir / "templates").mkdir()
(ext_dir / "specs" / "001-internal").mkdir(parents=True)
command = {
"name": f"speckit.{ext_id}.run",
"file": "commands/run.md",
"description": "Run",
}
if aliases:
command["aliases"] = aliases
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": ext_id,
"name": "Echelon",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {"commands": [command]},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.dump(manifest_data, f)
(ext_dir / "commands" / "run.md").write_text(
"---\ndescription: Run\n---\n\n"
"Read agents/control/commander.md for instructions.\n"
"Load knowledge-base/agent-scores.yaml for calibration.\n"
"Use templates/kill-report.md as output format.\n"
"Artifacts go to specs/001-internal/plan.md.\n"
"See commands/run.md for the source.\n"
)
return ext_dir
def test_codex_skill_registration_rewrites_extension_subdir_paths(
self, project_dir, temp_dir
):
"""Extension-relative subdir refs must point at the installed location."""
ext_dir = self._make_subdir_extension(temp_dir)
skills_dir = project_dir / ".agents" / "skills"
skills_dir.mkdir(parents=True)
manifest = ExtensionManifest(ext_dir / "extension.yml")
registrar = CommandRegistrar()
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
content = (skills_dir / "speckit-echelon-run" / "SKILL.md").read_text()
assert ".specify/extensions/echelon/agents/control/commander.md" in content
assert ".specify/extensions/echelon/knowledge-base/agent-scores.yaml" in content
assert ".specify/extensions/echelon/templates/kill-report.md" in content
assert "Read agents/" not in content
# specs/ refs point at the user's project artifacts, never the extension
assert "to specs/001-internal/plan.md" in content
assert ".specify/extensions/echelon/specs/" not in content
# commands/ refs are slash-command sources, not runtime reads
assert "See commands/run.md" in content
def test_skill_registration_rewrites_extension_subdir_paths_in_aliases(
self, project_dir, temp_dir
):
"""Alias skills reuse the rewritten body."""
ext_dir = self._make_subdir_extension(
temp_dir, ext_id="ext-alias-paths", aliases=["speckit.ext-alias-paths.go"]
)
skills_dir = project_dir / ".agents" / "skills"
skills_dir.mkdir(parents=True)
manifest = ExtensionManifest(ext_dir / "extension.yml")
registrar = CommandRegistrar()
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
alias_content = (
skills_dir / "speckit-ext-alias-paths-go" / "SKILL.md"
).read_text()
assert (
".specify/extensions/ext-alias-paths/agents/control/commander.md"
in alias_content
)
assert "Read agents/" not in alias_content
def test_markdown_registration_rewrites_extension_subdir_paths(
self, project_dir, temp_dir
):
"""Markdown-format agents get the same rewrite via the shared path."""
ext_dir = self._make_subdir_extension(temp_dir, ext_id="ext-md-paths")
amp_dir = project_dir / ".agents" / "commands"
amp_dir.mkdir(parents=True)
manifest = ExtensionManifest(ext_dir / "extension.yml")
registrar = CommandRegistrar()
registrar.register_commands_for_agent("amp", manifest, ext_dir, project_dir)
content = (amp_dir / "speckit.ext-md-paths.run.md").read_text()
assert ".specify/extensions/ext-md-paths/agents/control/commander.md" in content
assert "Read agents/" not in content
def test_rewrite_extension_paths_only_rewrites_existing_subdirs(self, temp_dir):
"""Only directories present in the extension are rewritten."""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
ext_dir = temp_dir / "ext-existing"
(ext_dir / "agents").mkdir(parents=True)
(ext_dir / ".hidden").mkdir()
text = (
"Read agents/one.md then knowledge-base/two.md.\n"
"Also ./agents/three.md but not /agents/abs.md.\n"
"Keep .hidden/secret.md alone.\n"
)
rewritten = AgentCommandRegistrar.rewrite_extension_paths(
text, "ext-existing", ext_dir
)
assert ".specify/extensions/ext-existing/agents/one.md" in rewritten
assert "Also .specify/extensions/ext-existing/agents/three.md" in rewritten
# absolute paths keep their meaning
assert "not /agents/abs.md" in rewritten
# knowledge-base/ does not exist in this extension: left untouched
assert "then knowledge-base/two.md" in rewritten
assert ".hidden/secret.md" in rewritten
assert ".specify/extensions/ext-existing/.hidden/" not in rewritten
def test_rewrite_extension_paths_handles_regex_special_replacement_text(
self, temp_dir
):
"""subdir/extension_id containing regex-replacement-special characters
(e.g. backslash / group references) must not raise or be misinterpreted
by re.sub's replacement template (#2101).
The subdir name uses brackets rather than a backslash: on Windows,
"\\" is a path separator, so a subdir literally named "assets\\q"
would create nested directories "assets/q" instead of a single
directory, and iterdir() would then only discover "assets" - never
exercising the intended replacement text. extension_id isn't used to
create a directory, so it's free to contain a real backslash/"\\1"
to verify the callable replacement treats it literally.
"""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
ext_dir = temp_dir / "ext-backslash"
weird_subdir = "assets[q]"
(ext_dir / weird_subdir).mkdir(parents=True)
# sanity-check the cross-platform assumption above
assert [p.name for p in ext_dir.iterdir()] == [weird_subdir]
text = f"Read {weird_subdir}/file.md but not /{weird_subdir}/abs.md.\n"
rewritten = AgentCommandRegistrar.rewrite_extension_paths(
text, "ext\\1", ext_dir
)
assert f".specify/extensions/ext\\1/{weird_subdir}/file.md" in rewritten
# absolute paths are still left untouched
assert f"/{weird_subdir}/abs.md" in rewritten
def test_rewrite_extension_paths_missing_dir_returns_text(self, temp_dir):
"""A missing extension directory leaves the text unchanged."""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
text = "Read agents/one.md."
assert (
AgentCommandRegistrar.rewrite_extension_paths(
text, "gone", temp_dir / "does-not-exist"
)
== text
)
def test_register_commands_for_copilot(self, extension_dir, project_dir):
"""Test registering commands for Copilot agent with .agent.md extension."""
# Create .github/agents directory (Copilot project)
agents_dir = project_dir / ".github" / "agents"
agents_dir.mkdir(parents=True)
manifest = ExtensionManifest(extension_dir / "extension.yml")
registrar = CommandRegistrar()
registered = registrar.register_commands_for_agent(
"copilot", manifest, extension_dir, project_dir
)
assert len(registered) == 1
assert "speckit.test-ext.hello" in registered
# Verify command file uses .agent.md extension
cmd_file = agents_dir / "speckit.test-ext.hello.agent.md"
assert cmd_file.exists()
# Verify NO plain .md file was created
plain_md_file = agents_dir / "speckit.test-ext.hello.md"
assert not plain_md_file.exists()
content = cmd_file.read_text()
assert "description: Test hello command" in content
assert "test-ext" in content
def test_dev_register_commands_symlinks_rendered_copilot_agent(
self, extension_dir, project_dir, temp_dir
):
"""Dev-mode registration should symlink agent files to rendered outputs."""
if not can_create_symlink(temp_dir):
pytest.skip("Current platform/user cannot create symlinks")
agents_dir = project_dir / ".github" / "agents"
agents_dir.mkdir(parents=True)
manifest = ExtensionManifest(extension_dir / "extension.yml")
registrar = CommandRegistrar()
registered = registrar.register_commands_for_agent(
"copilot",
manifest,
extension_dir,
project_dir,
link_outputs=True,
)
assert registered == ["speckit.test-ext.hello"]
cmd_file = agents_dir / "speckit.test-ext.hello.agent.md"
assert cmd_file.is_symlink()
target = cmd_file.resolve()
assert ".specify-dev" in target.parts
assert target.is_file()
assert "Extension: test-ext" in cmd_file.read_text(encoding="utf-8")
def test_dev_register_commands_replaces_codex_dev_symlink(
self, extension_dir, project_dir, temp_dir
):
"""Codex dev registration should replace prior symlinks with real files."""
if not can_create_symlink(temp_dir):
pytest.skip("Current platform/user cannot create symlinks")
skill_file = (
project_dir
/ ".agents"
/ "skills"
/ "speckit-test-ext-hello"
/ "SKILL.md"
)
skill_file.parent.mkdir(parents=True)
cache_file = (
extension_dir
/ ".specify-dev"
/ "agent-commands"
/ "codex"
/ "speckit-test-ext-hello"
/ "SKILL.md"
)
cache_file.parent.mkdir(parents=True)
cache_file.write_text("old linked content", encoding="utf-8")
os.symlink(os.path.relpath(cache_file, skill_file.parent), skill_file)
manifest = ExtensionManifest(extension_dir / "extension.yml")
registrar = CommandRegistrar()
registrar.register_commands_for_agent(
"codex",
manifest,
extension_dir,
project_dir,
link_outputs=True,
)
assert skill_file.exists()
assert not skill_file.is_symlink()
assert "name: speckit-test-ext-hello" in skill_file.read_text(
encoding="utf-8"
)
assert cache_file.read_text(encoding="utf-8") == "old linked content"
def test_dev_register_commands_falls_back_to_copy_when_symlink_fails(
self, extension_dir, project_dir, monkeypatch
):
"""Dev-mode registration stays functional when symlinks are unavailable."""
agents_dir = project_dir / ".github" / "agents"
agents_dir.mkdir(parents=True)
def raise_symlink_error(target, link):
raise OSError("symlink unavailable")
monkeypatch.setattr("specify_cli.agents.os.symlink", raise_symlink_error)
manifest = ExtensionManifest(extension_dir / "extension.yml")
registrar = CommandRegistrar()
registrar.register_commands_for_agent(
"copilot",
manifest,
extension_dir,
project_dir,
link_outputs=True,
)
cmd_file = agents_dir / "speckit.test-ext.hello.agent.md"
assert cmd_file.exists()
assert not cmd_file.is_symlink()
assert "Extension: test-ext" in cmd_file.read_text(encoding="utf-8")
assert (
extension_dir
/ ".specify-dev"
/ "agent-commands"
/ "copilot"
/ "speckit.test-ext.hello.agent.md"
).exists()
def test_dev_register_commands_falls_back_to_copy_when_relpath_fails(
self, extension_dir, project_dir, monkeypatch
):
"""Dev-mode registration stays functional across Windows drive roots."""
agents_dir = project_dir / ".github" / "agents"
agents_dir.mkdir(parents=True)
def raise_relpath_error(path, start=None):
raise ValueError("path is on mount 'D:', start on mount 'C:'")
monkeypatch.setattr("specify_cli.agents.os.path.relpath", raise_relpath_error)
manifest = ExtensionManifest(extension_dir / "extension.yml")
registrar = CommandRegistrar()
registrar.register_commands_for_agent(
"copilot",
manifest,
extension_dir,
project_dir,
link_outputs=True,
)
cmd_file = agents_dir / "speckit.test-ext.hello.agent.md"
assert cmd_file.exists()
assert not cmd_file.is_symlink()
assert "Extension: test-ext" in cmd_file.read_text(encoding="utf-8")
assert (
extension_dir
/ ".specify-dev"
/ "agent-commands"
/ "copilot"
/ "speckit.test-ext.hello.agent.md"
).exists()
def test_dev_register_commands_falls_back_to_copy_when_cache_write_fails(
self, extension_dir, project_dir, monkeypatch
):
"""Dev-mode registration stays functional when the dev cache is unwritable."""
agents_dir = project_dir / ".github" / "agents"
agents_dir.mkdir(parents=True)
original_write_text = Path.write_text
def raise_cache_write_error(path, *args, **kwargs):
if ".specify-dev" in path.parts:
raise OSError("cache is not writable")
return original_write_text(path, *args, **kwargs)
monkeypatch.setattr(Path, "write_text", raise_cache_write_error)
manifest = ExtensionManifest(extension_dir / "extension.yml")
registrar = CommandRegistrar()
registrar.register_commands_for_agent(
"copilot",
manifest,
extension_dir,
project_dir,
link_outputs=True,
)
cmd_file = agents_dir / "speckit.test-ext.hello.agent.md"
assert cmd_file.exists()
assert not cmd_file.is_symlink()
assert "Extension: test-ext" in cmd_file.read_text(encoding="utf-8")
assert not (
extension_dir
/ ".specify-dev"
/ "agent-commands"
/ "copilot"
/ "speckit.test-ext.hello.agent.md"
).exists()
def test_dev_register_commands_rejects_cache_path_traversal(self, temp_dir):
"""Dev-mode cache writes must stay inside the agent cache root."""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
source_dir = temp_dir / "extension"
source_dir.mkdir()
commands_dir = temp_dir / "commands"
commands_dir.mkdir()
with pytest.raises(ValueError, match="escapes directory"):
AgentCommandRegistrar._write_registered_output(
commands_dir / "safe.md",
"content",
source_dir,
"copilot",
"../escaped",
".md",
True,
)
assert not (
source_dir
/ ".specify-dev"
/ "agent-commands"
/ "escaped.md"
).exists()
def test_copilot_companion_prompt_created(self, extension_dir, project_dir):
"""Test that companion .prompt.md files are created in .github/prompts/."""
agents_dir = project_dir / ".github" / "agents"
agents_dir.mkdir(parents=True)
manifest = ExtensionManifest(extension_dir / "extension.yml")
registrar = CommandRegistrar()
registrar.register_commands_for_agent(
"copilot", manifest, extension_dir, project_dir
)
# Verify companion .prompt.md file exists
prompt_file = project_dir / ".github" / "prompts" / "speckit.test-ext.hello.prompt.md"
assert prompt_file.exists()
# Verify content has correct agent frontmatter
content = prompt_file.read_text()
assert content == "---\nagent: speckit.test-ext.hello\n---\n"
def test_copilot_aliases_get_companion_prompts(self, project_dir, temp_dir):
"""Test that aliases also get companion .prompt.md files for Copilot."""
import yaml
ext_dir = temp_dir / "ext-alias-copilot"
ext_dir.mkdir()
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "ext-alias-copilot",
"name": "Extension with Alias",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.ext-alias-copilot.cmd",
"file": "commands/cmd.md",
"aliases": ["speckit.ext-alias-copilot.shortcut"],
}
]
},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.dump(manifest_data, f)
(ext_dir / "commands").mkdir()
(ext_dir / "commands" / "cmd.md").write_text(
"---\ndescription: Test\n---\n\nTest"
)
# Set up Copilot project
(project_dir / ".github" / "agents").mkdir(parents=True)
manifest = ExtensionManifest(ext_dir / "extension.yml")
registrar = CommandRegistrar()
registered = registrar.register_commands_for_agent(
"copilot", manifest, ext_dir, project_dir
)
assert len(registered) == 2
# Both primary and alias get companion .prompt.md
prompts_dir = project_dir / ".github" / "prompts"
assert (prompts_dir / "speckit.ext-alias-copilot.cmd.prompt.md").exists()
assert (prompts_dir / "speckit.ext-alias-copilot.shortcut.prompt.md").exists()
def test_non_copilot_agent_no_companion_file(self, extension_dir, project_dir):
"""Test that non-copilot agents do NOT create .prompt.md files."""
claude_dir = project_dir / ".claude" / "skills"
claude_dir.mkdir(parents=True)
manifest = ExtensionManifest(extension_dir / "extension.yml")
registrar = CommandRegistrar()
registrar.register_commands_for_agent(
"claude", manifest, extension_dir, project_dir
)
# No .github/prompts directory should exist
prompts_dir = project_dir / ".github" / "prompts"
assert not prompts_dir.exists()
def test_unregister_skill_removes_parent_directory(self, project_dir, temp_dir):
"""Unregistering a SKILL.md command should remove the empty parent subdirectory."""
import yaml
ext_dir = temp_dir / "cleanup-ext"
ext_dir.mkdir()
(ext_dir / "commands").mkdir()
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "cleanup-ext",
"name": "Cleanup Extension",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.cleanup-ext.run",
"file": "commands/run.md",
"description": "Run",
}
]
},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.dump(manifest_data, f)
(ext_dir / "commands" / "run.md").write_text("---\ndescription: Run\n---\n\nBody")
skills_dir = project_dir / ".agents" / "skills"
skills_dir.mkdir(parents=True)
registrar = CommandRegistrar()
from specify_cli.extensions import ExtensionManifest
manifest = ExtensionManifest(ext_dir / "extension.yml")
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
skill_subdir = skills_dir / "speckit-cleanup-ext-run"
assert skill_subdir.exists(), "Skill subdirectory should exist after registration"
assert (skill_subdir / "SKILL.md").exists()
registrar.unregister_commands({"codex": ["speckit.cleanup-ext.run"]}, project_dir)
assert not (skill_subdir / "SKILL.md").exists(), "SKILL.md should be removed"
assert not skill_subdir.exists(), "Empty parent subdirectory should be removed"
# ===== Utility Function Tests =====
class TestVersionSatisfies:
"""Test version_satisfies utility function."""
def test_version_satisfies_simple(self):
"""Test simple version comparison."""
assert version_satisfies("1.0.0", ">=1.0.0")
assert version_satisfies("1.0.1", ">=1.0.0")
assert not version_satisfies("0.9.9", ">=1.0.0")
def test_version_satisfies_range(self):
"""Test version range."""
assert version_satisfies("1.5.0", ">=1.0.0,<2.0.0")
assert not version_satisfies("2.0.0", ">=1.0.0,<2.0.0")
assert not version_satisfies("0.9.0", ">=1.0.0,<2.0.0")
def test_version_satisfies_complex(self):
"""Test complex version specifier."""
assert version_satisfies("1.0.5", ">=1.0.0,!=1.0.3")
assert not version_satisfies("1.0.3", ">=1.0.0,!=1.0.3")
def test_version_satisfies_prerelease(self):
"""Prerelease builds should satisfy compatible lower bounds, but not higher bounds."""
assert version_satisfies("0.8.8.dev0", ">=0.2.0")
assert not version_satisfies("0.2.0.dev0", ">=0.2.0")
assert not version_satisfies("0.8.7.dev1", ">=0.8.8")
def test_version_satisfies_invalid(self):
"""Test invalid version strings."""
assert not version_satisfies("invalid", ">=1.0.0")
assert not version_satisfies("1.0.0", "invalid specifier")
# ===== Integration Tests =====
class TestIntegration:
"""Integration tests for complete workflows."""
def test_full_install_and_remove_workflow(self, extension_dir, project_dir):
"""Test complete installation and removal workflow."""
# Create Claude directory
(project_dir / ".claude" / "skills").mkdir(parents=True)
manager = ExtensionManager(project_dir)
# Install
manager.install_from_directory(
extension_dir,
"0.1.0",
register_commands=True
)
# Verify installation
assert manager.registry.is_installed("test-ext")
installed = manager.list_installed()
assert len(installed) == 1
assert installed[0]["id"] == "test-ext"
# Verify command registered
cmd_file = project_dir / ".claude" / "skills" / "speckit-test-ext-hello" / "SKILL.md"
assert cmd_file.exists()
# Verify registry has registered commands (now a dict keyed by agent)
metadata = manager.registry.get("test-ext")
registered_commands = metadata["registered_commands"]
# Check that the command is registered for at least one agent
assert any(
"speckit.test-ext.hello" in cmds
for cmds in registered_commands.values()
)
# Remove
result = manager.remove("test-ext")
assert result is True
# Verify removal
assert not manager.registry.is_installed("test-ext")
assert not cmd_file.exists()
assert len(manager.list_installed()) == 0
def test_copilot_cleanup_removes_prompt_files(self, extension_dir, project_dir):
"""Test that removing a Copilot extension also removes .prompt.md files."""
agents_dir = project_dir / ".github" / "agents"
agents_dir.mkdir(parents=True)
manager = ExtensionManager(project_dir)
manager.install_from_directory(extension_dir, "0.1.0", register_commands=True)
# Verify copilot was detected and registered
metadata = manager.registry.get("test-ext")
assert "copilot" in metadata["registered_commands"]
# Verify files exist before cleanup
agent_file = agents_dir / "speckit.test-ext.hello.agent.md"
prompt_file = project_dir / ".github" / "prompts" / "speckit.test-ext.hello.prompt.md"
assert agent_file.exists()
assert prompt_file.exists()
# Use the extension manager to remove — exercises the copilot prompt cleanup code
result = manager.remove("test-ext")
assert result is True
assert not agent_file.exists()
assert not prompt_file.exists()
def test_multiple_extensions(self, temp_dir, project_dir):
"""Test installing multiple extensions."""
import yaml
# Create two extensions
for i in range(1, 3):
ext_dir = temp_dir / f"ext{i}"
ext_dir.mkdir()
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": f"ext{i}",
"name": f"Extension {i}",
"version": "1.0.0",
"description": f"Extension {i}",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": f"speckit.ext{i}.cmd",
"file": "commands/cmd.md",
}
]
},
}
with open(ext_dir / "extension.yml", 'w') as f:
yaml.dump(manifest_data, f)
(ext_dir / "commands").mkdir()
(ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\nTest")
manager = ExtensionManager(project_dir)
# Install both
manager.install_from_directory(temp_dir / "ext1", "0.1.0", register_commands=False)
manager.install_from_directory(temp_dir / "ext2", "0.1.0", register_commands=False)
# Verify both installed
installed = manager.list_installed()
assert len(installed) == 2
assert {ext["id"] for ext in installed} == {"ext1", "ext2"}
# Remove first
manager.remove("ext1")
# Verify only second remains
installed = manager.list_installed()
assert len(installed) == 1
assert installed[0]["id"] == "ext2"
# ===== Extension Catalog Tests =====
class TestExtensionCatalog:
"""Test extension catalog functionality."""
def test_catalog_initialization(self, temp_dir):
"""Test catalog initialization."""
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
catalog = ExtensionCatalog(project_dir)
assert catalog.project_root == project_dir
assert catalog.cache_dir == project_dir / ".specify" / "extensions" / ".cache"
def test_cache_directory_creation(self, temp_dir):
"""Test catalog cache directory is created when fetching."""
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
catalog = ExtensionCatalog(project_dir)
# Create mock catalog data
catalog_data = {
"schema_version": "1.0",
"extensions": {
"test-ext": {
"name": "Test Extension",
"id": "test-ext",
"version": "1.0.0",
"description": "Test",
}
},
}
# Manually save to cache to test cache reading
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_text(json.dumps(catalog_data))
catalog.cache_metadata_file.write_text(
json.dumps(
{
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": "http://test.com/catalog.json",
}
)
)
# Should use cache
result = catalog.fetch_catalog()
assert result == catalog_data
def test_cache_expiration(self, temp_dir):
"""Test that expired cache is not used."""
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
catalog = ExtensionCatalog(project_dir)
# Create expired cache
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog_data = {"schema_version": "1.0", "extensions": {}}
catalog.cache_file.write_text(json.dumps(catalog_data))
# Set cache time to 2 hours ago (expired)
expired_time = datetime.now(timezone.utc).timestamp() - 7200
expired_datetime = datetime.fromtimestamp(expired_time, tz=timezone.utc)
catalog.cache_metadata_file.write_text(
json.dumps(
{
"cached_at": expired_datetime.isoformat(),
"catalog_url": "http://test.com/catalog.json",
}
)
)
# Cache should be invalid
assert not catalog.is_cache_valid()
def test_search_all_extensions(self, temp_dir):
"""Test searching all extensions without filters."""
import yaml as yaml_module
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
# Use a single-catalog config so community extensions don't interfere
config_path = project_dir / ".specify" / "extension-catalogs.yml"
with open(config_path, "w") as f:
yaml_module.dump(
{
"catalogs": [
{
"name": "test-catalog",
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
"priority": 1,
"install_allowed": True,
}
]
},
f,
)
catalog = ExtensionCatalog(project_dir)
# Create mock catalog
catalog_data = {
"schema_version": "1.0",
"extensions": {
"jira": {
"name": "Jira Integration",
"id": "jira",
"version": "1.0.0",
"description": "Jira integration",
"author": "Stats Perform",
"tags": ["issue-tracking", "jira"],
"verified": True,
},
"linear": {
"name": "Linear Integration",
"id": "linear",
"version": "0.9.0",
"description": "Linear integration",
"author": "Community",
"tags": ["issue-tracking"],
"verified": False,
},
},
}
# Save to cache
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_text(json.dumps(catalog_data))
catalog.cache_metadata_file.write_text(
json.dumps(
{
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": "http://test.com",
}
)
)
# Search without filters
results = catalog.search()
assert len(results) == 2
@pytest.mark.parametrize(
"downloads",
[
"1500", # plain string: crashed the ``:,`` format
"[/red]foo", # unbalanced closing tag: raises MarkupError unescaped
"[bold]x[/bold]", # balanced tags: would silently restyle the output
],
)
def test_info_renders_non_numeric_downloads(self, downloads):
"""A non-numeric ``downloads`` from an untrusted catalog must not crash the
info renderer — neither with 'Cannot specify ',' with 's'' (the ``:,``
format) nor with a Rich MarkupError (the joined stats are markup)."""
from unittest.mock import MagicMock
from specify_cli.extensions._commands import _print_extension_info
manager = MagicMock()
manager.registry.is_installed.return_value = False
ext_info = {
"name": "Jira", "id": "jira", "version": "1.0.0",
"description": "desc", "downloads": downloads, # from catalog JSON
}
# Must not raise ValueError or rich.errors.MarkupError.
_print_extension_info(ext_info, manager)
def test_info_renders_markup_bearing_stars(self):
"""``stars`` sits in the same joined stats string as ``downloads`` and is
equally catalog-controlled, so it must be escaped too."""
from unittest.mock import MagicMock
from specify_cli.extensions._commands import _print_extension_info
manager = MagicMock()
manager.registry.is_installed.return_value = False
ext_info = {
"name": "Jira", "id": "jira", "version": "1.0.0",
"description": "desc", "stars": "[/red]x",
}
_print_extension_info(ext_info, manager) # must not raise MarkupError
@pytest.mark.parametrize("downloads", ["1500", "[/red]foo"])
def test_search_survives_non_numeric_downloads(self, temp_dir, downloads):
"""`specify extension search` must not abort when a catalog entry's
``downloads`` is a non-numeric string — not with a raw ValueError from the
``:,`` format, nor with a Rich MarkupError from unescaped markup."""
import yaml as yaml_module
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
config_path = project_dir / ".specify" / "extension-catalogs.yml"
with open(config_path, "w") as f:
yaml_module.dump(
{"catalogs": [{
"name": "test-catalog",
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
"priority": 1, "install_allowed": True,
}]}, f,
)
catalog = ExtensionCatalog(project_dir)
catalog_data = {
"schema_version": "1.0",
"extensions": {"jira": {
"name": "Jira", "id": "jira", "version": "1.0.0",
"description": "Jira integration", "author": "x",
"tags": ["jira"], "verified": True,
"downloads": downloads, # non-numeric, straight from catalog JSON
}},
}
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_text(json.dumps(catalog_data))
catalog.cache_metadata_file.write_text(json.dumps({
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": "http://test.com",
}))
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["extension", "search"], catch_exceptions=True)
assert result.exit_code == 0, result.output
# Rendered literally (escaped), not interpreted as markup or dropped.
assert f"Downloads: {downloads}" in result.output
def test_search_by_query(self, temp_dir):
"""Test searching by query text."""
import yaml as yaml_module
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
# Use a single-catalog config so community extensions don't interfere
config_path = project_dir / ".specify" / "extension-catalogs.yml"
with open(config_path, "w") as f:
yaml_module.dump(
{
"catalogs": [
{
"name": "test-catalog",
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
"priority": 1,
"install_allowed": True,
}
]
},
f,
)
catalog = ExtensionCatalog(project_dir)
# Create mock catalog
catalog_data = {
"schema_version": "1.0",
"extensions": {
"jira": {
"name": "Jira Integration",
"id": "jira",
"version": "1.0.0",
"description": "Jira issue tracking",
"tags": ["jira"],
},
"linear": {
"name": "Linear Integration",
"id": "linear",
"version": "1.0.0",
"description": "Linear project management",
"tags": ["linear"],
},
},
}
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_text(json.dumps(catalog_data))
catalog.cache_metadata_file.write_text(
json.dumps(
{
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": "http://test.com",
}
)
)
# Search for "jira"
results = catalog.search(query="jira")
assert len(results) == 1
assert results[0]["id"] == "jira"
def test_search_by_tag(self, temp_dir):
"""Test searching by tag."""
import yaml as yaml_module
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
# Use a single-catalog config so community extensions don't interfere
config_path = project_dir / ".specify" / "extension-catalogs.yml"
with open(config_path, "w") as f:
yaml_module.dump(
{
"catalogs": [
{
"name": "test-catalog",
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
"priority": 1,
"install_allowed": True,
}
]
},
f,
)
catalog = ExtensionCatalog(project_dir)
# Create mock catalog
catalog_data = {
"schema_version": "1.0",
"extensions": {
"jira": {
"name": "Jira",
"id": "jira",
"version": "1.0.0",
"description": "Jira",
"tags": ["issue-tracking", "jira"],
},
"linear": {
"name": "Linear",
"id": "linear",
"version": "1.0.0",
"description": "Linear",
"tags": ["issue-tracking", "linear"],
},
"github": {
"name": "GitHub",
"id": "github",
"version": "1.0.0",
"description": "GitHub",
"tags": ["vcs", "github"],
},
},
}
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_text(json.dumps(catalog_data))
catalog.cache_metadata_file.write_text(
json.dumps(
{
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": "http://test.com",
}
)
)
# Search by tag "issue-tracking"
results = catalog.search(tag="issue-tracking")
assert len(results) == 2
assert {r["id"] for r in results} == {"jira", "linear"}
def test_search_tolerates_non_string_tags(self, temp_dir):
"""Non-string catalog tags must not crash tag/query search.
Catalog JSON is user-editable, so ``tags`` may contain non-strings
(e.g. ``tags: [1, 2]``). The tag filter (``t.lower()``) and the query
searchable-text ``" ".join(...)`` both assume strings; a numeric tag
must be skipped rather than raising AttributeError/TypeError.
"""
import yaml as yaml_module
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
config_path = project_dir / ".specify" / "extension-catalogs.yml"
with open(config_path, "w") as f:
yaml_module.dump(
{
"catalogs": [
{
"name": "test-catalog",
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
"priority": 1,
"install_allowed": True,
}
]
},
f,
)
catalog = ExtensionCatalog(project_dir)
# Mixed string / non-string tags, mirroring hand-edited catalog JSON.
catalog_data = {
"schema_version": "1.0",
"extensions": {
"jira": {
"name": "Jira",
"id": "jira",
"version": "1.0.0",
"description": "Jira",
"tags": ["issue-tracking", 1, 2],
},
},
}
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_text(json.dumps(catalog_data))
catalog.cache_metadata_file.write_text(
json.dumps(
{
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": "http://test.com",
}
)
)
# Tag filter: numeric tags skipped, string tag still matches.
results = catalog.search(tag="issue-tracking")
assert {r["id"] for r in results} == {"jira"}
# Query search: numeric tags skipped, no crash, string fields match.
results = catalog.search(query="jira")
assert {r["id"] for r in results} == {"jira"}
def test_search_and_info_tolerate_non_list_tags(self, temp_dir):
"""A scalar ``tags:`` value must not crash the search/info display.
``ExtensionCatalog.search`` guards its tag *filter* with
``isinstance(raw_tags, list)``, but the ``extension search`` and
``extension info`` display paths only tested truthiness before
iterating. ``tags: 5`` is truthy and not iterable, so both raised
``TypeError: 'int' object is not iterable``.
"""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
merged = [{
"id": "jira",
"name": "Jira",
"version": "1.0.0",
"description": "Jira",
"tags": 5,
}]
with patch.object(ExtensionCatalog, "_get_merged_extensions", return_value=merged), \
patch("specify_cli.extensions._commands._require_specify_project",
return_value=project_dir):
searched = CliRunner().invoke(app, ["extension", "search", "Jira"])
info = CliRunner().invoke(app, ["extension", "info", "jira"])
assert searched.exit_code == 0, searched.output
assert "Jira" in searched.output
assert "Tags:" not in searched.output
assert info.exit_code == 0, info.output
assert "Tags:" not in info.output
def test_search_tolerates_non_string_author_and_name(self, temp_dir):
"""Non-string catalog author/name must not crash author/query search.
Catalog JSON is user-editable, so ``author`` and ``name`` may be
non-strings. The author filter (``.lower()``) and the query
searchable-text ``" ".join(...)`` both assume strings; a numeric
value must be coerced rather than raising AttributeError/TypeError.
"""
import yaml as yaml_module
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
config_path = project_dir / ".specify" / "extension-catalogs.yml"
with open(config_path, "w") as f:
yaml_module.dump(
{
"catalogs": [
{
"name": "test-catalog",
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
"priority": 1,
"install_allowed": True,
}
]
},
f,
)
catalog = ExtensionCatalog(project_dir)
# Numeric author/name, mirroring hand-edited catalog JSON.
catalog_data = {
"schema_version": "1.0",
"extensions": {
"jira": {
"name": 123,
"id": "jira",
"version": "1.0.0",
"description": "Jira",
"author": 456,
},
},
}
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_text(json.dumps(catalog_data))
catalog.cache_metadata_file.write_text(
json.dumps(
{
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": "http://test.com",
}
)
)
# Author filter: non-string author coerced, no AttributeError.
results = catalog.search(author="456")
assert {r["id"] for r in results} == {"jira"}
# Query search: non-string name coerced into searchable text.
results = catalog.search(query="123")
assert {r["id"] for r in results} == {"jira"}
def test_search_verified_only(self, temp_dir):
"""Test searching verified extensions only."""
import yaml as yaml_module
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
# Use a single-catalog config so community extensions don't interfere
config_path = project_dir / ".specify" / "extension-catalogs.yml"
with open(config_path, "w") as f:
yaml_module.dump(
{
"catalogs": [
{
"name": "test-catalog",
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
"priority": 1,
"install_allowed": True,
}
]
},
f,
)
catalog = ExtensionCatalog(project_dir)
# Create mock catalog
catalog_data = {
"schema_version": "1.0",
"extensions": {
"jira": {
"name": "Jira",
"id": "jira",
"version": "1.0.0",
"description": "Jira",
"verified": True,
},
"linear": {
"name": "Linear",
"id": "linear",
"version": "1.0.0",
"description": "Linear",
"verified": False,
},
},
}
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_text(json.dumps(catalog_data))
catalog.cache_metadata_file.write_text(
json.dumps(
{
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": "http://test.com",
}
)
)
# Search verified only
results = catalog.search(verified_only=True)
assert len(results) == 1
assert results[0]["id"] == "jira"
def test_get_extension_info(self, temp_dir):
"""Test getting specific extension info."""
import yaml as yaml_module
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
# Use a single-catalog config so community extensions don't interfere
config_path = project_dir / ".specify" / "extension-catalogs.yml"
with open(config_path, "w") as f:
yaml_module.dump(
{
"catalogs": [
{
"name": "test-catalog",
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
"priority": 1,
"install_allowed": True,
}
]
},
f,
)
catalog = ExtensionCatalog(project_dir)
# Create mock catalog
catalog_data = {
"schema_version": "1.0",
"extensions": {
"jira": {
"name": "Jira Integration",
"id": "jira",
"version": "1.0.0",
"description": "Jira integration",
"author": "Stats Perform",
},
},
}
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_text(json.dumps(catalog_data))
catalog.cache_metadata_file.write_text(
json.dumps(
{
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": "http://test.com",
}
)
)
# Get extension info
info = catalog.get_extension_info("jira")
assert info is not None
assert info["id"] == "jira"
assert info["name"] == "Jira Integration"
# Non-existent extension
info = catalog.get_extension_info("nonexistent")
assert info is None
def test_clear_cache(self, temp_dir):
"""Test clearing catalog cache."""
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
catalog = ExtensionCatalog(project_dir)
# Create cache
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_text("{}")
catalog.cache_metadata_file.write_text("{}")
assert catalog.cache_file.exists()
assert catalog.cache_metadata_file.exists()
# Clear cache
catalog.clear_cache()
assert not catalog.cache_file.exists()
assert not catalog.cache_metadata_file.exists()
# --- _make_request / GitHub auth ---
def _make_catalog(self, temp_dir):
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
return ExtensionCatalog(project_dir)
def _inject_github_config(self, monkeypatch, token_env="GH_TOKEN"):
from tests.auth_helpers import inject_github_config
inject_github_config(monkeypatch, token_env)
def test_make_request_no_token_no_auth_header(self, temp_dir, monkeypatch):
"""Without a token, requests carry no Authorization header."""
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
monkeypatch.delenv("GH_TOKEN", raising=False)
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
assert "Authorization" not in req.headers
def test_make_request_whitespace_only_github_token_ignored(self, temp_dir, monkeypatch):
"""A whitespace-only GITHUB_TOKEN is treated as unset."""
monkeypatch.setenv("GITHUB_TOKEN", " ")
monkeypatch.delenv("GH_TOKEN", raising=False)
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
assert "Authorization" not in req.headers
def test_make_request_whitespace_github_token_falls_back_to_gh_token(self, temp_dir, monkeypatch):
"""When GITHUB_TOKEN is whitespace-only, GH_TOKEN is used as fallback."""
monkeypatch.setenv("GITHUB_TOKEN", " ")
monkeypatch.setenv("GH_TOKEN", "ghp_fallback")
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
assert req.get_header("Authorization") == "Bearer ghp_fallback"
def test_make_request_github_token_added_for_raw_githubusercontent(self, temp_dir, monkeypatch):
"""GITHUB_TOKEN is attached for raw.githubusercontent.com URLs."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
monkeypatch.delenv("GH_TOKEN", raising=False)
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
def test_make_request_gh_token_fallback(self, temp_dir, monkeypatch):
"""GH_TOKEN is used when GITHUB_TOKEN is absent."""
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
monkeypatch.setenv("GH_TOKEN", "ghp_ghtoken")
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://github.com/org/repo/releases/download/v1/ext.zip")
assert req.get_header("Authorization") == "Bearer ghp_ghtoken"
def test_make_request_gh_token_takes_precedence_over_github_token(self, temp_dir, monkeypatch):
"""When auth.json uses GH_TOKEN, that token is used regardless of GITHUB_TOKEN."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_secondary")
monkeypatch.setenv("GH_TOKEN", "ghp_primary")
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://api.github.com/repos/org/repo")
assert req.get_header("Authorization") == "Bearer ghp_primary"
def test_make_request_no_auth_for_non_matching_host(self, temp_dir, monkeypatch):
"""Auth is NOT attached to hosts not listed in auth.json."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://internal.example.com/catalog.json")
assert "Authorization" not in req.headers
def test_make_request_no_auth_when_no_config(self, temp_dir, monkeypatch):
"""No auth header when no auth.json config exists."""
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
monkeypatch.delenv("GH_TOKEN", raising=False)
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://github.com/org/repo/releases/download/v1/ext.zip")
assert "Authorization" not in req.headers
def test_make_request_token_added_for_api_github_com(self, temp_dir, monkeypatch):
"""GITHUB_TOKEN is attached for api.github.com URLs."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://api.github.com/repos/org/repo/releases/assets/1")
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
def test_make_request_token_added_for_codeload_github_com(self, temp_dir, monkeypatch):
"""GITHUB_TOKEN is attached for codeload.github.com URLs (GitHub archive redirects)."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://codeload.github.com/org/repo/zip/refs/tags/v1.0.0")
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
def test_fetch_single_catalog_sends_auth_header(self, temp_dir, monkeypatch):
"""_fetch_single_catalog passes Authorization header when a provider is configured."""
from unittest.mock import patch, MagicMock
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
catalog = self._make_catalog(temp_dir)
catalog_data = {"schema_version": "1.0", "extensions": {}}
mock_response = MagicMock()
mock_response.read.side_effect = io.BytesIO(json.dumps(catalog_data).encode()).read
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://raw.githubusercontent.com/org/repo/main/catalog.json"
captured = {}
mock_opener = MagicMock()
def fake_open(req, timeout=None):
captured["req"] = req
return mock_response
mock_opener.open.side_effect = fake_open
entry = CatalogEntry(
url="https://raw.githubusercontent.com/org/repo/main/catalog.json",
name="private",
priority=1,
install_allowed=True,
)
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
catalog._fetch_single_catalog(entry, force_refresh=True)
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
def test_fetch_single_catalog_revalidates_redirected_url(self, temp_dir):
"""An HTTPS catalog URL that redirects to http:// must be rejected AFTER
the redirect. _open_url follows redirects (auth stripped on downgrade),
so without re-validating response.geturl() the http payload would still
be fetched and trusted — and it supplies each extension's download_url +
sha256, defeating sha256 verification. Parity with the
integrations/presets/workflows catalog fetchers."""
from unittest.mock import patch, MagicMock
catalog = self._make_catalog(temp_dir)
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(
{"schema_version": "1.0", "extensions": {}}
).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "http://evil.test/catalog.json"
entry = CatalogEntry(
url="https://good.example/catalog.json",
name="c",
priority=1,
install_allowed=True,
)
with patch.object(catalog, "_open_url", return_value=mock_response):
with pytest.raises(ExtensionError, match="HTTPS"):
catalog._fetch_single_catalog(entry, force_refresh=True)
def test_fetch_single_catalog_validates_every_redirect_hop(self, temp_dir):
"""A redirect_validator is passed to _open_url and rejects a non-HTTPS
INTERMEDIATE hop — closing the https -> http -> attacker-https chain a
terminal-URL-only check would miss."""
catalog = self._make_catalog(temp_dir)
captured = {}
def fake_open(url, timeout=None, extra_headers=None, redirect_validator=None):
captured["rv"] = redirect_validator
redirect_validator("https://good.example/catalog.json", "http://evil.test/hop")
raise AssertionError("redirect_validator should have raised")
catalog._open_url = fake_open
entry = CatalogEntry(
url="https://good.example/catalog.json",
name="c",
priority=1,
install_allowed=True,
)
with pytest.raises(ExtensionError, match="HTTPS"):
catalog._fetch_single_catalog(entry, force_refresh=True)
assert captured["rv"] is not None
def test_fetch_catalog_legacy_revalidates_redirected_url(self, temp_dir):
"""The legacy single-catalog fetch_catalog() path also rejects an
HTTPS -> http redirected payload (final geturl() check) — it previously
parsed the body with no redirect check."""
from unittest.mock import patch, MagicMock
catalog = self._make_catalog(temp_dir)
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(
{"schema_version": "1.0", "extensions": {}}
).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "http://evil.test/catalog.json"
with patch.object(catalog, "_open_url", return_value=mock_response):
with pytest.raises(ExtensionError, match="HTTPS"):
catalog.fetch_catalog(force_refresh=True)
def test_fetch_catalog_legacy_validates_every_redirect_hop(self, temp_dir):
"""The legacy fetch_catalog() path also validates every INTERMEDIATE hop
(not just the terminal URL): it must supply a redirect_validator that
rejects an insecure hop, so an https -> http -> https chain is caught."""
catalog = self._make_catalog(temp_dir)
captured = {}
def fake_open(url, timeout=None, extra_headers=None, redirect_validator=None):
captured["rv"] = redirect_validator
redirect_validator(url, "http://evil.test/hop")
raise AssertionError("redirect_validator should have raised")
catalog._open_url = fake_open
with pytest.raises(ExtensionError, match="HTTPS"):
catalog.fetch_catalog(force_refresh=True)
assert captured["rv"] is not None
@pytest.mark.parametrize(
"payload",
[
# Root is not a JSON object.
[],
"oops",
42,
None,
# Root is fine but ``extensions`` is the wrong type.
{"schema_version": "1.0", "extensions": []},
{"schema_version": "1.0", "extensions": "oops"},
{"schema_version": "1.0", "extensions": None},
{"schema_version": "1.0", "extensions": 42},
],
)
def test_fetch_single_catalog_rejects_malformed_payload(self, temp_dir, payload):
"""Malformed catalog payloads raise ExtensionError, not AttributeError.
Without this guard, a payload like ``{"extensions": []}`` would pass the
key-presence check and then crash with ``AttributeError: 'list' object
has no attribute 'items'`` deep inside ``_get_merged_extensions``. The
sibling integration catalog reader already validates both the root
object and the nested mapping (see ``integrations/catalog.py``); the
extension catalog must stay consistent.
"""
from unittest.mock import patch, MagicMock
catalog = self._make_catalog(temp_dir)
mock_response = MagicMock()
mock_response.read.side_effect = io.BytesIO(json.dumps(payload).encode()).read
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://example.com/catalog.json"
entry = CatalogEntry(
url="https://example.com/catalog.json",
name="default",
priority=1,
install_allowed=True,
)
with patch.object(catalog, "_open_url", return_value=mock_response):
with pytest.raises(ExtensionError, match="Invalid catalog format"):
catalog._fetch_single_catalog(entry, force_refresh=True)
@pytest.mark.parametrize(
"cached_payload",
[
[],
"oops",
42,
None,
{"schema_version": "1.0", "extensions": []},
{"schema_version": "1.0", "extensions": "oops"},
{"schema_version": "1.0", "extensions": None},
],
)
def test_fetch_single_catalog_rejects_malformed_cached_payload(
self, temp_dir, cached_payload
):
"""A poisoned cache silently falls back to the network instead of
crashing — cached payloads pass through the same shape validation
as freshly-fetched ones.
Without this, a cache poisoned by an older spec-kit version (or a
manual edit, or an upstream that briefly served a bad payload
before the network guards landed) would re-crash every invocation
of ``_get_merged_extensions`` despite the cache being "valid" by
age. The recovery contract is: if the cached payload fails
validation, drop it and refetch — never propagate
``AttributeError`` to the caller.
"""
from unittest.mock import patch, MagicMock
catalog = self._make_catalog(temp_dir)
# Poison the default-URL cache. ``DEFAULT_CATALOG_URL`` is the
# branch that goes through ``is_cache_valid()`` (the non-default
# branch uses per-URL hashed cache files but the same code path
# below).
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_text(json.dumps(cached_payload))
catalog.cache_metadata_file.write_text(
json.dumps(
{
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": ExtensionCatalog.DEFAULT_CATALOG_URL,
}
)
)
# Network refetch returns a valid payload so the recovery path
# can complete.
valid = {
"schema_version": "1.0",
"extensions": {"foo": {"name": "Foo", "version": "1.0.0"}},
}
mock_response = MagicMock()
mock_response.read.side_effect = io.BytesIO(json.dumps(valid).encode()).read
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://example.com/catalog.json"
entry = CatalogEntry(
url=ExtensionCatalog.DEFAULT_CATALOG_URL,
name="default",
priority=1,
install_allowed=True,
)
with patch.object(catalog, "_open_url", return_value=mock_response):
result = catalog._fetch_single_catalog(entry, force_refresh=False)
# The poisoned cache was discarded and the network payload returned.
assert result == valid
@pytest.mark.parametrize(
"payload",
[
# Root is not a JSON object.
[],
"oops",
42,
None,
# Root is fine but ``extensions`` is the wrong type.
{"schema_version": "1.0", "extensions": []},
{"schema_version": "1.0", "extensions": "oops"},
{"schema_version": "1.0", "extensions": None},
],
)
def test_fetch_catalog_rejects_malformed_payload(self, temp_dir, payload):
"""Legacy ``fetch_catalog`` reuses the same shape-validation helper.
Before this change ``fetch_catalog`` only checked key presence — so
a payload like ``42`` would crash with
``TypeError: argument of type 'int' is not iterable`` during the
``"schema_version" in catalog_data`` check, and an entry mapping
of the wrong type would crash downstream. Reusing
``_validate_catalog_payload`` keeps the network-side behaviour of
the legacy single-catalog method consistent with the multi-catalog
``_fetch_single_catalog`` path.
"""
from unittest.mock import patch, MagicMock
catalog = self._make_catalog(temp_dir)
mock_response = MagicMock()
mock_response.read.side_effect = io.BytesIO(json.dumps(payload).encode()).read
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://example.com/catalog.json"
with patch.object(catalog, "_open_url", return_value=mock_response):
with pytest.raises(ExtensionError, match="Invalid catalog format"):
catalog.fetch_catalog(force_refresh=True)
def test_fetch_catalog_recovers_from_unreadable_cache(self, temp_dir):
"""An unreadable / wrong-encoded cache file silently refetches.
The cache contract is best-effort: a JSON-decode failure, an OS
read failure (permissions / disk / handle limit), or an invalid
text encoding on a cache file written by an older client must
all fall through to the network fetch rather than crash the
caller. Covers Copilot's review point that the previous
``except (json.JSONDecodeError,)`` was too narrow.
"""
from unittest.mock import patch, MagicMock
catalog = self._make_catalog(temp_dir)
# Write invalid UTF-8 bytes to the cache file so ``read_text``
# raises ``UnicodeDecodeError`` (a subclass of ``UnicodeError``).
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_bytes(b"\xff\xfe\x00not-utf-8")
catalog.cache_metadata_file.write_text(
json.dumps(
{
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": ExtensionCatalog.DEFAULT_CATALOG_URL,
}
),
encoding="utf-8",
)
valid = {
"schema_version": "1.0",
"extensions": {"foo": {"name": "Foo", "version": "1.0.0"}},
}
mock_response = MagicMock()
mock_response.read.side_effect = io.BytesIO(json.dumps(valid).encode()).read
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://example.com/catalog.json"
with patch.object(catalog, "_open_url", return_value=mock_response):
result = catalog.fetch_catalog(force_refresh=False)
# Recovered via network rather than crashing on the unreadable cache.
assert result == valid
def test_fetch_catalog_recovers_from_unreadable_metadata(self, temp_dir):
"""A wrongly-encoded metadata file degrades to a cache miss.
``is_cache_valid`` is consulted *before* the cache payload is
read; if the metadata file itself can't be decoded (e.g. it was
written on a Windows host whose default codec isn't UTF-8) the
validity check must return ``False`` rather than propagate
``UnicodeDecodeError``. Without that guard, a corrupted metadata
file would crash every invocation instead of falling through to
a network refetch.
"""
from unittest.mock import patch, MagicMock
catalog = self._make_catalog(temp_dir)
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_text("{}", encoding="utf-8")
# Bytes that are not valid UTF-8 — ``read_text(encoding="utf-8")``
# will raise ``UnicodeDecodeError`` (subclass of ``UnicodeError``).
catalog.cache_metadata_file.write_bytes(b"\xff\xfe\x00bad")
# is_cache_valid must absorb the decode failure, not crash.
assert catalog.is_cache_valid() is False
valid = {
"schema_version": "1.0",
"extensions": {"foo": {"name": "Foo", "version": "1.0.0"}},
}
mock_response = MagicMock()
mock_response.read.side_effect = io.BytesIO(json.dumps(valid).encode()).read
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://example.com/catalog.json"
with patch.object(catalog, "_open_url", return_value=mock_response):
result = catalog.fetch_catalog(force_refresh=False)
assert result == valid
@pytest.mark.parametrize(
"non_mapping_metadata",
[
"[]", # JSON array
'"oops"', # JSON string
"42", # JSON number
"true", # JSON bool
"null", # JSON null
],
)
def test_is_cache_valid_handles_non_mapping_metadata(
self, temp_dir, non_mapping_metadata
):
"""Metadata that parses to a non-mapping degrades to cache-invalid.
The cache-validity check calls ``metadata.get("cached_at", "")``
immediately after ``json.loads``. If the metadata file is valid
JSON but parses to a non-mapping (``[]``, ``"oops"``, ``42``,
``true``, ``null``), ``.get`` raises ``AttributeError`` — which
previously slipped past the except tuple and crashed the
caller. The contract documented on ``is_cache_valid`` says any
decode/shape failure should return ``False`` so ``fetch_catalog``
falls through to a network refetch. This test pins that
contract across every JSON non-mapping root type so a regression
in the except clause can't silently re-introduce the crash.
"""
catalog = self._make_catalog(temp_dir)
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_text("{}", encoding="utf-8")
catalog.cache_metadata_file.write_text(
non_mapping_metadata, encoding="utf-8"
)
# Must not raise — the contract is "any decode/shape failure → False".
assert catalog.is_cache_valid() is False
def test_fetch_catalog_writes_cache_as_utf8(self, temp_dir, monkeypatch):
"""Cache + metadata writes pass ``encoding="utf-8"``, observably.
The earlier version of this test claimed to assert UTF-8 at the
byte level but actually only round-tripped a non-ASCII string
through ``json.dumps`` and ``read_text(encoding="utf-8")``.
Because ``json.dumps`` defaults to ``ensure_ascii=True``, "café"
was serialized as the all-ASCII escape ``caf\\u00e9`` before it
ever reached ``write_text`` — the bytes on disk were identical
regardless of the encoding kwarg, so a locale-encoded write
would have round-tripped just fine. The drift Copilot's review
flagged wasn't actually being caught.
Fix: directly observe the ``encoding`` argument passed to every
``write_text`` call made against the cache directory. This is
the production code's encoding choice, which is exactly what
the regression guard cares about; non-ASCII payload tricks are
unnecessary because the assertion is about the kwarg, not the
bytes.
"""
from unittest.mock import patch, MagicMock
from pathlib import Path as _PathCls
catalog = self._make_catalog(temp_dir)
payload = {
"schema_version": "1.0",
"extensions": {"foo": {"name": "Foo", "version": "1.0.0"}},
}
mock_response = MagicMock()
mock_response.read.side_effect = io.BytesIO(json.dumps(payload).encode("utf-8")).read
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://example.com/catalog.json"
# Record every ``write_text`` call's encoding kwarg so the
# assertion observes the production writer's argument directly.
recorded: list[dict] = []
real_write_text = _PathCls.write_text
def recording_write_text(self, data, *args, **kwargs):
recorded.append(
{"path": str(self), "encoding": kwargs.get("encoding")}
)
return real_write_text(self, data, *args, **kwargs)
monkeypatch.setattr(_PathCls, "write_text", recording_write_text)
with patch.object(catalog, "_open_url", return_value=mock_response):
catalog.fetch_catalog(force_refresh=True)
# Filter to writes inside the catalog's cache directory so
# unrelated writes from other machinery don't pollute the
# assertion.
cache_writes = [
r for r in recorded if str(catalog.cache_dir) in r["path"]
]
assert cache_writes, "fetch_catalog made no writes to the cache dir"
for record in cache_writes:
assert record["encoding"] == "utf-8", (
f"write_text on {record['path']} used encoding "
f"{record['encoding']!r}; expected 'utf-8'"
)
def test_fetch_catalog_survives_unwritable_cache(self, temp_dir, monkeypatch):
"""An unwritable cache dir doesn't fail a successful fetch.
Cache writes are best-effort, mirroring the read side and the
``integrations/catalog.py`` precedent: if ``mkdir``/``write_text``
raises ``OSError`` (read-only checkout, permissions), the
already-fetched-and-validated payload must still be returned
rather than surfacing the cache failure to the caller.
"""
from unittest.mock import patch, MagicMock
from pathlib import Path as _PathCls
catalog = self._make_catalog(temp_dir)
valid = {
"schema_version": "1.0",
"extensions": {"foo": {"name": "Foo", "version": "1.0.0"}},
}
def make_response():
mock_response = MagicMock()
mock_response.read.side_effect = io.BytesIO(json.dumps(valid).encode()).read
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://example.com/catalog.json"
return mock_response
# Simulate an unwritable cache dir: every write_text under the
# cache directory raises PermissionError (an OSError subclass).
real_write_text = _PathCls.write_text
def failing_write_text(self, data, *args, **kwargs):
if str(catalog.cache_dir) in str(self):
raise PermissionError("cache dir is read-only")
return real_write_text(self, data, *args, **kwargs)
monkeypatch.setattr(_PathCls, "write_text", failing_write_text)
with patch.object(catalog, "_open_url", side_effect=lambda *a, **kw: make_response()):
# Legacy single-catalog path.
assert catalog.fetch_catalog(force_refresh=True) == valid
# Multi-catalog path.
entry = CatalogEntry(
url="https://example.com/catalog.json",
name="default",
priority=1,
install_allowed=True,
)
assert catalog._fetch_single_catalog(entry, force_refresh=True) == valid
def test_get_merged_extensions_skips_non_mapping_entries(self, temp_dir):
"""Per-entry guard: one malformed entry shouldn't poison the merge.
``_fetch_single_catalog`` validates that ``extensions`` is a mapping,
but it doesn't (and shouldn't) validate every entry inside it — a
single bad entry in an otherwise-valid catalog should be skipped, not
crash the whole resolve path. Mirrors the per-entry skip in
``integrations/catalog.py``: a malformed entry returns no error,
valid entries continue to merge normally.
"""
from unittest.mock import patch, MagicMock
catalog = self._make_catalog(temp_dir)
# Mix of valid entry, list-shaped entry, and string-shaped entry.
payload = {
"schema_version": "1.0",
"extensions": {
"good": {"name": "Good", "version": "1.0.0"},
"bad-list": [],
"bad-str": "oops",
},
}
mock_response = MagicMock()
mock_response.read.side_effect = io.BytesIO(json.dumps(payload).encode()).read
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://example.com/catalog.json"
entry = CatalogEntry(
url="https://example.com/catalog.json",
name="default",
priority=1,
install_allowed=True,
)
with patch.object(catalog, "_open_url", return_value=mock_response), \
patch.object(catalog, "get_active_catalogs", return_value=[entry]):
merged = catalog._get_merged_extensions(force_refresh=True)
# Only the well-formed entry survives; the two malformed entries are
# silently dropped rather than raising or crashing.
assert [ext["id"] for ext in merged] == ["good"]
def test_download_extension_sends_auth_header(self, temp_dir, monkeypatch):
"""download_extension passes Authorization header when a provider is configured."""
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)
# Build a minimal valid ZIP in memory
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()
release_response = MagicMock()
release_response.read.side_effect = io.BytesIO(json.dumps(
{
"assets": [
{
"name": "test-ext.zip",
"url": "https://api.github.com/repos/org/repo/releases/assets/1",
}
]
}
).encode()).read
release_response.__enter__ = lambda s: s
release_response.__exit__ = MagicMock(return_value=False)
asset_response = MagicMock()
asset_response.read.side_effect = io.BytesIO(zip_bytes).read
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)
if req.full_url.endswith("/releases/tags/v1"):
return release_response
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://github.com/org/repo/releases/download/v1/test-ext.zip",
}
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 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 _make_zip_bytes(self):
"""Build a minimal valid extension ZIP in memory for download tests."""
import zipfile
import io
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("extension.yml", "id: test-ext\nname: Test\nversion: 1.0.0\n")
return buf.getvalue()
def _mock_response(self, data):
"""Build a context-manager mock HTTP response returning ``data``."""
from unittest.mock import MagicMock
resp = MagicMock()
resp.read.side_effect = io.BytesIO(data).read
# Configure the context-manager protocol explicitly so `with resp`
# yields `resp` itself, independent of how the protocol is invoked.
resp.__enter__.return_value = resp
resp.__exit__.return_value = False
return resp
def test_fetch_single_catalog_rejects_oversized_body_without_cache(
self, temp_dir, monkeypatch
):
"""Catalog bounds are enforced at the extension call site."""
from unittest.mock import patch
catalog = self._make_catalog(temp_dir)
entry = CatalogEntry(
url="https://example.com/catalog.json",
name="default",
priority=1,
install_allowed=True,
)
body = b'{"schema_version":"1.0","extensions":{}}'
response = self._mock_response(body)
response.geturl.return_value = entry.url
monkeypatch.setattr(_ext_module, "MAX_JSON_CATALOG_BYTES", len(body) - 1)
with patch.object(catalog, "_open_url", return_value=response):
with pytest.raises(ExtensionError, match="exceeds maximum size"):
catalog._fetch_single_catalog(entry, force_refresh=True)
assert not catalog.cache_dir.exists() or not any(catalog.cache_dir.iterdir())
def test_download_extension_rejects_oversized_body_without_output(
self, temp_dir, monkeypatch
):
"""Package bounds fail before checksum verification or disk writes."""
from unittest.mock import patch
from specify_cli._download_security import (
read_response_limited as real_read_response_limited,
)
catalog = self._make_catalog(temp_dir)
ext_info = {
"id": "test-ext",
"name": "Test Extension",
"version": "1.0.0",
"download_url": "https://example.com/test-ext.zip",
}
def read_with_tiny_limit(response, **kwargs):
kwargs.pop("max_bytes", None)
return real_read_response_limited(response, max_bytes=4, **kwargs)
monkeypatch.setattr(
_ext_module,
"read_response_limited",
read_with_tiny_limit,
)
with patch.object(_ext_module, "verify_archive_sha256") as verify, \
patch.object(catalog, "get_extension_info", return_value=ext_info), \
patch.object(
catalog,
"_open_url",
return_value=self._mock_response(b"12345"),
):
with pytest.raises(ExtensionError, match="exceeds maximum size"):
catalog.download_extension("test-ext", target_dir=temp_dir)
verify.assert_not_called()
assert not (temp_dir / "test-ext-1.0.0.zip").exists()
def test_download_extension_rejects_unsafe_output_filename(self, temp_dir):
"""Catalog-controlled IDs cannot escape the requested target directory."""
from unittest.mock import patch
catalog = self._make_catalog(temp_dir)
outside_stem = temp_dir.parent / "outside-extension"
extension_id = str(outside_stem)
ext_info = {
"id": extension_id,
"name": "Test Extension",
"version": "1.0.0",
"download_url": "https://example.com/test-ext.zip",
}
with patch.object(catalog, "get_extension_info", return_value=ext_info), \
patch.object(catalog, "_open_url") as open_url:
with pytest.raises(ExtensionError, match="filename"):
catalog.download_extension(extension_id, target_dir=temp_dir)
open_url.assert_not_called()
assert not Path(f"{outside_stem}-1.0.0.zip").exists()
def test_download_extension_accepts_matching_sha256(self, temp_dir):
"""A catalog ``sha256`` that matches the archive is accepted."""
import hashlib
from unittest.mock import patch
catalog = self._make_catalog(temp_dir)
zip_bytes = self._make_zip_bytes()
ext_info = {
"id": "test-ext",
"name": "Test Extension",
"version": "1.0.0",
"download_url": "https://example.com/test-ext.zip",
"sha256": hashlib.sha256(zip_bytes).hexdigest(),
}
with patch.object(catalog, "get_extension_info", return_value=ext_info), \
patch.object(catalog, "_open_url", return_value=self._mock_response(zip_bytes)):
zip_path = catalog.download_extension("test-ext", target_dir=temp_dir)
assert zip_path.read_bytes() == zip_bytes
def test_download_extension_rejects_sha256_mismatch(self, temp_dir):
"""A catalog ``sha256`` that does not match the downloaded archive
aborts the install — a tampered or swapped archive is rejected.
"""
from unittest.mock import patch
catalog = self._make_catalog(temp_dir)
zip_bytes = self._make_zip_bytes()
ext_info = {
"id": "test-ext",
"name": "Test Extension",
"version": "1.0.0",
"download_url": "https://example.com/test-ext.zip",
"sha256": "0" * 64, # deliberately wrong
}
with patch.object(catalog, "get_extension_info", return_value=ext_info), \
patch.object(catalog, "_open_url", return_value=self._mock_response(zip_bytes)):
with pytest.raises(ExtensionError, match="[Ii]ntegrity"):
catalog.download_extension("test-ext", target_dir=temp_dir)
def test_download_extension_malformed_url_raises_extension_error(self, temp_dir):
"""A catalog ``download_url`` with a malformed authority (e.g. an
unterminated IPv6 bracket) surfaces a clean ``ExtensionError`` rather
than leaking a raw ``ValueError`` from ``urlparse``/``.hostname`` past
the command handler (which only catches ``ExtensionError``).
"""
from unittest.mock import patch
catalog = self._make_catalog(temp_dir)
for bad_url in (
"https://[::1",
"https://[not-an-ip]/x",
"https://example.com:65536/x",
"https:///x",
123,
):
ext_info = {
"id": "test-ext",
"name": "Test Extension",
"version": "1.0.0",
"download_url": bad_url,
}
with patch.object(catalog, "get_extension_info", return_value=ext_info), \
patch.object(catalog, "_open_url") as open_url:
with pytest.raises(ExtensionError, match="malformed"):
catalog.download_extension("test-ext", target_dir=temp_dir)
open_url.assert_not_called()
def test_download_extension_without_sha256_still_succeeds(self, temp_dir):
"""Entries without ``sha256`` keep working (backwards compatible)."""
from unittest.mock import patch
catalog = self._make_catalog(temp_dir)
zip_bytes = self._make_zip_bytes()
ext_info = {
"id": "test-ext",
"name": "Test Extension",
"version": "1.0.0",
"download_url": "https://example.com/test-ext.zip",
}
with patch.object(catalog, "get_extension_info", return_value=ext_info), \
patch.object(catalog, "_open_url", return_value=self._mock_response(zip_bytes)):
zip_path = catalog.download_extension("test-ext", target_dir=temp_dir)
assert zip_path.read_bytes() == zip_bytes
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.side_effect = io.BytesIO(zip_bytes).read
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"
# ===== CatalogEntry Tests =====
class TestCatalogEntry:
"""Test CatalogEntry dataclass."""
def test_catalog_entry_creation(self):
"""Test creating a CatalogEntry."""
entry = CatalogEntry(
url="https://example.com/catalog.json",
name="test",
priority=1,
install_allowed=True,
)
assert entry.url == "https://example.com/catalog.json"
assert entry.name == "test"
assert entry.priority == 1
assert entry.install_allowed is True
# ===== Catalog Stack Tests =====
class TestCatalogStack:
"""Test multi-catalog stack support."""
def _make_project(self, temp_dir: Path) -> Path:
"""Create a minimal spec-kit project directory."""
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
return project_dir
def _write_catalog_config(self, project_dir: Path, catalogs: list) -> None:
"""Write extension-catalogs.yml to project .specify dir."""
import yaml as yaml_module
config_path = project_dir / ".specify" / "extension-catalogs.yml"
with open(config_path, "w") as f:
yaml_module.dump({"catalogs": catalogs}, f)
def _write_valid_cache(
self, catalog: ExtensionCatalog, extensions: dict, url: str = "http://test.com"
) -> None:
"""Populate the primary cache file with mock extension data."""
catalog_data = {"schema_version": "1.0", "extensions": extensions}
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_text(json.dumps(catalog_data))
catalog.cache_metadata_file.write_text(
json.dumps(
{
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": url,
}
)
)
# --- get_active_catalogs ---
def test_default_stack(self, temp_dir):
"""Default stack includes default and community catalogs."""
project_dir = self._make_project(temp_dir)
catalog = ExtensionCatalog(project_dir)
entries = catalog.get_active_catalogs()
assert len(entries) == 2
assert entries[0].url == ExtensionCatalog.DEFAULT_CATALOG_URL
assert entries[0].name == "default"
assert entries[0].priority == 1
assert entries[0].install_allowed is True
assert entries[1].url == ExtensionCatalog.COMMUNITY_CATALOG_URL
assert entries[1].name == "community"
assert entries[1].priority == 2
assert entries[1].install_allowed is False
def test_env_var_overrides_default_stack(self, temp_dir, monkeypatch):
"""SPECKIT_CATALOG_URL replaces the entire default stack."""
project_dir = self._make_project(temp_dir)
custom_url = "https://example.com/catalog.json"
monkeypatch.setenv("SPECKIT_CATALOG_URL", custom_url)
catalog = ExtensionCatalog(project_dir)
entries = catalog.get_active_catalogs()
assert len(entries) == 1
assert entries[0].url == custom_url
assert entries[0].install_allowed is True
def test_env_var_invalid_url_raises(self, temp_dir, monkeypatch):
"""SPECKIT_CATALOG_URL with http:// (non-localhost) raises ValidationError."""
project_dir = self._make_project(temp_dir)
monkeypatch.setenv("SPECKIT_CATALOG_URL", "http://example.com/catalog.json")
catalog = ExtensionCatalog(project_dir)
with pytest.raises(ValidationError, match="HTTPS"):
catalog.get_active_catalogs()
def test_project_config_overrides_defaults(self, temp_dir):
"""Project-level extension-catalogs.yml overrides default stack."""
project_dir = self._make_project(temp_dir)
self._write_catalog_config(
project_dir,
[
{
"name": "custom",
"url": "https://example.com/catalog.json",
"priority": 1,
"install_allowed": True,
}
],
)
catalog = ExtensionCatalog(project_dir)
entries = catalog.get_active_catalogs()
assert len(entries) == 1
assert entries[0].url == "https://example.com/catalog.json"
assert entries[0].name == "custom"
def test_project_config_sorted_by_priority(self, temp_dir):
"""Catalog entries are sorted by priority (ascending)."""
project_dir = self._make_project(temp_dir)
self._write_catalog_config(
project_dir,
[
{
"name": "secondary",
"url": "https://example.com/secondary.json",
"priority": 5,
"install_allowed": False,
},
{
"name": "primary",
"url": "https://example.com/primary.json",
"priority": 1,
"install_allowed": True,
},
],
)
catalog = ExtensionCatalog(project_dir)
entries = catalog.get_active_catalogs()
assert len(entries) == 2
assert entries[0].name == "primary"
assert entries[1].name == "secondary"
def test_project_config_invalid_url_raises(self, temp_dir):
"""Project config with HTTP (non-localhost) URL raises ValidationError."""
project_dir = self._make_project(temp_dir)
self._write_catalog_config(
project_dir,
[
{
"name": "bad",
"url": "http://example.com/catalog.json",
"priority": 1,
"install_allowed": True,
}
],
)
catalog = ExtensionCatalog(project_dir)
with pytest.raises(ValidationError, match="HTTPS"):
catalog.get_active_catalogs()
def test_empty_project_config_raises_error(self, temp_dir):
"""Empty catalogs list in config raises ValidationError (fail-closed for security)."""
import yaml as yaml_module
project_dir = self._make_project(temp_dir)
config_path = project_dir / ".specify" / "extension-catalogs.yml"
with open(config_path, "w") as f:
yaml_module.dump({"catalogs": []}, f)
catalog = ExtensionCatalog(project_dir)
# Fail-closed: empty config should raise, not fall back to defaults
with pytest.raises(ValidationError) as exc_info:
catalog.get_active_catalogs()
assert "contains no 'catalogs' entries" in str(exc_info.value)
def test_catalog_entries_without_urls_raises_error(self, temp_dir):
"""Catalog entries without URLs raise ValidationError (fail-closed for security)."""
import yaml as yaml_module
project_dir = self._make_project(temp_dir)
config_path = project_dir / ".specify" / "extension-catalogs.yml"
with open(config_path, "w") as f:
yaml_module.dump({
"catalogs": [
{"name": "no-url-catalog", "priority": 1},
{"name": "another-no-url", "description": "Also missing URL"},
]
}, f)
catalog = ExtensionCatalog(project_dir)
# Fail-closed: entries without URLs should raise, not fall back to defaults
with pytest.raises(ValidationError) as exc_info:
catalog.get_active_catalogs()
assert "none have valid URLs" in str(exc_info.value)
# --- _load_catalog_config ---
def test_load_catalog_config_missing_file(self, temp_dir):
"""Returns None when config file doesn't exist."""
project_dir = self._make_project(temp_dir)
catalog = ExtensionCatalog(project_dir)
result = catalog._load_catalog_config(project_dir / ".specify" / "nonexistent.yml")
assert result is None
def test_load_catalog_config_localhost_allowed(self, temp_dir):
"""Localhost HTTP URLs are allowed in config."""
project_dir = self._make_project(temp_dir)
self._write_catalog_config(
project_dir,
[
{
"name": "local",
"url": "http://localhost:8000/catalog.json",
"priority": 1,
"install_allowed": True,
}
],
)
catalog = ExtensionCatalog(project_dir)
entries = catalog.get_active_catalogs()
assert len(entries) == 1
assert entries[0].url == "http://localhost:8000/catalog.json"
@pytest.mark.parametrize(
"config_content", ["[]\n", "false\n", "0\n", "''\n", "- item\n"]
)
def test_load_catalog_config_rejects_non_mapping_roots(
self, temp_dir, config_content
):
"""Malformed roots raise ValidationError, not fallback or AttributeError."""
project_dir = self._make_project(temp_dir)
config_path = project_dir / ".specify" / "extension-catalogs.yml"
config_path.write_text(config_content, encoding="utf-8")
catalog = ExtensionCatalog(project_dir)
with pytest.raises(
ValidationError, match="expected a YAML mapping at the root"
) as exc_info:
catalog.get_active_catalogs()
assert str(config_path) in str(exc_info.value)
def test_load_catalog_config_rejects_boolean_priority(self, temp_dir):
"""Boolean priorities are rejected instead of being coerced to 1 or 0."""
import yaml as yaml_module
project_dir = self._make_project(temp_dir)
config_path = project_dir / ".specify" / "extension-catalogs.yml"
config_path.write_text(
yaml_module.dump(
{
"catalogs": [
{
"name": "bad-priority",
"url": "https://example.com/catalog.json",
"priority": True,
}
]
}
),
encoding="utf-8",
)
catalog = ExtensionCatalog(project_dir)
with pytest.raises(
ValidationError, match="Invalid priority|expected integer"
) as exc_info:
catalog.get_active_catalogs()
assert str(config_path) in str(exc_info.value)
def test_load_catalog_config_rejects_infinite_priority(self, temp_dir):
"""A ``priority: .inf`` yields a clean validation error, not an uncaught
OverflowError from int(float('inf'))."""
import yaml as yaml_module
project_dir = self._make_project(temp_dir)
config_path = project_dir / ".specify" / "extension-catalogs.yml"
config_path.write_text(
yaml_module.dump(
{
"catalogs": [
{
"name": "inf-priority",
"url": "https://example.com/catalog.json",
"priority": float("inf"),
}
]
}
),
encoding="utf-8",
)
catalog = ExtensionCatalog(project_dir)
with pytest.raises(
ValidationError, match="Invalid priority|expected integer"
) as exc_info:
catalog.get_active_catalogs()
assert str(config_path) in str(exc_info.value)
def test_load_catalog_config_defaults_blank_names(self, temp_dir):
"""Blank and null names normalize by valid catalog order."""
import yaml as yaml_module
project_dir = self._make_project(temp_dir)
config_path = project_dir / ".specify" / "extension-catalogs.yml"
config_path.write_text(
yaml_module.dump(
{
"catalogs": [
{"name": "skipped", "url": " "},
{"name": None, "url": "https://one.example.com/catalog.json"},
{"name": " ", "url": "https://two.example.com/catalog.json"},
]
}
),
encoding="utf-8",
)
catalog = ExtensionCatalog(project_dir)
assert [entry.name for entry in catalog.get_active_catalogs()] == [
"catalog-1",
"catalog-2",
]
@pytest.mark.parametrize(
("url", "expected_detail"),
[
("relative/catalog.json", "HTTPS"),
("https:///no-host", "valid URL with a host"),
],
)
def test_load_catalog_config_invalid_url_includes_context(
self, temp_dir, url, expected_detail
):
"""Invalid catalog URLs include the config path and entry index."""
import yaml as yaml_module
project_dir = self._make_project(temp_dir)
config_path = project_dir / ".specify" / "extension-catalogs.yml"
config_path.write_text(
yaml_module.dump({"catalogs": [{"name": "bad", "url": url}]}),
encoding="utf-8",
)
catalog = ExtensionCatalog(project_dir)
with pytest.raises(ValidationError) as exc_info:
catalog.get_active_catalogs()
message = str(exc_info.value)
assert "Invalid catalog URL" in message
assert str(config_path) in message
assert "index 0" in message
assert expected_detail in message
# --- Merge conflict resolution ---
def test_merge_conflict_higher_priority_wins(self, temp_dir):
"""When same extension id is in two catalogs, higher priority wins."""
project_dir = self._make_project(temp_dir)
# Write project config with two catalogs
self._write_catalog_config(
project_dir,
[
{
"name": "primary",
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
"priority": 1,
"install_allowed": True,
},
{
"name": "secondary",
"url": ExtensionCatalog.COMMUNITY_CATALOG_URL,
"priority": 2,
"install_allowed": False,
},
],
)
catalog = ExtensionCatalog(project_dir)
# Write primary cache with jira v2.0.0
primary_data = {
"schema_version": "1.0",
"extensions": {
"jira": {
"name": "Jira Integration",
"id": "jira",
"version": "2.0.0",
"description": "Primary Jira",
}
},
}
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_text(json.dumps(primary_data))
catalog.cache_metadata_file.write_text(
json.dumps({"cached_at": datetime.now(timezone.utc).isoformat(), "catalog_url": "http://test.com"})
)
# Write secondary cache (URL-hash-based) with jira v1.0.0 (should lose)
import hashlib
url_hash = hashlib.sha256(ExtensionCatalog.COMMUNITY_CATALOG_URL.encode()).hexdigest()[:16]
secondary_cache = catalog.cache_dir / f"catalog-{url_hash}.json"
secondary_meta = catalog.cache_dir / f"catalog-{url_hash}-metadata.json"
secondary_data = {
"schema_version": "1.0",
"extensions": {
"jira": {
"name": "Jira Integration Community",
"id": "jira",
"version": "1.0.0",
"description": "Community Jira",
},
"linear": {
"name": "Linear",
"id": "linear",
"version": "0.9.0",
"description": "Linear from secondary",
},
},
}
secondary_cache.write_text(json.dumps(secondary_data))
secondary_meta.write_text(
json.dumps({"cached_at": datetime.now(timezone.utc).isoformat(), "catalog_url": ExtensionCatalog.COMMUNITY_CATALOG_URL})
)
results = catalog.search()
jira_results = [r for r in results if r["id"] == "jira"]
assert len(jira_results) == 1
# Primary catalog wins
assert jira_results[0]["version"] == "2.0.0"
assert jira_results[0]["_catalog_name"] == "primary"
assert jira_results[0]["_install_allowed"] is True
# linear comes from secondary
linear_results = [r for r in results if r["id"] == "linear"]
assert len(linear_results) == 1
assert linear_results[0]["_catalog_name"] == "secondary"
assert linear_results[0]["_install_allowed"] is False
def test_install_allowed_false_from_get_extension_info(self, temp_dir):
"""get_extension_info includes _install_allowed from source catalog."""
project_dir = self._make_project(temp_dir)
# Single catalog that is install_allowed=False
self._write_catalog_config(
project_dir,
[
{
"name": "discovery",
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
"priority": 1,
"install_allowed": False,
}
],
)
catalog = ExtensionCatalog(project_dir)
self._write_valid_cache(
catalog,
{
"jira": {
"name": "Jira Integration",
"id": "jira",
"version": "1.0.0",
"description": "Jira integration",
}
},
)
info = catalog.get_extension_info("jira")
assert info is not None
assert info["_install_allowed"] is False
assert info["_catalog_name"] == "discovery"
def test_search_results_include_catalog_metadata(self, temp_dir):
"""Search results include _catalog_name and _install_allowed."""
project_dir = self._make_project(temp_dir)
self._write_catalog_config(
project_dir,
[
{
"name": "org",
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
"priority": 1,
"install_allowed": True,
}
],
)
catalog = ExtensionCatalog(project_dir)
self._write_valid_cache(
catalog,
{
"jira": {
"name": "Jira Integration",
"id": "jira",
"version": "1.0.0",
"description": "Jira integration",
}
},
)
results = catalog.search()
assert len(results) == 1
assert results[0]["_catalog_name"] == "org"
assert results[0]["_install_allowed"] is True
class TestExtensionIgnore:
"""Test .extensionignore support during extension installation."""
def _make_extension(self, temp_dir, valid_manifest_data, extra_files=None, ignore_content=None):
"""Helper to create an extension directory with optional extra files and .extensionignore."""
import yaml
ext_dir = temp_dir / "ignored-ext"
ext_dir.mkdir()
# Write manifest
with open(ext_dir / "extension.yml", "w") as f:
yaml.dump(valid_manifest_data, f)
# Create commands directory with a command file
commands_dir = ext_dir / "commands"
commands_dir.mkdir()
(commands_dir / "hello.md").write_text(
"---\ndescription: \"Test hello command\"\n---\n\n# Hello\n\n$ARGUMENTS\n"
)
# Create any extra files/dirs
if extra_files:
for rel_path, content in extra_files.items():
p = ext_dir / rel_path
p.parent.mkdir(parents=True, exist_ok=True)
if content is None:
# Create directory
p.mkdir(parents=True, exist_ok=True)
else:
p.write_text(content)
# Write .extensionignore. Pinned to UTF-8 so non-ASCII patterns
# in tests (see ``test_extensionignore_utf8_patterns``) survive
# the round-trip on Windows runners with non-UTF-8 default locales.
if ignore_content is not None:
(ext_dir / ".extensionignore").write_text(
ignore_content, encoding="utf-8"
)
return ext_dir
def test_no_extensionignore(self, temp_dir, valid_manifest_data):
"""Without .extensionignore, all files are copied."""
ext_dir = self._make_extension(
temp_dir,
valid_manifest_data,
extra_files={"README.md": "# Hello", "tests/test_foo.py": "pass"},
)
proj_dir = temp_dir / "project"
proj_dir.mkdir()
(proj_dir / ".specify").mkdir()
manager = ExtensionManager(proj_dir)
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
dest = proj_dir / ".specify" / "extensions" / "test-ext"
assert (dest / "README.md").exists()
assert (dest / "tests" / "test_foo.py").exists()
def test_extensionignore_excludes_files(self, temp_dir, valid_manifest_data):
"""Files matching .extensionignore patterns are excluded."""
ext_dir = self._make_extension(
temp_dir,
valid_manifest_data,
extra_files={
"README.md": "# Hello",
"tests/test_foo.py": "pass",
"tests/test_bar.py": "pass",
".github/workflows/ci.yml": "on: push",
},
ignore_content="tests/\n.github/\n",
)
proj_dir = temp_dir / "project"
proj_dir.mkdir()
(proj_dir / ".specify").mkdir()
manager = ExtensionManager(proj_dir)
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
dest = proj_dir / ".specify" / "extensions" / "test-ext"
# Included
assert (dest / "README.md").exists()
assert (dest / "extension.yml").exists()
assert (dest / "commands" / "hello.md").exists()
# Excluded
assert not (dest / "tests").exists()
assert not (dest / ".github").exists()
def test_extensionignore_glob_patterns(self, temp_dir, valid_manifest_data):
"""Glob patterns like *.pyc are respected."""
ext_dir = self._make_extension(
temp_dir,
valid_manifest_data,
extra_files={
"README.md": "# Hello",
"helpers.pyc": b"\x00".decode("latin-1"),
"commands/cache.pyc": b"\x00".decode("latin-1"),
},
ignore_content="*.pyc\n",
)
proj_dir = temp_dir / "project"
proj_dir.mkdir()
(proj_dir / ".specify").mkdir()
manager = ExtensionManager(proj_dir)
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
dest = proj_dir / ".specify" / "extensions" / "test-ext"
assert (dest / "README.md").exists()
assert not (dest / "helpers.pyc").exists()
assert not (dest / "commands" / "cache.pyc").exists()
def test_extensionignore_comments_and_blanks(self, temp_dir, valid_manifest_data):
"""Comments and blank lines in .extensionignore are ignored."""
ext_dir = self._make_extension(
temp_dir,
valid_manifest_data,
extra_files={"README.md": "# Hello", "notes.txt": "some notes"},
ignore_content="# This is a comment\n\nnotes.txt\n\n# Another comment\n",
)
proj_dir = temp_dir / "project"
proj_dir.mkdir()
(proj_dir / ".specify").mkdir()
manager = ExtensionManager(proj_dir)
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
dest = proj_dir / ".specify" / "extensions" / "test-ext"
assert (dest / "README.md").exists()
assert not (dest / "notes.txt").exists()
def test_extensionignore_itself_excluded(self, temp_dir, valid_manifest_data):
""".extensionignore is never copied to the destination."""
ext_dir = self._make_extension(
temp_dir,
valid_manifest_data,
ignore_content="# nothing special here\n",
)
proj_dir = temp_dir / "project"
proj_dir.mkdir()
(proj_dir / ".specify").mkdir()
manager = ExtensionManager(proj_dir)
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
dest = proj_dir / ".specify" / "extensions" / "test-ext"
assert (dest / "extension.yml").exists()
assert not (dest / ".extensionignore").exists()
def test_extensionignore_relative_path_match(self, temp_dir, valid_manifest_data):
"""Patterns matching relative paths work correctly."""
ext_dir = self._make_extension(
temp_dir,
valid_manifest_data,
extra_files={
"docs/guide.md": "# Guide",
"docs/internal/draft.md": "draft",
"README.md": "# Hello",
},
ignore_content="docs/internal/draft.md\n",
)
proj_dir = temp_dir / "project"
proj_dir.mkdir()
(proj_dir / ".specify").mkdir()
manager = ExtensionManager(proj_dir)
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
dest = proj_dir / ".specify" / "extensions" / "test-ext"
assert (dest / "docs" / "guide.md").exists()
assert not (dest / "docs" / "internal" / "draft.md").exists()
def test_extensionignore_dotdot_pattern_is_noop(self, temp_dir, valid_manifest_data):
"""Patterns with '..' should not escape the extension root."""
ext_dir = self._make_extension(
temp_dir,
valid_manifest_data,
extra_files={"README.md": "# Hello"},
ignore_content="../sibling/\n",
)
proj_dir = temp_dir / "project"
proj_dir.mkdir()
(proj_dir / ".specify").mkdir()
manager = ExtensionManager(proj_dir)
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
dest = proj_dir / ".specify" / "extensions" / "test-ext"
# Everything should still be copied — the '..' pattern matches nothing inside
assert (dest / "README.md").exists()
assert (dest / "extension.yml").exists()
assert (dest / "commands" / "hello.md").exists()
def test_extensionignore_absolute_path_pattern_is_noop(self, temp_dir, valid_manifest_data):
"""Absolute path patterns should not match anything."""
ext_dir = self._make_extension(
temp_dir,
valid_manifest_data,
extra_files={"README.md": "# Hello", "passwd": "sensitive"},
ignore_content="/etc/passwd\n",
)
proj_dir = temp_dir / "project"
proj_dir.mkdir()
(proj_dir / ".specify").mkdir()
manager = ExtensionManager(proj_dir)
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
dest = proj_dir / ".specify" / "extensions" / "test-ext"
# Nothing matches — /etc/passwd is anchored to root and there's no 'etc' dir
assert (dest / "README.md").exists()
assert (dest / "passwd").exists()
def test_extensionignore_empty_file(self, temp_dir, valid_manifest_data):
"""An empty .extensionignore should exclude only itself."""
ext_dir = self._make_extension(
temp_dir,
valid_manifest_data,
extra_files={"README.md": "# Hello", "notes.txt": "notes"},
ignore_content="",
)
proj_dir = temp_dir / "project"
proj_dir.mkdir()
(proj_dir / ".specify").mkdir()
manager = ExtensionManager(proj_dir)
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
dest = proj_dir / ".specify" / "extensions" / "test-ext"
assert (dest / "README.md").exists()
assert (dest / "notes.txt").exists()
assert (dest / "extension.yml").exists()
# .extensionignore itself is still excluded
assert not (dest / ".extensionignore").exists()
def test_extensionignore_windows_backslash_patterns(self, temp_dir, valid_manifest_data):
"""Backslash patterns (Windows-style) are normalised to forward slashes."""
ext_dir = self._make_extension(
temp_dir,
valid_manifest_data,
extra_files={
"docs/internal/draft.md": "draft",
"docs/guide.md": "# Guide",
},
ignore_content="docs\\internal\\draft.md\n",
)
proj_dir = temp_dir / "project"
proj_dir.mkdir()
(proj_dir / ".specify").mkdir()
manager = ExtensionManager(proj_dir)
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
dest = proj_dir / ".specify" / "extensions" / "test-ext"
assert (dest / "docs" / "guide.md").exists()
assert not (dest / "docs" / "internal" / "draft.md").exists()
def test_extensionignore_utf8_patterns(self, temp_dir, valid_manifest_data):
"""Non-ASCII patterns in .extensionignore work on every locale.
``Path.read_text`` defaults to the system locale codec on Windows
(cp1252 / gb2312 / cp932). Without an explicit ``encoding="utf-8"``,
a pattern like ``ドキュメント/`` written by a UTF-8 host becomes
mojibake on a cp1252 host and silently fails to match — leaking
files the author intended to exclude. The existing
``test_extensionignore_windows_backslash_patterns`` already shows
the codebase treats this as a Windows-author-friendly file; UTF-8
is part of that same contract.
"""
ext_dir = self._make_extension(
temp_dir,
valid_manifest_data,
extra_files={
"ドキュメント/private.md": "secret",
"ドキュメント/public.md": "public",
"docs/guide.md": "# Guide",
"café/résumé.txt": "draft",
},
ignore_content="ドキュメント/\ncafé/\n",
)
proj_dir = temp_dir / "project"
proj_dir.mkdir()
(proj_dir / ".specify").mkdir()
manager = ExtensionManager(proj_dir)
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
dest = proj_dir / ".specify" / "extensions" / "test-ext"
# Multibyte patterns excluded.
assert not (dest / "ドキュメント").exists()
assert not (dest / "café").exists()
# ASCII path with no matching pattern is unaffected.
assert (dest / "docs" / "guide.md").exists()
def test_extensionignore_invalid_utf8_raises_validation_error(
self, temp_dir, valid_manifest_data
):
"""A non-UTF-8 ``.extensionignore`` surfaces as ``ValidationError``.
Pinning ``encoding="utf-8"`` on the reader means an
``.extensionignore`` written in some other codec (cp1252, etc.)
now triggers ``UnicodeDecodeError`` instead of silently
mojibake-ing patterns. Wrap that exception as ``ValidationError``
with a pointer to the offending byte — the same pattern
``ExtensionManifest._load_yaml`` uses for ``extension.yml`` —
so installation aborts with a user-friendly message instead of a
raw Python traceback.
"""
ext_dir = self._make_extension(temp_dir, valid_manifest_data)
# Write an .extensionignore whose bytes are not valid UTF-8.
# 0xE9 is 'é' in cp1252 but an invalid lead byte in UTF-8.
(ext_dir / ".extensionignore").write_bytes(b"caf\xe9/\n")
proj_dir = temp_dir / "project"
proj_dir.mkdir()
(proj_dir / ".specify").mkdir()
manager = ExtensionManager(proj_dir)
with pytest.raises(
ValidationError, match=r"\.extensionignore is not valid UTF-8"
):
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
def test_extensionignore_star_does_not_cross_directories(self, temp_dir, valid_manifest_data):
"""'*' should NOT match across directory boundaries (gitignore semantics)."""
ext_dir = self._make_extension(
temp_dir,
valid_manifest_data,
extra_files={
"docs/api.draft.md": "draft",
"docs/sub/api.draft.md": "nested draft",
},
ignore_content="docs/*.draft.md\n",
)
proj_dir = temp_dir / "project"
proj_dir.mkdir()
(proj_dir / ".specify").mkdir()
manager = ExtensionManager(proj_dir)
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
dest = proj_dir / ".specify" / "extensions" / "test-ext"
# docs/*.draft.md should only match directly inside docs/, NOT subdirs
assert not (dest / "docs" / "api.draft.md").exists()
assert (dest / "docs" / "sub" / "api.draft.md").exists()
def test_extensionignore_doublestar_crosses_directories(self, temp_dir, valid_manifest_data):
"""'**' should match across directory boundaries."""
ext_dir = self._make_extension(
temp_dir,
valid_manifest_data,
extra_files={
"docs/api.draft.md": "draft",
"docs/sub/api.draft.md": "nested draft",
"docs/guide.md": "guide",
},
ignore_content="docs/**/*.draft.md\n",
)
proj_dir = temp_dir / "project"
proj_dir.mkdir()
(proj_dir / ".specify").mkdir()
manager = ExtensionManager(proj_dir)
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
dest = proj_dir / ".specify" / "extensions" / "test-ext"
assert not (dest / "docs" / "api.draft.md").exists()
assert not (dest / "docs" / "sub" / "api.draft.md").exists()
assert (dest / "docs" / "guide.md").exists()
def test_extensionignore_negation_pattern(self, temp_dir, valid_manifest_data):
"""'!' negation re-includes a previously excluded file."""
ext_dir = self._make_extension(
temp_dir,
valid_manifest_data,
extra_files={
"docs/guide.md": "# Guide",
"docs/internal.md": "internal",
"docs/api.md": "api",
},
ignore_content="docs/*.md\n!docs/api.md\n",
)
proj_dir = temp_dir / "project"
proj_dir.mkdir()
(proj_dir / ".specify").mkdir()
manager = ExtensionManager(proj_dir)
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
dest = proj_dir / ".specify" / "extensions" / "test-ext"
# docs/*.md excludes all .md in docs, but !docs/api.md re-includes it
assert not (dest / "docs" / "guide.md").exists()
assert not (dest / "docs" / "internal.md").exists()
assert (dest / "docs" / "api.md").exists()
class TestExtensionAddCLI:
"""CLI integration tests for extension add command."""
def test_catalog_add_escapes_url_markup(self, tmp_path):
"""Catalog add should render user-supplied URLs literally."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
project_dir = tmp_path / "test-project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
url = "https://example.com/[red]catalog[/red].json"
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app,
[
"extension",
"catalog",
"add",
url,
"--name",
"community",
],
catch_exceptions=True,
)
assert result.exit_code == 0, result.output
assert f"URL: {url}" in result.output
def test_catalog_add_escapes_config_saved_path_markup(self, tmp_path):
"""Catalog add's saved-path label should render literally under Rich."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
project_dir = tmp_path / "test-project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
display_path = "project[red]/.specify/extension-catalogs.yml"
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch("specify_cli.extensions._commands._display_project_path", return_value=display_path):
result = runner.invoke(
app,
[
"extension",
"catalog",
"add",
"https://example.com/catalog.json",
"--name",
"community",
],
catch_exceptions=True,
)
assert result.exit_code == 0, result.output
assert f"Config saved to {display_path}" in result.output
def test_catalog_list_escapes_config_path_markup(self, tmp_path):
"""Catalog list's config-path label should render literally under Rich."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
import yaml
project_dir = tmp_path / "test-project"
project_dir.mkdir()
specify_dir = project_dir / ".specify"
specify_dir.mkdir()
(specify_dir / "extension-catalogs.yml").write_text(
yaml.safe_dump(
{
"catalogs": [
{
"name": "community",
"url": "https://example.com/catalog.json",
"priority": 10,
"install_allowed": False,
}
]
}
),
encoding="utf-8",
)
display_path = "project[red]/.specify/extension-catalogs.yml"
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch("specify_cli.extensions._commands._display_project_path", return_value=display_path):
result = runner.invoke(
app,
["extension", "catalog", "list"],
catch_exceptions=True,
)
assert result.exit_code == 0, result.output
assert f"Config: {display_path}" in result.output
def test_catalog_add_escapes_config_read_exception_markup(self, tmp_path):
"""Catalog config parse errors can include user-controlled file content."""
import yaml
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
project_dir = tmp_path / "test-project"
project_dir.mkdir()
specify_dir = project_dir / ".specify"
specify_dir.mkdir()
(specify_dir / "extension-catalogs.yml").write_text("[red]bad[/red]", encoding="utf-8")
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch(
"specify_cli.extensions._commands.yaml.safe_load",
side_effect=yaml.YAMLError("bad [red]catalog[/red] yaml"),
):
result = runner.invoke(
app,
[
"extension",
"catalog",
"add",
"https://example.com/catalog.json",
"--name",
"community",
],
catch_exceptions=True,
)
assert result.exit_code == 1, result.output
assert "bad [red]catalog[/red]" in result.output
assert "yaml" in result.output
def test_catalog_add_escapes_url_validation_exception_markup(self, tmp_path):
"""URL validation errors may include user-controlled URL text."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
project_dir = tmp_path / "test-project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch.object(
ExtensionCatalog,
"_validate_catalog_url",
side_effect=ValidationError("bad [red]url[/red]"),
):
result = runner.invoke(
app,
[
"extension",
"catalog",
"add",
"https://example.com/[red]catalog[/red].json",
"--name",
"community",
],
catch_exceptions=True,
)
assert result.exit_code == 1, result.output
assert "bad [red]url[/red]" in result.output
def test_add_dev_links_copilot_agent_when_supported(
self, extension_dir, project_dir, temp_dir
):
"""extension add --dev should link generated agent files when possible."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
(project_dir / ".github" / "agents").mkdir(parents=True)
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app,
["extension", "add", str(extension_dir), "--dev"],
catch_exceptions=True,
)
assert result.exit_code == 0, result.output
agent_file = (
project_dir
/ ".github"
/ "agents"
/ "speckit.test-ext.hello.agent.md"
)
assert agent_file.exists()
if can_create_symlink(temp_dir):
assert agent_file.is_symlink()
assert ".specify-dev" in agent_file.resolve().parts
else:
assert not agent_file.is_symlink()
@pytest.mark.skipif(
os.name == "nt", reason="POSIX execute bits are not meaningful on Windows"
)
def test_add_makes_shipped_scripts_executable(self, extension_dir, project_dir):
"""extension add must restore execute bits on bundled POSIX scripts.
Archives are unpacked with zipfile.extractall and --dev installs copy the
tree; neither restores a stripped Unix mode, so a shipped *.sh can land
non-executable and a documented `.specify/extensions/<id>/scripts/...`
invocation then fails with "Permission denied". init / migrate /
integration-install already call ensure_executable_scripts(); this guards
that `extension add` does too.
"""
import stat
scripts_dir = extension_dir / "scripts"
scripts_dir.mkdir()
script = scripts_dir / "gate.sh"
script.write_text("#!/usr/bin/env bash\necho hi\n")
script.chmod(0o644) # non-executable, as an unpacked/copied script may be
assert not os.access(script, os.X_OK)
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app,
["extension", "add", str(extension_dir), "--dev"],
catch_exceptions=True,
)
assert result.exit_code == 0, result.output
installed = (
project_dir / ".specify" / "extensions" / "test-ext" / "scripts" / "gate.sh"
)
assert installed.exists(), result.output
assert os.access(installed, os.X_OK), (
f"installed script not executable: mode="
f"{stat.S_IMODE(installed.stat().st_mode):o}"
)
def test_add_dev_writes_codex_skills_as_files(self, extension_dir, project_dir):
"""Codex dev skills should be written as files so Codex can load them."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
init_options = project_dir / ".specify" / "init-options.json"
init_options.write_text(
json.dumps({"ai": "codex", "ai_skills": True}), encoding="utf-8"
)
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app,
["extension", "add", str(extension_dir), "--dev"],
catch_exceptions=True,
)
assert result.exit_code == 0, result.output
skill_file = (
project_dir
/ ".agents"
/ "skills"
/ "speckit-test-ext-hello"
/ "SKILL.md"
)
assert skill_file.exists()
assert not skill_file.is_symlink()
content = skill_file.read_text(encoding="utf-8")
assert "name: speckit-test-ext-hello" in content
assert "metadata:" in content
assert "source: test-ext:commands/hello.md" in content
def test_add_dev_replaces_existing_codex_skill_symlink(
self, extension_dir, project_dir, temp_dir
):
"""Codex dev installs should migrate expected dev symlinks to files."""
if not can_create_symlink(temp_dir):
pytest.skip("Current platform/user cannot create symlinks")
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
init_options = project_dir / ".specify" / "init-options.json"
init_options.write_text(
json.dumps({"ai": "codex", "ai_skills": True}), encoding="utf-8"
)
skill_file = (
project_dir
/ ".agents"
/ "skills"
/ "speckit-test-ext-hello"
/ "SKILL.md"
)
skill_file.parent.mkdir(parents=True)
cache_file = (
extension_dir
/ ".specify-dev"
/ "extension-skills"
/ "speckit-test-ext-hello"
/ "SKILL.md"
)
cache_file.parent.mkdir(parents=True)
cache_file.write_text("old linked content", encoding="utf-8")
os.symlink(os.path.relpath(cache_file, skill_file.parent), skill_file)
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app,
["extension", "add", str(extension_dir), "--dev"],
catch_exceptions=True,
)
assert result.exit_code == 0, result.output
assert skill_file.exists()
assert not skill_file.is_symlink()
content = skill_file.read_text(encoding="utf-8")
assert "name: speckit-test-ext-hello" in content
assert "source: test-ext:commands/hello.md" in content
assert cache_file.read_text(encoding="utf-8") == "old linked content"
def test_add_dev_falls_back_to_copy_when_windows_symlinks_unavailable(
self, extension_dir, project_dir, monkeypatch
):
"""extension add --dev should work when Windows cannot create symlinks."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
(project_dir / ".github" / "agents").mkdir(parents=True)
def raise_windows_symlink_error(target, link):
raise OSError("A required privilege is not held by the client")
monkeypatch.setattr(
"specify_cli.agents.os.symlink", raise_windows_symlink_error
)
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app,
["extension", "add", str(extension_dir), "--dev"],
catch_exceptions=True,
)
assert result.exit_code == 0, result.output
agent_file = (
project_dir
/ ".github"
/ "agents"
/ "speckit.test-ext.hello.agent.md"
)
assert agent_file.exists()
assert not agent_file.is_symlink()
assert "Extension: test-ext" in agent_file.read_text(encoding="utf-8")
assert (
project_dir
/ ".specify"
/ "extensions"
/ "test-ext"
/ ".specify-dev"
/ "agent-commands"
/ "copilot"
/ "speckit.test-ext.hello.agent.md"
).exists()
def test_add_by_display_name_uses_resolved_id_for_download(self, tmp_path):
"""extension add by display name should use resolved ID for download_extension()."""
from typer.testing import CliRunner
from unittest.mock import patch, MagicMock
from specify_cli import app
runner = CliRunner()
# Create project structure
project_dir = tmp_path / "test-project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
(project_dir / ".specify" / "extensions").mkdir(parents=True)
# Mock catalog that returns extension by display name
mock_catalog = MagicMock()
mock_catalog.get_extension_info.return_value = None # ID lookup fails
mock_catalog.search.return_value = [
{
"id": "acme-jira-integration",
"name": "Jira Integration",
"version": "1.0.0",
"description": "Jira integration extension",
"_install_allowed": True,
}
]
# Track what ID was passed to download_extension
download_called_with = []
def mock_download(extension_id):
download_called_with.append(extension_id)
# Return a path that will fail install (we just want to verify the ID)
raise ExtensionError("Mock download - checking ID was resolved")
mock_catalog.download_extension.side_effect = mock_download
with patch("specify_cli.extensions.ExtensionCatalog", return_value=mock_catalog), \
patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app,
["extension", "add", "Jira Integration"],
catch_exceptions=True,
)
assert result.exit_code != 0, (
f"Expected non-zero exit code since mock download raises, got {result.exit_code}"
)
# Verify download_extension was called with the resolved ID, not the display name
assert len(download_called_with) == 1
assert download_called_with[0] == "acme-jira-integration", (
f"Expected download_extension to be called with resolved ID 'acme-jira-integration', "
f"but was called with '{download_called_with[0]}'"
)
def test_info_by_name_tolerates_non_string_catalog_name(self, tmp_path):
"""Display-name resolution must not crash on a non-string catalog name.
Catalog JSON is user-editable, so ``catalog.search()`` may return an
entry whose ``name`` is a non-string (e.g. ``name: 123``). The
display-name filter calls ``.lower()`` on it; without coercion this
raises ``AttributeError`` and takes down ``extension info``/``add``.
The entry with the bad name must simply not match, yielding a clean
"not found" rather than a traceback.
"""
from typer.testing import CliRunner
from unittest.mock import patch, MagicMock
from specify_cli import app
runner = CliRunner()
project_dir = tmp_path / "test-project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
(project_dir / ".specify" / "extensions").mkdir(parents=True)
# Catalog search returns an entry with a non-string name.
mock_catalog = MagicMock()
mock_catalog.get_extension_info.return_value = None # ID lookup fails
mock_catalog.search.return_value = [
{
"id": "acme-thing",
"name": 123,
"version": "1.0.0",
"description": "A thing",
"_install_allowed": True,
}
]
with patch("specify_cli.extensions.ExtensionCatalog", return_value=mock_catalog), \
patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app,
["extension", "info", "Some Name"],
catch_exceptions=True,
)
# Must not crash with AttributeError; the bad-named entry just doesn't
# match, so resolution ends as a clean not-found error exit.
assert not isinstance(result.exception, AttributeError), (
f"non-string catalog name crashed resolution: {result.exception!r}"
)
assert result.exit_code != 0
def test_add_bundled_extension_not_found_gives_clear_error(self, tmp_path):
"""extension add should give a clear error when a bundled extension is not found locally."""
from typer.testing import CliRunner
from unittest.mock import patch, MagicMock
from specify_cli import app
runner = CliRunner()
# Create project structure
project_dir = tmp_path / "test-project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
(project_dir / ".specify" / "extensions").mkdir(parents=True)
# Mock catalog that returns a bundled extension without download_url
mock_catalog = MagicMock()
mock_catalog.get_extension_info.return_value = {
"id": "git",
"name": "Git Branching Workflow",
"version": "1.0.0",
"description": "Git branching extension",
"bundled": True,
"_install_allowed": True,
}
mock_catalog.search.return_value = []
with patch("specify_cli.extensions.ExtensionCatalog", return_value=mock_catalog), \
patch("specify_cli._locate_bundled_extension", return_value=None), \
patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app,
["extension", "add", "git"],
catch_exceptions=True,
)
assert result.exit_code != 0
assert "bundled with spec-kit" in result.output
assert "reinstall" in result.output.lower()
def test_add_from_url_prompts_before_spinner(self, tmp_path):
"""Confirm prompt for --from <url> must fire before the console.status spinner.
Regression test for #2783: typer.confirm() inside console.status()
was overwritten by the Rich spinner, making the command appear hung.
"""
from typer.testing import CliRunner
from unittest.mock import patch, MagicMock
from specify_cli import app
project_dir = tmp_path / "test-project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
call_order: list[str] = []
original_status = MagicMock()
def record_status(*args, **kwargs):
call_order.append("spinner")
return original_status
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch("specify_cli.console.status", side_effect=record_status), \
patch("typer.confirm", side_effect=lambda *a, **kw: (call_order.append("confirm"), False)[-1]):
result = runner.invoke(
app,
["extension", "add", "my-ext", "--from", "https://example.com/ext.zip"],
catch_exceptions=True,
)
assert "confirm" in call_order, "confirm prompt was never called"
# The confirm must fire BEFORE the spinner is entered
if "spinner" in call_order:
assert call_order.index("confirm") < call_order.index("spinner"), \
f"confirm must precede spinner, got: {call_order}"
assert result.exit_code == 0 # user declined → clean exit
def test_add_from_malformed_ipv6_url_exits_cleanly(self, tmp_path):
"""A malformed IPv6 URL must produce a clean error, not a ValueError traceback."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
project_dir = tmp_path / "test-project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app,
["extension", "add", "my-ext", "--from", "https://[::1/ext.zip"],
catch_exceptions=True,
)
assert result.exit_code == 1
assert result.exception is None or isinstance(result.exception, SystemExit)
plain = strip_ansi(result.output)
assert "Invalid URL" in plain
@pytest.mark.parametrize(
"url",
[
"https:///ext.zip",
"https://example.com:99999/ext.zip",
],
)
def test_add_from_invalid_url_exits_before_prompt(self, tmp_path, url):
"""Hostless URLs and invalid ports fail before prompting or downloading."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
project_dir = tmp_path / "test-project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch("typer.confirm") as confirm, \
patch("specify_cli.authentication.http.open_url") as open_url:
result = runner.invoke(
app,
["extension", "add", "my-ext", "--from", url],
catch_exceptions=True,
)
assert result.exit_code == 1
assert "Invalid URL" in strip_ansi(result.output)
confirm.assert_not_called()
open_url.assert_not_called()
def test_add_from_bracketed_non_ip_url_exits_cleanly(self, tmp_path):
"""A bracketed-but-invalid IPv6 host must produce a clean error, not a
ValueError traceback. "https://[not-an-ip]/ext.zip" is a malformed
authority that raises ValueError during URL validation; the try/except
guard around parsing and the .hostname read must turn that into a clean
"Invalid URL" message.
"""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
project_dir = tmp_path / "test-project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app,
["extension", "add", "my-ext", "--from", "https://[not-an-ip]/ext.zip"],
catch_exceptions=True,
)
assert result.exit_code == 1
assert result.exception is None or isinstance(result.exception, SystemExit)
plain = strip_ansi(result.output)
assert "Invalid URL" in plain
def test_add_from_url_lazy_hostname_valueerror_exits_cleanly(self, tmp_path, monkeypatch):
"""Synthetic defensive coverage: monkeypatch urlparse() to return an
object whose .hostname raises ValueError lazily. This does not reproduce
any specific CPython behavior -- it just exercises the case where the
ValueError surfaces on the .hostname read rather than at parse time, so a
raw ValueError would leak if .hostname were read outside the try/except.
"""
import urllib.parse
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
real_urlparse = urllib.parse.urlparse
class _LazyHostnameRaiser:
def __init__(self, parsed):
self._parsed = parsed
@property
def hostname(self):
raise ValueError("simulated lazy IPv6 hostname failure")
def __getattr__(self, name):
return getattr(self._parsed, name)
def _fake_urlparse(url, *args, **kwargs):
return _LazyHostnameRaiser(real_urlparse(url, *args, **kwargs))
monkeypatch.setattr(urllib.parse, "urlparse", _fake_urlparse)
project_dir = tmp_path / "test-project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app,
["extension", "add", "my-ext", "--from", "https://example.com/ext.zip"],
catch_exceptions=True,
)
assert result.exit_code == 1
assert result.exception is None or isinstance(result.exception, SystemExit)
assert "Invalid URL" in strip_ansi(result.output)
def test_add_status_escapes_extension_markup(self, tmp_path):
"""User-controlled extension names must not be parsed as Rich markup."""
from rich.markup import escape as escape_markup
from typer.testing import CliRunner
from unittest.mock import MagicMock, patch
from specify_cli import app
project_dir = tmp_path / "test-project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
status_messages: list[str] = []
def record_status(message, *args, **kwargs):
status_messages.append(message)
return MagicMock()
extension_name = "[red]bad[/red]"
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch("specify_cli.console.status", side_effect=record_status):
result = runner.invoke(
app,
["extension", "add", extension_name, "--dev"],
catch_exceptions=True,
)
assert result.exit_code == 1
assert status_messages == [
f"[cyan]Installing extension: {escape_markup(extension_name)}[/cyan]"
]
def test_add_post_install_hint_escapes_manifest_id_markup(self, tmp_path):
"""Extension IDs printed in Rich-rendered hints must stay literal."""
import io
from types import SimpleNamespace
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
class FakeResponse(io.BytesIO):
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
project_dir = tmp_path / "test-project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
manifest_id = "[red]bad[/red]"
def fake_install_from_zip(self_obj, zip_path, speckit_version, priority=10, force=False):
return SimpleNamespace(
id=manifest_id,
name="Bad Extension",
version="1.0.0",
description="Test extension",
warnings=[],
commands=[],
hooks=[],
)
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch("typer.confirm", return_value=True), \
patch("specify_cli.authentication.http.open_url", return_value=FakeResponse(_MINIMAL_ZIP_BYTES)), \
patch.object(ExtensionManager, "install_from_zip", fake_install_from_zip), \
patch.object(ExtensionRegistry, "get", return_value={}):
result = runner.invoke(
app,
["extension", "add", "bad", "--from", "https://example.com/ext.zip"],
catch_exceptions=True,
)
assert result.exit_code == 0, result.output
assert ".specify/extensions/[red]bad[/red]/" in result.output
def test_add_from_url_cancel_exits_cleanly(self, tmp_path):
"""Declining the --from <url> confirmation should exit with code 0."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
project_dir = tmp_path / "test-project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch("typer.confirm", return_value=False):
result = runner.invoke(
app,
["extension", "add", "my-ext", "--from", "https://example.com/ext.zip"],
catch_exceptions=True,
)
assert result.exit_code == 0
assert "Cancelled" in result.output
def test_add_from_url_escapes_download_exception_markup(self, tmp_path):
"""Download errors can include user-controlled URL text."""
import urllib.error
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
project_dir = tmp_path / "test-project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch("typer.confirm", return_value=True), \
patch(
"specify_cli.authentication.http.open_url",
side_effect=urllib.error.URLError("bad [red]download[/red]"),
):
result = runner.invoke(
app,
[
"extension",
"add",
"my-ext",
"--from",
"https://example.com/[red]ext[/red].zip",
],
catch_exceptions=True,
)
assert result.exit_code == 1, result.output
assert "https://example.com/[red]ext[/red].zip" in result.output
assert "bad [red]download[/red]" in result.output
def test_add_from_url_rejects_non_zip_login_page(self, tmp_path):
"""An HTML login page (unauthenticated fetch) must fail clearly, not BadZipFile."""
import io
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
class FakeResponse(io.BytesIO):
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
project_dir = tmp_path / "test-project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch("typer.confirm", return_value=True), \
patch(
"specify_cli.authentication.http.open_url",
return_value=FakeResponse(b"<!DOCTYPE html><html>Sign in</html>"),
), \
patch.object(ExtensionManager, "install_from_zip") as install:
result = runner.invoke(
app,
["extension", "add", "my-ext", "--from", "https://raw.ghe.example/o/r/ext.zip"],
catch_exceptions=True,
)
assert result.exit_code == 1, result.output
assert "did not return a ZIP archive" in result.output
install.assert_not_called()
def test_add_from_url_rejects_oversized_download_before_install(
self, tmp_path, monkeypatch
):
"""The direct URL path must use the same bounded reader as catalogs."""
import io
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
from specify_cli.extensions import _commands as extension_commands
class FakeResponse(io.BytesIO):
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def reject_oversized(*_args, **_kwargs):
raise ExtensionError("extension URL download exceeds maximum size")
monkeypatch.setattr(
extension_commands,
"read_response_limited",
reject_oversized,
raising=False,
)
project_dir = tmp_path / "test-project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch("typer.confirm", return_value=True), \
patch(
"specify_cli.authentication.http.open_url",
return_value=FakeResponse(_MINIMAL_ZIP_BYTES),
), \
patch.object(ExtensionManager, "install_from_zip") as install:
result = runner.invoke(
app,
[
"extension",
"add",
"my-ext",
"--from",
"https://example.com/ext.zip",
],
catch_exceptions=True,
)
assert result.exit_code == 1
assert "exceeds maximum size" in result.output
install.assert_not_called()
def test_add_from_url_resolves_ghes_release_asset(self, tmp_path):
"""A GHES release-download URL resolves to /api/v3 with octet-stream Accept."""
import io
from types import SimpleNamespace
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
import json
class FakeResponse(io.BytesIO):
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
project_dir = tmp_path / "test-project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
seen = {}
def fake_open_url(url, timeout=10, extra_headers=None, redirect_validator=None):
if "/releases/tags/" in url:
body = json.dumps({
"assets": [{
"name": "ext.zip",
"url": "https://ghes.example/api/v3/repos/org/repo/releases/assets/42",
}]
}).encode()
return FakeResponse(body)
seen["url"] = url
seen["headers"] = extra_headers
return FakeResponse(_MINIMAL_ZIP_BYTES)
def fake_install(self_obj, zip_path, speckit_version, priority=10, force=False):
return SimpleNamespace(
id="x", name="X", version="1.0.0", description="", warnings=[], commands=[], hooks=[]
)
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch("typer.confirm", return_value=True), \
patch("specify_cli.authentication.http.github_provider_hosts", return_value=("ghes.example",)), \
patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url), \
patch.object(ExtensionManager, "install_from_zip", fake_install):
result = runner.invoke(
app,
["extension", "add", "x", "--from",
"https://ghes.example/org/repo/releases/download/v1.0/ext.zip"],
catch_exceptions=True,
)
assert result.exit_code == 0, result.output
assert "/api/v3/repos/org/repo/releases/assets/" in seen["url"]
assert seen["headers"] == {"Accept": "application/octet-stream"}
@pytest.mark.parametrize(
("exc_type", "label"),
[
(ValidationError, "Validation Error"),
(CompatibilityError, "Compatibility Error"),
(ExtensionError, "Error"),
],
)
def test_add_exception_handlers_escape_markup(self, tmp_path, exc_type, label):
"""Extension install exceptions can include manifest-controlled values."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
project_dir = tmp_path / "test-project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
ext_dir = tmp_path / "ext"
ext_dir.mkdir()
(ext_dir / "extension.yml").write_text("extension:\n id: test\n", encoding="utf-8")
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch.object(
ExtensionManager,
"install_from_directory",
side_effect=exc_type("bad [red]extension[/red]"),
):
result = runner.invoke(
app,
["extension", "add", str(ext_dir), "--dev"],
catch_exceptions=True,
)
assert result.exit_code == 1, result.output
assert f"{label}:" in result.output
assert "bad [red]extension[/red]" in result.output
def test_add_from_url_uses_cache_tempfile_for_untrusted_extension_name(self, tmp_path):
"""The extension argument must not control the downloaded ZIP path."""
import io
from types import SimpleNamespace
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
class FakeResponse(io.BytesIO):
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
project_dir = tmp_path / "test-project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
downloads_dir = project_dir / ".specify" / "extensions" / ".cache" / "downloads"
installed = {}
def fake_install_from_zip(self_obj, zip_path, speckit_version, priority=10, force=False):
captured_path = Path(zip_path)
installed["zip_path"] = captured_path
installed["zip_bytes"] = captured_path.read_bytes()
return SimpleNamespace(
id="escape",
name="Escape Test",
version="1.0.0",
description="Test extension",
warnings=[],
commands=[],
hooks=[],
)
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch("typer.confirm", return_value=True), \
patch("specify_cli.authentication.http.open_url", return_value=FakeResponse(_MINIMAL_ZIP_BYTES)), \
patch.object(ExtensionManager, "install_from_zip", fake_install_from_zip):
result = runner.invoke(
app,
["extension", "add", "../outside", "--from", "https://example.com/ext.zip"],
catch_exceptions=True,
)
assert result.exit_code == 0
assert installed["zip_bytes"] == _MINIMAL_ZIP_BYTES
assert installed["zip_path"].resolve().is_relative_to(downloads_dir.resolve())
assert installed["zip_path"].name.startswith("extension-url-download-")
assert not installed["zip_path"].exists()
class TestDownloadExtensionBundled:
"""Tests for download_extension handling of bundled extensions."""
def test_download_extension_raises_for_bundled(self, temp_dir):
"""download_extension should raise a clear error for bundled extensions without a URL."""
from unittest.mock import patch
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
catalog = ExtensionCatalog(project_dir)
bundled_ext_info = {
"name": "Git Branching Workflow",
"id": "git",
"version": "1.0.0",
"description": "Git workflow",
"bundled": True,
}
with patch.object(catalog, "get_extension_info", return_value=bundled_ext_info):
with pytest.raises(ExtensionError, match="bundled with spec-kit"):
catalog.download_extension("git")
def test_download_extension_allows_bundled_with_url(self, temp_dir):
"""download_extension should allow bundled extensions that have a download_url (newer version)."""
from unittest.mock import patch, MagicMock
import urllib.request
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
catalog = ExtensionCatalog(project_dir)
bundled_with_url = {
"name": "Git Branching Workflow",
"id": "git",
"version": "2.0.0",
"description": "Git workflow",
"bundled": True,
"download_url": "https://example.com/git-2.0.0.zip",
}
mock_response = MagicMock()
mock_response.read.side_effect = io.BytesIO(b"fake zip data").read
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://example.com/catalog.json"
with patch.object(catalog, "get_extension_info", return_value=bundled_with_url), \
patch.object(urllib.request, "urlopen", return_value=mock_response):
result = catalog.download_extension("git")
assert result.name == "git-2.0.0.zip"
def test_download_extension_raises_no_url_for_non_bundled(self, temp_dir):
"""download_extension should raise 'no download URL' for non-bundled extensions without URL."""
from unittest.mock import patch
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
catalog = ExtensionCatalog(project_dir)
non_bundled_ext_info = {
"name": "Some Extension",
"id": "some-ext",
"version": "1.0.0",
"description": "Test",
}
with patch.object(catalog, "get_extension_info", return_value=non_bundled_ext_info):
with pytest.raises(ExtensionError, match="has no download URL"):
catalog.download_extension("some-ext")
class TestExtensionUpdateCLI:
"""CLI integration tests for extension update command."""
@staticmethod
def _create_extension_source(base_dir: Path, version: str, include_config: bool = False) -> Path:
"""Create a minimal extension source directory for install tests."""
import yaml
ext_dir = base_dir / f"test-ext-{version}"
ext_dir.mkdir(parents=True, exist_ok=True)
manifest = {
"schema_version": "1.0",
"extension": {
"id": "test-ext",
"name": "Test Extension",
"version": version,
"description": "A test extension",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.test-ext.hello",
"file": "commands/hello.md",
"description": "Test command",
}
]
},
"hooks": {
"after_tasks": {
"command": "speckit.test-ext.hello",
"optional": True,
}
},
}
(ext_dir / "extension.yml").write_text(yaml.dump(manifest, sort_keys=False))
commands_dir = ext_dir / "commands"
commands_dir.mkdir(exist_ok=True)
(commands_dir / "hello.md").write_text("---\ndescription: Test\n---\n\n$ARGUMENTS\n")
if include_config:
(ext_dir / "linear-config.yml").write_text("custom: true\nvalue: original\n")
return ext_dir
@staticmethod
def _create_catalog_zip(
zip_path: Path,
version: str,
manifest_path: str = "extension.yml",
extra_manifest_path: str | None = None,
):
"""Create a minimal ZIP that passes extension_update ID validation."""
import zipfile
import yaml
manifest = {
"schema_version": "1.0",
"extension": {
"id": "test-ext",
"name": "Test Extension",
"version": version,
"description": "A test extension",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {"commands": [{"name": "speckit.test-ext.hello", "file": "commands/hello.md"}]},
}
with zipfile.ZipFile(zip_path, "w") as zf:
manifest_text = yaml.dump(manifest, sort_keys=False)
zf.writestr(manifest_path, manifest_text)
if extra_manifest_path is not None:
zf.writestr(extra_manifest_path, manifest_text)
@pytest.mark.parametrize(
"manifest_path",
[
"../extension.yml",
"/extension.yml",
"./extension.yml",
"C:/extension.yml",
],
)
def test_update_rejects_unsafe_manifest_path_before_removal(
self, tmp_path, manifest_path
):
"""Unsafe manifest paths fail before the installed extension is removed."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
project_dir = tmp_path / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
(project_dir / ".claude" / "skills").mkdir(parents=True)
manager = ExtensionManager(project_dir)
v1_dir = self._create_extension_source(tmp_path, "1.0.0")
manager.install_from_directory(v1_dir, "0.1.0")
installed_extension_dir = manager.extensions_dir / "test-ext"
removed_paths = []
real_rmtree = shutil.rmtree
def track_rmtree(path, *args, **kwargs):
removed_paths.append(Path(path).resolve())
return real_rmtree(path, *args, **kwargs)
zip_path = tmp_path / "unsafe-manifest.zip"
self._create_catalog_zip(
zip_path,
"2.0.0",
manifest_path=manifest_path,
)
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch.object(ExtensionCatalog, "get_extension_info", return_value={
"id": "test-ext",
"name": "Test Extension",
"version": "2.0.0",
"_install_allowed": True,
}), \
patch.object(
ExtensionCatalog,
"download_extension",
return_value=zip_path,
), \
patch.object(shutil, "rmtree", side_effect=track_rmtree), \
patch.object(ExtensionManager, "remove") as remove, \
patch.object(ExtensionManager, "install_from_zip") as install:
result = runner.invoke(
app,
["extension", "update", "test-ext"],
input="y\n",
catch_exceptions=True,
)
assert result.exit_code == 1
assert "Unsafe path in ZIP archive" in result.output
remove.assert_not_called()
install.assert_not_called()
assert installed_extension_dir.resolve() not in removed_paths
assert not list(
(manager.extensions_dir / ".backup").glob(
"update-*-*"
)
)
assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "1.0.0"
@pytest.mark.parametrize(
("first_path", "second_path"),
[
("repo/extension.yml", "repo\\extension.yml"),
("repo/extension.yml", "repo/EXTENSION.YML"),
("caf\u00e9/extension.yml", "cafe\u0301/extension.yml"),
],
)
def test_update_rejects_normalized_manifest_collision_before_removal(
self, tmp_path, first_path, second_path
):
"""Pre-scan and extraction must agree on the manifest identity."""
import yaml
import zipfile
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
project_dir = tmp_path / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
(project_dir / ".claude" / "skills").mkdir(parents=True)
manager = ExtensionManager(project_dir)
v1_dir = self._create_extension_source(tmp_path, "1.0.0")
manager.install_from_directory(v1_dir, "0.1.0")
valid_manifest = yaml.safe_dump(
{
"schema_version": "1.0",
"extension": {
"id": "test-ext",
"name": "Test Extension",
"version": "2.0.0",
},
}
)
injected_manifest = yaml.safe_dump(
{
"schema_version": "1.0",
"extension": {
"id": "injected",
"name": "Injected",
"version": "2.0.0",
},
}
)
zip_path = tmp_path / "manifest-collision.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr(first_path, valid_manifest)
zf.writestr(second_path, injected_manifest)
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch.object(ExtensionCatalog, "get_extension_info", return_value={
"id": "test-ext",
"name": "Test Extension",
"version": "2.0.0",
"_install_allowed": True,
}), \
patch.object(
ExtensionCatalog,
"download_extension",
return_value=zip_path,
), \
patch.object(ExtensionManager, "remove") as remove, \
patch.object(ExtensionManager, "install_from_zip") as install:
result = runner.invoke(
app,
["extension", "update", "test-ext"],
input="y\n",
catch_exceptions=True,
)
assert result.exit_code == 1
assert "multiple extension.yml" in result.output
remove.assert_not_called()
install.assert_not_called()
assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "1.0.0"
def test_update_preflights_entry_count_before_opening_zip(
self, tmp_path
):
"""Manifest inspection must not bypass the bounded ZIP opener."""
import struct
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
project_dir = tmp_path / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
(project_dir / ".claude" / "skills").mkdir(parents=True)
manager = ExtensionManager(project_dir)
v1_dir = self._create_extension_source(tmp_path, "1.0.0")
manager.install_from_directory(v1_dir, "0.1.0")
zip_path = tmp_path / "too-many.zip"
zip_path.write_bytes(
struct.pack(
"<4s4H2LH",
b"PK\x05\x06",
0,
0,
513,
513,
0,
0,
0,
)
)
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch.object(ExtensionCatalog, "get_extension_info", return_value={
"id": "test-ext",
"name": "Test Extension",
"version": "2.0.0",
"_install_allowed": True,
}), \
patch.object(
ExtensionCatalog,
"download_extension",
return_value=zip_path,
), \
patch(
"specify_cli._download_security.zipfile.ZipFile",
side_effect=AssertionError("ZipFile constructor was called"),
), \
patch.object(ExtensionManager, "remove") as remove, \
patch.object(ExtensionManager, "install_from_zip") as install:
result = runner.invoke(
app,
["extension", "update", "test-ext"],
input="y\n",
catch_exceptions=True,
)
assert result.exit_code == 1
assert "too many entries" in result.output
remove.assert_not_called()
install.assert_not_called()
@pytest.mark.parametrize(
("manifest_path", "extra_manifest_path"),
[
("extension.yml", None),
("repo/extension.yml", None),
("extension.yml", "repo/extension.yml"),
],
)
def test_update_success_preserves_installed_at(
self, tmp_path, manifest_path, extra_manifest_path
):
"""Successful update should keep original installed_at and apply new version."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
project_dir = tmp_path / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
(project_dir / ".claude" / "skills").mkdir(parents=True)
manager = ExtensionManager(project_dir)
v1_dir = self._create_extension_source(tmp_path, "1.0.0", include_config=True)
manager.install_from_directory(v1_dir, "0.1.0")
original_installed_at = manager.registry.get("test-ext")["installed_at"]
original_config_content = (
project_dir / ".specify" / "extensions" / "test-ext" / "linear-config.yml"
).read_text()
zip_path = tmp_path / "test-ext-update.zip"
self._create_catalog_zip(
zip_path,
"2.0.0",
manifest_path=manifest_path,
extra_manifest_path=extra_manifest_path,
)
v2_dir = self._create_extension_source(tmp_path, "2.0.0")
def fake_install_from_zip(self_obj, _zip_path, speckit_version):
return self_obj.install_from_directory(v2_dir, speckit_version)
with patch.object(Path, "cwd", return_value=project_dir), \
patch.object(ExtensionCatalog, "get_extension_info", return_value={
"id": "test-ext",
"name": "Test Extension",
"version": "2.0.0",
"_install_allowed": True,
}), \
patch.object(ExtensionCatalog, "download_extension", return_value=zip_path), \
patch.object(ExtensionManager, "install_from_zip", fake_install_from_zip):
result = runner.invoke(app, ["extension", "update", "test-ext"], input="y\n", catch_exceptions=True)
assert result.exit_code == 0, result.output
updated = ExtensionManager(project_dir).registry.get("test-ext")
assert updated["version"] == "2.0.0"
assert updated["installed_at"] == original_installed_at
restored_config_content = (
project_dir / ".specify" / "extensions" / "test-ext" / "linear-config.yml"
).read_text()
assert restored_config_content == original_config_content
def test_update_failure_rolls_back_registry_hooks_and_commands(self, tmp_path, monkeypatch):
"""Failed update should restore original registry, hooks, and command files."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
import yaml
# Isolate home directory so Hermes' global ~/.hermes/skills/ doesn't
# interfere — without a real skills dir, Hermes is skipped during
# command registration, keeping the test focused on Claude/Codex/etc.
fake_home = tmp_path / "home"
fake_home.mkdir()
monkeypatch.setattr(Path, "home", lambda: fake_home)
runner = CliRunner()
project_dir = tmp_path / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
(project_dir / ".claude" / "skills").mkdir(parents=True)
manager = ExtensionManager(project_dir)
v1_dir = self._create_extension_source(tmp_path, "1.0.0")
manager.install_from_directory(v1_dir, "0.1.0")
backup_registry_entry = manager.registry.get("test-ext")
hooks_before = yaml.safe_load((project_dir / ".specify" / "extensions.yml").read_text())
registered_commands = backup_registry_entry.get("registered_commands", {})
command_files = []
from specify_cli.agents import CommandRegistrar as AgentRegistrar
agent_registrar = AgentRegistrar()
for agent_name, cmd_names in registered_commands.items():
if agent_name not in agent_registrar.AGENT_CONFIGS:
continue
agent_cfg = agent_registrar.AGENT_CONFIGS[agent_name]
commands_dir = AgentRegistrar._resolve_agent_dir(
agent_name, agent_cfg, project_dir
)
for cmd_name in cmd_names:
output_name = AgentRegistrar._compute_output_name(agent_name, cmd_name, agent_cfg)
cmd_path = commands_dir / f"{output_name}{agent_cfg['extension']}"
command_files.append(cmd_path)
assert command_files, "Expected at least one registered command file"
for cmd_file in command_files:
assert cmd_file.exists(), f"Expected command file to exist before update: {cmd_file}"
zip_path = tmp_path / "test-ext-update.zip"
self._create_catalog_zip(zip_path, "2.0.0")
with patch.object(Path, "cwd", return_value=project_dir), \
patch.object(ExtensionCatalog, "get_extension_info", return_value={
"id": "test-ext",
"name": "Test Extension",
"version": "2.0.0",
"_install_allowed": True,
}), \
patch.object(ExtensionCatalog, "download_extension", return_value=zip_path), \
patch.object(ExtensionManager, "install_from_zip", side_effect=RuntimeError("install failed")):
result = runner.invoke(app, ["extension", "update", "test-ext"], input="y\n", catch_exceptions=True)
assert result.exit_code == 1, result.output
restored_entry = ExtensionManager(project_dir).registry.get("test-ext")
assert restored_entry == backup_registry_entry
hooks_after = yaml.safe_load((project_dir / ".specify" / "extensions.yml").read_text())
assert hooks_after == hooks_before
for cmd_file in command_files:
assert cmd_file.exists(), f"Expected command file to be restored after rollback: {cmd_file}"
def test_update_failure_after_skill_registration_restores_old_skills(
self, tmp_path, monkeypatch
):
"""Rollback must not depend on a new registry entry to restore skills."""
import zipfile
import yaml
from specify_cli import app
from typer.testing import CliRunner
from unittest.mock import patch
fake_home = tmp_path / "home"
fake_home.mkdir()
monkeypatch.setattr(Path, "home", lambda: fake_home)
project_dir = tmp_path / "project"
project_dir.mkdir()
specify_dir = project_dir / ".specify"
specify_dir.mkdir()
copilot_agents_dir = project_dir / ".github" / "agents"
copilot_agents_dir.mkdir(parents=True)
(specify_dir / "init-options.json").write_text(
json.dumps(
{
"ai": "claude",
"ai_skills": True,
"script": "sh",
}
),
encoding="utf-8",
)
manager = ExtensionManager(project_dir)
v1_dir = self._create_extension_source(tmp_path, "1.0.0")
manager.install_from_directory(
v1_dir,
"0.1.0",
register_commands=False,
)
old_registry_entry = manager.registry.get("test-ext")
skills_dir = project_dir / ".claude" / "skills"
old_skill = skills_dir / "speckit-test-ext-hello"
old_skill_content = (old_skill / "SKILL.md").read_text(encoding="utf-8")
assert old_registry_entry["registered_skills"] == [old_skill.name]
new_skill = skills_dir / "speckit-test-ext-new"
new_skill.mkdir()
user_skill_content = (
"---\n"
"name: user-new-skill\n"
"description: User-owned skill\n"
"metadata:\n"
" source: user\n"
"---\n\nUSER SKILL\n"
)
(new_skill / "SKILL.md").write_text(
user_skill_content,
encoding="utf-8",
)
user_support_file = new_skill / "support.txt"
user_support_file.write_text("USER CONTENT", encoding="utf-8")
v2_dir = self._create_extension_source(tmp_path, "2.0.0")
manifest_path = v2_dir / "extension.yml"
manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8"))
manifest["provides"]["commands"].append(
{
"name": "speckit.test-ext.new",
"file": "commands/new.md",
"description": "New command",
}
)
manifest["provides"]["commands"].append(
{
"name": "speckit.test-ext.fresh",
"file": "commands/fresh.md",
"description": "Fresh command",
}
)
manifest_path.write_text(
yaml.safe_dump(manifest, sort_keys=False),
encoding="utf-8",
)
(v2_dir / "commands" / "hello.md").write_text(
"---\ndescription: New hello\n---\n\nNEW HELLO\n",
encoding="utf-8",
)
(v2_dir / "commands" / "new.md").write_text(
"---\ndescription: New command\n---\n\nNEW COMMAND\n",
encoding="utf-8",
)
(v2_dir / "commands" / "fresh.md").write_text(
"---\ndescription: Fresh command\n---\n\nFRESH COMMAND\n",
encoding="utf-8",
)
zip_path = tmp_path / "test-ext-update.zip"
with zipfile.ZipFile(zip_path, "w") as archive:
for source_path in v2_dir.rglob("*"):
if source_path.is_file():
archive.write(
source_path,
source_path.relative_to(v2_dir),
)
def fail_after_skill_registration(self, manifest):
raise RuntimeError("Hook registration failed")
runner = CliRunner()
with (
patch.object(Path, "cwd", return_value=project_dir),
patch.object(
ExtensionCatalog,
"get_extension_info",
return_value={
"id": "test-ext",
"name": "Test Extension",
"version": "2.0.0",
"_install_allowed": True,
},
),
patch.object(
ExtensionCatalog,
"download_extension",
return_value=zip_path,
),
patch.object(
HookExecutor,
"register_hooks",
fail_after_skill_registration,
),
):
result = runner.invoke(
app,
["extension", "update", "test-ext"],
input="y\n",
catch_exceptions=True,
)
assert result.exit_code == 1, result.output
assert "Hook registration failed" in result.output
assert "Rollback successful" in result.output
assert ExtensionManager(project_dir).registry.get("test-ext") == old_registry_entry
assert (old_skill / "SKILL.md").read_text(encoding="utf-8") == old_skill_content
assert user_support_file.read_text(encoding="utf-8") == "USER CONTENT"
assert (
new_skill / "SKILL.md"
).read_text(encoding="utf-8") == user_skill_content
assert not (skills_dir / "speckit-test-ext-fresh").exists()
for command_name in ("hello", "new", "fresh"):
qualified_name = f"speckit.test-ext.{command_name}"
assert not (
copilot_agents_dir / f"{qualified_name}.agent.md"
).exists()
assert not (
project_dir
/ ".github"
/ "prompts"
/ f"{qualified_name}.prompt.md"
).exists()
@pytest.mark.parametrize(
("manifest_text", "expected_detail"),
[
("- not\n- a\n- mapping\n", "YAML mapping"),
("extension: []\n", "'extension' mapping"),
],
)
def test_update_rejects_malformed_zip_manifest(
self, tmp_path, monkeypatch, manifest_text, expected_detail
):
"""Downloaded extension.yml shape must be valid before ID validation."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
import zipfile
fake_home = tmp_path / "home"
fake_home.mkdir()
monkeypatch.setattr(Path, "home", lambda: fake_home)
runner = CliRunner()
project_dir = tmp_path / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
(project_dir / ".claude" / "skills").mkdir(parents=True)
manager = ExtensionManager(project_dir)
v1_dir = self._create_extension_source(tmp_path, "1.0.0")
manager.install_from_directory(v1_dir, "0.1.0")
original_registry_entry = manager.registry.get("test-ext")
zip_path = tmp_path / "bad-manifest.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("extension.yml", manifest_text)
with patch.object(Path, "cwd", return_value=project_dir), \
patch.object(ExtensionCatalog, "get_extension_info", return_value={
"id": "test-ext",
"name": "Test Extension",
"version": "2.0.0",
"_install_allowed": True,
}), \
patch.object(ExtensionCatalog, "download_extension", return_value=zip_path):
result = runner.invoke(
app,
["extension", "update", "test-ext"],
input="y\n",
catch_exceptions=True,
)
assert result.exit_code == 1, result.output
assert "Invalid extension manifest in downloaded archive" in result.output
assert expected_detail in result.output
assert "AttributeError" not in result.output
assert ExtensionManager(project_dir).registry.get("test-ext") == original_registry_entry
class TestExtensionListCLI:
"""Test extension list CLI output format."""
def test_list_shows_extension_id(self, extension_dir, project_dir):
"""extension list should display the extension ID."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
# Install the extension using the manager
manager = ExtensionManager(project_dir)
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["extension", "list"])
assert result.exit_code == 0, result.output
plain = strip_ansi(result.output)
# Verify the extension ID is shown in the output
assert "test-ext" in plain
# Verify name and version are also shown
assert "Test Extension" in plain
assert "1.0.0" in plain
class TestExtensionPriority:
"""Test extension priority-based resolution."""
def test_list_by_priority_empty(self, temp_dir):
"""Test list_by_priority on empty registry."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)
result = registry.list_by_priority()
assert result == []
def test_list_by_priority_single(self, temp_dir):
"""Test list_by_priority with single extension."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)
registry.add("test-ext", {"version": "1.0.0", "priority": 5})
result = registry.list_by_priority()
assert len(result) == 1
assert result[0][0] == "test-ext"
assert result[0][1]["priority"] == 5
def test_list_by_priority_ordering(self, temp_dir):
"""Test list_by_priority returns extensions sorted by priority."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)
# Add in non-priority order
registry.add("ext-low", {"version": "1.0.0", "priority": 20})
registry.add("ext-high", {"version": "1.0.0", "priority": 1})
registry.add("ext-mid", {"version": "1.0.0", "priority": 10})
result = registry.list_by_priority()
assert len(result) == 3
# Lower priority number = higher precedence (first)
assert result[0][0] == "ext-high"
assert result[1][0] == "ext-mid"
assert result[2][0] == "ext-low"
def test_list_by_priority_default(self, temp_dir):
"""Test list_by_priority uses default priority of 10."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)
# Add without explicit priority
registry.add("ext-default", {"version": "1.0.0"})
registry.add("ext-high", {"version": "1.0.0", "priority": 1})
registry.add("ext-low", {"version": "1.0.0", "priority": 20})
result = registry.list_by_priority()
assert len(result) == 3
# ext-high (1), ext-default (10), ext-low (20)
assert result[0][0] == "ext-high"
assert result[1][0] == "ext-default"
assert result[2][0] == "ext-low"
def test_list_by_priority_invalid_priority_defaults(self, temp_dir):
"""Malformed priority values fall back to the default priority."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)
registry.add("ext-high", {"version": "1.0.0", "priority": 1})
registry.data["extensions"]["ext-invalid"] = {
"version": "1.0.0",
"priority": "high",
}
registry._save()
result = registry.list_by_priority()
assert [item[0] for item in result] == ["ext-high", "ext-invalid"]
assert result[1][1]["priority"] == 10
def test_list_by_priority_excludes_disabled(self, temp_dir):
"""Test that list_by_priority excludes disabled extensions by default."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)
registry.add("ext-enabled", {"version": "1.0.0", "enabled": True, "priority": 5})
registry.add("ext-disabled", {"version": "1.0.0", "enabled": False, "priority": 1})
registry.add("ext-default", {"version": "1.0.0", "priority": 10}) # no enabled field = True
# Default: exclude disabled
by_priority = registry.list_by_priority()
ext_ids = [p[0] for p in by_priority]
assert "ext-enabled" in ext_ids
assert "ext-default" in ext_ids
assert "ext-disabled" not in ext_ids
def test_list_by_priority_includes_disabled_when_requested(self, temp_dir):
"""Test that list_by_priority includes disabled extensions when requested."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)
registry.add("ext-enabled", {"version": "1.0.0", "enabled": True, "priority": 5})
registry.add("ext-disabled", {"version": "1.0.0", "enabled": False, "priority": 1})
# Include disabled
by_priority = registry.list_by_priority(include_disabled=True)
ext_ids = [p[0] for p in by_priority]
assert "ext-enabled" in ext_ids
assert "ext-disabled" in ext_ids
# Disabled ext has lower priority number, so it comes first when included
assert ext_ids[0] == "ext-disabled"
def test_install_with_priority(self, extension_dir, project_dir):
"""Test that install_from_directory stores priority."""
manager = ExtensionManager(project_dir)
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False, priority=5)
metadata = manager.registry.get("test-ext")
assert metadata["priority"] == 5
def test_install_default_priority(self, extension_dir, project_dir):
"""Test that install_from_directory uses default priority of 10."""
manager = ExtensionManager(project_dir)
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
metadata = manager.registry.get("test-ext")
assert metadata["priority"] == 10
def test_list_installed_includes_priority(self, extension_dir, project_dir):
"""Test that list_installed includes priority in returned data."""
manager = ExtensionManager(project_dir)
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False, priority=3)
installed = manager.list_installed()
assert len(installed) == 1
assert installed[0]["priority"] == 3
def test_priority_preserved_on_update(self, temp_dir):
"""Test that registry update preserves priority."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)
registry.add("test-ext", {"version": "1.0.0", "priority": 5, "enabled": True})
# Update with new metadata (no priority specified)
registry.update("test-ext", {"enabled": False})
updated = registry.get("test-ext")
assert updated["priority"] == 5 # Preserved
assert updated["enabled"] is False # Updated
def test_corrupted_extension_entry_not_picked_up_as_unregistered(self, project_dir):
"""Corrupted registry entries are still tracked and NOT picked up as unregistered."""
extensions_dir = project_dir / ".specify" / "extensions"
valid_dir = extensions_dir / "valid-ext" / "templates"
valid_dir.mkdir(parents=True)
(valid_dir / "other-template.md").write_text("# Valid\n")
broken_dir = extensions_dir / "broken-ext" / "templates"
broken_dir.mkdir(parents=True)
(broken_dir / "target-template.md").write_text("# Broken Target\n")
registry = ExtensionRegistry(extensions_dir)
registry.add("valid-ext", {"version": "1.0.0", "priority": 10})
# Corrupt the entry - should still be tracked, not picked up as unregistered
registry.data["extensions"]["broken-ext"] = "corrupted"
registry._save()
from specify_cli.presets import PresetResolver
resolver = PresetResolver(project_dir)
# Corrupted extension templates should NOT be resolved
resolved = resolver.resolve("target-template")
assert resolved is None
# Valid extension template should still resolve
valid_resolved = resolver.resolve("other-template")
assert valid_resolved is not None
assert "Valid" in valid_resolved.read_text()
class TestExtensionPriorityCLI:
"""Test extension priority CLI integration."""
def test_add_with_priority_option(self, extension_dir, project_dir):
"""Test extension add command with --priority option."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, [
"extension", "add", str(extension_dir), "--dev", "--priority", "3"
])
assert result.exit_code == 0, result.output
manager = ExtensionManager(project_dir)
metadata = manager.registry.get("test-ext")
assert metadata["priority"] == 3
def test_list_shows_priority(self, extension_dir, project_dir):
"""Test extension list shows priority."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
# Install extension with priority
manager = ExtensionManager(project_dir)
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False, priority=7)
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["extension", "list"])
assert result.exit_code == 0, result.output
plain = strip_ansi(result.output)
assert "Priority: 7" in plain
def test_set_priority_changes_priority(self, extension_dir, project_dir):
"""Test set-priority command changes extension priority."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
# Install extension with default priority
manager = ExtensionManager(project_dir)
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
# Verify default priority
assert manager.registry.get("test-ext")["priority"] == 10
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["extension", "set-priority", "test-ext", "5"])
assert result.exit_code == 0, result.output
plain = strip_ansi(result.output)
assert "priority changed: 10 → 5" in plain
# Reload registry to see updated value
manager2 = ExtensionManager(project_dir)
assert manager2.registry.get("test-ext")["priority"] == 5
def test_set_priority_same_value_no_change(self, extension_dir, project_dir):
"""Test set-priority with same value shows already set message."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
# Install extension with priority 5
manager = ExtensionManager(project_dir)
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False, priority=5)
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["extension", "set-priority", "test-ext", "5"])
assert result.exit_code == 0, result.output
plain = strip_ansi(result.output)
assert "already has priority 5" in plain
def test_set_priority_repairs_corrupted_bool(self, extension_dir, project_dir):
"""A corrupted boolean priority must be repaired, not skipped.
``isinstance(True, int)`` is True and ``True == 1`` in Python, so a
stored ``True`` priority would short-circuit the ``already has
priority 1`` skip path and never get rewritten to a real int —
contradicting the comment that promises corrupted values are
repaired. The guard must exclude bools (like normalize_priority).
"""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
manager = ExtensionManager(project_dir)
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False, priority=5
)
# Inject a corrupted boolean priority (True == 1).
manager.registry.update("test-ext", {"priority": True})
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["extension", "set-priority", "test-ext", "1"])
assert result.exit_code == 0, result.output
plain = strip_ansi(result.output)
# The corrupted bool must be repaired, not reported as already-set.
assert "already has priority" not in plain
assert "priority changed" in plain
# The stored value is now a real int, not a bool.
reloaded = ExtensionManager(project_dir).registry.get("test-ext")
assert reloaded["priority"] == 1
assert not isinstance(reloaded["priority"], bool)
def test_set_priority_invalid_value(self, extension_dir, project_dir):
"""Test set-priority rejects invalid priority values."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
# Install extension
manager = ExtensionManager(project_dir)
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["extension", "set-priority", "test-ext", "0"])
assert result.exit_code == 1, result.output
assert "Priority must be a positive integer" in result.output
def test_set_priority_not_installed(self, project_dir):
"""Test set-priority fails for non-installed extension."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
# Ensure .specify exists
(project_dir / ".specify").mkdir(parents=True, exist_ok=True)
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["extension", "set-priority", "nonexistent", "5"])
assert result.exit_code == 1, result.output
assert "not installed" in result.output.lower() or "no extensions installed" in result.output.lower()
def test_set_priority_by_display_name(self, extension_dir, project_dir):
"""Test set-priority works with extension display name."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
# Install extension
manager = ExtensionManager(project_dir)
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
# Use display name "Test Extension" instead of ID "test-ext"
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["extension", "set-priority", "Test Extension", "3"])
assert result.exit_code == 0, result.output
assert "priority changed" in result.output
# Reload registry to see updated value
manager2 = ExtensionManager(project_dir)
assert manager2.registry.get("test-ext")["priority"] == 3
class TestExtensionPriorityBackwardsCompatibility:
"""Test backwards compatibility for extensions installed before priority feature."""
def test_legacy_extension_without_priority_field(self, temp_dir):
"""Extensions installed before priority feature should default to 10."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
# Simulate legacy registry entry without priority field
registry = ExtensionRegistry(extensions_dir)
registry.data["extensions"]["legacy-ext"] = {
"version": "1.0.0",
"source": "local",
"enabled": True,
"installed_at": "2025-01-01T00:00:00Z",
# No "priority" field - simulates pre-feature extension
}
registry._save()
# Reload registry
registry2 = ExtensionRegistry(extensions_dir)
# list_by_priority should use default of 10
result = registry2.list_by_priority()
assert len(result) == 1
assert result[0][0] == "legacy-ext"
# Priority defaults to 10 and is normalized in returned metadata
assert result[0][1]["priority"] == 10
def test_legacy_extension_in_list_installed(self, extension_dir, project_dir):
"""list_installed returns priority=10 for legacy extensions without priority field."""
manager = ExtensionManager(project_dir)
# Install extension normally
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
# Manually remove priority to simulate legacy extension
ext_data = manager.registry.data["extensions"]["test-ext"]
del ext_data["priority"]
manager.registry._save()
# list_installed should still return priority=10
installed = manager.list_installed()
assert len(installed) == 1
assert installed[0]["priority"] == 10
def test_mixed_legacy_and_new_extensions_ordering(self, temp_dir):
"""Legacy extensions (no priority) sort with default=10 among prioritized extensions."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)
# Add extension with explicit priority=5
registry.add("ext-with-priority", {"version": "1.0.0", "priority": 5})
# Add legacy extension without priority (manually)
registry.data["extensions"]["legacy-ext"] = {
"version": "1.0.0",
"source": "local",
"enabled": True,
# No priority field
}
registry._save()
# Add extension with priority=15
registry.add("ext-low-priority", {"version": "1.0.0", "priority": 15})
# Reload and check ordering
registry2 = ExtensionRegistry(extensions_dir)
result = registry2.list_by_priority()
assert len(result) == 3
# Order: ext-with-priority (5), legacy-ext (defaults to 10), ext-low-priority (15)
assert result[0][0] == "ext-with-priority"
assert result[1][0] == "legacy-ext"
assert result[2][0] == "ext-low-priority"
class _StubManifest(ExtensionManifest):
"""ExtensionManifest stub for HookExecutor tests.
Subclasses the real manifest so it satisfies ``register_hooks``'s type
while bypassing the file-based parsing/validation pipeline. The inherited
``id`` and ``hooks`` properties read from ``data``, so populating ``data``
is enough.
"""
def __init__(self, ext_id: str, hooks: dict):
self.data = {"extension": {"id": ext_id}, "hooks": hooks}
class TestHookExecutorRegistration:
"""Tests for HookExecutor.register_hooks / get_hooks_for_event with
multi-entry hook events and per-entry priority ordering."""
def test_register_hooks_single_mapping_back_compat(self, project_dir):
"""Single-mapping form continues to register exactly one entry with
default priority."""
executor = HookExecutor(project_dir)
executor.register_hooks(
_StubManifest("ext-a", {"after_tasks": {"command": "speckit.ext-a.go"}})
)
config = executor.get_project_config()
entries = config["hooks"]["after_tasks"]
assert len(entries) == 1
assert entries[0]["extension"] == "ext-a"
assert entries[0]["command"] == "speckit.ext-a.go"
assert entries[0]["priority"] == DEFAULT_HOOK_PRIORITY
def test_register_hooks_multiple_entries_same_event(self, project_dir):
"""A list of mappings registers each entry under the same event."""
executor = HookExecutor(project_dir)
executor.register_hooks(
_StubManifest(
"ext-a",
{
"after_tasks": [
{"command": "speckit.ext-a.first", "description": "1st"},
{"command": "speckit.ext-a.second", "description": "2nd"},
]
},
)
)
entries = executor.get_project_config()["hooks"]["after_tasks"]
assert len(entries) == 2
assert [e["command"] for e in entries] == [
"speckit.ext-a.first",
"speckit.ext-a.second",
]
assert all(e["extension"] == "ext-a" for e in entries)
def test_register_hooks_dedup_on_extension_and_command(self, project_dir):
"""Re-registering the same (extension, command) updates in place
rather than appending a duplicate entry."""
executor = HookExecutor(project_dir)
manifest = _StubManifest(
"ext-a",
{
"after_tasks": [
{"command": "speckit.ext-a.first", "description": "v1"},
{"command": "speckit.ext-a.second", "description": "v1"},
]
},
)
executor.register_hooks(manifest)
manifest.hooks["after_tasks"][0]["description"] = "v2"
executor.register_hooks(manifest)
entries = executor.get_project_config()["hooks"]["after_tasks"]
assert len(entries) == 2
first = next(e for e in entries if e["command"] == "speckit.ext-a.first")
assert first["description"] == "v2"
def test_register_hooks_shape_change_removes_orphans(self, project_dir):
"""Reinstalling with a shorter hook shape (list → single mapping, or a
shrunk list) purges the dropped commands instead of leaving orphans."""
executor = HookExecutor(project_dir)
executor.register_hooks(
_StubManifest(
"ext-a",
{
"after_tasks": [
{"command": "speckit.ext-a.first"},
{"command": "speckit.ext-a.second"},
]
},
)
)
executor.register_hooks(
_StubManifest("ext-a", {"after_tasks": {"command": "speckit.ext-a.first"}})
)
entries = executor.get_project_config()["hooks"]["after_tasks"]
assert [e["command"] for e in entries] == ["speckit.ext-a.first"]
def test_register_hooks_single_to_list_reinstall_adds_entries(self, project_dir):
"""Reinstalling a single-mapping hook as a list adds the new entries."""
executor = HookExecutor(project_dir)
executor.register_hooks(
_StubManifest("ext-a", {"after_tasks": {"command": "speckit.ext-a.first"}})
)
executor.register_hooks(
_StubManifest(
"ext-a",
{
"after_tasks": [
{"command": "speckit.ext-a.first"},
{"command": "speckit.ext-a.second"},
]
},
)
)
entries = executor.get_project_config()["hooks"]["after_tasks"]
assert [e["command"] for e in entries] == [
"speckit.ext-a.first",
"speckit.ext-a.second",
]
def test_register_hooks_skips_entry_without_command(self, project_dir):
"""An entry lacking a command is skipped (defensive; validated
manifests never reach this state)."""
executor = HookExecutor(project_dir)
executor.register_hooks(
_StubManifest(
"ext-a",
{
"after_tasks": [
{"command": "speckit.ext-a.go"},
{"optional": True},
]
},
)
)
entries = executor.get_project_config()["hooks"]["after_tasks"]
assert [e["command"] for e in entries] == ["speckit.ext-a.go"]
def test_register_hooks_skips_non_dict_entry(self, project_dir):
"""A non-dict entry in a hook list is skipped rather than crashing
(defensive; validated manifests never reach this state)."""
executor = HookExecutor(project_dir)
executor.register_hooks(
_StubManifest(
"ext-a",
{"after_tasks": [{"command": "speckit.ext-a.go"}, "not-a-mapping"]},
)
)
entries = executor.get_project_config()["hooks"]["after_tasks"]
assert [e["command"] for e in entries] == ["speckit.ext-a.go"]
def test_register_hooks_purges_dropped_event_orphans(self, project_dir):
"""Re-registering without an event it previously declared purges this
extension's entries from that event, scoped to this extension."""
executor = HookExecutor(project_dir)
executor.register_hooks(
_StubManifest(
"ext-a",
{
"after_tasks": {"command": "speckit.ext-a.tasks"},
"after_plan": {"command": "speckit.ext-a.plan"},
"after_implement": {"command": "speckit.ext-a.impl"},
},
)
)
executor.register_hooks(
_StubManifest("ext-b", {"after_plan": {"command": "speckit.ext-b.plan"}})
)
executor.register_hooks(
_StubManifest("ext-a", {"after_tasks": {"command": "speckit.ext-a.tasks"}})
)
hooks = executor.get_project_config()["hooks"]
assert [e["command"] for e in hooks["after_tasks"]] == ["speckit.ext-a.tasks"]
assert [e["command"] for e in hooks["after_plan"]] == ["speckit.ext-b.plan"]
assert "after_implement" not in hooks
def test_register_hooks_dropping_all_hooks_purges_orphans(self, project_dir):
"""Reinstalling with an empty hooks mapping still purges this
extension's entries, scoped to this extension."""
executor = HookExecutor(project_dir)
executor.register_hooks(
_StubManifest("ext-a", {"after_tasks": {"command": "speckit.ext-a.go"}})
)
executor.register_hooks(
_StubManifest("ext-b", {"after_tasks": {"command": "speckit.ext-b.go"}})
)
executor.register_hooks(_StubManifest("ext-a", {}))
hooks = executor.get_project_config()["hooks"]
assert [e["command"] for e in hooks["after_tasks"]] == ["speckit.ext-b.go"]
def test_register_hooks_empty_hooks_purge_survives_corrupt_entry(self, project_dir):
"""A corrupt non-dict entry already on disk does not break the
empty-hooks orphan purge; it is dropped and valid entries survive."""
executor = HookExecutor(project_dir)
executor.register_hooks(
_StubManifest("ext-a", {"after_tasks": {"command": "speckit.ext-a.go"}})
)
executor.register_hooks(
_StubManifest("ext-b", {"after_tasks": {"command": "speckit.ext-b.go"}})
)
config = executor.get_project_config()
config["hooks"]["after_tasks"].append("corrupt-non-dict-entry")
executor.save_project_config(config)
executor.register_hooks(_StubManifest("ext-a", {}))
hooks = executor.get_project_config()["hooks"]
assert [e["command"] for e in hooks["after_tasks"]] == ["speckit.ext-b.go"]
def test_register_hooks_duplicate_command_moves_to_end(self, project_dir):
"""A command repeated in one manifest keeps the last value and the last
insertion position, so equal-priority tie order is 'last wins'."""
executor = HookExecutor(project_dir)
executor.register_hooks(
_StubManifest(
"ext-a",
{
"after_tasks": [
{"command": "speckit.ext-a.dup", "description": "first"},
{"command": "speckit.ext-a.other"},
{"command": "speckit.ext-a.dup", "description": "last"},
]
},
)
)
entries = executor.get_project_config()["hooks"]["after_tasks"]
assert [e["command"] for e in entries] == [
"speckit.ext-a.other",
"speckit.ext-a.dup",
]
assert entries[-1]["description"] == "last"
def test_register_hooks_preserves_other_extensions(self, project_dir):
"""Re-registering one extension must not disturb another extension's
entries on the same event."""
executor = HookExecutor(project_dir)
executor.register_hooks(
_StubManifest("ext-a", {"after_tasks": {"command": "speckit.ext-a.go"}})
)
executor.register_hooks(
_StubManifest("ext-b", {"after_tasks": {"command": "speckit.ext-b.go"}})
)
executor.register_hooks(
_StubManifest("ext-a", {"after_tasks": {"command": "speckit.ext-a.go"}})
)
entries = executor.get_project_config()["hooks"]["after_tasks"]
assert sorted(e["extension"] for e in entries) == ["ext-a", "ext-b"]
def test_get_hooks_for_event_sorts_by_priority(self, project_dir):
"""Returned entries are sorted by priority ascending; equal priorities
preserve insertion order via stable sort."""
executor = HookExecutor(project_dir)
executor.register_hooks(
_StubManifest(
"ext-a",
{
"after_tasks": [
{"command": "speckit.ext-a.mid", "priority": 10},
{"command": "speckit.ext-a.first", "priority": 1},
{"command": "speckit.ext-a.late", "priority": 20},
{"command": "speckit.ext-a.mid-tied", "priority": 10},
]
},
)
)
ordered = executor.get_hooks_for_event("after_tasks")
assert [e["command"] for e in ordered] == [
"speckit.ext-a.first",
"speckit.ext-a.mid",
"speckit.ext-a.mid-tied",
"speckit.ext-a.late",
]
def test_get_hooks_for_event_orders_across_extensions(self, project_dir):
"""Priority controls execution order across extensions regardless of
install order (Issue #2378 use case)."""
executor = HookExecutor(project_dir)
executor.register_hooks(
_StubManifest(
"ext-report",
{"after_plan": {"command": "speckit.ext-report.run", "priority": 20}},
)
)
executor.register_hooks(
_StubManifest(
"ext-verify",
{"after_plan": {"command": "speckit.ext-verify.run", "priority": 5}},
)
)
ordered = executor.get_hooks_for_event("after_plan")
assert [e["command"] for e in ordered] == [
"speckit.ext-verify.run",
"speckit.ext-report.run",
]
def test_get_hooks_for_event_treats_missing_priority_as_default(self, project_dir):
"""Entries persisted before priority was introduced should be sorted
as if their priority equaled DEFAULT_HOOK_PRIORITY."""
executor = HookExecutor(project_dir)
# Legacy on-disk entry with no priority key.
# register_hooks now always sets one, so write this state directly.
executor.save_project_config({
"installed": [],
"settings": {"auto_execute_hooks": True},
"hooks": {
"after_tasks": [
{
"extension": "legacy",
"command": "speckit.legacy.go",
"enabled": True,
},
{
"extension": "newer",
"command": "speckit.newer.first",
"enabled": True,
"priority": 1,
},
]
},
})
ordered = executor.get_hooks_for_event("after_tasks")
assert [e["command"] for e in ordered] == [
"speckit.newer.first",
"speckit.legacy.go",
]
def test_get_hooks_for_event_tolerates_corrupted_priority(self, project_dir):
"""A corrupted on-disk ``priority`` (non-numeric, None, or < 1) is
normalized to the default instead of raising during sort."""
executor = HookExecutor(project_dir)
executor.save_project_config({
"installed": [],
"settings": {"auto_execute_hooks": True},
"hooks": {
"after_tasks": [
{
"extension": "corrupt",
"command": "speckit.corrupt.go",
"enabled": True,
"priority": "not-a-number",
},
{
"extension": "early",
"command": "speckit.early.go",
"enabled": True,
"priority": 1,
},
]
},
})
ordered = executor.get_hooks_for_event("after_tasks")
assert [e["command"] for e in ordered] == [
"speckit.early.go",
"speckit.corrupt.go",
]
def test_unregister_hooks_removes_all_extension_entries(self, project_dir):
"""unregister_hooks removes every entry for the extension regardless
of how many were registered to a given event."""
executor = HookExecutor(project_dir)
executor.register_hooks(
_StubManifest(
"ext-a",
{
"after_tasks": [
{"command": "speckit.ext-a.first"},
{"command": "speckit.ext-a.second"},
]
},
)
)
executor.register_hooks(
_StubManifest("ext-b", {"after_tasks": {"command": "speckit.ext-b.solo"}})
)
executor.unregister_hooks("ext-a")
entries = executor.get_project_config()["hooks"].get("after_tasks", [])
assert [e["extension"] for e in entries] == ["ext-b"]
class TestHookInvocationRendering:
"""Test hook invocation formatting for different agent modes."""
def test_kimi_hooks_render_skill_invocation(self, project_dir):
"""Kimi projects should render /skill:speckit-* invocations."""
init_options = project_dir / ".specify" / "init-options.json"
init_options.parent.mkdir(parents=True, exist_ok=True)
init_options.write_text(json.dumps({"ai": "kimi", "ai_skills": False}))
hook_executor = HookExecutor(project_dir)
message = hook_executor.format_hook_message(
"before_plan",
[
{
"extension": "test-ext",
"command": "speckit.plan",
"optional": False,
}
],
)
assert "Executing: `/skill:speckit-plan`" in message
assert "EXECUTE_COMMAND: speckit.plan" in message
assert "EXECUTE_COMMAND_INVOCATION: /skill:speckit-plan" in message
def test_codex_hooks_render_dollar_skill_invocation(self, project_dir):
"""Codex projects with skills mode should render $speckit-* invocations."""
init_options = project_dir / ".specify" / "init-options.json"
init_options.parent.mkdir(parents=True, exist_ok=True)
init_options.write_text(json.dumps({"ai": "codex", "ai_skills": True}))
hook_executor = HookExecutor(project_dir)
execution = hook_executor.execute_hook(
{
"extension": "test-ext",
"command": "speckit.tasks",
"optional": False,
}
)
assert execution["command"] == "speckit.tasks"
assert execution["invocation"] == "$speckit-tasks"
def test_zcode_hooks_render_dollar_skill_invocation(self, project_dir):
"""ZCode projects with skills mode should render $speckit-* invocations."""
init_options = project_dir / ".specify" / "init-options.json"
init_options.parent.mkdir(parents=True, exist_ok=True)
init_options.write_text(json.dumps({"ai": "zcode", "ai_skills": True}))
hook_executor = HookExecutor(project_dir)
execution = hook_executor.execute_hook(
{
"extension": "test-ext",
"command": "speckit.tasks",
"optional": False,
}
)
assert execution["command"] == "speckit.tasks"
assert execution["invocation"] == "$speckit-tasks"
def test_non_boolean_ai_skills_keeps_default_hook_invocation(self, project_dir):
"""Corrupted truthy ai_skills values should not enable skill invocation."""
init_options = project_dir / ".specify" / "init-options.json"
init_options.parent.mkdir(parents=True, exist_ok=True)
init_options.write_text(
json.dumps({"ai": "codex", "ai_skills": "false"}), encoding="utf-8"
)
hook_executor = HookExecutor(project_dir)
execution = hook_executor.execute_hook(
{
"extension": "test-ext",
"command": "speckit.tasks",
"optional": False,
}
)
assert execution["command"] == "speckit.tasks"
assert execution["invocation"] == "/speckit.tasks"
def test_cline_hooks_render_hyphenated_invocation(self, project_dir):
"""Cline projects should render /speckit-* invocations."""
init_options = project_dir / ".specify" / "init-options.json"
init_options.parent.mkdir(parents=True, exist_ok=True)
init_options.write_text(json.dumps({"ai": "cline"}))
hook_executor = HookExecutor(project_dir)
execution = hook_executor.execute_hook(
{
"extension": "test-ext",
"command": "speckit.tasks",
"optional": False,
}
)
assert execution["command"] == "speckit.tasks"
assert execution["invocation"] == "/speckit-tasks"
def test_cline_hooks_render_extension_command(self, project_dir):
"""Cline projects should render /speckit-my-ext-cmd for extension hooks."""
init_options = project_dir / ".specify" / "init-options.json"
init_options.parent.mkdir(parents=True, exist_ok=True)
init_options.write_text(json.dumps({"ai": "cline"}))
hook_executor = HookExecutor(project_dir)
# Test with a non-speckit. command
execution = hook_executor.execute_hook(
{
"extension": "test-ext",
"command": "my-extension.do-something",
"optional": False,
}
)
assert execution["command"] == "my-extension.do-something"
assert execution["invocation"] == "/speckit-my-extension-do-something"
def test_forge_hooks_render_hyphenated_invocation(self, project_dir):
"""Forge projects should render /speckit-* invocations (like Cline)."""
init_options = project_dir / ".specify" / "init-options.json"
init_options.parent.mkdir(parents=True, exist_ok=True)
init_options.write_text(json.dumps({"ai": "forge"}))
hook_executor = HookExecutor(project_dir)
execution = hook_executor.execute_hook(
{
"extension": "test-ext",
"command": "speckit.tasks",
"optional": False,
}
)
assert execution["command"] == "speckit.tasks"
assert execution["invocation"] == "/speckit-tasks"
def test_forge_hooks_render_extension_command(self, project_dir):
"""Forge projects should render /speckit-my-ext-cmd for extension hooks."""
init_options = project_dir / ".specify" / "init-options.json"
init_options.parent.mkdir(parents=True, exist_ok=True)
init_options.write_text(json.dumps({"ai": "forge"}))
hook_executor = HookExecutor(project_dir)
execution = hook_executor.execute_hook(
{
"extension": "test-ext",
"command": "my-extension.do-something",
"optional": False,
}
)
assert execution["command"] == "my-extension.do-something"
assert execution["invocation"] == "/speckit-my-extension-do-something"
def test_non_skill_command_keeps_slash_invocation(self, project_dir):
"""Custom hook commands should keep slash invocation style."""
init_options = project_dir / ".specify" / "init-options.json"
init_options.parent.mkdir(parents=True, exist_ok=True)
init_options.write_text(json.dumps({"ai": "kimi", "ai_skills": False}))
hook_executor = HookExecutor(project_dir)
message = hook_executor.format_hook_message(
"before_tasks",
[
{
"extension": "test-ext",
"command": "pre_tasks_test",
"optional": False,
}
],
)
assert "Executing: `/pre_tasks_test`" in message
assert "EXECUTE_COMMAND: pre_tasks_test" in message
assert "EXECUTE_COMMAND_INVOCATION: /pre_tasks_test" in message
def test_extension_command_uses_hyphenated_skill_invocation(self, project_dir):
"""Multi-segment extension command ids should map to hyphenated skills."""
init_options = project_dir / ".specify" / "init-options.json"
init_options.parent.mkdir(parents=True, exist_ok=True)
init_options.write_text(json.dumps({"ai": "kimi", "ai_skills": False}))
hook_executor = HookExecutor(project_dir)
message = hook_executor.format_hook_message(
"after_tasks",
[
{
"extension": "test-ext",
"command": "speckit.test-ext.hello",
"optional": False,
}
],
)
assert "Executing: `/skill:speckit-test-ext-hello`" in message
assert "EXECUTE_COMMAND: speckit.test-ext.hello" in message
assert "EXECUTE_COMMAND_INVOCATION: /skill:speckit-test-ext-hello" in message
def test_hook_executor_caches_init_options_lookup(self, project_dir, monkeypatch):
"""Init options should be loaded once per executor instance."""
calls = {"count": 0}
def fake_load_init_options(_project_root):
calls["count"] += 1
return {"ai": "kimi", "ai_skills": False}
monkeypatch.setattr("specify_cli.load_init_options", fake_load_init_options)
hook_executor = HookExecutor(project_dir)
assert hook_executor._render_hook_invocation("speckit.plan") == "/skill:speckit-plan"
assert hook_executor._render_hook_invocation("speckit.tasks") == "/skill:speckit-tasks"
assert calls["count"] == 1
def test_hook_message_falls_back_when_invocation_is_empty(self, project_dir):
"""Hook messages should still render actionable command placeholders."""
init_options = project_dir / ".specify" / "init-options.json"
init_options.parent.mkdir(parents=True, exist_ok=True)
init_options.write_text(json.dumps({"ai": "kimi", "ai_skills": False}))
hook_executor = HookExecutor(project_dir)
message = hook_executor.format_hook_message(
"after_tasks",
[
{
"extension": "test-ext",
"command": None,
"optional": False,
}
],
)
assert "Executing: `/<missing command>`" in message
assert "EXECUTE_COMMAND: <missing command>" in message
assert "EXECUTE_COMMAND_INVOCATION: /<missing command>" in message
class TestExtensionRemoveCLI:
"""CLI tests for `specify extension remove` confirmation prompt wording."""
def _install_ext(self, project_dir, ext_dir):
"""Install extension and return the manager."""
manager = ExtensionManager(project_dir)
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
return manager
def test_remove_confirmation_singular_command(self, tmp_path, extension_dir):
"""Confirmation prompt should say '1 command' (singular) when one command registered."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
project_dir = tmp_path / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
manager = self._install_ext(project_dir, extension_dir)
# Inject registered_commands with 1 entry so cmd_count == 1
manager.registry.update("test-ext", {"registered_commands": {"claude": ["speckit.test-ext.hello"]}})
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app, ["extension", "remove", "test-ext"], input="n\n", catch_exceptions=False
)
assert "1 command" in result.output
assert "1 commands" not in result.output
def test_remove_confirmation_plural_commands(self, tmp_path, extension_dir):
"""Confirmation prompt should say '2 commands' (plural) when two commands registered."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
project_dir = tmp_path / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
manager = self._install_ext(project_dir, extension_dir)
# Inject registered_commands with 2 entries so cmd_count == 2
manager.registry.update("test-ext", {"registered_commands": {"claude": ["speckit.test-ext.hello", "speckit.test-ext.run"]}})
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app, ["extension", "remove", "test-ext"], input="n\n", catch_exceptions=False
)
assert "2 commands" in result.output
def test_remove_output_escapes_extension_id_markup(self, tmp_path):
"""Removal paths and reinstall hints must not parse extension IDs as markup."""
from types import SimpleNamespace
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
project_dir = tmp_path / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
extension_id = "[red]bad[/red]"
installed = [
{
"id": extension_id,
"name": "Bad Extension",
"version": "1.0.0",
"description": "Test extension",
"enabled": True,
}
]
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch.object(ExtensionManager, "list_installed", return_value=installed), \
patch.object(ExtensionManager, "get_extension", return_value=SimpleNamespace(commands=[])), \
patch.object(ExtensionRegistry, "get", return_value={"registered_commands": {}, "registered_skills": []}), \
patch.object(ExtensionManager, "remove", return_value=True):
result = runner.invoke(
app,
["extension", "remove", extension_id, "--force"],
catch_exceptions=True,
)
assert result.exit_code == 0, result.output
assert ".specify/extensions/.backup/[red]bad[/red]/" in result.output
assert "specify extension add [red]bad[/red]" in result.output
class TestExtensionStateCLI:
"""CLI tests for installed extension state commands."""
def test_enable_registry_error_escapes_extension_id_markup(self, tmp_path):
"""Registry-corruption errors should render extension IDs literally."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
project_dir = tmp_path / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
extension_id = "[red]bad[/red]"
installed = [
{
"id": extension_id,
"name": "Bad Extension",
"version": "1.0.0",
"description": "Test extension",
"enabled": False,
}
]
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch.object(ExtensionManager, "list_installed", return_value=installed), \
patch.object(ExtensionRegistry, "get", return_value=None):
result = runner.invoke(
app,
["extension", "enable", extension_id],
catch_exceptions=True,
)
assert result.exit_code == 1, result.output
assert "Extension '[red]bad[/red]' not found in registry" in result.output
def test_disable_reenable_hint_escapes_extension_id_markup(self, tmp_path):
"""Disable success hints should not parse extension IDs as markup."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
project_dir = tmp_path / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
extension_id = "[red]bad[/red]"
installed = [
{
"id": extension_id,
"name": "Bad Extension",
"version": "1.0.0",
"description": "Test extension",
"enabled": True,
}
]
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch.object(ExtensionManager, "list_installed", return_value=installed), \
patch.object(ExtensionRegistry, "get", return_value={"enabled": True}), \
patch.object(ExtensionRegistry, "update", return_value=None), \
patch.object(HookExecutor, "get_project_config", return_value={}):
result = runner.invoke(
app,
["extension", "disable", extension_id],
catch_exceptions=True,
)
assert result.exit_code == 0, result.output
assert "specify extension enable [red]bad[/red]" in result.output
class TestClineExtensionHyphenation:
"""Test that Cline integration uses hyphenated commands and frontmatter references."""
def _setup_mock_extension(self, tmp_path, ai_name):
import yaml
import json
# 1. Setup mock project
project_dir = tmp_path / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
init_options = project_dir / ".specify" / "init-options.json"
init_options.write_text(json.dumps({"ai": ai_name}), encoding="utf-8")
if ai_name == "cline":
commands_dest_dir = project_dir / ".clinerules" / "workflows"
else:
commands_dest_dir = project_dir / ".agents" / "commands"
commands_dest_dir.mkdir(parents=True, exist_ok=True)
# 2. Setup mock extension directory
ext_dir = tmp_path / "mock-ext"
ext_dir.mkdir()
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "mock-ext",
"name": "Mock Extension",
"version": "1.0.0",
"description": f"Mock extension for {ai_name} tests",
"author": "Tester",
"repository": "https://github.com/test/mock-ext",
"license": "MIT",
},
"requires": {
"speckit_version": ">=0.1.0",
},
"provides": {
"commands": [
{
"name": "speckit.mock-ext.hello",
"file": "commands/hello.md",
"description": "Test hello command",
"aliases": ["speckit.mock-ext.greet"]
}
]
}
}
with open(ext_dir / "extension.yml", "w", encoding="utf-8") as f:
yaml.dump(manifest_data, f)
commands_dir = ext_dir / "commands"
commands_dir.mkdir()
# Command file with dotted speckit references in frontmatter and body
cmd_content = """---
description: "Test hello command"
agent: speckit.tasks
handoffs:
- agent: speckit.iterate.start
message: "Hand off to start"
---
# Test Hello Command
Please refer to speckit.mock-ext.greet for instructions.
$ARGUMENTS
"""
(commands_dir / "hello.md").write_text(cmd_content, encoding="utf-8")
return project_dir, ext_dir, commands_dest_dir
def test_cline_extension_hyphenation(self, tmp_path):
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
from specify_cli.agents import CommandRegistrar
project_dir, ext_dir, cline_workflows_dir = self._setup_mock_extension(tmp_path, "cline")
# 3. Run specify extension add
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app, ["extension", "add", str(ext_dir), "--dev"], catch_exceptions=False
)
# Verify CLI printed hyphenated commands
# Note: We assert that the primary command 'speckit-mock-ext-hello' is printed,
# but we do not assert that the alias 'speckit-mock-ext-greet' is printed in the console
# because manifest.commands only lists primary commands.
assert "speckit-mock-ext-hello" in result.output
assert "speckit.mock-ext.hello" not in result.output
# Verify on-disk command names are hyphenated
hello_file = cline_workflows_dir / "speckit-mock-ext-hello.md"
greet_file = cline_workflows_dir / "speckit-mock-ext-greet.md"
assert hello_file.exists()
assert greet_file.exists()
# Verify frontmatter in the generated files is recursively hyphenated
hello_text = hello_file.read_text(encoding="utf-8")
hello_fm, hello_body = CommandRegistrar.parse_frontmatter(hello_text)
assert hello_fm["agent"] == "speckit-tasks"
assert hello_fm["handoffs"][0]["agent"] == "speckit-iterate-start"
# Verify body references are hyphenated for Cline
assert "speckit-mock-ext-greet" in hello_body
assert "speckit.mock-ext.greet" not in hello_body
def test_non_cline_extension_no_hyphenation(self, tmp_path):
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
from specify_cli.agents import CommandRegistrar
project_dir, ext_dir, agents_commands_dir = self._setup_mock_extension(tmp_path, "amp")
# 3. Run specify extension add
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app, ["extension", "add", str(ext_dir), "--dev"], catch_exceptions=False
)
# Verify CLI printed dotted commands
# Note: We assert that the primary command 'speckit.mock-ext.hello' is printed,
# but we do not assert that the alias 'speckit.mock-ext.greet' is printed in the console
# because manifest.commands only lists primary commands.
assert "speckit.mock-ext.hello" in result.output
assert "speckit-mock-ext-hello" not in result.output
# Verify on-disk command names are dotted
hello_file = agents_commands_dir / "speckit.mock-ext.hello.md"
greet_file = agents_commands_dir / "speckit.mock-ext.greet.md"
assert hello_file.exists()
assert greet_file.exists()
# Verify frontmatter references are still dotted
hello_text = hello_file.read_text(encoding="utf-8")
hello_fm, hello_body = CommandRegistrar.parse_frontmatter(hello_text)
assert hello_fm["agent"] == "speckit.tasks"
assert hello_fm["handoffs"][0]["agent"] == "speckit.iterate.start"
# Verify body references are still dotted for non-Cline
assert "speckit.mock-ext.greet" in hello_body
assert "speckit-mock-ext-greet" not in hello_body
class TestExtensionForceCLI:
"""CLI tests for `specify extension add --dev --force`."""
def _create_minimal_extension(self, base_dir: str | Path, ext_id: str = "test-ext") -> Path:
"""Create a minimal extension directory with manifest."""
import yaml
ext_dir = Path(base_dir) / ext_id
ext_dir.mkdir(parents=True, exist_ok=True)
(ext_dir / "commands").mkdir()
manifest = {
"schema_version": "1.0",
"extension": {
"id": ext_id,
"name": "Test Extension",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": f"speckit.{ext_id}.hello",
"file": "commands/hello.md",
"description": "Test command",
}
]
},
}
(ext_dir / "extension.yml").write_text(yaml.dump(manifest))
(ext_dir / "commands" / "hello.md").write_text(
"---\ndescription: Test\n---\n\nHello $ARGUMENTS\n"
)
return ext_dir
def test_add_dev_force_reinstall(self, tmp_path):
"""extension add --dev --force should reinstall without error."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
project_dir = tmp_path / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
ext_src = self._create_minimal_extension(tmp_path)
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
# First install
result1 = runner.invoke(
app, ["extension", "add", str(ext_src), "--dev"], catch_exceptions=False
)
assert result1.exit_code == 0, strip_ansi(result1.output)
assert "installed" in strip_ansi(result1.output)
# Force reinstall
result2 = runner.invoke(
app, ["extension", "add", str(ext_src), "--dev", "--force"], catch_exceptions=False
)
assert result2.exit_code == 0, strip_ansi(result2.output)
assert "installed" in strip_ansi(result2.output)
def test_extension_wrapper_resolves_ghes_asset_when_host_configured(tmp_path, monkeypatch):
"""End-to-end wiring: auth.json github host → GHES asset resolution."""
from specify_cli.authentication import http as _auth_http
from specify_cli.authentication.config import AuthConfigEntry
from specify_cli.extensions import ExtensionCatalog
monkeypatch.setattr(_auth_http, "_config_override", [
AuthConfigEntry(hosts=("ghes.example",), provider="github",
auth="bearer", token="t"),
])
catalog = ExtensionCatalog(tmp_path)
captured = []
@contextmanager
def fake_open(url, timeout=None, extra_headers=None):
captured.append(url)
resp = MagicMock()
resp.read.side_effect = io.BytesIO(json.dumps({
"assets": [{"name": "ext.zip",
"url": "https://ghes.example/api/v3/repos/o/r/releases/assets/7"}]
}).encode()).read
yield resp
monkeypatch.setattr(catalog, "_open_url", fake_open)
resolved = catalog._resolve_github_release_asset_api_url(
"https://ghes.example/o/r/releases/download/v1/ext.zip"
)
assert resolved == "https://ghes.example/api/v3/repos/o/r/releases/assets/7"
assert captured == ["https://ghes.example/api/v3/repos/o/r/releases/tags/v1"]
class TestConfigManagerNonMappingYaml:
"""A non-mapping YAML config root must not crash config/hook resolution."""
def _make(self, tmp_path, body: str):
ext_dir = tmp_path / ".specify" / "extensions" / "jira"
ext_dir.mkdir(parents=True)
(ext_dir / "jira-config.yml").write_text(body, encoding="utf-8")
return ConfigManager(tmp_path, "jira")
def test_get_config_coerces_list_root(self, tmp_path):
"""A YAML list root previously raised AttributeError in _merge_configs."""
cm = self._make(tmp_path, "- foo\n- bar\n")
assert cm.get_config() == {}
def test_get_config_coerces_scalar_root(self, tmp_path):
cm = self._make(tmp_path, "just a string\n")
assert cm.get_config() == {}
def test_has_value_and_get_value_do_not_raise(self, tmp_path):
cm = self._make(tmp_path, "- foo\n")
assert cm.has_value("anything") is False
assert cm.get_value("anything") is None
def test_valid_local_config_layers_over_list_root_project_config(self, tmp_path):
"""A malformed project config must not block a valid local config."""
ext_dir = tmp_path / ".specify" / "extensions" / "jira"
ext_dir.mkdir(parents=True)
(ext_dir / "jira-config.yml").write_text("- foo\n- bar\n", encoding="utf-8")
(ext_dir / "local-config.yml").write_text(
"notifications:\n enabled: true\n", encoding="utf-8"
)
cm = ConfigManager(tmp_path, "jira")
assert cm.get_value("notifications.enabled") is True
def test_hook_condition_returns_false_without_raising(self, tmp_path):
"""`config.x is set` on a scalar-root config must evaluate cleanly.
Before the fix, _merge_configs raised AttributeError and the
exception was swallowed by should_execute_hook, silently disabling
every config-based hook for the extension. Assert on
_evaluate_condition directly so the crash isn't masked.
"""
ext_dir = tmp_path / ".specify" / "extensions" / "jira"
ext_dir.mkdir(parents=True)
(ext_dir / "jira-config.yml").write_text("just a string\n", encoding="utf-8")
executor = HookExecutor(tmp_path)
assert executor._evaluate_condition("config.x is set", "jira") is False
class TestConfigManagerEnvPrefixCollision:
"""Prefix-colliding env vars must not crash or clobber nested config."""
def test_scalar_then_nested_yields_nested(self, tmp_path, monkeypatch):
"""SPECKIT_X_CONNECTION=x then SPECKIT_X_CONNECTION_URL=y.
The scalar-first order previously raised TypeError ('str' object
does not support item assignment) when the walk indexed into 'x'.
"""
monkeypatch.setenv("SPECKIT_TESTEXT_CONNECTION", "x")
monkeypatch.setenv("SPECKIT_TESTEXT_CONNECTION_URL", "y")
cm = ConfigManager(tmp_path, "testext")
assert cm._get_env_config() == {"connection": {"url": "y"}}
def test_nested_then_scalar_does_not_clobber(self, tmp_path, monkeypatch):
"""Reverse order previously returned {'connection': 'x'}, losing url."""
monkeypatch.setenv("SPECKIT_TESTEXT_CONNECTION_URL", "y")
monkeypatch.setenv("SPECKIT_TESTEXT_CONNECTION", "x")
cm = ConfigManager(tmp_path, "testext")
assert cm._get_env_config() == {"connection": {"url": "y"}}
def test_colliding_env_does_not_disable_hook_condition(self, tmp_path, monkeypatch):
"""`config.connection.url is set` must stay True under colliding env.
Before the fix the TypeError propagated into should_execute_hook's
blanket `except Exception: return False`, silently disabling the hook.
"""
ext_dir = tmp_path / ".specify" / "extensions" / "testext"
ext_dir.mkdir(parents=True)
(ext_dir / "testext-config.yml").write_text(
"connection:\n url: https://example.com\n", encoding="utf-8"
)
monkeypatch.setenv("SPECKIT_TESTEXT_CONNECTION", "x")
monkeypatch.setenv("SPECKIT_TESTEXT_CONNECTION_URL", "y")
executor = HookExecutor(tmp_path)
# Exercise the public API: before the fix the TypeError was swallowed
# by should_execute_hook's `except Exception: return False`, so the
# hook was silently disabled (False); after the fix it returns True.
assert executor.should_execute_hook(
{"condition": "config.connection.url is set", "extension": "testext"}
) is True
def test_malformed_env_names_ignored(self, tmp_path, monkeypatch):
"""A name with no key (SPECKIT_X_) or empty parts (consecutive
underscores) must not create an entry under an empty key."""
monkeypatch.setenv("SPECKIT_TESTEXT_", "orphan") # no key at all
monkeypatch.setenv("SPECKIT_TESTEXT_A__B", "z") # empty middle part
cm = ConfigManager(tmp_path, "testext")
cfg = cm._get_env_config()
assert "" not in cfg
assert cfg == {"a": {"b": "z"}}
class TestConfigManagerCrossExtensionEnvLeak:
"""Cross-extension env-var leak: a longer, co-installed sibling ID must
own its own env vars instead of leaking them into a shorter-prefix sibling.
Before the fix, ``SPECKIT_GIT_HOOKS_URL`` (intended for a ``git-hooks``
extension) also surfaced inside the ``git`` extension's config as
``{'hooks': {'url': ...}}`` because ``SPECKIT_GIT_`` is a strict prefix of
``SPECKIT_GIT_HOOKS_``.
"""
def _install(self, project_root, ext_id):
extensions_dir = project_root / ".specify" / "extensions"
(extensions_dir / ext_id).mkdir(parents=True)
# Register in the extension registry — the registry is the source of
# truth for "installed" (a bare directory can be a config-only leftover
# from ``ExtensionManager.remove(..., keep_config=True)``).
ExtensionRegistry(extensions_dir).add(ext_id, {})
def test_sibling_owns_longer_prefix_env(self, tmp_path, monkeypatch):
"""SPECKIT_GIT_HOOKS_URL belongs to git-hooks when co-installed with git."""
self._install(tmp_path, "git")
self._install(tmp_path, "git-hooks")
monkeypatch.setenv("SPECKIT_GIT_URL", "for_git")
monkeypatch.setenv("SPECKIT_GIT_HOOKS_URL", "for_git_hooks")
git_cfg = ConfigManager(tmp_path, "git")._get_env_config()
gh_cfg = ConfigManager(tmp_path, "git-hooks")._get_env_config()
# 'git' must NOT see the git-hooks var — no cross-extension leak.
assert git_cfg == {"url": "for_git"}
# 'git-hooks' still receives its own var (unchanged behaviour).
assert gh_cfg == {"url": "for_git_hooks"}
def test_no_sibling_installed_keeps_legacy_absorption(self, tmp_path, monkeypatch):
"""Without a longer-prefix sibling installed, the legacy behaviour is
preserved: ``SPECKIT_GIT_HOOKS_URL`` is absorbed as a nested key of
the ``git`` extension. This keeps the fix strictly to the *collision*
case and avoids surprising users who deliberately set a nested key
via env with no sibling to disambiguate against.
"""
self._install(tmp_path, "git")
monkeypatch.setenv("SPECKIT_GIT_HOOKS_URL", "for_git_hooks")
cfg = ConfigManager(tmp_path, "git")._get_env_config()
assert cfg == {"hooks": {"url": "for_git_hooks"}}
def test_non_prefix_sibling_ignored(self, tmp_path, monkeypatch):
"""A sibling whose ID does not extend our own is not a collision.
e.g. current='git' and sibling='not-git''not-git' normalized to
'NOT_GIT' does not start with 'GIT_', so its presence must not
influence git's env-var interpretation.
"""
self._install(tmp_path, "git")
self._install(tmp_path, "not-git")
monkeypatch.setenv("SPECKIT_GIT_HOOKS_URL", "for_git_hooks")
cfg = ConfigManager(tmp_path, "git")._get_env_config()
assert cfg == {"hooks": {"url": "for_git_hooks"}}
def test_boundary_prevents_false_positive(self, tmp_path, monkeypatch):
"""Sibling ID 'hook' (not 'hooks') must NOT eat env keys starting
with 'hooks'. The trailing-underscore boundary in the sibling prefix
prevents this false positive.
"""
self._install(tmp_path, "git")
self._install(tmp_path, "git-hook")
monkeypatch.setenv("SPECKIT_GIT_HOOKS_URL", "for_git_key_hooks")
# git-hook's prefix is 'HOOK_', which does not match 'HOOKS_URL',
# so 'git' keeps the env var (single-installed semantics).
cfg = ConfigManager(tmp_path, "git")._get_env_config()
assert cfg == {"hooks": {"url": "for_git_key_hooks"}}
def test_missing_extensions_dir_does_not_crash(self, tmp_path, monkeypatch):
"""A ConfigManager built against a project without ``.specify/extensions``
(fresh project, ad-hoc test harness) must still evaluate env config
rather than raising from the sibling scan.
"""
# Note: no _install call — extensions dir intentionally absent.
monkeypatch.setenv("SPECKIT_TESTEXT_URL", "v")
cfg = ConfigManager(tmp_path, "testext")._get_env_config()
assert cfg == {"url": "v"}
def test_config_only_leftover_not_treated_as_sibling(self, tmp_path, monkeypatch):
"""A directory left behind by ``remove(..., keep_config=True)`` must
NOT be treated as an installed sibling.
``ExtensionManager.remove(keep_config=True)`` preserves the extension
directory (config files remain, dormant, for a possible reinstall) but
removes the registry entry. The sibling scan is sourced from the
registry, so a leftover ``git-hooks/`` directory without a registry
entry must not silently discard ``SPECKIT_GIT_HOOKS_*`` from ``git``.
"""
self._install(tmp_path, "git")
# Simulate ``remove('git-hooks', keep_config=True)``: dir present,
# config file preserved, but no registry entry.
gh_dir = tmp_path / ".specify" / "extensions" / "git-hooks"
gh_dir.mkdir(parents=True)
(gh_dir / "git-hooks-config.yml").write_text("url: leftover\n")
# Sanity: git-hooks is NOT registered.
registry = ExtensionRegistry(tmp_path / ".specify" / "extensions")
assert "git-hooks" not in registry.keys()
monkeypatch.setenv("SPECKIT_GIT_HOOKS_URL", "for_git")
cfg = ConfigManager(tmp_path, "git")._get_env_config()
# git absorbs the var (no registered sibling owns it).
assert cfg == {"hooks": {"url": "for_git"}}
def test_non_utf8_registry_does_not_crash(self, tmp_path, monkeypatch):
"""A registry file with invalid text encoding must NOT propagate
``UnicodeDecodeError`` out of the sibling scan and abort every
config read. ``ExtensionRegistry._load()`` catches ``JSONDecodeError``
/ ``FileNotFoundError`` only, so ``_sibling_extension_ids`` must
additionally swallow ``UnicodeError`` and degrade to the documented
pre-fix behaviour.
"""
extensions_dir = tmp_path / ".specify" / "extensions"
extensions_dir.mkdir(parents=True)
# Write bytes that are not valid UTF-8 to the registry file.
(extensions_dir / ExtensionRegistry.REGISTRY_FILE).write_bytes(
b"\xff\xfe invalid utf-8 registry \xc3\x28"
)
monkeypatch.setenv("SPECKIT_TESTEXT_URL", "v")
# Must not raise; must fall back to the "no siblings" path.
cfg = ConfigManager(tmp_path, "testext")._get_env_config()
assert cfg == {"url": "v"}
def test_forge_extension_install_listing_hyphenates_command_names(
extension_dir, project_dir
):
"""The post-install 'Provided commands' listing must show hyphenated
/speckit-<name> command names for a Forge project (Forge registers
hyphenated names), mirroring the existing Cline handling."""
import json
import os
from typer.testing import CliRunner
from specify_cli import app
init_options = project_dir / ".specify" / "init-options.json"
init_options.write_text(json.dumps({"ai": "forge", "script": "sh"}))
old_cwd = os.getcwd()
try:
os.chdir(project_dir)
result = CliRunner().invoke(
app, ["extension", "add", str(extension_dir), "--dev"]
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
# Forge registers hyphenated command names, so the summary must match.
assert "speckit-test-ext-hello" in result.output
assert "speckit.test-ext.hello" not in result.output
def test_forge_extension_info_hyphenates_command_names(
extension_dir, project_dir, monkeypatch
):
"""`extension info` for an installed extension must show hyphenated
/speckit-<name> command names on a Forge project, matching the names Forge
actually registers — the same parity `extension add`'s listing already has.
"""
import io
import json
import os
from rich.console import Console
from specify_cli.extensions import _commands
init_options = project_dir / ".specify" / "init-options.json"
init_options.write_text(json.dumps({"ai": "forge", "script": "sh"}))
manager = ExtensionManager(project_dir)
manager.install_from_directory(
extension_dir, "1.0.0", register_commands=False
)
# Force the "installed locally, not in catalog" branch (the one that prints
# the local manifest's Commands section) and avoid any network catalog
# lookup.
monkeypatch.setattr(
_commands, "_resolve_catalog_extension", lambda *a, **k: (None, None)
)
# Call the handler directly against a plain captured Console. (Driving it
# through CliRunner reformats output via Rich's live console, which
# recurses under pytest's captured stdout — unrelated to this code path.)
buf = io.StringIO()
original_console = _commands.console
_commands.console = Console(file=buf, force_terminal=False, width=200)
old_cwd = os.getcwd()
try:
os.chdir(project_dir)
_commands.extension_info("test-ext")
except SystemExit:
pass
finally:
os.chdir(old_cwd)
_commands.console = original_console
output = buf.getvalue()
# The Commands section must render the hyphenated form Forge registers,
# not the manifest's dotted name.
assert "speckit-test-ext-hello" in output, output
assert "speckit.test-ext.hello" not in output, output