Compare commits

..
41 Commits
Author SHA1 Message Date
github-actions[bot] 5dce710ce0 chore: bump version to 0.16.0 2026-08-05 13:39:31 +00:00
deborreandGitHub 4d5458c883 fix: keep long frontmatter values on a single line (#3989)
`CommandRegistrar.render_frontmatter` calls `yaml.dump()` without `width=`,
so PyYAML applies its default ~80-column wrap and folds any long scalar onto
a continuation line.

A `description` longer than roughly 80 characters is therefore rendered as:

    ---
    name: speckit-implement
    description: Execute the implementation plan by processing and executing all tasks
      defined in tasks.md
    ---

The YAML remains valid and round-trips faithfully through `yaml.safe_load`,
so this is not data loss. It is a shape inconsistency with real consequences:

- Hand-written core command templates always keep `description` on one line,
  so preset- and extension-rendered commands do not match the files they sit
  beside in the same directory.
- Consumers that read frontmatter line-wise rather than with a YAML parser
  see the description truncated at the fold, followed by a stray line. Spec
  Kit itself hand-builds SKILL.md frontmatter in the skills path (see #3391),
  so this is not a hypothetical class of consumer.
- `speckit.implement`'s own description is 89 characters, so a preset that
  overrides it hits this immediately.

`width=float("inf")` disables the line-wrapping only; escaping, quoting and
the handling of genuinely multi-line values are unchanged, since PyYAML
selects the scalar style before applying width.

Adds a regression test that fails without the change.

Verified against the repo's own suite: 6354 passed. Four failures in
tests/integrations/test_integration_subcommand.py are present on a clean
checkout too (ANSI escapes in captured output) and are unrelated.
2026-08-05 08:38:26 -05:00
Quratulain-bilalandGitHub 0ecb277f0e fix: skip corrupted run state files in list_runs (#3817)
* fix: skip corrupted run state files in list_runs

* fix: address review comments - add UnicodeDecodeError, dict validation, and regression tests

- Catch UnicodeDecodeError for invalid UTF-8 encoding
- Validate loaded JSON is a dict with required 'run_id' key
- Add 5 regression tests for corrupted state files

Fixes #3817
2026-08-05 08:37:12 -05:00
Quratulain-bilalandGitHub 1e85d4ff53 fix: skip corrupted run state files in list_runs (#3814)
* fix: skip corrupted run state files in list_runs

* fix: address review comments - add UnicodeDecodeError, dict validation, and regression tests

- Catch UnicodeDecodeError for invalid UTF-8 encoding
- Validate loaded JSON is a dict with required 'run_id' key
- Add 5 regression tests for corrupted state files

Fixes #3814
2026-08-05 08:11:38 -05:00
Manfred RiemandGitHub 03d71b3363 Add July 2026 newsletter (#3987)
* Add July 2026 newsletter

* docs(newsletters): remove internal press-index figures from earlier editions

Replace article counts, volume superlatives, and discovery-methodology
references (derived from an internal press index) with qualitative phrasing
in the April, May, and June editions, keeping only publicly verifiable data.
2026-08-04 12:12:43 -05:00
6e7818f837 fix(presets): start fresh on a non-UTF-8 preset registry (#3955)
PresetRegistry._load() catches json.JSONDecodeError and
FileNotFoundError to start fresh on a corrupted or missing registry, but
a registry file with invalid UTF-8 bytes raised UnicodeDecodeError
before JSON parsing began, crashing every preset command.

Catch UnicodeDecodeError in the same clause: undecodable bytes are the
same corruption class as unparseable JSON. OSError stays uncaught on
purpose — the data may be intact on disk, and starting fresh would let a
later _save() wipe it (same fail-closed reasoning as the workflow
catalog cache loader).

Assisted-by: GitHub Copilot (model: claude-fable-5, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-04 09:07:13 -05:00
0824a09d0f docs: clarify agent PR review prioritization (#3985)
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 31dc6b66-8484-46b5-a282-360029e14ff2
2026-08-04 08:57:13 -05:00
a9bde5c204 fix(events): preserve a non-UTF-8 config.toml on hook install/teardown (#3963)
_merge_toml_fragment() and _remove_toml_entries() read the user's
config.toml with bare read_text() calls, so a non-UTF-8 (or otherwise
unreadable) file crashed install_integration_events() and
remove_integration_events() with a raw UnicodeDecodeError — and the
merge path regenerates the file from what it read, so it would have
discarded the user's bytes had it not crashed first. Every JSON
merge/remove path already goes through _load_user_json(), which skips
on an unreadable file to preserve user content (#22).

Abort the merge (returning False so the caller skips tracking, S5) and
skip the teardown cleanup with a warning, leaving the user's bytes
untouched in both directions.

Assisted-by: GitHub Copilot (model: claude-fable-5, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-04 08:35:59 -05:00
f245c6cd1a fix(extensions): treat an unreadable staged backup as a conflict (#3962)
The rescue-retry loop in install_from_directory() reads each staged
backup with bare stat()/read_bytes() calls, so a staged config that
cannot be read crashed the reinstall with a raw OSError. Every sibling
read in this path — the live twin four lines below, the packaged
baseline check, the mode sidecar — already catches OSError.

Treat an unreadable staged file like an uncomparable live config:
add it to the conflict set so both copies are preserved and the retry
aborts with the existing resolution guidance while dest_dir is still
untouched.

Assisted-by: GitHub Copilot (model: claude-fable-5, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-04 08:18:33 -05:00
cd996f74eb fix(manifests): reject non-string requires.speckit_version (#3980)
`requires.speckit_version` was presence-checked but never type-checked in
both the extension and preset manifest validators, so an unquoted YAML
`speckit_version: 1.0` (a float) passed validation and reached
`SpecifierSet(required)` in `check_compatibility()`.

That call is guarded by `except InvalidSpecifier` alone, which a
non-string escapes two different ways:

- a float/int/bool/None raises `TypeError: 'float' object is not
  iterable` from the `SpecifierSet` constructor;
- a list or dict is an *iterable*, so `SpecifierSet` accepts it and the
  failure surfaces much later as `AttributeError: 'str' object has no
  attribute 'filter'` from inside `.contains()`.

Neither is a `CompatibilityError`/`PresetCompatibilityError`, so both
bypass the CLI's "Compatibility Error" handler in `_commands.py` and exit
1 with a raw traceback that names no field, leaving the author with no
hint which manifest key is wrong.

Type-check the field in both validators, requiring a non-empty string,
and additionally guard `check_compatibility()` in both managers since
each is public and reachable with a hand-built or mutated manifest. This
mirrors the sibling `IntegrationDescriptor`, which already requires a
non-empty string for the same key, and completes the type-checking pass
started in #3943 for the neighbouring `extension`/`preset` fields.

Adds 33 regression tests across both modules covering every escape path;
26 of them fail without this change.

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

Assisted-by: Claude Code (model: Claude Opus 5, supervised)
2026-08-04 08:03:00 -05:00
0fa86e8e9c fix(extensions): reject reinstall when a kept config cannot be read (#3960)
The keep-config rescue branch of install_from_directory() reads each
preserved config with bare read_bytes()/stat() calls, so a kept config
that cannot be read (permission or I/O error) crashed the reinstall
with a raw OSError. The sibling symlink guard four lines above already
rejects with a ValidationError and resolution guidance for the same
reason: bytes that cannot be safely rescued must not reach the rmtree
below.

Wrap the read and raise ValidationError with guidance, while dest_dir
is still untouched so the preserved bytes are never lost.

Assisted-by: GitHub Copilot (model: claude-fable-5, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-04 08:01:16 -05:00
github-actions[bot]GitHubCopilotmnriemgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
e9f653318e [extension] Update Charter extension to v0.5.1 (#3983)
* Update Charter extension to v0.5.1

Update charter extension submitted by @Huljo:
- extensions/catalog.community.json (version, download_url, updated_at)

Closes #3944

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Limit catalog diff to Charter fields and top-level timestamp

Assisted-by: GitHub Copilot (model: unknown, autonomous)

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
2026-08-04 07:45:36 -05:00
316cd1235a fix(events): return None for an unparseable script command (#3957)
_script_command() split the configured command with a bare
shlex.split(), so a command string with unbalanced quotes crashed event
dispatch with a raw ValueError. The dispatcher-template twin a few
lines up already wraps the same call in try/except ValueError and
returns None so dispatch falls back cleanly.

Wrap the split the same way and return None, restoring parity between
the two paths.

Assisted-by: GitHub Copilot (model: claude-fable-5, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-04 07:34:02 -05:00
kanfilandGitHub 99970560db feat(events): context injection for opencode and JSON-envelope agent hooks (#3934)
* feat(events): context injection for opencode and JSON-envelope agent hooks

Adds first-class context injection to agent runtime events:

1. opencode: maps session_start to experimental.chat.system.transform (injects into system prompt) and user_prompt_submit to chat.message (injects synthetic TextPart). TS plugin captures runEvent stdout (stdio pipe, encoding utf-8) and pushes into output objects. Part IDs derive from output.parts[last].id to preserve OpenCode's prt_ brand and prevent session schema crashes.

2. JSON-envelope hook wrapping: adds events_context_envelope to IntegrationBase so agents that require JSON on stdout receive their target envelope via the dispatcher's 5th argument:
   - gemini, tabnine, qwen, devin: hookSpecificOutput.additionalContext on session_start/user_prompt_submit; suppress on non-injectable events (prevents systemMessage user-facing noise)
   - copilot: top-level additionalContext on session_start
   - cursor: top-level additional_context on session_start; suppress elsewhere
   - claude, codex: plain stdout passthrough (already injected)

3. Dispatcher template and resolve_and_run_event_command parse the 5th envelope arg and wrap stdout accordingly.

Tests added for opencode TextPart schema, part ID derivation, envelope command generation, and dispatcher output wrapping. All 162 events/integration tests pass.

* fix(events): address code review on #3934

- Qwen/Gemini/Tabnine/Devin: include native hookEventName inside
  hookSpecificOutput envelope (required by Qwen's hooks spec). Thread
  the native event name from the integration's CANONICAL_TO_NATIVE
  through _dispatcher_command as a 6th dispatcher argument, through
  the dispatcher template's main()/_run_inline()/_emit(), and through
  resolve_and_run_event_command()/_emit_event_stdout().
- Copilot: map user_prompt_submit to additionalContext (previously
  unmapped, breaking per-prompt context injection despite Copilot CLI
  supporting it via userPromptSubmitted).
- OpenCode: guard experimental.chat.system.transform so canonical
  session_start handlers only run when input.sessionID is present —
  OpenCode fires this hook for non-session operations (e.g. agent
  generation) with no sessionID.

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

* fix(events): address second Copilot review round on #3934

- Positional arg alignment: always emit default timeout (60s) as the
  4th dispatcher argument even when timeout_seconds is omitted, so the
  envelope (5th) and native_event (6th) land in the correct argv slots.
  Previously, omitting timeout_seconds caused the envelope to be parsed
  as an invalid timeout, silently falling back to plain stdout.
- OpenCode session_start caching: cache handler output per sessionID in
  the generated TS plugin so non-idempotent handlers (setup, telemetry,
  file-mutating scripts) run once per session instead of on every LLM
  request. Cache is evicted on session.deleted.
- Updated PR description to reflect Copilot user_prompt_submit now maps
  to additionalContext (was documented as plain/unprocessed).

Assisted-by: opencode (model: glm-5.2, supervised)
2026-08-04 07:32:30 -05:00
github-actions[bot]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>Copilot
e57a86cc9c Add TDD Extension to community catalog (#3982)
Add tdd extension submitted by @d0whc3r to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes #3978

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-04 07:25:15 -05:00
github-actions[bot]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>Copilot
4962ffe926 Update Archive Extension to v1.1.0 (#3981)
Update archive extension submitted by @stn1slv:
- extensions/catalog.community.json (version, download_url, updated_at)
- docs/community/extensions.md community extensions table

Closes #3977

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-04 07:25:01 -05:00
0fbd99d594 feat(copilot): default integration to skills (#3976)
* feat(copilot): default integration to skills

Make Copilot skills the default while retaining the commands layout behind --integration-options="--commands". Preserve historical project layouts and validate conflicting mode flags before switch teardown.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 930d846b-8921-44ef-9f45-3e77c036b6b5

* fix(copilot): preserve layout state during migration

Keep target integration options isolated from fallback state, prefer the Copilot manifest when resolving layouts, and update dispatch coverage for the skills-first default.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 930d846b-8921-44ef-9f45-3e77c036b6b5

---------

Copilot-Session: 930d846b-8921-44ef-9f45-3e77c036b6b5
2026-08-04 06:15:40 -05:00
ab468c4db7 fix(events): ignore non-UTF-8 event overrides (#3897)
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-03 16:31:22 -05:00
Quratulain-bilalandGitHub f8b3d604e1 fix: cap stdin read at 1 MiB to prevent DoS (#3857)
* fix: cap stdin read at 1 MiB to prevent DoS

Unbounded sys.stdin.read() allowed a malicious caller to exhaust memory
by sending a multi-gigabyte payload. Cap at 1 MiB and raise typer.Exit
if truncated.

* fix: improve stdin payload limit error handling in event.py

- Rename _MAX_PAYLOAD to MAX_STDIN_BYTES (clearer constant naming)
- Improve error message to suggest truncation or smaller payload
- Better code formatting for readability

Assisted-by: GitHub Copilot (model: mimo-v2-free, supervised)
2026-08-03 14:32:10 -05:00
cda7a921a4 fix(workflows): reject mismatched run state IDs (#3899)
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-03 14:30:55 -05:00
Manfred RiemGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
58f5d6e258 chore: release 0.15.2, begin 0.15.3.dev0 development (#3953)
* chore: bump version to 0.15.2

* chore: begin 0.15.3.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-03 14:08:18 -05:00
chelsealongandGitHub f4e3110560 fix(presets): restore core skills instead of deleting them on preset remove (#3929)
* fix(presets): restore core skills instead of deleting them on preset remove

Skill restoration looked for core command templates under
.specify/templates/commands, a directory specify init never populates
in real projects. Since that lookup always missed, presets overriding
a core command (e.g. speckit.plan) had their skill deleted outright on
removal instead of restored — the actual core templates live in the
bundled core_pack (wheel install) or the repo-root templates/ tree.

Restoration now falls back to that bundled location, gated behind a
restore_from_bundled_core flag so the existing "retire a stale skill
superseded by a command-mode winner" path keeps deleting rather than
resurrecting a duplicate skill.

Fixes #3928

* fix(tests): use explicit utf-8 encoding reading restored skill content

read_text() defaults to the platform locale encoding, which is cp1252
("charmap") on Windows. The bundled specify.md core template contains a
UTF-8 multi-byte emoji whose bytes aren't valid cp1252, so the Windows CI
job failed decoding the restored SKILL.md with UnicodeDecodeError.

* fix(presets): keep extension restore priority over bundled-core fallback

The bundled-core fallback added for #3928 ran before the
extension_restore_index lookup, so a skill an installed extension owns
could be silently replaced by lower-priority bundled core content on
preset removal instead of preserving the extension's winning layer.
2026-08-03 13:54:12 -05:00
2d8904a21e fix(manifests): reject non-string metadata instead of crashing on it (#3943)
* fix(manifests): reject non-string metadata instead of crashing on it

`ExtensionManifest` and `PresetManifest` checked only key PRESENCE for
`id`/`name`/`version`/`description`, then fed the values straight to
`re.match()` and `packaging.Version()`. Both raise a bare `TypeError` on a
non-string, which is neither `ValidationError` nor `PresetValidationError`,
so it escaped every caller that already handles a malformed manifest.

YAML makes this an easy authoring slip rather than a contrived one: an
unquoted `version: 1.0` parses as a float and `id: 2` as an int.

The user-visible symptom is the one the in-tree comment above the section
guards was written to prevent (#3898 for presets, and its extension twin):
`list_installed()` degrades a bad manifest to "⚠️ Corrupted extension" but
catches only the domain error, so a single bad manifest made
`specify extension list` / `specify preset list` exit 1 with a raw
traceback and *no output at all* — hiding every healthy extension/preset
too, not just the broken one.

Also unguarded on the same path:
- extension `provides.commands[].name` → `TypeError` from the command-name
  pattern match. The sibling `file` field was already safe, since
  `relative_extension_path_violation()` rejects a non-string.
- preset `provides.templates[].name`/`.file` → `TypeError` from `re.match`
  and `os.path.normpath` respectively.

The third manifest twin, `IntegrationDescriptor`, is already hardened: it
type-checks the same four fields and catches `TypeError` alongside
`InvalidVersion`. This brings the other two in line with it.

Tests: 68 added across both suites, covering each field against float, int,
None, list, dict, and bool, plus an end-to-end guard per manifest type
asserting a healthy entry still lists while the bad one degrades. All 68
fail with the source change reverted.

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

* Potential fix for pull request finding

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-03 13:53:08 -05:00
Quratulain-bilalandGitHub 7ddb8194d9 fix: narrow bare except Exception in invoke separator resolution (#3856)
* fix: narrow exception in invoke separator resolution and add regression test

Narrow 'except Exception' to 'except (ImportError, ValueError, KeyError)'
in register_commands() invoke separator resolution. Add regression test
that verifies TypeError propagates instead of being silently swallowed.

* fix: remove duplicate pass statement in agents.py

Remove redundant second pass statement in the except block
for invoke separator resolution. The narrowed exception handler
now has a single clean pass statement.

Assisted-by: GitHub Copilot (model: mimo-v2-free, supervised)
2026-08-03 13:49:37 -05:00
84a2114338 fix(workflows): keep the init step's documented ignore_agent_tools default on an explicit null (#3889)
The step documents the default twice:

  class docstring: "Because workflows run unattended, the step defaults to
                    ``--ignore-agent-tools``"
  field docs:      "Skip checks for the coding agent CLI (defaults to ``true``)"

It implements that with `config.get("ignore_agent_tools", True)`, which
applies the default only when the key is ABSENT. A bare
`ignore_agent_tools:` in YAML parses to None, and `_resolve_bool(None)`
returns False:

  key ABSENT                -> True   flag emitted: YES
  bare ignore_agent_tools:  -> False  flag emitted: NO   <-- bug
  explicit true             -> True   flag emitted: YES
  explicit false            -> False  flag emitted: NO

So the flag is dropped, `specify init` re-runs the agent-CLI presence
check, and an unattended run fails with "Agent Detection Error" for any
integration whose CLI is not installed on the runner.

Normalize an explicit null to the default, mirroring the while/do-while
`max_iterations` handling.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 13:14:31 -05:00
b69147c841 fix(kimi): preserve non-UTF-8 user skills (#3895)
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-03 13:11:19 -05:00
14fab0a1af fix(presets): tolerate non-UTF-8 legacy commands (#3896)
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-03 12:33:39 -05:00
e9ffc9d8e7 feat: allow overriding default init integration via SPECKIT_INTEGRATION_DEFAULT (#3952)
* feat: allow overriding default init integration via SPECKIT_INTEGRATION_DEFAULT

Resolve the non-interactive/init default integration from the
SPECKIT_INTEGRATION_DEFAULT environment variable, fitting the existing
SPECKIT_INTEGRATION_* namespace. Falls back to the hardcoded "copilot"
default when unset, and warns to stderr (rather than silently falling
back) when the value is not a registered integration key.

Wires the resolver into specify init (interactive prompt default and
non-interactive fallback), the init workflow step, and the bundle init
default. Adds unit and CLI tests and documents the variable.

Closes #3939

Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: eecda55f-fa13-42f7-99bf-bfb0bb8565a0

* test: cover env-var default wiring for picker, workflow step, and bundle

Address PR review: add regression tests so each SPECKIT_INTEGRATION_DEFAULT
wiring site cannot silently revert to the hardcoded constant.

- init.py: interactive picker receives the resolved key as default_key.
- workflow init step: no step/workflow default + env var drives output
  integration and argv.
- bundle _resolve_init_integration: env-var default applies when unspecified,
  while explicit override and manifest-declared integration still win.

Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: eecda55f-fa13-42f7-99bf-bfb0bb8565a0

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: eecda55f-fa13-42f7-99bf-bfb0bb8565a0
2026-08-03 12:20:49 -05:00
github-actions[bot]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>Copilot
4751777a38 Add adrkit extension to community catalog (#3947)
Add adrkit extension submitted by @mbeacom to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes #3942


Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-03 11:56:04 -05:00
15cb7d9a66 feat(extensions): scaffold config templates on extension add/enable (#2000)
* feat(extensions): scaffold config templates on extension add/enable

Deploy an extension's provides.config templates into .specify/ when the
extension is added or enabled. Existing files are never overwritten, so
user customizations are preserved.

Addresses the review on #2000:
- ExtensionManifest.config returns [] unless provides.config is a list of
  dicts, so a malformed manifest cannot crash callers.
- scaffold_config returns a consistent (deployed, skipped_existing, failed)
  tuple on every path, including a missing manifest.
- Template paths must resolve inside the extension dir and targets inside
  .specify/; symlinks and non-regular files are rejected.
- Callers distinguish "already exists (preserved)" from "not scaffolded",
  and extension_enable no longer crashes on a corrupt manifest.
- Tests cover traversal, absolute paths, symlinks, directory templates,
  malformed provides.config, and the missing-manifest tuple shape.

Ported onto the extensions package introduced by #3014: the manager and
manifest changes land in extensions/__init__.py and the CLI wiring in
extensions/_commands.py.

* fix(extensions): deploy config where it is read, and contain the write

Addresses @Copilot's review.

Config now lands in .specify/extensions/<id>/ rather than the .specify/
root. ConfigManager._get_project_config() reads
.specify/extensions/<id>/<id>-config.yml, and the bundled scripts and
READMEs use the same path, so a scaffolded git-config.yml was being
written somewhere the git extension never looks.

Containment is checked component by component before .specify is used as
the root. Resolving it first and trusting the result let a symlinked
component point outside the project, after which every target satisfied
relative_to and copy2 wrote externally. This matches the project
safe-write path in shared_infra.

mkdir moved inside the OSError handler. A nested target like
foo/config.yml raised out of scaffolding when its parent could not be
created, and on extension add that happened after the extension was
already installed.

The 'Configuration may be required' warning is now conditional. It ran
unconditionally after the scaffolding block, so it contradicted the
success output directly above it and fired for extensions with no
provides.config at all.

Tests cover the corrected location, a symlinked config root, and an
uncreatable nested target.

* fix(extensions): only scaffold config targets that removal preserves

remove(keep_config=True) rmtree's every subdirectory and keeps only top-level
-config.yml / -config.local.yml files; the backup path globs the same
top-level pattern. Scaffolding a nested or differently-named target therefore
handed the user a file that 'extension add --force' silently deleted and
replaced with the template default, losing customization.

Constrain scaffold targets to that convention rather than widening four
removal paths.

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-08-03 11:24:21 -05:00
4343cd5e80 fix(events): skip non-UTF-8 extension manifests (#3900)
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-03 08:40:54 -05:00
d1e86f6382 fix(workflows): fail a gate whose on_reject is not abort/skip/retry (#3888)
execute() reads `on_reject = config.get("on_reject", "abort")` and, in the
reject branch, handles only "abort" and "retry" before falling through to
its `# on_reject == "skip"` case. So any other value makes a REJECTED gate
report COMPLETED and the run walks straight past the review the gate
exists to enforce:

  on_reject='abort'  -> failed     "Gate rejected by user at step 'g'"
  on_reject='retry'  -> paused
  on_reject='skip'   -> completed  (by design)
  on_reject='Abort'  -> completed  <-- rejection silently discarded
  on_reject='fail'   -> completed  <-- same
  on_reject='stop'   -> completed  <-- same
  on_reject=None     -> completed  <-- same
  on_reject=5        -> completed  <-- same

Reachable by a capitalisation slip, a guessed verb, a non-string, or a
bare `on_reject:` — note `config.get(k, default)` does NOT substitute the
default for an explicit YAML null.

`validate` already rejects anything outside abort/skip/retry, but the
engine does not auto-validate before execute(). Fail loudly instead,
mirroring the `options` and `verdict_input` guards in the same method, and
placed before the non-TTY short-circuit so it surfaces in CI too.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:45:15 -05:00
400ad01f12 fix(presets): validate required manifest mappings (#3898)
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-31 12:15:18 -05:00
Quratulain-bilalandGitHub 642fa56c0a fix: eliminate TOCTOU race in zip packaging (#3855)
* fix: eliminate TOCTOU race in zip packaging

Open file once and derive both stat info and content from the same file
descriptor to prevent race conditions where the file is modified between
stat() and read_bytes() calls.

* test: add regression test for TOCTOU stat/read consistency in packager

The old implementation called file_path.stat() then file_path.read_bytes()
as separate syscalls. The fix opens the file once and uses os.fstat() +
fh.read() on the same handle. This test verifies the archived bytes and
mode are consistent with the opened file descriptor.
2026-07-31 12:05:47 -05:00
521020bc3a fix(workflows): fail a fan-in step whose output is not a mapping (#3887)
execute() did:

    output_config = config.get("output") or {}
    if not isinstance(output_config, dict):
        output_config = {}

so every non-mapping `output` was silently discarded and the step still
returned COMPLETED — every declared aggregation key vanished, and
downstream `{{ steps.<id>.output.<key> }}` resolved to None and
interpolated as an empty string:

  output=[]       -> completed, error=None
  output=False    -> completed, error=None
  output=0        -> completed, error=None
  output=''       -> completed, error=None
  output=['a']    -> completed, error=None
  output='oops'   -> completed, error=None
  output=5        -> completed, error=None

`validate` already rejects this and its comment names the flaw exactly:
"execute() silently coerces a non-mapping output to {}, so the author's
declared aggregation keys would vanish with no error." The engine does not
auto-validate before execute(), so on an unvalidated run that is what
happened — and `x or {}` masked the falsy shapes before the isinstance
check even ran.

Fail loudly with validate()'s own message, mirroring the `wait_for` guard
in the same method. An explicit `output:` (YAML null) stays valid.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:04:02 -05:00
14e82353cb fix(workflows): refetch non-UTF-8 catalog caches (#3901)
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-31 12:02:49 -05:00
1831fffde6 fix(bundler): wrap local catalog decode failures (#3902)
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-31 11:52:29 -05:00
ba7ae79c66 Add --extension flag to specify init for opting into extensions at init time (#3914)
* Add --extension flag to specify init for installing extensions at init time

Adds a repeatable --extension flag to `specify init` so users can opt into
extensions (bundled name, local path, or HTTPS URL) during initialization,
without a separate `specify extension add` step.

- New `_install_extension_during_init` helper in commands/init.py that
  auto-detects source type (URL / local path / bundled name / catalog) and
  installs via ExtensionManager. Failures are non-fatal and recorded in the
  tracker without aborting init.
- Extension tracker steps are pre-registered before the Live context and run
  after preset install, before finalize.
- Five new tests in TestExtensionFlag covering bundled name, multiple
  extensions, local absolute path, unknown extension (graceful error), and
  combination with --preset.

Rebased onto upstream/main and adapted to the refactored init command
(moved to src/specify_cli/commands/init.py) from stale PR #2396.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Address review: reuse hardened downloader, refresh events, escape labels, fix bundler call

Responds to review feedback on #3914 and fixes CI (pytest bundler failure).

- Extract shared `install_extension_from_url` helper in extensions/_commands.py
  that reuses the authenticated, redirect-guarded, bounded (50 MiB) download
  and TOCTOU-safe transient archive used by `extension add --from`. Both
  `extension add --from` and `specify init --extension <url>` now go through
  this single downloader instead of a second raw urlopen path.
- Refresh native event configuration once after successful extension installs
  during init (mirrors `_refresh_events_and_warn` in the add path) so an
  extension declaring `events:` has its hooks activated.
- Escape user-controlled extension specs and error text before interpolating
  them into StepTracker labels (Rich markup injection).
- Pass `extensions=None` from bundler's `_run_init` so the init callback no
  longer receives the typer OptionInfo sentinel ('OptionInfo' object is not
  iterable), which broke `test_install_initializes_uninitialized_project`.
- Add init URL coverage in TestExtensionFlag: non-HTTPS rejection and a
  successful HTTPS ZIP install with download-cache cleanup assertion.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Add default-deny trust confirmation for URL extension installs at init

URL-based --extension installs now require explicit trust, matching the
`extension add --from` posture. Interactive sessions show an "Untrusted
Source" panel and prompt (default no); non-interactive sessions deny by
default unless --trust-extension-urls is passed. Trust is resolved before
the Live display since the prompt can't be answered under the spinner.

- Add --trust-extension-urls option and _ext_spec_is_url /
  _confirm_extension_url_trust helpers
- Skip (not abort) unconfirmed URL extensions, consistent with other
  non-fatal extension failures
- Pass trust_extension_urls=False from the bundler init callback
- Add tests for deny-by-default, interactive confirm, and trusted install

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8bc6802d-81b8-48f4-8f60-cba3aebc3bb3

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8bc6802d-81b8-48f4-8f60-cba3aebc3bb3
2026-07-31 11:15:25 -05:00
Quratulain-bilalandGitHub 36cb7e3c11 fix: bound response reads in extension catalog and download (#3775)
* fix: bound response reads in extension catalog and download

Replace unbounded 
esponse.read() calls with 
ead_response_limited()
from _download_security in extensions/__init__.py to prevent denial-
of-service via oversized catalog or extension archive responses.

Three call sites fixed:
- _fetch_single_catalog JSON read (catalog metadata)
- _fetch_catalog JSON read (legacy path)
- download_extension ZIP read (binary download)

All existing mock tests updated to use side_effect with BytesIO.read
instead of 
eturn_value, ensuring compatibility with the chunked read
loop in 
ead_response_limited.

Two regression tests added:
- test_oversized_catalog_response_rejected
- test_oversized_extension_download_rejected

* fix: remove .decode utf-8 to preserve bytes for json.loads

json.loads accepts bytes directly. Removing .decode maintains
compatibility with BOM-bearing or UTF-16/32 catalogs.
2026-07-31 10:02:25 -05:00
cf71d00dfe fix(workflows): reject a retry gate whose verdict enum forbids the reset value (#3912)
A gate with `on_reject: retry` consumes a bound reject verdict before
pausing by resetting the named input to `""` (documented behaviour, so a
later resume prompts again). Every `resume()` re-resolves the persisted
inputs through `_coerce_input`.

Those two rules collide when the bound input declares an `enum` that does
not list `""`. The reset writes a value the input's own enum forbids, and
the run wedges:

    inputs:
      spec_verdict:
        type: string
        enum: [approve, reject]
    steps:
      - id: review
        type: gate
        options: [approve, reject]
        on_reject: retry
        verdict_input: spec_verdict

    $ specify workflow run wf --input spec_verdict=reject
    Status: paused
    $ specify workflow resume <run_id> --input note=b
    Error: Input 'spec_verdict' value '' not in allowed values:
           ['approve', 'reject'].

The workflow validates clean and the first run looks fine, so the failure
only appears at the second resume. It is also unrecoverable in practice:
`_resolve_inputs` re-coerces the whole persisted map, so *any* resume that
supplies an input dies on the stored `""`. Only a resume with no inputs at
all still works -- and that is precisely the call that cannot deliver a new
verdict, which is the one thing the retry cycle exists to allow.

Extend the existing `verdict_input` cross-check (which already confirms the
name is declared) to also require that a retry-bound input's `enum` admits
the reset sentinel, and report it with a fix hint. To do that, thread the
input *definitions* through `_validate_steps` instead of just their names.

Rejected the alternative of popping the key instead of writing `""`: that
lets the input's `default` flow back in on the next resume, so a gate the
user just rejected would silently auto-approve.

Docs: note the `enum` requirement next to the reset behaviour it follows
from.

Adds 4 validation tests for the new guard plus a characterization test that
drives the engine directly to pin the wedge it prevents.

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

Assisted-by: Claude Opus 5 (1M context)
2026-07-31 09:53:25 -05:00
Manfred RiemGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
7f40c82945 chore: release 0.15.1, begin 0.15.2.dev0 development (#3913)
* chore: bump version to 0.15.1

* chore: begin 0.15.2.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-31 08:38:33 -05:00
57 changed files with 4318 additions and 513 deletions
+21 -24
View File
@@ -260,13 +260,13 @@ The base classes handle most work automatically. Override only when the agent de
| Override | When to use | Example |
|---|---|---|
| `command_filename(template_name)` | Custom file naming or extension | Copilot → `speckit.{name}.agent.md` |
| `options()` | Integration-specific CLI flags via `--integration-options` | Codex → `--skills` flag, Copilot → `--skills` flag |
| `setup()` | Custom install logic (companion files, settings merge) | Copilot → `.agent.md` + `.prompt.md` + `.vscode/settings.json` (default) or `speckit-<name>/SKILL.md` (skills mode) |
| `options()` | Integration-specific CLI flags via `--integration-options` | Codex → `--skills` flag, Copilot → `--commands` flag |
| `setup()` | Custom install logic (companion files, settings merge) | Copilot → `speckit-<name>/SKILL.md` (default) or `.agent.md` + `.prompt.md` + `.vscode/settings.json` (`--commands`) |
| `teardown()` | Custom uninstall logic | Rarely needed; base handles manifest-tracked files |
**Example — Copilot (fully custom `setup`):**
Copilot extends `IntegrationBase` directly because it creates `.agent.md` commands, companion `.prompt.md` files, and merges `.vscode/settings.json`. It also supports a `--skills` mode that scaffolds `speckit-<name>/SKILL.md` under `.github/skills/` using composition with an internal `_CopilotSkillsHelper`. See `src/specify_cli/integrations/copilot/__init__.py` for the full implementation.
Copilot extends `IntegrationBase` directly because it supports two layouts. It scaffolds `speckit-<name>/SKILL.md` under `.github/skills/` by default using composition with an internal `_CopilotSkillsHelper`. Its `--commands` mode creates `.agent.md` commands, companion `.prompt.md` files, and merges `.vscode/settings.json`. See `src/specify_cli/integrations/copilot/__init__.py` for the full implementation.
### 7. Update Devcontainer files (Optional)
@@ -415,36 +415,28 @@ Some agents require custom processing beyond the standard template transformatio
### Copilot Integration
GitHub Copilot has unique requirements:
GitHub Copilot uses skills by default, scaffolded as
`speckit-<name>/SKILL.md` under `.github/skills/`.
- Commands use `.agent.md` extension (not `.md`)
**Commands mode (`--commands`):** Copilot also supports a commands-based layout
via `--integration-options="--commands"`. When enabled:
- Commands use `.agent.md` extension under `.github/agents/`
- Each command gets a companion `.prompt.md` file in `.github/prompts/`
- Installs `.vscode/settings.json` with prompt file recommendations
- Context file lives at `.github/copilot-instructions.md`
- `.vscode/settings.json` is merged with prompt file recommendations
- `build_command_invocation()` returns bare args for `--agent` dispatch
Implementation: Extends `IntegrationBase` with custom `setup()` method that:
1. Processes templates with `process_template()`
2. Generates companion `.prompt.md` files
3. Merges VS Code settings
**Skills mode (`--skills`):** Copilot also supports an alternative skills-based layout
via `--integration-options="--skills"`. When enabled:
- Commands are scaffolded as `speckit-<name>/SKILL.md` under `.github/skills/`
- No companion `.prompt.md` files are generated
- No `.vscode/settings.json` merge
- `post_process_skill_content()` injects a `mode: speckit.<stem>` frontmatter field
- `build_command_invocation()` returns `/speckit-<stem>` instead of bare args
In the default skills mode, no companion prompts or VS Code settings merge are
created, and `build_command_invocation()` returns `/speckit-<stem>`.
The two modes are mutually exclusive — a project uses one or the other:
```bash
# Default mode: .agent.md agents + .prompt.md companions + settings merge
# Default skills mode: speckit-<name>/SKILL.md under .github/skills/
specify init my-project --integration copilot
# Skills mode: speckit-<name>/SKILL.md under .github/skills/
specify init my-project --integration copilot --integration-options="--skills"
# Commands mode: .agent.md agents + .prompt.md companions + settings merge
specify init my-project --integration copilot --integration-options="--commands"
```
### Forge Integration
@@ -509,6 +501,11 @@ When an issue exists, include its number immediately after the prefix — this i
Disclosure is **continuous**, not a one-time event. A single AI-disclosure paragraph in the PR body does **not** cover the commits and replies you add during review rounds. Each of the following must independently attest to agent authorship.
### Opening pull requests
- Before opening a pull request, check whether the account that will file it already has three open pull requests in this repository.
- If so, alert the user that additional submissions may receive lower review priority and ask for explicit permission to proceed. Do not assume consent.
### Commits
- **Every commit you author must carry an `Assisted-by:` trailer** identifying the agent and whether it acted autonomously or under direct human supervision, for example:
+50
View File
@@ -2,6 +2,56 @@
<!-- insert new changelog below this comment -->
## [0.16.0] - 2026-08-05
### Changed
- fix: keep long frontmatter values on a single line (#3989)
- fix: skip corrupted run state files in list_runs (#3817)
- fix: skip corrupted run state files in list_runs (#3814)
- Add July 2026 newsletter (#3987)
- fix(presets): start fresh on a non-UTF-8 preset registry (#3955)
- docs: clarify agent PR review prioritization (#3985)
- fix(events): preserve a non-UTF-8 config.toml on hook install/teardown (#3963)
- fix(extensions): treat an unreadable staged backup as a conflict (#3962)
- fix(manifests): reject non-string requires.speckit_version (#3980)
- fix(extensions): reject reinstall when a kept config cannot be read (#3960)
- [extension] Update Charter extension to v0.5.1 (#3983)
- fix(events): return None for an unparseable script command (#3957)
- feat(events): context injection for opencode and JSON-envelope agent hooks (#3934)
- Add TDD Extension to community catalog (#3982)
- Update Archive Extension to v1.1.0 (#3981)
- feat(copilot): default integration to skills (#3976)
- fix(events): ignore non-UTF-8 event overrides (#3897)
- fix: cap stdin read at 1 MiB to prevent DoS (#3857)
- fix(workflows): reject mismatched run state IDs (#3899)
- chore: release 0.15.2, begin 0.15.3.dev0 development (#3953)
## [0.15.2] - 2026-08-03
### Changed
- fix(presets): restore core skills instead of deleting them on preset remove (#3929)
- fix(manifests): reject non-string metadata instead of crashing on it (#3943)
- fix: narrow bare except Exception in invoke separator resolution (#3856)
- fix(workflows): keep the init step's documented ignore_agent_tools default on an explicit null (#3889)
- fix(kimi): preserve non-UTF-8 user skills (#3895)
- fix(presets): tolerate non-UTF-8 legacy commands (#3896)
- feat: allow overriding default init integration via SPECKIT_INTEGRATION_DEFAULT (#3952)
- Add adrkit extension to community catalog (#3947)
- feat(extensions): scaffold config templates on extension add/enable (#2000)
- fix(events): skip non-UTF-8 extension manifests (#3900)
- fix(workflows): fail a gate whose on_reject is not abort/skip/retry (#3888)
- fix(presets): validate required manifest mappings (#3898)
- fix: eliminate TOCTOU race in zip packaging (#3855)
- fix(workflows): fail a fan-in step whose output is not a mapping (#3887)
- fix(workflows): refetch non-UTF-8 catalog caches (#3901)
- fix(bundler): wrap local catalog decode failures (#3902)
- Add `--extension` flag to `specify init` for opting into extensions at init time (#3914)
- fix: bound response reads in extension catalog and download (#3775)
- fix(workflows): reject a retry gate whose verdict enum forbids the reset value (#3912)
- chore: release 0.15.1, begin 0.15.2.dev0 development (#3913)
## [0.15.1] - 2026-07-31
### Changed
+2
View File
@@ -55,6 +55,8 @@ Here are a few things you can do that will increase the likelihood of your pull
- Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html).
- Test your changes with the Spec-Driven Development workflow to ensure compatibility.
Accounts with three open pull requests may continue submitting changes, but additional submissions may be placed behind contributions from other authors in the review queue. Coding agents should disclose this possibility and obtain the filer's confirmation before opening another pull request.
### Branch naming
We recommend naming branches as `<type>/<number>-<short-slug>`, where `<number>` is the issue or PR number (whichever comes first) and `<type>` is one of:
+2
View File
@@ -25,6 +25,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Extension | Purpose | Category | Effect | URL |
|-----------|---------|----------|--------|-----|
| adrkit — decision memory for spec-driven development | Pulls the decisions governing this work into agent context, checks produced plans against them, and drafts an ADR from a plan artifact | `process` | Read+Write | [adrkit](https://github.com/mbeacom/adrkit) |
| Agent Assign | Assign specialized Claude Code agents to spec-kit tasks for targeted execution | `process` | Read+Write | [spec-kit-agent-assign](https://github.com/xymelon/spec-kit-agent-assign) |
| Agent Governance | Generate agent-platform repository governance files from Spec Kit metadata | `process` | Read+Write | [spec-kit-agent-governance](https://github.com/bigsmartben/spec-kit-agent-governance) |
| AI-Driven Engineering (AIDE) | A structured 7-step workflow for building new projects from scratch with AI assistants — from vision through implementation | `process` | Read+Write | [aide](https://github.com/mnriem/spec-kit-extensions/tree/main/aide) |
@@ -152,6 +153,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Superpowers Implementation Bridge | Thin orchestrator between Spec Kit (design) and Superpowers (implementation). Cross-agent. | `process` | Read+Write | [speckit-superpowers-bridge](https://github.com/lihan3238/speckit-superpowers-bridge) |
| Superspec | Bridges spec-kit with obra/superpowers (brainstorming, TDD, subagent, code-review) into a unified, resumable workflow with graceful degradation and session progress tracking | `process` | Read+Write | [superspec](https://github.com/WangX0111/superspec) |
| Tasks to GitHub Project | Publish and synchronize Spec Kit tasks as cards on a GitHub Project (v2) kanban board, with priority and status sync between spec.md/tasks.md and the board. | `integration` | Read+Write | [spec-kit-tasks-to-project](https://github.com/mancioshell/spec-kit-tasks-to-project) |
| TDD Extension | Drives spec-kit implementation with tests: a language-agnostic red-green-refactor loop with a per-feature test list, recorded red and green evidence, and mutation-checked test strength. | `process` | Read+Write | [spec-kit-tdd](https://github.com/d0whc3r/spec-kit-tdd) |
| Team Assign | Assign tasks.md items to human engineers, split into subtasks, and generate a per-engineer workboard | `process` | Read+Write | [spec-kit-team-assign](https://github.com/tarunkumarbhati/spec-kit-team-assign) |
| Test Coverage Drift Control | Generate incremental coverage drift reports and planned remediation tasks after implementation | `code` | Read+Write | [spec-kit-test-coverage-drift-control](https://github.com/benizzio/spec-kit-test-coverage-drift-control) |
| Time Machine | Retroactively apply the full SDD workflow to existing codebases — analyse, spec, and ship feature-by-feature | `process` | Read+Write | [spec-kit-time-machine](https://github.com/teeyo/spec-kit-time-machine) |
+2 -1
View File
@@ -25,7 +25,7 @@ Creates a new Spec Kit project with the necessary directory structure, templates
Use `<project_name>` to create a new directory, or `--here` (or `.`) to initialize in the current directory. If the directory already has files, use `--force` to merge without confirmation.
When `--integration` is omitted, interactive terminals prompt you to choose an integration. Non-interactive sessions, such as CI or piped runs, default to GitHub Copilot; pass `--integration <key>` to choose a different integration explicitly.
When `--integration` is omitted, interactive terminals prompt you to choose an integration. Non-interactive sessions, such as CI or piped runs, default to GitHub Copilot; pass `--integration <key>` to choose a different integration explicitly, or set `SPECKIT_INTEGRATION_DEFAULT` to change the fallback (see [Environment Variables](#environment-variables)).
### Examples
@@ -50,6 +50,7 @@ specify init my-project --integration copilot --preset compliance
| Variable | Description |
| ----------------- | ------------------------------------------------------------------------ |
| `SPECKIT_INTEGRATION_DEFAULT` | Override the fallback integration used by `specify init` when `--integration` is omitted (interactive prompt default and non-interactive fallback). Set it to any registered integration key (e.g. `gemini`, `claude`). An unrecognized value is ignored with a warning and the built-in default (`copilot`) is used. An explicit `--integration <key>` always takes precedence. |
| `SPECIFY_INIT_DIR` | Target a member project from outside its directory (e.g. a monorepo root) without `cd`, for non-interactive / CI use. Set it to the **project root** — the directory *containing* `.specify/` (relative paths resolve against the current directory). The path must exist and contain `.specify/`, otherwise the command errors and does **not** fall back to the current directory. Resolved once in the core root helper (`get_repo_root` in Bash, `Get-RepoRoot` in PowerShell), so it is honored by the core feature scripts (`/speckit.plan`, `/speckit.tasks`, …) and the Git extension's feature-branch creation, which inherit it. The `specify` CLI applies the **same** validation rules to every project-scoped subcommand (`specify integration …`, `specify extension …`, `specify workflow …`, `specify preset …`, and the rest that operate on a `.specify/` project), so those can target a member project too. When unset, Bash/PowerShell helpers keep their existing upward search; the `specify` CLI keeps its project-scoped resolver cwd-only unless a command explicitly defines broader detection (for example, bundle commands). |
| `SPECIFY_FEATURE_DIRECTORY` | Override the active feature directory *within* the resolved project (takes precedence over `.specify/feature.json`). Relative paths resolve under the project root. Combine with `SPECIFY_INIT_DIR` to pick both the project and the feature non-interactively. |
| `SPECIFY_FEATURE` | Override feature detection for non-Git repositories. Set to the feature directory name (e.g., `001-photo-albums`) to work on a specific feature when not using Git branches. Must be set in the context of the agent prior to using `/speckit.plan` or follow-up commands. |
+3 -2
View File
@@ -20,7 +20,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [Firebender](https://firebender.com/) | `firebender` | IDE-based agent for Android Studio / IntelliJ |
| [Forge](https://forgecode.dev/) | `forge` | |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | |
| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | Defaults to legacy markdown mode: `.agent.md` command files under `.github/agents/`, companion `.prompt.md` files under `.github/prompts/`, and a `.vscode/settings.json` merge. Pass `--integration-options="--skills"` to scaffold skills as `speckit-<command>/SKILL.md` under `.github/skills/` instead. Legacy markdown mode is deprecated and will stop being the default in a future release. |
| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | Skills-based by default; installs `speckit-<command>/SKILL.md` under `.github/skills/`. Pass `--integration-options="--commands"` to use the supported commands layout: `.agent.md` files under `.github/agents/`, companion `.prompt.md` files under `.github/prompts/`, and a `.vscode/settings.json` merge. |
| [Goose](https://goose-docs.ai/) | `goose` | Uses YAML recipe format in `.goose/recipes/` |
| [Grok Build](https://docs.x.ai/build/overview) | `grok` | Skills-based integration; installs skills into `.grok/skills` and invokes them as `/speckit-<command>` |
| [Hermes](https://github.com/NousResearch/hermes-agent) | `hermes` | Skills-based integration; installs skills globally into `~/.hermes/skills/` |
@@ -234,7 +234,8 @@ Some integrations accept additional options via `--integration-options`:
| ----------- | ------------------- | -------------------------------------------------------------- |
| `generic` | `--commands-dir` | Required. Directory for command files |
| `kimi` | `--migrate-legacy` | Migrate legacy `.kimi/skills/` installs to `.kimi-code/skills/` (including dotted→hyphenated skill naming, e.g. `speckit.xxx` → `speckit-xxx`) |
| `copilot` | `--skills` | Scaffold commands as agent skills (`speckit-<command>/SKILL.md` under `.github/skills/`, invoked as `/speckit-<command>`) instead of the default legacy markdown mode (`.github/agents/*.agent.md` plus `.github/prompts/*.prompt.md` and a `.vscode/settings.json` merge). Without this flag, install warns that legacy markdown mode is deprecated. |
| `copilot` | `--commands` | Scaffold `.github/agents/*.agent.md` commands with `.github/prompts/*.prompt.md` companions and merge `.vscode/settings.json` instead of using the default skills layout. |
| `copilot` | `--skills` | Force the default skills layout, overriding an existing commands layout during an explicit migration. |
Example:
+13
View File
@@ -623,6 +623,19 @@ pauses: the named stored input is reset to `""`. A later resume therefore
prompts or pauses again until another verdict is supplied. Approve, abort, and
skip outcomes leave the input unchanged.
Because of that reset, a verdict input used with `on_reject: retry` must accept
`""`. If it declares an `enum`, include the empty string — otherwise the reset
value violates the input's own `enum` and the run can no longer be resumed with
any input. `specify workflow add` reports this as a validation error.
```yaml
inputs:
spec_verdict:
type: string
enum: ["", approve, reject]
default: ""
```
## FAQ
### What happens when a workflow hits a gate step?
+85 -8
View File
@@ -1,8 +1,43 @@
{
"schema_version": "1.0",
"updated_at": "2026-07-29T00:00:00Z",
"updated_at": "2026-08-04T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"adrkit": {
"name": "adrkit — decision memory for spec-driven development",
"id": "adrkit",
"description": "Pulls the decisions governing this work into agent context, checks produced plans against them, and drafts an ADR from a plan artifact.",
"author": "Mark Beacom (@mbeacom)",
"version": "0.1.2",
"download_url": "https://github.com/mbeacom/adrkit/releases/download/spec-kit-v0.1.2/adrkit.zip",
"repository": "https://github.com/mbeacom/adrkit",
"homepage": "https://adrkit.dev",
"documentation": "https://github.com/mbeacom/adrkit/blob/main/packages/adapters/spec-kit/README.md",
"changelog": "https://github.com/mbeacom/adrkit/blob/main/CHANGELOG.md",
"license": "Apache-2.0",
"category": "process",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.13.0,<0.16.0",
"tools": [{ "name": "adr", "version": ">=0.3.0", "required": true }]
},
"provides": {
"commands": 3,
"hooks": 1
},
"tags": [
"adr",
"governance",
"decision-records",
"architecture",
"compliance"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-08-03T00:00:00Z",
"updated_at": "2026-08-03T00:00:00Z"
},
"aide": {
"name": "AI-Driven Engineering (AIDE)",
"id": "aide",
@@ -327,8 +362,8 @@
"id": "archive",
"description": "Archive merged features into main project memory, resolving gaps and conflicts.",
"author": "Stanislav Deviatov",
"version": "1.0.0",
"download_url": "https://github.com/stn1slv/spec-kit-archive/archive/refs/tags/v1.0.0.zip",
"version": "1.1.0",
"download_url": "https://github.com/stn1slv/spec-kit-archive/archive/refs/tags/v1.1.0.zip",
"repository": "https://github.com/stn1slv/spec-kit-archive",
"homepage": "https://github.com/stn1slv/spec-kit-archive",
"documentation": "https://github.com/stn1slv/spec-kit-archive/blob/main/README.md",
@@ -353,7 +388,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-03-14T00:00:00Z",
"updated_at": "2026-03-14T00:00:00Z"
"updated_at": "2026-08-04T00:00:00Z"
},
"azure-devops": {
"name": "Azure DevOps Integration",
@@ -776,8 +811,8 @@
"id": "charter",
"description": "Compose modular project constitutions from shared fragment registries. Centralize governance rules, select per-project fragments, track upstream changes, and keep multi-project setups consistent.",
"author": "Fyloss",
"version": "0.3.1",
"download_url": "https://github.com/Fyloss/spec-kit-charter/archive/refs/tags/v0.3.1.zip",
"version": "0.5.1",
"download_url": "https://github.com/Fyloss/spec-kit-charter/archive/refs/tags/v0.5.1.zip",
"repository": "https://github.com/Fyloss/spec-kit-charter",
"homepage": "https://github.com/Fyloss/spec-kit-charter",
"documentation": "https://github.com/Fyloss/spec-kit-charter/tree/master/docs",
@@ -786,7 +821,8 @@
"category": "process",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.11.9"
"speckit_version": ">=0.11.9",
"tools": [{ "name": "git", "required": false }]
},
"provides": {
"commands": 5,
@@ -803,7 +839,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-07-06T00:00:00Z",
"updated_at": "2026-07-06T00:00:00Z"
"updated_at": "2026-08-04T00:00:00Z"
},
"ci-guard": {
"name": "CI Guard",
@@ -4453,6 +4489,47 @@
"created_at": "2026-06-22T00:00:00Z",
"updated_at": "2026-06-22T00:00:00Z"
},
"tdd": {
"name": "TDD Extension",
"id": "tdd",
"description": "Drives spec-kit implementation with tests: a language-agnostic red-green-refactor loop with a per-feature test list, recorded red and green evidence, and mutation-checked test strength.",
"author": "d0whc3r",
"version": "1.1.2",
"download_url": "https://github.com/d0whc3r/spec-kit-tdd/releases/download/v1.1.2/tdd-1.1.2.zip",
"repository": "https://github.com/d0whc3r/spec-kit-tdd",
"homepage": "https://d0whc3r.github.io/spec-kit-tdd/",
"documentation": "https://github.com/d0whc3r/spec-kit-tdd/wiki",
"changelog": "https://github.com/d0whc3r/spec-kit-tdd/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "process",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.11.9"
},
"provides": {
"commands": 4,
"hooks": 3
},
"tags": [
"acceptance-tests",
"mutation-testing",
"property-based-testing",
"quality",
"red-green-refactor",
"spec-kit",
"spec-kit-extension",
"tdd",
"test-driven-development",
"test-first",
"testing",
"unit-tests"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-08-04T00:00:00Z",
"updated_at": "2026-08-04T00:00:00Z"
},
"team-assign": {
"name": "Team Assign",
"id": "team-assign",
+2 -2
View File
@@ -4,7 +4,7 @@ This edition covers Spec Kit activity in April 2026. Seventeen releases shipped
| **Spec Kit Core (Apr 2026)** | **Community & Content** | **SDD Ecosystem & Next** |
| --- | --- | --- |
| Seventeen releases shipped with major features: integration plugin architecture, workflow engine, preset composition, integration catalog, bundled lean preset, documentation site, and academic citation support. Three new agents added (Forgecode, Goose, Devin for Terminal). The repo grew from ~82k to **92,038 stars**. [\[github.com\]](https://github.com/github/spec-kit/releases) | Thoughtworks Technology Radar placed Spec Kit in the "Assess" ring. Community catalog grew from 26 to **83 extensions** and from 2 to **12 presets**. 12 substantive external articles published. XB Software documented a real legacy project. Fabián Silva shipped the Caramelo VS Code extension. | Matt Rickard argued for "smaller specs, harder checks." Will Torber's three-framework comparison recommended OpenSpec for most teams. The "Spec Layer" debate emerged: specs as constraint surfaces for AI agents. Spec Kit leads in breadth and portability; competitors differentiate on drift detection and orchestration depth. |
| Seventeen releases shipped with major features: integration plugin architecture, workflow engine, preset composition, integration catalog, bundled lean preset, documentation site, and academic citation support. Three new agents added (Forgecode, Goose, Devin for Terminal). The repo grew from ~82k to **92,038 stars**. [\[github.com\]](https://github.com/github/spec-kit/releases) | Thoughtworks Technology Radar placed Spec Kit in the "Assess" ring. Community catalog grew from 26 to **83 extensions** and from 2 to **12 presets**. External coverage continued across developer blogs and industry press. XB Software documented a real legacy project. Fabián Silva shipped the Caramelo VS Code extension. | Matt Rickard argued for "smaller specs, harder checks." Will Torber's three-framework comparison recommended OpenSpec for most teams. The "Spec Layer" debate emerged: specs as constraint surfaces for AI agents. Spec Kit leads in breadth and portability; competitors differentiate on drift detection and orchestration depth. |
***
@@ -94,7 +94,7 @@ On **April 15**, the **Thoughtworks Technology Radar Volume 34** placed GitHub S
### Developer Articles and Blog Posts
April produced 12 substantive external articles (plus one excluded as AI-generated SEO spam).
April produced a steady stream of external articles.
**Matt Rickard** published *"The Spec Layer: Why Spec-Driven Development (SDD) Works"* on April 1. His thesis: specs reduce execution freedom for AI agents, functioning as constraint surfaces. He compared Spec Kit, Kiro, OpenSpec, Tessl, Intent, and Symphony, and advocated for **"smaller specs, harder checks, less guessing."** [\[blog.matt-rickard.com\]](https://blog.matt-rickard.com/p/the-spec-layer)
+152
View File
@@ -0,0 +1,152 @@
# Spec Kit - July 2026 Newsletter
This edition covers Spec Kit activity in July 2026 — a month of hardening and expanding the envelope. Twenty-eight releases shipped (v0.12.3 through v0.15.1), crossing three minor bumps and delivering three headline capabilities: the **`assess` "Idea Assessment Pipeline" extension**, which pushes spec-driven development *upstream* of the spec to answer "should we even build this?"; the new **`py` (Python) script type** and the broad shell→Python port that underpins it; and a **first-class agent-native runtime events layer** for integrations. Beneath the features, the month's dominant engineering theme was a sustained **security-hardening wave** — bounded HTTP reads, strict redirect validation, TOCTOU-race elimination, and defensive validation across the workflow engine. Externally, coverage broadened structurally: mainstream tech press (heise online) covered the v0.13 `assess` release in two languages, and a **companion-tooling ecosystem** bloomed around the project — spec↔code drift detectors, model-sizing advisors, and testing-gap tools all built *on top of* Spec Kit. A summary is in the table below, followed by details.
| **Spec Kit Core (Jul 2026)** | **Community & Content** | **SDD Ecosystem & Next** |
| --- | --- | --- |
| Twenty-eight releases shipped (v0.12.3–v0.15.1), crossing v0.13, v0.14, and v0.15. Headline features: the `assess` **Idea Assessment Pipeline** extension (capture→evidence→refine→design→go/clarify/kill), the new **`py` script type** plus a shell→Python port of the core scripts, git extension, and agent-context updater, and an **agent-native runtime events layer** for integrations. Three agents joined (Grok Build, Factory Droid CLI, Alquimia AI), the label-driven **bug-fix/bug-test** automation completed the triage pipeline, and a heavy **security-hardening** wave landed. The repo grew from ~117,400 to **124,655 stars**. [\[github.com\]](https://github.com/github/spec-kit/releases) | The community extension catalog grew from 125 to **144 entries**; presets reached **29**, community workflows **2**, bundles **1**. **heise online** covered the v0.13 `assess` release in English and German. Coverage shifted toward comparisons, companion tooling, and "who verifies the spec?" critiques. **~258 contributors** now listed. | A **companion-tooling ecosystem** emerged — artgraph (deterministic spec↔code drift), SpecJudge (model right-sizing), GAUNTLEX (security-testing gap), and custom skills like `speckit-next` and `prefill`. Comparisons increasingly pit Spec Kit against Kiro; balanced reviews keep flagging documentation proliferation and cognitive load, precisely the gaps the `assess` upstream step and the drift/companion ecosystem are built to close. |
***
> **Hardening the Foundation, Expanding the Envelope.** If June was defined by external validation, July was defined by internal consolidation and reach. No single release carried the weight of `converge` or `bundle`, but the month moved the project in two directions at once. It reached *upstream* — the new `assess` pipeline lets a team evaluate an idea (capture evidence, refine, design, then go/clarify/kill) *before* a spec exists, extending SDD past the spec into the decision to build. And it reached *down to the metal* — a new `py` script type and a systematic port of the core scripts, git extension, and agent-context updater from shell to Python, alongside a security-hardening wave that bounded every HTTP read, validated every redirect hop, eliminated file-race conditions, and taught the workflow engine to fail loudly instead of crashing on malformed input. Meanwhile the ecosystem answered the project's most-cited critique — "who verifies the spec, and who reads all this documentation?" — not with complaints but with *code*: a wave of companion tools built directly on Spec Kit artifacts. None of this happens without the community — the contributors, extension and preset authors, bundle builders, agent-integration maintainers, and practitioners writing in more than 20 languages. Thank you.
## Spec Kit Project Updates
### Releases Overview
**v0.12.3–v0.12.18** (July 1–17) was the month's longest patch run and carried two features amid heavy hardening. The **`py` script type** landed (#3285), adding Python interpreter resolution alongside the existing `sh`/`ps` options, and the **label-driven bug-fix (#3258) and bug-test (#3239) agentic workflows** completed the `bug-assess → bug-test → bug-fix` triage pipeline. The systematic shell→Python port began here: the **`update-agent-context` script** (#3387), the **git extension scripts** (#3400), and a **`check-prerequisites` proof-of-concept** (#3302) were all ported. **PyPI was documented as a first-class second install route** (#3516). New agents arrived — **Grok Build** (#3535) as a skills-based integration — while **Roo Code was retired** as a shut-down product (#3212). The rest was a broad defensive-validation sweep across the workflow engine (case-insensitive gate reject, quote-aware interpolation, host-less catalog-URL rejection, and dozens of "fail loudly on malformed input" guards). [\[github.com\]](https://github.com/github/spec-kit/releases)
**v0.13.0–v0.13.4** (July 17–22) delivered the month's headline feature: the **`assess` Idea Assessment Pipeline extension** (#3568), a pre-spec evaluation flow. The release also completed the Python port of the three core scripts — **`create-new-feature`, `setup-plan`, and `setup-tasks`** (#3386) — and added **Azure DevOps `az`-CLI token acquisition** hardening (#3527), **community bundle submission automation** (#3553), and the standalone **`WorkflowResolver`** refactor (#3557). **Factory Droid CLI** joined as an integration (#3587), **Bob was updated to a skills-based layout for Bob 2.0** (#3415), the **`pipeline` workflow** was added to the community catalog (#3338), and the **spec-of-specs feature-breakdown** approach was documented for handling complex features (#3648). [\[github.com\]](https://github.com/github/spec-kit/releases)
**v0.14.0–v0.14.4** (July 23–29) crossed a minor with a **security-hardening focus**: **bounded HTTP reads and strict redirect enforcement** (#3140, #3671), **secured extension/preset archive downloads** (#3141), and the **removal of the `shell` parameter from `run_command`** (#3716). The **git extension gained configurable Conventional Commit support** (#3413), the wheel now **bundles `scripts/python`** so `--script py` works from a clean install (#3665), and **Alquimia AI** joined as the month's third new agent (#2734). Documentation added a **Simplified Chinese README translation** (#3740), and the constitution stopped **propagating guidance into templates** (#3790). A long run of bundler, preset, and integration validation fixes rounded out the cluster. [\[github.com\]](https://github.com/github/spec-kit/releases)
**v0.15.0–v0.15.1** (July 30–31) closed the month with a **first-class agent-native runtime events layer for integrations** (#3704) — the release's headline — plus a continued security pass: **TOCTOU-race elimination in file-unlink calls** (#3811, #3815, #3819), **UTF-8 encoding on registry file opens** (#3810, #3816), and **hardening of the extension URL-download cache against symlink/junction races** (#3869). Workflows gained the ability to **bind a gate verdict to a workflow input via `verdict_input`** (#3725), an opt-in **`constitution-sync` preset** shipped (#3873), the **`yolo` workflow** was added to the community catalog (#3864), and installs gained **tar-archive support** (#3874). [\[github.com\]](https://github.com/github/spec-kit/releases)
### The Idea Assessment Pipeline: `assess`
July's headline feature was the **`assess` extension** (#3568), an *idea-assessment pipeline* that ships as an opt-in extension and extends spec-driven development one step further upstream than it has ever reached. Where the core lifecycle begins at `/speckit.specify` — assuming the decision to build has already been made — `assess` addresses the question that comes *before* the spec: **should this idea be built at all, and is it understood well enough to specify?**
The pipeline runs a staged flow — **capture → evidence → refine → design → decision** — that takes a raw idea, gathers supporting evidence, refines it into something concrete, sketches a design, and terminates in an explicit **go / clarify / kill** verdict. A `go` feeds a well-formed problem into the existing `/speckit.specify` step; a `clarify` routes back for more information; a `kill` stops work before a line of spec is written. Its input is just an idea — pasted text, a URL, a ticket, or a codebase pointer — so the pipeline works **equally well on an empty, freshly-initialized project or on an existing codebase** (#3732); a team can evaluate a green idea before any scaffolding exists, or assess a change against a repo that already has one.
The feature drew the month's most prominent mainstream-press coverage: **heise online** ran *"From Idea to Spec: The New Feature in Spec Kit 0.13"* in both English and German, framing `assess` as the notable addition of the 0.13 line alongside the Azure DevOps CLI support and the bundler/preset validation fixes. Coming from a major European technology outlet rather than a developer blog, it was a signal that Spec Kit's release cadence is now tracked as mainstream tooling news. [\[heise.de\]](https://www.heise.de/en/news/From-Idea-to-Spec-The-New-Feature-in-Spec-Kit-0-13-11371866.html)
### The Python Migration: the `py` Script Type
Spec Kit's second July theme was quieter but structurally important: the project began migrating its shell scripts to **Python**. The new **`py` script type** (#3285) joins `sh` (bash) and `ps` (PowerShell) as a third option at `specify init`, backed by Python-interpreter resolution that skips broken stubs (including the Windows Store `python3` alias, #3385). The `py` type is the project's answer to the perennial bash/PowerShell parity tax — every script fix previously had to be written twice and kept in sync, a recurring source of the Windows-parity bugs that filled prior months' changelogs.
Behind the new type, a systematic port landed piece by piece across the month: the **`update-agent-context`** updater (#3387), the **git extension scripts** (#3400), a **`check-prerequisites`** proof-of-concept (#3302), and finally the three core scripts — **`create-new-feature`, `setup-plan`, and `setup-tasks`** (#3386). The wheel was updated to bundle `scripts/python` so `--script py` works from a clean PyPI install (#3665), and the installation docs and init option table were updated to document the new type and the sh/ps migration plan (#3284, #3640). The end state is a single, cross-platform script implementation that removes an entire class of parity bugs. [\[github.com\]](https://github.com/github/spec-kit/releases)
### Agent-Native Runtime Events
The v0.15.0 headline was a **first-class agent-native runtime events layer for integrations** (#3704). It bridges Spec Kit to the host agent's own lifecycle via a set of canonical, snake_case event names — `session_start`, `pre_tool_use`, `post_tool_use`, `user_prompt_submit`, `stop`, and `session_end`. A lightweight, zero-dependency **Event Dispatcher** (`.specify/events.py`) is scaffolded during `specify init`, and per-integration **Event Adapters** translate each canonical event into the agent's *native* hook configuration — `.github/hooks/speckit.json` (bash/PowerShell variants) for **Copilot CLI**, `.claude/settings.json` for Claude Code, `.cursor/hooks.json` for Cursor, `.codex/config.toml` for Codex, a TypeScript plugin for opencode, and native settings merges for Gemini, Qwen, Devin, and Tabnine — so extension authors declare `events:` in `extension.yml` once and never learn agent-specific names. Resolution is a four-tier stack (CLI `--events false` → user `.specify/integration-events.yml` override → extension-declared events → built-in defaults), and multiple extensions declaring the same event all run. The change accompanied a broader integration-refinement run: agents that use an always-slash invocation (Droid, Forge, Cline) now render hyphenated `/speckit-<name>` commands correctly (#3688, #3642, #3622), native skill-invocation prefixes are preserved (#3663), and several agents (kiro-cli, Lingma, Pi, omp) were declared multi-install-safe. The through-line is that integrations are increasingly *native* to each agent rather than a lowest-common-denominator overlay. [\[github.com\]](https://github.com/github/spec-kit/releases)
### The Security-Hardening Wave
The dominant engineering theme across all twenty-eight releases was **security and robustness**. The work fell into three bands. **Bounded I/O:** every catalog, download, and bundle HTTP response is now read under a byte cap with strict redirect validation on every hop (#3140, #3671, #3763, #3141), closing a class of unbounded-read / DoS exposure. **Race elimination:** TOCTOU races in file-unlink and state-file handling were removed (#3811, #3815, #3819), the extension URL-download cache was hardened against symlink and junction races (#3869), and registry file opens were pinned to UTF-8 (#3810, #3816). **Injection and input hardening:** the `shell` parameter was removed from `run_command` (#3716), user-supplied catalog metadata is escaped in every discovery/list/`init` output path (#3772, #3773, #3774, #3806, #3826, #3863), and catalog URLs are re-validated *after* redirects to preserve HTTPS/host guarantees (#3523, #3524).
Running alongside this was a systematic **"fail loudly, don't crash"** campaign across the workflow engine and catalog loaders: dozens of PRs replaced raw `ValueError`/`OverflowError`/crash paths with clean validation errors on malformed input — non-string commands, prompts, integrations, and models; non-list branches and `wait_for` entries; `priority: .inf` and boolean priorities; non-mapping manifest blocks; and superscript-digit gate prompts. The entire month's hardening arrived as prevention rather than response. [\[github.com\]](https://github.com/github/spec-kit/releases)
### The Workflow Engine & Bundles Mature
Beyond hardening, the **workflow engine** kept gaining capability. Steps can now read their **workflow source directory** (#3469), the shell and prompt steps got **configurable, validated timeouts** (#3404, #3847, #3768), a **gate verdict can bind to a workflow input** via `verdict_input` (#3725), and the **`WorkflowResolver`** was extracted as a standalone component (#3557). Two community workflows reached the catalog — the guided **`pipeline`** (#3338, which chains into the core `/speckit.converge`) and **`yolo`** (#3864) — bringing the standalone-workflow count to two.
The **bundle subsystem** introduced in June matured through a long tail of correctness work — reproducible builds via canonical POSIX arcnames (#3658), literal UTF-8 manifest dumps (#3660), strict rejection of malformed `requires`/`provides`/`integration`/`catalogs` blocks, and a clean `BundlerError` on malformed download URLs (#3586). **Community bundle submission automation** landed (#3553) and the **SicarioSpec Security & Governance Bundle** became a cataloged community bundle (#3636), making bundles a live community-submittable artifact type in practice. [\[github.com\]](https://github.com/github/spec-kit/releases)
### Agent Integrations
The agent portfolio grew net **+3 to 37 integrations**. Three joined — **Grok Build** (#3535), **Factory Droid CLI** (#3587, closing the 300+-day #822), and **Alquimia AI** (#2734) — while **Roo Code** was retired as a shut-down product (#3212). **Bob** was migrated to a skills-based layout for Bob 2.0 (#3415), **Kilocode** now installs commands under `.kilo/commands` (#3672), and a broad correctness pass fixed hyphenated-command rendering and dispatch for the always-slash agents (Droid, Forge, Cline) and preserved native skill-invocation prefixes (#3663). The pattern continues from June — pruning dead products while making the surviving integrations more native to each agent. [\[github.com\]](https://github.com/github/spec-kit/releases)
### The Extension & Preset Ecosystem
The community extension catalog grew from 125 to **144 entries** during July — nineteen net additions. Community presets grew from 23 to **29**, community workflows reached **2**, and the first community **bundle** (SicarioSpec) was cataloged.
Notable new extensions by category:
- **Verification, drift & evidence**: Test Coverage Drift Control, PatchWarden Evidence Pack, Quality Gates (Enforcement Layer), Verify Review Ship, Intent Reconciliation
- **Requirements & intake**: EARS Requirements Syntax, the `assess` Idea Assessment Pipeline, Spec-Kit BDD, Charter
- **External trackers & round-trip**: Linear Weave, Multi-Repo Branch Sync, ContextForge MCP
- **Design & docs**: Spec Kit Figma, Figma Starter, Blueprint Index — Living Architecture Map, LLM Wiki, Dotdog
- **Knowledge & orchestration**: OKF Knowledge Bundle Generator, Orchestration Task Context Management, Spec Kit Memory
The catalog also showed strong maintenance activity: **DocGuard — CDD Enforcement** advanced through several releases (to v0.33.0), **Verify Review Ship** and **Quality Gates (Enforcement Layer)** iterated rapidly, and **Architecture Guard**, **Golden Demo**, **Coding Standards Drift Control**, **Ripple**, and the **Ralph Loop** all shipped updates. The preset side was the month's busiest: a large **governance-preset** family expanded and iterated — the **Autonomous Run Governance** and **Parallel Autonomous Run Governance** presets, a full **Intake** governance suite (Authoring, Review, Sequencing), **Test-First Governance**, and coordinated version bumps across the A11Y, Agent-Parity, Cross-Platform, iSAQB-Architecture, Architecture, and Security governance presets. [\[github.com\]](https://github.github.io/spec-kit/community/extensions.html)
### Documentation & Docs Site
July's documentation work paired the new features with a landing-page refresh. The **spec-of-specs feature-breakdown** approach was documented for handling complex features (#3648), the **`py` (Python) script type** was documented in the installation guide and init option table (#3284, #3625, #3640), and the **`__SPECKIT_COMMAND`** token for portable cross-command references was documented (#3503). The landing page was reframed to weave the **harness/SDLC framing** and modernize the install and positioning story (#3565, #3567), ecosystem stats were refreshed (#3561), and **`extensions.yml` hook configuration** was documented (#3563). Upgrade guidance clarified that project-file upgrades flow through `integration upgrade` / `extension update` (#3326) and that Claude Code files live in `.claude/skills` (#3708). [\[github.com\]](https://github.com/github/spec-kit/releases)
## Community & Content
### Press and Industry Coverage
July's coverage shifted from "what is SDD" explainers toward tool comparisons, companion tooling, and pointed "who verifies the spec?" critiques. No first-party Microsoft or GitHub post appeared in July; the nearest remained June's Microsoft Developer Blog piece.
**heise online** (Wolf Hosbach, July 21) was the month's most prominent mainstream-press coverage, publishing *"From Idea to Spec: The New Feature in Spec Kit 0.13"* in both English and German — news coverage of the `assess` Idea Assessment Pipeline, the Azure DevOps CLI support, and the 0.13 validation fixes. Mainstream European tech press now tracks Spec Kit's minor releases as tooling news. [\[heise.de\]](https://www.heise.de/en/news/From-Idea-to-Spec-The-New-Feature-in-Spec-Kit-0-13-11371866.html)
**Towards AI** (Rost Glukhov, July 12) compared **GitHub Spec Kit vs Kiro vs Claude Code** on SDD workflow rather than model capability, part of a July-long current of "which SDD tool?" comparisons that increasingly pit Spec Kit specifically against Kiro. [\[pub.towardsai.net\]](https://pub.towardsai.net/github-spec-kit-vs-kiro-vs-claude-code-sdd-workflows-a9e7fab3e545)
**ranjankumar.in** (Ranjan Kumar, July 13) argued that four SDD frameworks — BMAD, Spec Kit, Kiro, and Superpowers — converge on the same structural "invariants," engaging Spec Kit's actual internals (`workflows.md`, run-state `state.json`) rather than treating it as a black box. [\[ranjankumar.in\]](https://ranjankumar.in/spec-driven-development-invariants-not-frameworks)
Release-trackers continued their factual coverage of the 0.13–0.15 run, and **Level Up Coding** (JingJing "Chris" Bao) published a three-part practitioner series on Spec Kit's presets, extensions, and pipeline/workflow features as the path beyond linear slash-commands. [\[levelup.gitconnected.com\]](https://levelup.gitconnected.com/from-linear-commands-to-automated-pipelines-how-spec-kit-orchestrates-nonlinear-ai-development-55ca03c5617b)
### The Companion-Tooling Ecosystem
July's most telling signal was not an article but a pattern: independent developers responded to Spec Kit's most-cited critiques by **building tools on top of it**. The recurring complaint — documentation proliferation and "who verifies the generated spec?" — turned into code.
- **artgraph** (mori-shin, July 20) — a deterministic, hash-based spec↔code drift-detection CLI with an `artgraph integrate speckit` hook, built specifically to give Spec Kit's LLM-prompt-based verification a deterministic backstop. [\[zenn.dev\]](https://zenn.dev/mrmtsntr/articles/artgraph-spec-code-drift)
- **SpecJudge** (Joaquín Ruiz, July 21) — a companion CLI that reads Spec Kit's constitution/spec/tasks artifacts to recommend a *right-sized* model for the project. [\[dev.to\]](https://dev.to/jokiruiz/specjudge-which-ai-model-is-right-sized-for-your-project-ask-your-specs-2edp)
- **GAUNTLEX** (Sanjoy Ghosh, July 16) — named Spec Kit a leading SDD tool while arguing SDD leaves a security-testing gap, and shipped a tool to fill it. [\[hashnode.dev\]](https://sanjoy1234.hashnode.dev/the-testing-gap-nobody-s-talking-about-in-spec-driven-development)
- **Custom skills** — [`speckit-next`](https://qiita.com/htcd/items/ec76f2b7194be3297b93) (htcd, July 31), a skill that recommends the next command because the names and order are hard to remember, and [`prefill`](https://velog.io/@k3nta/ai-adoption-journey-1-tools) (k3nta, July 1), a skill that patches `clarify`'s blind spots.
Together these are the clearest evidence yet that Spec Kit has become a *platform* — its artifacts are stable enough, and its gaps well-enough understood, that a third-party tooling layer is forming around it. [\[zenn.dev\]](https://zenn.dev/mrmtsntr/articles/artgraph-spec-code-drift)
### Developer Articles and Blog Posts
July's articles skewed heavily multilingual — strong hands-on series in Japanese, Chinese, and Korean — with a clear thread of honest, use-it-in-anger critique.
Notable articles:
- **ta_kawano** (note.com, July 28–31) published a consolidated four-part **Kiro vs Spec Kit** head-to-head, completing a 108-task / 301-test build with Spec Kit where Kiro ran out of credit, praising measurable Success Criteria and auto-listed edge cases while flagging ~15,000 lines of generated documentation — and reframing Spec Kit as a requirements-elicitation tool. [\[note.com\]](https://note.com/takawano/n/ncb552ee37331)
- **magebyte / 码哥字节** (SegmentFault, July 26) built a Go REST API through the five-step workflow, covered the three spec-persistence models and the extension/preset system, and claimed ~80% less AI "hallucination" rework. [\[segmentfault.com\]](https://segmentfault.com/a/1190000048082146)
- **Nil Seri** (Medium, July 16) published a brownfield guide adding Spec Kit to an existing Spring Boot / Maven project with Jira and Confluence integration — "from Jira ticket to verified code." [\[medium.com\]](https://medium.com/@senoritadeveloper/using-spec-kit-in-an-existing-spring-boot-maven-project-from-jira-ticket-to-verified-code-4e6da99b5d19)
- **kitroc7134** (Qiita, July 4) tested `/speckit.converge` with deliberate fault-injection on a FastAPI Todo API — detect drift → append convergence tasks → re-implement — validating June's convergence loop in the field. [\[qiita.com\]](https://qiita.com/kitroc7134/items/117d4839f259bc403626)
- **yutakaosada** (Zenn, July 25) — a Microsoft-MVP .NETラボ talk that uses Spec Kit but candidly flags AI-credit consumption, over-production of docs, and single-source-of-truth collapse, comparing it with Copilot Plan mode. [\[zenn.dev\]](https://zenn.dev/yutakaosada/articles/70e01981647159)
Additional coverage appeared on TechWealthBuzz, Hashnode, TabNews-adjacent outlets, CSDN and 腾讯云 (Chinese), Naver/velog/Tistory (Korean), and Qiita/note (Japanese) — including several "is it too heavy?" and documentation-proliferation critiques, and a [Korean instructor's piece](https://blog.naver.com/gaussian88/224363268926) citing Spec Kit's star growth from ~90k in May to ~120k in July. [\[note.com\]](https://note.com/takawano/n/ncb552ee37331)
### Community Growth by the Numbers
| Metric | Start of July | End of July | Change |
| --- | --- | --- | --- |
| GitHub stars | 117,423 | 124,655 | +7,232 (+6%) |
| Forks | 10,382 | 11,125 | +743 |
| Contributors | 245 | ~258 | +~13 |
| Releases (total) | 177 | 205 | +28 (v0.12.3–v0.15.1) |
| Community extensions | 125 | 144 | +19 |
| Community presets | 23 | 29 | +6 |
| Community workflows | 1 | 2 | +1 |
| Community bundles | 1 | 1 | steady |
| Agent integrations | 34 | 37 | +3 (net) |
| Discussions (open) | 457 | ~467 | +~10 |
## SDD Ecosystem & Industry Trends
### From Tool to Platform
July's clearest ecosystem signal was structural: the conversation moved from "how do I use Spec Kit?" to "what do I build *around* it?" The companion tools — artgraph for deterministic drift, SpecJudge for model sizing, GAUNTLEX for the testing gap, and a growing set of custom agent skills — treat Spec Kit's artifacts (constitution, spec, tasks, run-state) as a stable substrate to build against. The public community catalog reinforces the point: the loudest theme across the 144 cataloged extensions is verification and quality (review, validate, drift, sync, verify, audit), and core SDD verbs are increasingly *re-expressed* by extensions rather than merely overridden — evidence of demand for composable, overridable core commands. [\[github.com\]](https://github.github.io/spec-kit/community/extensions.html)
### Competitive Landscape
The "which SDD tool?" comparison remained the dominant content genre, but July's framing narrowed: where June's surveys ran a seven-tool field, July's most substantive pieces increasingly went head-to-head **Spec Kit vs Kiro** (ta_kawano, [faruryo](https://qiita.com/faruryo/items/87a14728299e89f80ff4), Towards AI). The recurring verdict held — Spec Kit is the heaviest and most flexible option, strong on measurable Success Criteria, requirements elicitation, and greenfield decomposition, while its documentation proliferation and cognitive load are the consistent trade-off. The convergence-invariants analyses (ranjankumar.in) went further, arguing the frameworks are converging on the same structural primitives, which shifts the competitive question from "which tool" to "which ecosystem and governance model." On that axis, Spec Kit's widening catalog, agent-neutrality, and now a forming companion-tooling layer are its differentiators. [\[note.com\]](https://note.com/takawano/n/ncb552ee37331)
## Roadmap
Areas under discussion or in progress for future development:
- **Upstream of the spec** — the `assess` Idea Assessment Pipeline extends SDD before the spec exists. Expect the capture→evidence→refine→design→decision flow to deepen, and the boundary between idea assessment and `/speckit.specify` to be a key area to refine as the pipeline sees real use. [\[heise.de\]](https://www.heise.de/en/news/From-Idea-to-Spec-The-New-Feature-in-Spec-Kit-0-13-11371866.html)
- **The Python migration** — the `py` script type and the port of the core scripts, git extension, and agent-context updater establish Python as the path out of the bash/PowerShell parity tax. Completing the port and making `py` a well-trodden default (rather than sh/ps) is the payoff: an entire class of Windows-parity bugs disappears. [\[github.com\]](https://github.com/github/spec-kit/releases)
- **Agent-native runtime events** — the first-class events layer lets integrations wire Spec Kit into each agent's own runtime through canonical event names and per-agent adapters. The layer is **actively evolving** — early signals point to opencode context injection and JSON-envelope agent hooks. Expect more agents to gain event adapters and extension authors to lean on the declarative `events:` surface as the integration layer shifts from lowest-common-denominator overlay to genuinely native behavior. [\[github.com\]](https://github.com/github/spec-kit/releases)
- **Copilot skills as the default** — July shipped a warning ahead of the skills-default rollout, and the signals now point to the **default flip being in progress** — moving `specify init --integration copilot` to the skills-based layout and making the default init integration overridable via an environment variable, with the markdown-command layout becoming the legacy path. [\[github.com\]](https://github.com/github/spec-kit/releases)
- **A Copilot-native surface** — the first-party [`github/spec-kit-copilot`](https://github.com/github/spec-kit-copilot) repo (a listed community friend) wraps the `specify` CLI as a **Copilot skills plugin** (nine skills across setup, init, extensions, presets, bundles, workflows, and self-upgrade) for the Copilot CLI and App, aligned to CLI v0.15.0. The emerging direction is a **visual, Copilot-driven surface** — early work explores canvas dashboards for the Spec-Driven Development flow, a Bug Fix Pipeline, and `assess` — turning the CLI's flows into an interactive layer. [\[github.com\]](https://github.com/github/spec-kit-copilot)
- **The companion-tooling layer** — artgraph, SpecJudge, GAUNTLEX, and custom skills signal a third-party ecosystem forming on Spec Kit artifacts. The open question is whether the project absorbs these patterns (as it did drift → `converge`) or leaves them to the ecosystem; the verification/drift demand in the extension catalog suggests continued upstream pull. [\[github.com\]](https://github.com/github/spec-kit/releases)
- **Security and robustness as a standing discipline** — July's hardening wave (bounded reads, strict redirects, TOCTOU elimination, fail-loudly validation) shifted from feature to routine, and the signals point to it **continuing as an ongoing campaign** — a stdin read cap to close a DoS path and a broader atomicity push (atomic temp-file writes, subprocess timeouts, structured logging, and narrowed exception handlers). Sustaining the no-unbounded-read invariant as the surface (bundles, workflows, catalogs, events) grows is the ongoing work. [\[github.com\]](https://github.com/github/spec-kit/releases)
- **Experience simplification** — documentation proliferation and cognitive load remain the single most-cited concern across July's balanced reviews (ta_kawano, yutakaosada, and multiple Japanese/Korean pieces). The `assess` upstream gate, the lean/TinySpec presets, `/speckit.converge`, and the forming companion-tooling layer all provide answers; surfacing them to new users is the persistent opportunity. [\[note.com\]](https://note.com/takawano/n/ncb552ee37331)
+6 -6
View File
@@ -1,14 +1,14 @@
# Spec Kit - June 2026 Newsletter
This edition covers Spec Kit activity in June 2026 — a month of maturation and mainstream validation. Twenty-five releases shipped (v0.9.0 through v0.12.2), spanning four minor bumps and delivering two headline capabilities: the **`/speckit.converge` command**, which closes the loop between a spec and the code that implements it, and the new **`specify bundle` subsystem**, a role-based distribution layer that composes extensions, presets, workflows, and steps into a single installable unit. The workflow engine became programmable, the git extension went opt-in as the first real breaking change, and the ecosystem crossed **120+ community extensions**. Externally, June was the highest-volume press month on record — Microsoft's own Developer Blog published a first-party spec-driven development post, an enterprise reported 2–4× velocity gains, and 75 substantive articles appeared across 25+ languages. A summary is in the table below, followed by details.
This edition covers Spec Kit activity in June 2026 — a month of maturation and mainstream validation. Twenty-five releases shipped (v0.9.0 through v0.12.2), spanning four minor bumps and delivering two headline capabilities: the **`/speckit.converge` command**, which closes the loop between a spec and the code that implements it, and the new **`specify bundle` subsystem**, a role-based distribution layer that composes extensions, presets, workflows, and steps into a single installable unit. The workflow engine became programmable, the git extension went opt-in as the first real breaking change, and the ecosystem crossed **120+ community extensions**. Externally, June brought broad validation — Microsoft's own Developer Blog published a first-party spec-driven development post, an enterprise reported 2–4× velocity gains, and coverage spanned dozens of languages. A summary is in the table below, followed by details.
| **Spec Kit Core (Jun 2026)** | **Community & Content** | **SDD Ecosystem & Next** |
| --- | --- | --- |
| Twenty-five releases shipped (v0.9.0–v0.12.2) with key features: the `/speckit.converge` convergence loop, the `specify bundle` role-based packaging subsystem, a programmable workflow engine (step catalog, JSON output, `from_json`), the git extension becoming opt-in (`--no-git` removed), and six new agents (Cline, rovodev, Zed, Firebender, ZCode, omp). The repo grew from ~107k to **~116,500 stars**. [\[github.com\]](https://github.com/github/spec-kit/releases) | The community extension catalog grew from 105 to **124 entries**; presets reached **23**. Microsoft's Developer Blog published a first-party SDD post naming Spec Kit as the operationalizing toolkit. June was the highest-volume press month yet — **75 substantive articles** across 25+ languages. **245 contributors** now listed. | An enterprise (SNCF Connect & Tech) reported **2–4× velocity** from SDD. Analysts and comparisons increasingly name Spec Kit "the category anchor" and agent-neutral default. Competitors differentiate on brownfield and drift; balanced reviews continue to flag review-overload and ceremony for small tasks. |
| Twenty-five releases shipped (v0.9.0–v0.12.2) with key features: the `/speckit.converge` convergence loop, the `specify bundle` role-based packaging subsystem, a programmable workflow engine (step catalog, JSON output, `from_json`), the git extension becoming opt-in (`--no-git` removed), and six new agents (Cline, rovodev, Zed, Firebender, ZCode, omp). The repo grew from ~107k to **~116,500 stars**. [\[github.com\]](https://github.com/github/spec-kit/releases) | The community extension catalog grew from 105 to **124 entries**; presets reached **23**. Microsoft's Developer Blog published a first-party SDD post naming Spec Kit as the operationalizing toolkit. Press coverage spanned dozens of languages. **245 contributors** now listed. | An enterprise (SNCF Connect & Tech) reported **2–4× velocity** from SDD. Analysts and comparisons increasingly name Spec Kit "the category anchor" and agent-neutral default. Competitors differentiate on brownfield and drift; balanced reviews continue to flag review-overload and ceremony for small tasks. |
***
> **Spec-Driven Development, Institutionalized.** If May was defined by milestone 100s, June was defined by validation from outside the project. Microsoft's own Developer Blog published a first-party post presenting spec-driven development and positioning Spec Kit as the toolkit that operationalizes it. An enterprise — SNCF Connect & Tech — went on the record with **2–4× velocity gains** from adopting SDD. A record **75 substantive articles** appeared in more than 25 languages, and the recurring verdict across independent comparisons was that Spec Kit is "the category anchor" and the agent-neutral default. Meanwhile the core matured from v0.9 to v0.12: the workflow engine became genuinely programmable, the first real breaking change shipped, and the new convergence loop and bundle subsystem gave the project answers to its two most-cited gaps — drift and distribution. None of this happens without the community — the contributors, extension and preset authors, bundle builders, and practitioners writing in a dozen languages. Thank you.
> **Spec-Driven Development, Institutionalized.** If May was defined by milestone 100s, June was defined by validation from outside the project. Microsoft's own Developer Blog published a first-party post presenting spec-driven development and positioning Spec Kit as the toolkit that operationalizes it. An enterprise — SNCF Connect & Tech — went on the record with **2–4× velocity gains** from adopting SDD. Coverage appeared in dozens of languages, and the recurring verdict across independent comparisons was that Spec Kit is "the category anchor" and the agent-neutral default. Meanwhile the core matured from v0.9 to v0.12: the workflow engine became genuinely programmable, the first real breaking change shipped, and the new convergence loop and bundle subsystem gave the project answers to its two most-cited gaps — drift and distribution. None of this happens without the community — the contributors, extension and preset authors, bundle builders, and practitioners writing in a dozen languages. Thank you.
## Spec Kit Project Updates
@@ -84,7 +84,7 @@ On **June 10**, the **Microsoft Developer Blog** published *"Spec-Driven Develop
### Press and Industry Coverage
June was the **highest-volume coverage month on record — 75 substantive articles** across more than 25 languages.
June's press coverage spanned dozens of languages and platforms.
**Xebia / XPRT Magazine #21** (Hidde de Smet & Emanuele Bartolesi, June 17) published a 32-minute full six-command walkthrough covering both greenfield and brownfield, honest about markdown-review overhead and where spec quality becomes the bottleneck. [\[xebia.com\]](https://xebia.com/blog/building-software-with-spec-kit/)
@@ -102,7 +102,7 @@ June was the **highest-volume coverage month on record — 75 substantive articl
### Developer Articles and Blog Posts
June's 75 articles skewed heavily multilingual, with deep hands-on series in Chinese, Japanese, and Korean, and a strong current of "which tool should I choose?" comparisons.
June's coverage skewed heavily multilingual, with deep hands-on series in Chinese, Japanese, and Korean, and a strong current of "which tool should I choose?" comparisons.
Notable English-language articles:
@@ -137,7 +137,7 @@ Coverage also appeared on TabNews (Portuguese), Habr and CSDN, note.com, Substac
### The Category Consolidates
Across June's record article volume, a consistent framing emerged: spec-driven development is now an established category, and Spec Kit is its reference implementation. SSOJet called it "the category anchor," Design News and multiple comparison pieces called it the agent-neutral default, and ToolTwist's CxO guide named it the "safe default for scaling teams." The Microsoft Developer Blog post and the SNCF enterprise interview extended that framing beyond the developer press into institutional and enterprise contexts. [\[ssojet.com\]](https://ssojet.com/blog/best-spec-driven-development-tools)
Across June's broad article coverage, a consistent framing emerged: spec-driven development is now an established category, and Spec Kit is its reference implementation. SSOJet called it "the category anchor," Design News and multiple comparison pieces called it the agent-neutral default, and ToolTwist's CxO guide named it the "safe default for scaling teams." The Microsoft Developer Blog post and the SNCF enterprise interview extended that framing beyond the developer press into institutional and enterprise contexts. [\[ssojet.com\]](https://ssojet.com/blog/best-spec-driven-development-tools)
### Competitive Landscape
+2 -2
View File
@@ -4,7 +4,7 @@ This edition covers Spec Kit activity in May 2026 — a month defined by three m
| **Spec Kit Core (May 2026)** | **Community & Content** | **SDD Ecosystem & Next** |
| --- | --- | --- |
| Fourteen releases shipped with key features: multi-install for concurrent agent integrations, constitution governance in implement, authentication provider registry, Hermes and Lingma agents, and a `__init__.py` decomposition series. The repo grew from ~92k to **106,951 stars**, crossing **100K** on May 21. [\[github.com\]](https://github.com/github/spec-kit/releases) | The community extension catalog crossed **100 entries** (now 105). Open Source Friday livestream drove a press wave: Visual Studio Magazine, DevOps.com, MarkTechPost, HackerNoon, and 25+ more articles — now tracked across multiple languages following an expanded discovery methodology. **217 contributors** now listed. | MarkTechPost called Spec Kit "the most community-adopted open-source option" for SDD. The Futurum Group's Mitch Ashley framed specs as "the unit of governance across agents and contributors." Truong Phung published a 61-min production playbook referencing Spec Kit. Competitors grew but differentiate on orchestration; Spec Kit leads in portability and community. |
| Fourteen releases shipped with key features: multi-install for concurrent agent integrations, constitution governance in implement, authentication provider registry, Hermes and Lingma agents, and a `__init__.py` decomposition series. The repo grew from ~92k to **106,951 stars**, crossing **100K** on May 21. [\[github.com\]](https://github.com/github/spec-kit/releases) | The community extension catalog crossed **100 entries** (now 105). Open Source Friday livestream drove a press wave: Visual Studio Magazine, DevOps.com, MarkTechPost, HackerNoon, and many more across multiple languages. **217 contributors** now listed. | MarkTechPost called Spec Kit "the most community-adopted open-source option" for SDD. The Futurum Group's Mitch Ashley framed specs as "the unit of governance across agents and contributors." Truong Phung published a 61-min production playbook referencing Spec Kit. Competitors grew but differentiate on orchestration; Spec Kit leads in portability and community. |
***
@@ -76,7 +76,7 @@ May produced the broadest press coverage to date, with publications from the mai
### Developer Articles and Blog Posts
May produced a wave of independent coverage — well beyond any previous month. Starting this month, article discovery was expanded beyond English-centric search engines to include language-appropriate engines for 25+ languages, so the broader coverage partly reflects wider discovery rather than a sudden spike.
May produced a wave of independent coverage across many languages.
Notable non-English coverage:
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.15.1"
version = "0.16.0"
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
readme = "README.md"
requires-python = ">=3.11"
+2
View File
@@ -77,7 +77,9 @@ from ._version import (
from ._agent_config import (
AGENT_CONFIG as AGENT_CONFIG,
DEFAULT_INIT_INTEGRATION as DEFAULT_INIT_INTEGRATION,
DEFAULT_INIT_INTEGRATION_ENV_VAR as DEFAULT_INIT_INTEGRATION_ENV_VAR,
SCRIPT_TYPE_CHOICES as SCRIPT_TYPE_CHOICES,
resolve_default_init_integration as resolve_default_init_integration,
)
from ._init_options import (
INIT_OPTIONS_FILE as INIT_OPTIONS_FILE,
+32
View File
@@ -1,6 +1,8 @@
"""Agent configuration constants derived from the integration registry."""
from __future__ import annotations
import os
import sys
from typing import Any
@@ -17,6 +19,36 @@ AGENT_CONFIG: dict[str, dict[str, Any]] = _build_agent_config()
DEFAULT_INIT_INTEGRATION = "copilot"
#: Environment variable used to override the fallback integration that
#: ``specify init`` selects in non-interactive sessions. Follows the existing
#: ``SPECKIT_INTEGRATION_*`` namespace (see ``SPECKIT_INTEGRATION_<KEY>_EXECUTABLE``
#: and ``SPECKIT_INTEGRATION_CATALOG_URL``).
DEFAULT_INIT_INTEGRATION_ENV_VAR = "SPECKIT_INTEGRATION_DEFAULT"
def resolve_default_init_integration() -> str:
"""Return the default init integration, honoring an env-var override.
Reads :data:`DEFAULT_INIT_INTEGRATION_ENV_VAR`
(``SPECKIT_INTEGRATION_DEFAULT``). When it names a registered integration
key, that key is returned; otherwise the hardcoded
:data:`DEFAULT_INIT_INTEGRATION` (``"copilot"``) is used. An invalid value
emits a warning to stderr rather than silently falling back, so operators
can tell a typo from an intentional default.
"""
override = (os.environ.get(DEFAULT_INIT_INTEGRATION_ENV_VAR) or "").strip()
if not override:
return DEFAULT_INIT_INTEGRATION
if override in AGENT_CONFIG:
return override
print(
f"Warning: {DEFAULT_INIT_INTEGRATION_ENV_VAR}='{override}' is not a "
f"recognized integration; falling back to '{DEFAULT_INIT_INTEGRATION}'. "
f"Choose from: {', '.join(sorted(AGENT_CONFIG.keys()))}.",
file=sys.stderr,
)
return DEFAULT_INIT_INTEGRATION
SCRIPT_TYPE_CHOICES: dict[str, str] = {
"sh": "POSIX Shell (bash/zsh)",
"ps": "PowerShell",
+6 -2
View File
@@ -157,7 +157,11 @@ class CommandRegistrar:
return ""
yaml_str = yaml.dump(
fm, default_flow_style=False, sort_keys=False, allow_unicode=True
fm,
default_flow_style=False,
sort_keys=False,
allow_unicode=True,
width=float("inf"),
)
return f"---\n{yaml_str}---\n"
@@ -679,7 +683,7 @@ class CommandRegistrar:
_integ = get_integration(agent_name)
if _integ is not None:
_sep = _integ.invoke_separator_for_mode(registrar_writes_skills)
except Exception:
except (ImportError, ValueError, KeyError):
pass
_prefix = get_invocation_prefix(agent_name, registrar_writes_skills)
+3 -3
View File
@@ -18,7 +18,7 @@ from urllib.request import url2pathname
from ..._assets import _locate_core_pack, _repo_root
from ..._download_security import MAX_JSON_CATALOG_BYTES, read_response_limited
from .. import BundlerError
from ..lib.yamlio import loads_json
from ..lib.yamlio import load_json, loads_json
from ..models.catalog import CatalogSource
from ..models.manifest import ComponentRef
@@ -145,13 +145,13 @@ def make_catalog_fetcher(*, allow_network: bool = True):
path = _file_url_to_path(parsed)
if not path.exists():
raise BundlerError(f"Catalog file not found: {path}")
return loads_json(path.read_text(encoding="utf-8"), origin=str(path))
return load_json(path)
if scheme == "" or _is_windows_drive_path(url):
path = Path(url)
if not path.exists():
raise BundlerError(f"Catalog file not found: {path}")
return loads_json(path.read_text(encoding="utf-8"), origin=str(path))
return load_json(path)
if scheme in ("http", "https"):
if not allow_network:
+5 -3
View File
@@ -93,9 +93,11 @@ def build_bundle(
# extraction, but collapse to two canonical modes (0755 when any
# execute bit is set on the source, otherwise 0644) so identical
# inputs yield a byte-for-byte identical artifact.
mode = 0o755 if file_path.stat().st_mode & 0o111 else 0o644
info.external_attr = mode << 16
archive.writestr(info, file_path.read_bytes())
with file_path.open("rb") as fh:
st = os.fstat(fh.fileno())
mode = 0o755 if st.st_mode & 0o111 else 0o644
info.external_attr = mode << 16
archive.writestr(info, fh.read())
return BuildResult(artifact_path=artifact_path, file_count=len(files))
+4 -2
View File
@@ -119,6 +119,8 @@ def _run_init(integration: str, *, script_type: str, offline: bool = False) -> N
preset=None,
integration=integration,
integration_options=None,
extensions=None,
trust_extension_urls=False,
)
except typer.Exit as exc:
if exc.exit_code:
@@ -129,13 +131,13 @@ def _run_init(integration: str, *, script_type: str, offline: bool = False) -> N
def _resolve_init_integration(override: str | None, manifest) -> str:
"""Precedence (FR-013): explicit override → bundle-declared → default."""
from ..._agent_config import DEFAULT_INIT_INTEGRATION
from ..._agent_config import resolve_default_init_integration
if override:
return override
if manifest is not None and manifest.integration is not None:
return manifest.integration.id
return DEFAULT_INIT_INTEGRATION
return resolve_default_init_integration()
# ===== Consume =====
+13 -2
View File
@@ -24,8 +24,19 @@ def event_run(
"""Resolve and run an event-driven command script with stdin payload."""
from ..events import resolve_and_run_event_command
# Read payload from stdin if available
payload = sys.stdin.read() if not sys.stdin.isatty() else "{}"
# Read payload from stdin if available (capped at 1 MiB to prevent DoS).
MAX_STDIN_BYTES = 1 * 1024 * 1024
if not sys.stdin.isatty():
raw = sys.stdin.read(MAX_STDIN_BYTES)
if not sys.stdin.eof:
raise typer.Exit(
code=1,
message="stdin payload exceeds 1 MiB limit; "
"truncate or pipe a smaller payload",
)
payload = raw
else:
payload = "{}"
# Run the event command
project_root = Path.cwd() # The agent runs events from project root
+220 -5
View File
@@ -14,8 +14,8 @@ from rich.panel import Panel
from .._agent_config import (
AGENT_CONFIG,
DEFAULT_INIT_INTEGRATION,
SCRIPT_TYPE_CHOICES,
resolve_default_init_integration,
)
from .._assets import (
_locate_bundled_preset,
@@ -30,6 +30,145 @@ def _stdin_is_interactive() -> bool:
return sys.stdin.isatty()
def _ext_spec_is_url(ext_spec: str) -> bool:
"""Return True when *ext_spec* is an http(s) URL rather than a name/path."""
from urllib.parse import urlparse
try:
return urlparse(ext_spec).scheme in ("http", "https")
except ValueError:
return False
def _confirm_extension_url_trust(
url_specs: list[str], *, trust_override: bool
) -> dict[str, bool]:
"""Resolve trust for each URL-based extension before the Live display.
URL installs pull an arbitrary external extension, so they get the same
default-deny confirmation as ``extension add --from``. Returns a mapping of
``url_spec -> approved``. With *trust_override* every URL is pre-approved.
In a non-interactive session without the override, every URL is denied
(the prompt cannot be answered), mirroring the default-deny posture.
"""
from rich.markup import escape as _escape_markup
from rich.panel import Panel
approvals: dict[str, bool] = {}
interactive = _stdin_is_interactive()
for spec in url_specs:
if trust_override:
approvals[spec] = True
continue
if not interactive:
approvals[spec] = False
continue
console.print()
console.print(
Panel(
"[bold]You are installing an extension from an external URL that is not\n"
"listed in any of your configured extension catalogs.[/bold]\n\n"
f"URL: {_escape_markup(spec)}\n\n"
"Only install extensions from sources you trust.",
title="[bold yellow]⚠ Untrusted Source[/bold yellow]",
border_style="yellow",
padding=(1, 2),
)
)
console.print()
approvals[spec] = typer.confirm(
f"Install extension from {spec}?", default=False
)
return approvals
def _install_extension_during_init(project_path: Path, ext_spec: str, speckit_version: str) -> str:
"""Install a single extension during ``specify init``.
Handles bundled extension names, local directory paths, and HTTPS URLs.
Returns a short status message on success.
Raises ``ValueError`` on failure so the caller can convert it to a
tracker error without aborting the entire init.
"""
from urllib.parse import urlparse
from .._assets import _locate_bundled_extension
from ..extensions import ExtensionCatalog, ExtensionError, ExtensionManager
from ..extensions._commands import (
_resolve_catalog_extension,
install_extension_from_url,
)
manager = ExtensionManager(project_path)
# --- URL ---
parsed = urlparse(ext_spec)
if parsed.scheme in ("http", "https"):
try:
manifest = install_extension_from_url(
manager, project_path, ext_spec, speckit_version
)
except ExtensionError as exc:
raise ValueError(str(exc)) from exc
return f"{manifest.name} v{manifest.version} installed"
# --- Local path ---
if ext_spec.startswith(("./", "../", "/", "~/", ".\\", "..\\")) or Path(ext_spec).is_absolute():
source_path = Path(ext_spec).expanduser().resolve()
if not source_path.exists():
raise ValueError(f"Directory not found: {source_path}")
if not (source_path / "extension.yml").exists():
raise ValueError(f"No extension.yml found in {source_path}")
manifest = manager.install_from_directory(source_path, speckit_version)
return f"{manifest.name} v{manifest.version} installed"
# --- Bundled extension name or catalog ID ---
bundled_path = _locate_bundled_extension(ext_spec)
if bundled_path is not None:
if manager.registry.is_installed(ext_spec):
return "already installed"
manifest = manager.install_from_directory(bundled_path, speckit_version)
return f"{manifest.name} v{manifest.version} installed"
# Fall back to catalog
catalog = ExtensionCatalog(project_path)
ext_info, catalog_error = _resolve_catalog_extension(ext_spec, catalog, "add")
if catalog_error:
raise ValueError(f"Could not query extension catalog: {catalog_error}")
if not ext_info:
raise ValueError(f"Extension '{ext_spec}' not found in bundled extensions or catalog")
resolved_id = ext_info["id"]
if resolved_id != ext_spec:
bundled_path = _locate_bundled_extension(resolved_id)
if bundled_path is not None:
if manager.registry.is_installed(resolved_id):
return "already installed"
manifest = manager.install_from_directory(bundled_path, speckit_version)
return f"{manifest.name} v{manifest.version} installed"
if ext_info.get("bundled") and not ext_info.get("download_url"):
from ..extensions import REINSTALL_COMMAND
raise ValueError(
f"Extension '{resolved_id}' is bundled with spec-kit but not found in the installed package. "
f"Try reinstalling spec-kit: {REINSTALL_COMMAND}"
)
if not ext_info.get("_install_allowed", True):
catalog_name = ext_info.get("_catalog_name", "community")
raise ValueError(
f"Extension '{ext_spec}' is in the '{catalog_name}' catalog but installation is not allowed from that catalog"
)
zip_path = catalog.download_extension(resolved_id)
try:
manifest = manager.install_from_zip(zip_path, speckit_version)
finally:
zip_path.unlink(missing_ok=True)
return f"{manifest.name} v{manifest.version} installed"
def ensure_constitution_from_template(
project_path: Path, tracker: StepTracker | None = None
) -> None:
@@ -142,6 +281,16 @@ def register(app: typer.Typer) -> None:
"--integration-options",
help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")',
),
extensions: list[str] | None = typer.Option(
None,
"--extension",
help="Install an extension during initialization (bundled name, local path, or HTTPS URL). Repeatable.",
),
trust_extension_urls: bool = typer.Option(
False,
"--trust-extension-urls",
help="Pre-authorize installing extensions from external URLs without the interactive trust prompt (required for non-interactive URL installs).",
),
):
"""
Initialize a new Specify project.
@@ -174,6 +323,10 @@ def register(app: typer.Typer) -> None:
specify init --here --integration gemini
specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/" # Bring your own agent; requires --commands-dir
specify init my-project --integration claude --preset healthcare-compliance # With preset
specify init my-project --integration copilot --extension git # With bundled extension
specify init my-project --extension git --extension selftest # Multiple extensions
specify init my-project --extension ./my-extensions/custom-ext # Local path extension
specify init my-project --extension https://example.com/extensions/my-ext.zip --trust-extension-urls # URL extension (non-interactive)
"""
# Lazy imports to avoid circular dependency — __init__.py imports this module
from .. import (
@@ -313,17 +466,18 @@ def register(app: typer.Typer) -> None:
raise typer.Exit(1)
selected_ai = integration
elif not _stdin_is_interactive():
default_integration = resolve_default_init_integration()
console.print(
f"[dim]Non-interactive session detected: defaulting to '{DEFAULT_INIT_INTEGRATION}'. "
f"[dim]Non-interactive session detected: defaulting to '{default_integration}'. "
"Use --integration to choose a different agent.[/dim]"
)
selected_ai = DEFAULT_INIT_INTEGRATION
selected_ai = default_integration
else:
ai_choices = {key: config["name"] for key, config in AGENT_CONFIG.items()}
selected_ai = select_with_arrows(
ai_choices,
"Choose your coding agent integration:",
DEFAULT_INIT_INTEGRATION,
resolve_default_init_integration(),
)
if not integration:
@@ -413,10 +567,31 @@ def register(app: typer.Typer) -> None:
("chmod", "Ensure scripts executable"),
("constitution", "Constitution setup"),
("workflow", "Install bundled workflow"),
("final", "Finalize"),
]:
tracker.add(key, label)
if extensions:
from rich.markup import escape as _escape_markup
for i, ext_spec in enumerate(extensions):
tracker.add(
f"extension-{i}", f"Install extension: {_escape_markup(ext_spec)}"
)
tracker.add("final", "Finalize")
# Resolve trust for URL-based extensions BEFORE entering the Live
# display: the confirmation prompt cannot be shown/answered underneath
# the Rich Live spinner. URL installs are default-deny unless the user
# confirms interactively or passes --trust-extension-urls.
extension_url_approvals: dict[str, bool] = {}
if extensions:
url_specs = [e for e in extensions if _ext_spec_is_url(e)]
if url_specs:
extension_url_approvals = _confirm_extension_url_trust(
url_specs, trust_override=trust_extension_urls
)
# Disable transient mode on Windows: PowerShell 5.1's legacy console
# hangs when Rich tries to restore cursor state via VT escape sequences.
_transient = sys.platform != "win32"
@@ -626,6 +801,46 @@ def register(app: typer.Typer) -> None:
continuing="Continuing without the optional preset.",
)
# Install extensions specified via --extension
if extensions:
from rich.markup import escape as _escape_markup
from ..extensions._commands import _refresh_events_and_warn
speckit_ver = get_speckit_version()
any_extension_installed = False
for i, ext_spec in enumerate(extensions):
tracker.start(f"extension-{i}")
# Skip URL extensions the user did not confirm as trusted
# (default-deny; resolved before the Live display).
if _ext_spec_is_url(ext_spec) and not extension_url_approvals.get(
ext_spec, False
):
tracker.error(
f"extension-{i}",
"skipped: untrusted URL not confirmed "
"(use --trust-extension-urls)",
)
continue
try:
status_msg = _install_extension_during_init(
project_path, ext_spec, speckit_ver
)
tracker.complete(f"extension-{i}", status_msg)
any_extension_installed = True
except Exception as ext_err:
sanitized_ext = str(ext_err).replace("\n", " ").strip()
tracker.error(
f"extension-{i}",
f"failed: {_escape_markup(sanitized_ext[:120])}",
)
# Refresh native event configuration once after the batch so
# that an extension declaring ``events:`` has its hooks
# activated, mirroring the ``extension add`` path.
if any_extension_installed:
_refresh_events_and_warn(project_path)
# Seed the constitution AFTER preset installation so that a
# preset-provided constitution-template (resolved via the
# priority stack) wins over the core template.
+280 -33
View File
@@ -251,7 +251,7 @@ def _resolve_argv(template_path, project_root, ext_id):
return [str(script_abs), *rest]
def _run_inline(command_name, payload, project_root, timeout):
def _run_inline(command_name, payload, project_root, timeout, envelope="plain", native_event=""):
"""Resolve and run the event command with stdlib only (no specify_cli)."""
template_path, ext_id = _find_command_template(command_name, project_root)
if not template_path:
@@ -269,7 +269,7 @@ def _run_inline(command_name, payload, project_root, timeout):
cwd=str(project_root),
)
if result.stdout:
sys.stdout.write(result.stdout)
_emit(result.stdout, envelope, native_event)
if result.returncode != 0:
if result.stderr:
sys.stderr.write(result.stderr)
@@ -283,6 +283,46 @@ def _run_inline(command_name, payload, project_root, timeout):
return 2
def _emit(output, envelope, native_event=""):
"""Write handler output to stdout in the agent's context-injection shape.
Not every agent injects a hook's plain-text stdout as model context:
Gemini/Tabnine/Qwen/Devin are JSON-only protocols (plain text becomes
user-facing noise, never context), Copilot discards non-JSON stdout, and
Cursor parses stdout as JSON. The native hook command passes the envelope
as the dispatcher's 5th argument (see events_context_envelope on the
integration classes), and the native event name as the 6th argument so
hookSpecificOutput can include hookEventName:
hookSpecificOutput → {"hookSpecificOutput": {"hookEventName": ..., "additionalContext": ...}}
additionalContext → {"additionalContext": ...} (top-level, Copilot)
additional_context → {"additional_context": ...} (top-level, Cursor)
suppress → emit nothing (strict-JSON agents on events whose
output can't be used)
plain (default) → passthrough (Claude/Codex inject plain stdout)
Empty output emits nothing under any envelope (an empty additionalContext
is useless noise).
"""
if not output:
return
if envelope == "suppress":
return
if envelope == "hookSpecificOutput":
payload = {"additionalContext": output}
if native_event:
payload["hookEventName"] = native_event
sys.stdout.write(json.dumps({"hookSpecificOutput": payload}) + "\\n")
return
if envelope == "additionalContext":
sys.stdout.write(json.dumps({"additionalContext": output}) + "\\n")
return
if envelope == "additional_context":
sys.stdout.write(json.dumps({"additional_context": output}) + "\\n")
return
sys.stdout.write(output)
def main():
if len(sys.argv) < 3:
sys.exit(0)
@@ -297,6 +337,16 @@ def main():
timeout = int(sys.argv[3])
except (TypeError, ValueError):
timeout = 120
# Optional 5th arg: context-injection envelope for stdout (C13): plain
# (default), hookSpecificOutput, additionalContext, additional_context,
# or suppress. Unknown values fall back to plain passthrough.
envelope = sys.argv[4] if len(sys.argv) >= 5 else "plain"
if envelope not in ("plain", "hookSpecificOutput", "additionalContext", "additional_context", "suppress"):
envelope = "plain"
# Optional 6th arg: native event name for hookSpecificOutput's
# hookEventName field (required by Qwen's hooks spec; included by
# Gemini/Tabnine/Devin which derive from the same protocol).
native_event = sys.argv[5] if len(sys.argv) >= 6 else ""
payload = sys.stdin.read() if not sys.stdin.isatty() else "{}"
project_root = Path(__file__).parent.parent.resolve()
@@ -307,14 +357,14 @@ def main():
from specify_cli.events import resolve_and_run_event_command
sys.exit(
resolve_and_run_event_command(
command_name, _event_name, payload, project_root, timeout=timeout
command_name, _event_name, payload, project_root, timeout=timeout, envelope=envelope, native_event=native_event
)
)
except ImportError:
except (ImportError, TypeError):
pass
# Fallback: self-contained stdlib resolver (one-time/temporary installs).
sys.exit(_run_inline(command_name, payload, project_root, timeout))
sys.exit(_run_inline(command_name, payload, project_root, timeout, envelope, native_event))
if __name__ == "__main__":
@@ -362,17 +412,21 @@ function resolveDispatcher(directory: string): void {{
) as string;
}}
function runEvent(command: string, event: string, input: any, output: any, timeoutSec: number): void {{
if (!DISPATCHER) return;
function runEvent(command: string, event: string, input: any, output: any, timeoutSec: number): string {{
if (!DISPATCHER) return '';
try {{
// execFileSync with an argv array invokes the interpreter directly — no
// shell — so command/event strings with metacharacters can't break out
// of the dispatcher argument (C9). The dispatcher arg is seconds; the
// execFileSync timeout is ms with a buffer so the outer cap fires after
// the dispatcher's inner subprocess (S3).
execFileSync(INTERPRETER, [DISPATCHER, command, event, String(timeoutSec)], {{
// the dispatcher's inner subprocess (S3). stdout is captured and
// returned so context-injection hooks (experimental.chat.system.transform,
// chat.message) can push it into their outputs; stderr stays inherited so
// dispatcher errors remain visible (C11).
return execFileSync(INTERPRETER, [DISPATCHER, command, event, String(timeoutSec)], {{
input: JSON.stringify({{ input, output }}),
stdio: ['pipe', 'inherit', 'inherit'],
stdio: ['pipe', 'pipe', 'inherit'],
encoding: 'utf-8',
timeout: (timeoutSec + {buffer}) * 1000,
}});
}} catch (e) {{
@@ -382,6 +436,12 @@ function runEvent(command: string, event: string, input: any, output: any, timeo
}}
}}
// Cache session_start handler output per sessionID so non-idempotent
// handlers (setup, telemetry, file-mutating scripts) run once per session
// instead of on every LLM request (experimental.chat.system.transform
// fires per LLM turn). Evicted on session.deleted.
const sessionStartCache = new Map<string, string>();
{event_entries}
export default (async ({{ client, project, directory, $ }}) => {{
@@ -524,7 +584,13 @@ def _resolve_event_command_argv(
else:
base = project_root / ".specify"
tokens = shlex.split(script_cmd, posix=(os.name != "nt"))
try:
tokens = shlex.split(script_cmd, posix=(os.name != "nt"))
except ValueError:
# Mirror the generated dispatcher's _resolve_argv: a scripts: value
# shlex cannot tokenize (e.g. an unclosed quote) declares no runnable
# script, so degrade to "no argv" instead of raising.
return None
if not tokens:
return None
script_abs = base / tokens[0]
@@ -585,12 +651,26 @@ def resolve_and_run_event_command(
project_root: Path,
*,
timeout: int = 120,
envelope: str = "plain",
native_event: str = "",
) -> int:
"""Core entry point to resolve and execute an event-driven command.
*timeout* is the per-handler timeout in seconds, passed through from the
native hook config via the dispatcher (S4) so a handler configured above
the previous fixed 120s cap can run for its full duration.
*envelope* selects how the handler's stdout is emitted for the agent's
context-injection protocol (C13): ``plain`` passthrough (Claude/Codex
inject plain stdout), ``hookSpecificOutput``/``additionalContext``/
``additional_context`` JSON wrappers (Gemini/Tabnine/Qwen/Devin, Copilot,
Cursor respectively), or ``suppress`` (strict-JSON agents on events whose
output can't be used).
*native_event* is the agent's native hookEventName (e.g. ``"SessionStart"``),
required inside ``hookSpecificOutput`` by Qwen's hooks spec (and included
by the Claude Code hooks spec Gemini/Tabnine/Devin derive from). Only
used when *envelope* is ``hookSpecificOutput``.
"""
template_path, ext_id = _find_command_template(command_name, project_root)
if not template_path:
@@ -610,7 +690,7 @@ def resolve_and_run_event_command(
cwd=str(project_root),
)
if result.stdout:
sys.stdout.write(result.stdout)
_emit_event_stdout(result.stdout, envelope, native_event)
if result.returncode != 0:
if result.stderr:
sys.stderr.write(result.stderr)
@@ -624,6 +704,35 @@ def resolve_and_run_event_command(
return 2
def _emit_event_stdout(output: str, envelope: str, native_event: str = "") -> None:
"""Write handler stdout in the agent's context-injection shape (C13).
Mirrors the ``_emit`` helper inside the generated dispatcher template;
keep both in sync. Empty output emits nothing under any envelope.
*native_event* is the agent's native hookEventName, required inside
``hookSpecificOutput`` by Qwen's hooks spec (and included by the
Claude Code hooks spec Gemini/Tabnine/Devin derive from).
"""
if not output:
return
if envelope == "suppress":
return
if envelope == "hookSpecificOutput":
payload = {"additionalContext": output}
if native_event:
payload["hookEventName"] = native_event
sys.stdout.write(json.dumps({"hookSpecificOutput": payload}) + "\n")
return
if envelope == "additionalContext":
sys.stdout.write(json.dumps({"additionalContext": output}) + "\n")
return
if envelope == "additional_context":
sys.stdout.write(json.dumps({"additional_context": output}) + "\n")
return
sys.stdout.write(output)
# -- Sourcing events map (CLI/Orchestration domain) -------------------------
# Resolved events map: each canonical event name maps to an *ordered list* of
@@ -737,8 +846,10 @@ def resolve_events(
if override_file.exists():
try:
override = yaml.safe_load(override_file.read_text(encoding="utf-8")) or {}
except yaml.YAMLError:
logger.warning("Could not parse %s; ignoring override", override_file)
except (OSError, UnicodeError, yaml.YAMLError):
logger.warning(
"Could not read or parse %s; ignoring override", override_file
)
override = {}
integrations = override.get("integrations", {}) if isinstance(override, dict) else {}
if isinstance(integrations, dict) and integration_key in integrations:
@@ -895,7 +1006,7 @@ def collect_extension_events(project_root: Path) -> ResolvedEvents:
continue
try:
data = yaml.safe_load(ext_yml.read_text(encoding="utf-8")) or {}
except yaml.YAMLError:
except (UnicodeDecodeError, yaml.YAMLError):
continue
if not isinstance(data, dict):
continue
@@ -1016,7 +1127,17 @@ def _dispatcher_command(
When *timeout_seconds* is given, the resolved timeout (in the
integration's native unit) is appended as a 4th argument so the dispatcher
and inner runner honor the per-handler timeout instead of a fixed 120s cap
that would kill a handler configured for longer (S4).
that would kill a handler configured for longer (S4). When omitted, a
default of 60s is emitted so the positional argument order
(command event timeout envelope native_event) stays aligned — otherwise
the envelope would land in the timeout slot and the dispatcher would
silently fall back to plain stdout.
When the integration declares a context-injection envelope for this
canonical event (``events_context_envelope``, C13), the envelope token is
appended as a 5th argument so the dispatcher wraps stdout in the JSON
shape the agent's hook protocol requires. Plain-passthrough agents
(Claude/Codex) declare no envelope and get no extra argument.
"""
if target_os == "host":
interpreter = _resolve_interpreter(project_root)
@@ -1036,16 +1157,44 @@ def _dispatcher_command(
# operator. Prefix & for the explicit windows target only.
prefix = "& " if target_os == "windows" else ""
base = f"{prefix}{q_interp} {dispatcher} {q_command} {q_event}"
if timeout_seconds is not None:
# R2: the dispatcher interprets this argument as seconds, so pass the
# raw seconds — NOT _native_timeout(...) (which converts to ms for
# Gemini/Qwen/Tabnine and would yield 60000 seconds). The buffer is
# applied to the native hook timeout field (in the adapter formatters)
# so the agent's outer cap fires after the inner subprocess timeout.
base += f" {_shell_quote(str(int(timeout_seconds)), target_os)}"
# Always emit the timeout (4th positional arg) so the dispatcher's argv
# parsing stays aligned when an envelope (5th) or native_event (6th)
# follows. Without it the envelope would land in the timeout slot and
# the dispatcher would fall back to plain stdout (R3).
resolved_timeout = 60 if timeout_seconds is None else int(timeout_seconds)
# R2: the dispatcher interprets this argument as seconds, so pass the
# raw seconds — NOT _native_timeout(...) (which converts to ms for
# Gemini/Qwen/Tabnine and would yield 60000 seconds). The buffer is
# applied to the native hook timeout field (in the adapter formatters)
# so the agent's outer cap fires after the inner subprocess timeout.
base += f" {_shell_quote(str(resolved_timeout), target_os)}"
envelope = _context_envelope_for(integration, event_name)
if envelope:
base += f" {_shell_quote(envelope, target_os)}"
# hookSpecificOutput requires the native hookEventName inside the
# envelope (Qwen's hooks spec marks it mandatory; the Claude Code
# hooks spec that Gemini/Tabnine/Devin derive from includes it).
# Append the native event name as a 6th dispatcher argument so the
# dispatcher can populate hookEventName in the JSON output.
if envelope == "hookSpecificOutput":
native_event = getattr(integration, "CANONICAL_TO_NATIVE", {}).get(event_name, "")
if native_event:
base += f" {_shell_quote(native_event, target_os)}"
return base
def _context_envelope_for(integration: IntegrationBase, canonical_event: str) -> str | None:
"""Resolve the context-injection envelope for an integration + event (C13).
The event key wins; ``"*"`` is the fallback. Returns ``None`` when the
integration declares no envelope for the event (plain stdout passthrough).
"""
mapping = getattr(integration, "events_context_envelope", None) or {}
if canonical_event in mapping:
return mapping[canonical_event]
return mapping.get("*")
def install_integration_events(
integration: IntegrationBase,
project_root: Path,
@@ -1193,11 +1342,12 @@ def install_integration_events(
lines.append(f'timeout = {_native_timeout(integration, cfg.get("timeout", 60) + EVENT_TIMEOUT_BUFFER)}')
lines.append('speckit_marker = true')
lines.append('')
_merge_toml_fragment(config_path, "\n".join(lines))
rel = str(config_path.relative_to(project_root))
if rel not in manifest.files:
manifest.record_existing(rel)
created.append(config_path)
# S5: only track when the merge wrote (skips on unreadable file).
if _merge_toml_fragment(config_path, "\n".join(lines)):
rel = str(config_path.relative_to(project_root))
if rel not in manifest.files:
manifest.record_existing(rel)
created.append(config_path)
elif fmt == "json-flat":
# Cursor hooks.json custom merge. Flat command-string entries, one
@@ -1588,6 +1738,14 @@ def _build_opencode_plugin(
an argv array (C9). Both the ``input`` and ``output`` callback arguments
are forwarded to ``runEvent`` (C7) so pre_tool_use can inspect tool
arguments and post_tool_use can inspect the result.
Context-injection natives get dedicated hook bodies: for
``experimental.chat.system.transform`` the handlers' concatenated stdout
is pushed into ``output.system`` (system-prompt injection, re-applied per
LLM request so the context survives compaction); for ``chat.message`` it
is pushed as a synthetic text part on the user message (C11). Other
natives keep their side-effect behavior (tool.execute.* args mutation;
session.* lifecycle events via the generic ``event`` hook).
"""
event_entries: list[str] = []
plugin_returns: list[str] = []
@@ -1602,11 +1760,16 @@ def _build_opencode_plugin(
ev_lit = json.dumps(ev)
native_lit = json.dumps(native)
is_injection = native in ("experimental.chat.system.transform", "chat.message")
# Build the body: one runEvent() call per handler wrapped in try/catch,
# forwarding both input and output (C7). An optional tool-name matcher
# guard applies to tool.execute.* hooks. All handlers execute before
# any aggregate error is thrown.
# any aggregate error is thrown. Injection hooks additionally collect
# each handler's stdout and return the concatenation.
body_lines: list[str] = [" const errors: string[] = [];"]
if is_injection:
body_lines.append(" const contexts: string[] = [];")
for cfg in handlers:
command = str(cfg.get("command", ""))
command_lit = json.dumps(command)
@@ -1628,6 +1791,10 @@ def _build_opencode_plugin(
body_lines.append(
f" try {{ runEvent({command_lit}, {ev_lit}, input, output, {timeout_sec}); }} catch (e) {{ errors.push((e as Error).message); }}"
)
elif is_injection:
body_lines.append(
f" try {{ const ctx = runEvent({command_lit}, {ev_lit}, input, output, {timeout_sec}); if (ctx) contexts.push(ctx); }} catch (e) {{ errors.push((e as Error).message); }}"
)
else:
body_lines.append(
f" try {{ runEvent({command_lit}, {ev_lit}, input, output, {timeout_sec}); }} catch (e) {{ errors.push((e as Error).message); }}"
@@ -1646,14 +1813,65 @@ def _build_opencode_plugin(
f" _{ev}(input, output);\n"
f" }},"
)
elif native == "experimental.chat.system.transform":
body_lines.append(' return contexts.join("\\n\\n");')
event_entries.append(
f"function _{ev}(input: any, output: any): string {{\n"
+ "\n".join(body_lines) + "\n"
" }"
)
# OpenCode fires experimental.chat.system.transform for non-session
# operations (e.g. agent generation) with no sessionID. Guard so
# canonical session_start handlers only run when a session is
# present, preventing their output from being injected into
# internal prompts. Cache the handler output per sessionID so
# non-idempotent handlers (setup, telemetry, file-mutating
# scripts) execute once per session instead of on every LLM
# request; the cache is evicted on session.deleted.
plugin_returns.append(
f" {native_lit}: async (input: any, output: any) => {{\n"
f" if (!input.sessionID) return;\n"
f" let ctx = sessionStartCache.get(input.sessionID);\n"
f" if (ctx === undefined) {{\n"
f" ctx = _{ev}(input, output);\n"
f" sessionStartCache.set(input.sessionID, ctx ?? \"\");\n"
f" }}\n"
f" if (ctx) output.system.push(ctx);\n"
f" }},"
)
elif native == "chat.message":
body_lines.append(' return contexts.join("\\n\\n");')
event_entries.append(
f"function _{ev}(input: any, output: any): string {{\n"
+ "\n".join(body_lines) + "\n"
" }"
)
# Part id must start with "prt" (opencode's Identifier brand): an
# invalid id fails the user-part schema validation and crashes the
# whole session (C12). Derive it from the last existing part so the
# brand survives an opencode prefix change, falling back to "prt_"
# when output.parts is empty.
plugin_returns.append(
f" {native_lit}: async (input: any, output: any) => {{\n"
f" const ctx = _{ev}(input, output);\n"
f" if (!ctx) return;\n"
f" const base = output.parts[output.parts.length - 1]?.id ?? \"prt_\" + Date.now().toString(36) + Math.random().toString(36).slice(2, 10);\n"
f" output.parts.push({{ id: base + \".speckit\" + Math.random().toString(36).slice(2, 8), sessionID: input.sessionID, messageID: output.message.id, type: \"text\", text: ctx, synthetic: true }});\n"
f" }},"
)
else:
event_entries.append(
f"function _{ev}(input: any, output: any) {{\n"
+ "\n".join(body_lines) + "\n"
" }"
)
# Evict the sessionStartCache when the session is deleted so the
# cache doesn't grow unbounded across sessions.
eviction = ""
if native == "session.deleted":
eviction = "if (event.sessionID) sessionStartCache.delete(event.sessionID); "
event_handlers.append(
f" if (event.type === {native_lit}) {{ _{ev}(event, event); }}"
f" if (event.type === {native_lit}) {{ {eviction}_{ev}(event, event); }}"
)
if event_handlers:
@@ -1715,11 +1933,27 @@ def _remove_opencode_entries(config_path: Path) -> bool:
return False
def _merge_toml_fragment(dst: Path, fragment: str) -> None:
def _merge_toml_fragment(dst: Path, fragment: str) -> bool:
"""Merge Specify-owned TOML entries into *dst*, regenerating the file.
An unreadable or undecodable pre-existing file aborts the merge instead
of discarding the user's bytes, mirroring ``_load_user_json`` (#22).
Returns False when skipped so callers avoid tracking the untouched file
(S5).
"""
_ensure_safe_destination(dst)
existing = ""
if dst.exists():
existing = dst.read_text(encoding="utf-8")
try:
existing = dst.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as exc:
logger.warning(
"Could not read %s (it may be unreadable or not UTF-8); "
"skipping event-config merge to preserve user content.",
dst,
)
logger.debug("Read error detail: %s", exc)
return False
existing = re.sub(
r'\[\[hooks\.\w+\]\]\n(?:(?!\[\[hooks\.\w+\]\]).)*?speckit_marker = true\n*',
"",
@@ -1728,6 +1962,7 @@ def _merge_toml_fragment(dst: Path, fragment: str) -> None:
)
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_text(existing.rstrip() + "\n\n" + fragment + "\n", encoding="utf-8")
return True
def _remove_toml_entries(dst: Path) -> bool:
@@ -1741,7 +1976,19 @@ def _remove_toml_entries(dst: Path) -> bool:
# the config after install can't make teardown overwrite a file outside
# the project (the merge/write path already validates; teardown must too).
_ensure_safe_destination(dst)
existing = dst.read_text(encoding="utf-8")
try:
existing = dst.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as exc:
# An unreadable or undecodable file is left untouched rather than
# crashing teardown — it contains only user content as far as we can
# tell, and the caller drops the manifest claim either way (S9).
logger.warning(
"Could not read %s (it may be unreadable or not UTF-8); "
"skipping event-config cleanup to preserve user content.",
dst,
)
logger.debug("Read error detail: %s", exc)
return False
cleaned = re.sub(
r'\[\[hooks\.\w+\]\]\n(?:(?!\[\[hooks\.\w+\]\]).)*?speckit_marker = true\n*',
"",
+238 -6
View File
@@ -285,9 +285,25 @@ class ExtensionManifest:
raise ValidationError(
f"Invalid extension: expected a mapping, got {type(ext).__name__}"
)
# Check presence AND type: the format/version checks below feed these
# values straight to ``re.match`` and ``packaging.Version``, both of
# which raise a bare TypeError on a non-string. YAML makes that an easy
# authoring slip -- unquoted ``version: 1.0`` parses as a float and
# ``id: 2`` as an int -- and TypeError is not a ValidationError, so it
# escapes every caller that already handles a malformed manifest (see
# list_installed()'s "Corrupted extension" fallback, which catches
# ValidationError only, making one bad extension exit ``specify
# extension list`` with a raw traceback and hide the healthy ones).
# Mirrors the sibling IntegrationDescriptor, which already type-checks
# the same four fields.
for field in ["id", "name", "version", "description"]:
if field not in ext:
raise ValidationError(f"Missing extension.{field}")
if not isinstance(ext[field], str):
raise ValidationError(
f"Invalid extension.{field}: expected a string, "
f"got {type(ext[field]).__name__}"
)
# Validate extension ID format
if not re.match(r"^[a-z0-9-]+$", ext["id"]):
@@ -325,6 +341,25 @@ class ExtensionManifest:
)
if "speckit_version" not in requires:
raise ValidationError("Missing requires.speckit_version")
# Presence alone is not enough: check_compatibility() feeds this value to
# ``SpecifierSet(required)``, guarded only by ``except InvalidSpecifier``,
# which a non-string escapes two different ways. A float/int/bool/None
# raises TypeError from the constructor, while a list or dict is an
# *iterable*, so SpecifierSet accepts it and the failure surfaces much
# later as ``AttributeError: 'str' object has no attribute 'filter'`` from
# inside .contains(). Neither is a CompatibilityError, so both bypass the
# CLI's "Compatibility Error" handler and exit 1 with a raw traceback
# naming no field. An unquoted ``speckit_version: 1.0`` is an easy YAML
# slip. Mirrors the sibling IntegrationDescriptor, which already requires
# a non-empty string here.
if (
not isinstance(requires["speckit_version"], str)
or not requires["speckit_version"].strip()
):
raise ValidationError(
"Invalid requires.speckit_version: expected a non-empty string, "
f"got {type(requires['speckit_version']).__name__}"
)
# Validate provides section
provides = self.data["provides"]
@@ -391,6 +426,16 @@ class ExtensionManifest:
)
if "name" not in cmd or "file" not in cmd:
raise ValidationError("Command missing 'name' or 'file'")
# The pattern match below would raise a bare TypeError on a
# non-string name (``name: 2``), escaping the ValidationError
# contract. The 'file' field needs no check here:
# relative_extension_path_violation() below already rejects a
# non-string value.
if not isinstance(cmd["name"], str):
raise ValidationError(
f"Invalid command name: expected a string, "
f"got {type(cmd['name']).__name__}"
)
# Validate the 'file' field at manifest-load time using the single
# shared policy in relative_extension_path_violation(), so manifest
@@ -562,6 +607,14 @@ class ExtensionManifest:
"""Get list of provided commands."""
return self.data.get("provides", {}).get("commands", [])
@property
def config(self) -> List[Dict[str, Any]]:
"""Get list of provided config templates, normalized to dictionaries."""
raw = self.data.get("provides", {}).get("config", [])
if not isinstance(raw, list) or not all(isinstance(entry, dict) for entry in raw):
return []
return raw
@property
def hooks(self) -> Dict[str, Any]:
"""Get hook definitions."""
@@ -1817,6 +1870,17 @@ class ExtensionManager:
required = manifest.requires_speckit_version
# Parse version specifier (e.g., ">=0.1.0,<2.0.0")
# Defense in depth: the manifest validator now rejects a non-string
# requires.speckit_version, but this method is public and also reachable
# with a hand-built manifest object. ``InvalidSpecifier`` alone does not
# cover a non-string -- scalars raise TypeError from the constructor, and
# a list/dict is iterable so it constructs here and only breaks inside
# .contains(). Reject up front so this always reports a CompatibilityError.
if not isinstance(required, str):
raise CompatibilityError(
"Invalid version specifier: expected a string, got "
f"{type(required).__name__} ({required!r})"
)
try:
SpecifierSet(required) # Just to validate
except InvalidSpecifier:
@@ -2059,8 +2123,19 @@ class ExtensionManager:
_staged_modes = _loaded_modes
for staged_name in sorted(staged_names):
staged_file = rescue_staging_dir / staged_name
staged_stat = staged_file.stat()
staged_bytes = staged_file.read_bytes()
# A staged backup that cannot be read or stat'ed must not
# crash the retry with a raw OSError: like an uncomparable
# live config below, treat it as a conflict so both copies
# are preserved and the user resolves it while dest_dir is
# still untouched. Every sibling read in this path (live
# twin, packaged baseline, mode sidecar) already catches
# OSError.
try:
staged_stat = staged_file.stat()
staged_bytes = staged_file.read_bytes()
except OSError:
conflicting.add(staged_name)
continue
# Prefer the sidecar-recorded mode; fall back to the staged
# file's own mode for backwards-compat with staging dirs
# written before the sidecar was introduced.
@@ -2152,10 +2227,24 @@ class ExtensionManager:
"a regular file or remove it — then reinstall."
)
if cfg_file.is_file():
stranded_configs[cfg_file.name] = (
cfg_file.read_bytes(),
cfg_file.stat().st_mode,
)
# A kept config that cannot be read or stat'ed must not
# crash the reinstall with a raw OSError — and must not
# reach the rmtree below unrescued. Like the symlink
# guard above, reject while dest_dir is untouched so the
# preserved bytes are never lost.
try:
stranded_configs[cfg_file.name] = (
cfg_file.read_bytes(),
cfg_file.stat().st_mode,
)
except OSError as exc:
raise ValidationError(
"Preserved extension config for "
f"'{manifest.id}' cannot be read "
f"({cfg_file.name}) in {dest_dir}: {exc}. "
"Resolve manually — fix its permissions or "
"remove it — then reinstall."
) from exc
if stranded_configs and not staging_is_complete:
# Write a durable backup outside dest_dir before any
@@ -2489,6 +2578,149 @@ class ExtensionManager:
extension_dir, speckit_version, priority=priority, force=force
)
def _config_root_is_contained(self, specify_dir: Path) -> bool:
"""Report whether `.specify` is a real directory inside the project.
Checked component by component so a symlink anywhere on the path is
rejected before it becomes the containment root. A missing `.specify`
is fine: scaffolding creates it under the project root.
"""
try:
root = self.project_root.resolve()
except OSError:
return False
current = self.project_root
for part in specify_dir.relative_to(self.project_root).parts:
current = current / part
if current.is_symlink():
return False
if not current.exists():
return True
try:
if current.resolve().relative_to(root) is None:
return False
except (OSError, ValueError):
return False
return current.is_dir()
@staticmethod
def _target_follows_preserved_convention(target_name: str) -> bool:
"""True when a scaffold target survives remove/backup/restore.
Those paths only handle top-level ``*-config.yml`` and
``*-config.local.yml`` files, so anything nested or otherwise named is
not preserved across an update.
"""
if "/" in target_name or "\\" in target_name:
return False
return target_name.endswith("-config.yml") or target_name.endswith(
"-config.local.yml"
)
def scaffold_config(self, extension_id: str) -> tuple[List[str], List[str], List[str]]:
"""Deploy config templates from an installed extension to the project.
Reads the extension's manifest provides.config section and copies
each config template to the project's .specify/ directory. Existing
config files are never overwritten (user customizations are preserved).
Args:
extension_id: ID of the installed extension
Returns:
Tuple of (deployed, skipped_existing, failed) where each is a list
of config file names.
"""
ext_dir = self.extensions_dir / extension_id
manifest_path = ext_dir / "extension.yml"
if not manifest_path.exists():
return [], [], []
manifest = ExtensionManifest(manifest_path)
deployed = []
skipped_existing = []
failed = []
provides = manifest.data.get("provides", {})
raw_config = provides.get("config", [])
config_is_malformed = (
"config" in provides
and (
not isinstance(raw_config, list)
or not all(isinstance(entry, dict) for entry in raw_config)
)
)
if config_is_malformed:
return deployed, skipped_existing, ["provides.config"]
ext_dir_resolved = ext_dir.resolve()
# Config is deployed beneath the extension's own directory because that
# is where it is read from: ConfigManager._get_project_config() loads
# `.specify/extensions/<id>/<id>-config.yml`, and the bundled scripts
# and READMEs use the same location. Writing to `.specify/<name>` put
# the file somewhere nothing ever looks.
config_dir = self.project_root / ".specify" / "extensions" / extension_id
# Resolving that directory and trusting the result as the containment
# root lets a symlinked component point outside the project: every
# target would then satisfy relative_to and copy2 would write
# externally. Refuse a symlinked component up front, matching the
# project safe-write path in shared_infra.
if not self._config_root_is_contained(config_dir):
return deployed, skipped_existing, ["provides.config"]
config_dir_resolved = config_dir.resolve()
for config_entry in manifest.config:
template_name = config_entry.get("template", "")
target_name = config_entry.get("name", template_name)
failure_name = target_name if isinstance(target_name, str) and target_name else "provides.config"
if not isinstance(template_name, str) or not template_name:
failed.append(failure_name)
continue
if not isinstance(target_name, str) or not target_name:
failed.append(failure_name)
continue
# Only scaffold what removal actually preserves. remove(keep_config)
# keeps top-level files ending in -config.yml / -config.local.yml and
# rmtree's every subdirectory; the backup path globs the same
# top-level pattern. A nested or differently-named target would be
# silently destroyed by `extension add --force` and replaced with the
# template default, losing the user's customization.
if not self._target_follows_preserved_convention(target_name):
failed.append(failure_name)
continue
template_candidate = ext_dir / template_name
template_path = template_candidate.resolve()
target_path = (config_dir / target_name).resolve()
try:
template_path.relative_to(ext_dir_resolved)
target_path.relative_to(config_dir_resolved)
except ValueError:
failed.append(failure_name)
continue
if template_candidate.is_symlink() or not template_path.is_file():
failed.append(failure_name)
continue
if target_path.exists():
skipped_existing.append(target_name)
continue
try:
# mkdir belongs inside the handler: a nested target like
# foo/config.yml must land in `failed` when `.specify/foo` is a
# file or cannot be created, not raise out of scaffolding after
# `extension add` has already installed the extension.
target_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(template_path, target_path)
except OSError:
failed.append(target_name)
continue
deployed.append(target_name)
return deployed, skipped_existing, failed
def install_from_zip(
self,
zip_path: Path,
+194 -128
View File
@@ -95,6 +95,141 @@ def _refresh_events_and_warn(project_root: Path) -> None:
console.print(f" {key}: {_escape_markup(detail)}")
def install_extension_from_url(
manager,
project_root: Path,
url: str,
speckit_version: str,
*,
priority: int = 10,
force: bool = False,
):
"""Download an archive from *url* and install it, reusing the hardened path.
Shares the same download hardening as ``extension add --from``:
HTTPS enforcement, the catalog's authenticated + redirect-guarded
``_open_url`` fetch, a bounded (50 MiB) response read, archive-format
detection (ZIP or tar.gz/tgz), and a TOCTOU-safe transient download file
consumed directly by ``install_from_zip``.
Returns the installed manifest. Raises ``ExtensionError`` on any failure so
callers can present a uniform message without a second downloader.
"""
import urllib.error
from . import ExtensionCatalog, ExtensionError
if not is_https_or_localhost_http(url):
raise ExtensionError(
"URL must use HTTPS (HTTP is only allowed for localhost)"
)
download_dir = _validate_safe_cache_dir(project_root)
archive_filename = f"extension-url-download-{uuid4().hex}.archive"
# Only used for diagnostic messages: the real archive is a transient inode
# (unlinked on POSIX, O_TEMPORARY on Windows) consumed via ``archive_file``
# below, so this path is never opened again.
archive_path = download_dir / archive_filename
try:
dl_catalog = ExtensionCatalog(project_root)
download_url = url
extra_headers = None
resolved_url = dl_catalog._resolve_github_release_asset_api_url(download_url)
if resolved_url:
download_url = resolved_url
extra_headers = {"Accept": "application/octet-stream"}
with dl_catalog._open_url(
download_url, timeout=60, extra_headers=extra_headers
) as response:
archive_data = read_response_limited(
response,
error_type=ExtensionError,
label=f"extension {url}",
)
final_url = (
response.geturl() if hasattr(response, "geturl") else download_url
)
content_type = (
response.getheader("Content-Type")
if hasattr(response, "getheader")
else None
)
except urllib.error.URLError as exc:
raise ExtensionError(f"Failed to download from {url}: {exc}") from exc
download_fd = -1
download_file = None
try:
try:
download_fd = _safe_open_download_zip(
project_root, download_dir, archive_filename
)
except OSError as exc:
raise ExtensionError(
f"Could not safely create download file: {exc}"
) from exc
try:
download_file = os.fdopen(download_fd, "w+b")
download_fd = -1
download_file.write(archive_data)
download_file.flush()
download_file.seek(0)
except OSError as exc:
raise ExtensionError(
f"Could not safely write download file: {exc}"
) from exc
format_source = (
final_url
if archive_format_from_name(final_url) is not None
else url
)
try:
detect_archive_format(
archive_path,
archive_file=download_file,
source_name=format_source,
content_type=content_type,
error_type=ExtensionError,
)
except ExtensionError as exc:
raise ExtensionError(
f"{url} did not return a ZIP archive or tar.gz/tgz archive "
f"(got {len(archive_data)} bytes). This usually means the request "
"was not authenticated and a login/HTML page was returned. "
"Verify the URL and configured credentials."
) from exc
# Consume the transient inode reserved above rather than reopening the
# cache pathname during extraction.
try:
return manager.install_from_zip(
archive_path,
speckit_version,
priority=priority,
force=force,
archive_file=download_file,
)
except OSError as exc:
raise ExtensionError(
f"Could not install extension from downloaded archive: {exc}"
) from exc
finally:
if download_file is not None:
try:
download_file.close()
except OSError:
pass
elif download_fd >= 0:
try:
os.close(download_fd)
except OSError:
pass
def _load_catalog_command_config(project_root: Path, config_path: Path) -> dict:
"""Load extension catalog CLI config with user-facing shape errors."""
try:
@@ -810,134 +945,20 @@ def extension_add(
)
elif from_url:
# Install from an archive URL.
import urllib.error
# Install from URL archive via the shared hardened downloader
# (HTTPS enforcement, authenticated redirect-guarded fetch,
# bounded read, archive-format detection, TOCTOU-safe transient
# archive). Same path used by ``specify init --extension <url>``.
console.print(f"Downloading from {safe_url}...")
manifest = install_extension_from_url(
manager,
project_root,
from_url,
speckit_version,
priority=priority,
force=force,
)
download_dir = _validate_safe_cache_dir(project_root)
archive_filename = f"extension-url-download-{uuid4().hex}.archive"
# Only used for diagnostic messages: the real archive is a
# transient inode (unlinked on POSIX, O_TEMPORARY on Windows)
# consumed via ``archive_file`` below, so this path is never
# opened again.
archive_path = download_dir / archive_filename
try:
# Use the catalog's authenticated fetch so configured
# credentials (incl. GitHub Enterprise Server) are applied
# and GHES release-asset URLs resolve via /api/v3 — keeping
# --from consistent with catalog-based installs.
dl_catalog = ExtensionCatalog(project_root)
download_url = from_url
extra_headers = None
resolved_url = dl_catalog._resolve_github_release_asset_api_url(download_url)
if resolved_url:
download_url = resolved_url
extra_headers = {"Accept": "application/octet-stream"}
with dl_catalog._open_url(
download_url, timeout=60, extra_headers=extra_headers
) as response:
archive_data = read_response_limited(
response,
error_type=ExtensionError,
label=f"extension {from_url}",
)
final_url = (
response.geturl()
if hasattr(response, "geturl")
else download_url
)
content_type = (
response.getheader("Content-Type")
if hasattr(response, "getheader")
else None
)
download_fd = -1
download_file = None
try:
try:
download_fd = _safe_open_download_zip(
project_root, download_dir, archive_filename
)
except OSError as exc:
console.print(
"[red]Error:[/red] Could not safely create download file: "
f"{_escape_markup(str(exc))}"
)
raise typer.Exit(1)
try:
download_file = os.fdopen(download_fd, "w+b")
download_fd = -1
download_file.write(archive_data)
download_file.flush()
download_file.seek(0)
except OSError as exc:
console.print(
"[red]Error:[/red] Could not safely write download file: "
f"{_escape_markup(str(exc))}"
)
raise typer.Exit(1)
format_source = (
final_url
if archive_format_from_name(final_url) is not None
else from_url
)
try:
detect_archive_format(
archive_path,
archive_file=download_file,
source_name=format_source,
content_type=content_type,
error_type=ExtensionError,
)
except ExtensionError:
console.print(
f"[red]Error:[/red] {safe_url} did not return a ZIP archive "
"or tar.gz/tgz archive "
f"(got {len(archive_data)} bytes). This usually means "
"the request was not authenticated and a login/HTML page was "
"returned. Verify the URL and configured credentials."
)
raise typer.Exit(1)
# Consume the transient inode reserved above rather
# than reopening the cache pathname during extraction.
try:
manifest = manager.install_from_zip(
archive_path,
speckit_version,
priority=priority,
force=force,
archive_file=download_file,
)
except OSError as exc:
console.print(
"[red]Error:[/red] Could not install extension from downloaded archive: "
f"{_escape_markup(str(exc))}"
)
raise typer.Exit(1)
finally:
if download_file is not None:
try:
download_file.close()
except OSError:
pass
elif download_fd >= 0:
try:
os.close(download_fd)
except OSError:
pass
except urllib.error.URLError as e:
console.print(
f"[red]Error:[/red] Failed to download from {safe_url}: "
f"{_escape_markup(str(e))}"
)
raise typer.Exit(1)
else:
# Try bundled extensions first (shipped with spec-kit)
bundled_path = _locate_bundled_extension(extension)
@@ -1050,8 +1071,29 @@ def extension_add(
if reg_skills:
console.print(f"\n[green]✓[/green] {len(reg_skills)} agent skill(s) auto-registered")
console.print("\n[yellow]⚠[/yellow] Configuration may be required")
console.print(f" Check: .specify/extensions/{_escape_markup(str(manifest.id))}/")
# Scaffold config templates automatically
deployed, skipped, failed = manager.scaffold_config(manifest.id)
config_home = f".specify/extensions/{_escape_markup(str(manifest.id))}"
if deployed:
console.print("\n[bold cyan]Config scaffolded:[/bold cyan]")
for cfg in deployed:
console.print(f" • {config_home}/{_escape_markup(str(cfg))}")
if skipped:
console.print(f"\n[dim]Config files already exist (preserved): {_escape_markup(', '.join(skipped))}[/dim]")
if failed:
console.print(
f"\n[yellow]Warning:[/yellow] Config templates not scaffolded: "
f"{_escape_markup(', '.join(failed))}. "
"Verify the extension manifest and template files."
)
# Only warn when configuration is actually unresolved. Scaffolding that
# deployed or preserved every template has already answered this, and an
# extension without provides.config has nothing to configure; the blanket
# warning contradicted the output directly above it.
if failed or not (deployed or skipped):
console.print("\n[yellow]⚠[/yellow] Configuration may be required")
console.print(f" Check: {config_home}/")
except ValidationError as e:
console.print(f"\n[red]Validation Error:[/red] {_escape_markup(str(e))}")
@@ -2531,6 +2573,30 @@ def extension_enable(
# are re-emitted in installed integrations.
_refresh_events_and_warn(project_root)
# Scaffold config templates on enable
try:
deployed, skipped, failed = manager.scaffold_config(extension_id)
except Exception as exc:
console.print(
f"\n[yellow]Warning:[/yellow] Failed to scaffold config for extension "
f"'{_escape_markup(str(display_name))}'."
)
console.print(f"[dim]Details: {_escape_markup(str(exc))}[/dim]")
deployed, skipped, failed = [], [], []
config_home = f".specify/extensions/{_escape_markup(str(extension_id))}"
if deployed:
console.print("\n[bold cyan]Config scaffolded:[/bold cyan]")
for cfg in deployed:
console.print(f" • {config_home}/{_escape_markup(str(cfg))}")
if skipped:
console.print(f"\n[dim]Config files already exist (preserved): {_escape_markup(', '.join(skipped))}[/dim]")
if failed:
console.print(
f"\n[yellow]Warning:[/yellow] Config templates not scaffolded: "
f"{_escape_markup(', '.join(failed))}. "
"Verify the extension manifest and template files."
)
@extension_app.command("disable")
def extension_disable(
+2 -2
View File
@@ -70,8 +70,8 @@ def with_integration_setting(
# ``script_type`` changes (``parsed_options`` and ``raw_options`` both
# None), the previously-stored ``parsed_options`` are retained above, so
# deriving the separator from the argument (None) would drop an
# options-dependent separator (e.g. Copilot ``--skills`` -> "-") back to
# the default ".".
# options-dependent separator (e.g. Copilot ``--commands`` -> ".") back to
# the default "-".
current["invoke_separator"] = integration.effective_invoke_separator(
current.get("parsed_options"), project_root
)
@@ -331,6 +331,14 @@ def integration_switch(
selected_script = _resolve_script_type(project_root, script)
# Resolve and validate target options before uninstalling the current
# integration. Invalid options must not leave the project partially
# switched with the previous integration already removed.
target_raw_options, target_parsed_options = _resolve_integration_options(
target_integration, current, target, integration_options
)
target_integration.is_skills_mode(target_parsed_options, project_root)
# Phase 1: Uninstall current integration (if any)
if installed_key:
current_integration = get_integration(installed_key)
@@ -403,7 +411,10 @@ def integration_switch(
fallback_key = installed_keys[0]
fallback_integration = get_integration(fallback_key)
if fallback_integration is not None:
raw_options, parsed_options = _resolve_integration_options(
(
fallback_raw_options,
fallback_parsed_options,
) = _resolve_integration_options(
fallback_integration, current, fallback_key, None
)
_set_default_integration_or_exit(
@@ -412,8 +423,8 @@ def integration_switch(
fallback_key,
fallback_integration,
installed_keys,
raw_options=raw_options,
parsed_options=parsed_options,
raw_options=fallback_raw_options,
parsed_options=fallback_parsed_options,
)
else:
_write_integration_json(
@@ -423,13 +434,6 @@ def integration_switch(
_remove_integration_json(project_root)
current = _read_integration_json(project_root)
# Build parsed options from --integration-options so the integration
# can determine its effective invoke separator before shared infra
# is installed.
raw_options, parsed_options = _resolve_integration_options(
target_integration, current, target, integration_options
)
# Refresh shared infrastructure to the current CLI version. Switching
# integrations is exactly when stale vendored shared scripts (e.g.
# update-agent-context.sh that pre-dates the target integration's
@@ -445,11 +449,11 @@ def integration_switch(
force=refresh_shared_infra,
refresh_managed=True,
invoke_separator=_invoke_separator_for_integration(
target_integration, current, target, parsed_options,
target_integration, current, target, target_parsed_options,
project_root=project_root,
),
invoke_prefix=_invoke_prefix_for_integration(
target_integration, target, parsed_options, project_root
target_integration, target, target_parsed_options, project_root
),
refresh_hint=(
"To overwrite customizations, re-run with "
@@ -471,14 +475,14 @@ def integration_switch(
target_integration.key,
target_integration.config,
project_root,
parsed_options,
target_parsed_options,
)
try:
target_integration.setup(
project_root, manifest,
parsed_options=parsed_options,
parsed_options=target_parsed_options,
script_type=selected_script,
raw_options=raw_options,
raw_options=target_raw_options,
events=events_map,
)
manifest.save()
@@ -489,8 +493,8 @@ def integration_switch(
target_integration,
_dedupe_integration_keys([*installed_keys, target_integration.key]),
script_type=selected_script,
raw_options=raw_options,
parsed_options=parsed_options,
raw_options=target_raw_options,
parsed_options=target_parsed_options,
)
except Exception as exc:
+16 -2
View File
@@ -225,8 +225,8 @@ class IntegrationBase(ABC):
on-disk layout to avoid silently migrating an existing project to a
different mode. The default ignores it.
The default (command-first integrations, e.g. Copilot's default
layout) is skills mode only when ``--skills`` was requested.
The default for command-first integrations is skills mode only when
``--skills`` was requested.
``SkillsIntegration`` overrides this to return ``True`` by default;
skills-first integrations that expose a legacy opt-out (e.g. Bob)
override it to honor their own flag.
@@ -957,6 +957,20 @@ class IntegrationBase(ABC):
"""Return True if this integration supports agent-native events."""
return bool(getattr(self, "CANONICAL_TO_NATIVE", None) and getattr(self, "events_config_file", None))
# Context-injection envelope for hook stdout, keyed by canonical event
# (with "*" as the fallback). Not every agent injects a hook's plain-text
# stdout as model context: Gemini/Tabnine/Qwen/Devin are JSON-only
# protocols (plain text becomes user-facing noise), Copilot discards
# non-JSON stdout, and Cursor parses stdout as JSON. Values:
# "hookSpecificOutput" → {"hookSpecificOutput": {"additionalContext": ...}}
# "additionalContext" → {"additionalContext": ...} (top-level, Copilot)
# "additional_context" → {"additional_context": ...} (top-level, Cursor)
# "suppress" → emit nothing (strict-JSON agents on events whose
# output can't be used)
# Absent (no matching key and no "*") → plain stdout passthrough
# (Claude/Codex inject plain stdout; opencode injects via its TS plugin).
events_context_envelope: dict[str, str] = {}
# -- Convenience helpers for subclasses -------------------------------
def install(
+153 -61
View File
@@ -1,13 +1,19 @@
"""Copilot integration — GitHub Copilot in VS Code.
Copilot has several unique behaviors compared to standard markdown agents:
Copilot supports two layouts:
- Skills are the default and use ``speckit-<name>/SKILL.md`` directories under
``.github/skills/``
- ``--commands`` uses ``.agent.md`` files, companion ``.prompt.md`` files, and
a VS Code settings merge
The two modes are mutually exclusive. The commands layout remains supported,
but is no longer the preferred default.
The commands layout has several unique behaviors compared to standard markdown
agents:
- Commands use ``.agent.md`` extension (not ``.md``)
- Each command gets a companion ``.prompt.md`` file in ``.github/prompts/``
- Installs ``.vscode/settings.json`` with prompt file recommendations
When ``--skills`` is passed via ``--integration-options``, Copilot scaffolds
commands as ``speckit-<name>/SKILL.md`` directories under ``.github/skills/``
instead. The two modes are mutually exclusive.
"""
from __future__ import annotations
@@ -19,9 +25,24 @@ import warnings
from pathlib import Path
from typing import Any
import typer
from ..base import IntegrationBase, IntegrationOption, SkillsIntegration
from ..manifest import IntegrationManifest
_COPILOT_CORE_COMMANDS = {
"analyze",
"checklist",
"clarify",
"constitution",
"converge",
"implement",
"plan",
"specify",
"tasks",
"taskstoissues",
}
def _copilot_executable() -> str:
"""Return the executable name for Copilot CLI on this platform.
@@ -57,22 +78,24 @@ def _allow_all() -> bool:
return True
def _warn_legacy_markdown_default() -> None:
"""Warn that Copilot's default markdown scaffold is being phased out."""
warnings.warn(
"Copilot legacy markdown mode is deprecated and will stop being the "
'default in a future Spec Kit release; pass --integration-options "--skills" '
"to opt in to Copilot skills mode now.",
UserWarning,
stacklevel=3,
)
def _validate_mode_options(parsed_options: dict[str, Any] | None) -> None:
"""Reject the two explicit Copilot layout selectors used together."""
opts = parsed_options or {}
if opts.get("skills") and opts.get("commands"):
from ..._console import console
console.print(
"[red]Error:[/red] --skills and --commands are mutually exclusive; "
"pass only one."
)
raise typer.Exit(1)
class _CopilotSkillsHelper(SkillsIntegration):
"""Internal helper used when Copilot is scaffolded in skills mode.
Not registered in the integration registry — only used as a delegate
by ``CopilotIntegration`` when ``--skills`` is passed.
Not registered in the integration registry — only used as the default
skills-layout delegate by ``CopilotIntegration``.
"""
key = "copilot"
@@ -94,13 +117,11 @@ class _CopilotSkillsHelper(SkillsIntegration):
class CopilotIntegration(IntegrationBase):
"""Integration for GitHub Copilot (VS Code IDE + CLI).
The IDE integration (``requires_cli: False``) installs ``.agent.md``
command files. Workflow dispatch additionally requires the
``copilot`` CLI to be installed separately.
When ``--skills`` is passed via ``--integration-options``, commands
are scaffolded as ``speckit-<name>/SKILL.md`` under ``.github/skills/``
instead of the default ``.agent.md`` + ``.prompt.md`` layout.
The default IDE integration (``requires_cli: False``) installs skills under
``.github/skills/``. Pass ``--commands`` via ``--integration-options`` to
install the supported ``.agent.md`` + ``.prompt.md`` layout instead.
Workflow dispatch additionally requires the ``copilot`` CLI to be installed
separately.
"""
key = "copilot"
@@ -117,6 +138,7 @@ class CopilotIntegration(IntegrationBase):
"args": "$ARGUMENTS",
"extension": ".agent.md",
}
invoke_separator = "-"
CANONICAL_TO_NATIVE = {
"session_start": "sessionStart",
@@ -130,40 +152,101 @@ class CopilotIntegration(IntegrationBase):
}
events_config_file = ".github/hooks/speckit.json"
events_format = "copilot-json"
# Copilot sessionStart and userPromptSubmitted inject a top-level
# additionalContext field into the model-facing prompt (C13). Non-JSON
# stdout is discarded harmlessly by Copilot on other events, so no other
# event needs an envelope.
events_context_envelope = {
"session_start": "additionalContext",
"user_prompt_submit": "additionalContext",
}
# Mutable flag set by setup() — indicates the active scaffolding mode.
_skills_mode: bool = False
_skills_mode: bool = True
def effective_invoke_separator(
self,
parsed_options: dict[str, Any] | None = None,
project_root: Path | None = None,
) -> str:
"""Return ``"-"`` when skills mode is requested, ``"."`` otherwise."""
if parsed_options and parsed_options.get("skills"):
return "-"
if self._skills_mode:
return "-"
return self.invoke_separator
"""Return the separator for the resolved Copilot layout."""
return "-" if self.is_skills_mode(parsed_options, project_root) else "."
def is_skills_mode(
self,
parsed_options: dict[str, Any] | None = None,
project_root: Path | None = None,
) -> bool:
"""Copilot is skills mode when ``--skills`` was requested.
"""Copilot defaults to skills; ``--commands`` opts into commands mode.
On the init path ``setup()`` has already recorded the choice in
``self._skills_mode``; on the ``use``/``install`` path (where no
``setup()`` runs) the signal comes from *parsed_options* (#3550), which
round-trips because ``--skills`` is persisted in the stored options.
Explicit flags override on-disk detection. Without a flag, existing
projects retain their managed Spec Kit layout while fresh projects use
skills. This prevents ``use`` and ``upgrade`` from silently migrating
projects created before skills became the default.
"""
if parsed_options and parsed_options.get("skills"):
opts = parsed_options or {}
_validate_mode_options(opts)
if opts.get("skills"):
return True
return self._skills_mode
if opts.get("commands"):
return False
if project_root is not None:
project_root = Path(project_root)
manifest_path = (
project_root
/ ".specify"
/ "integrations"
/ "copilot.manifest.json"
)
if manifest_path.is_file():
try:
manifest_files = IntegrationManifest.load(
self.key, Path(project_root)
).files
except (OSError, ValueError):
manifest_files = None
if manifest_files is not None and any(
path.startswith(".github/skills/speckit-")
and path.endswith("/SKILL.md")
for path in manifest_files
):
return True
if manifest_files is not None and any(
path.startswith(".github/agents/speckit.")
and path.endswith(".agent.md")
for path in manifest_files
):
return False
github_dir = project_root / ".github"
has_managed_skills = any(
(
github_dir
/ "skills"
/ f"speckit-{command}"
/ "SKILL.md"
).is_file()
for command in _COPILOT_CORE_COMMANDS
)
has_managed_commands = any(
(
github_dir
/ "agents"
/ f"speckit.{command}.agent.md"
).is_file()
or (
github_dir
/ "prompts"
/ f"speckit.{command}.prompt.md"
).is_file()
for command in _COPILOT_CORE_COMMANDS
)
if has_managed_commands and not has_managed_skills:
return False
return True
def invoke_separator_for_mode(self, skills_enabled: bool) -> str:
"""Skills projects render ``/speckit-<cmd>``; default markdown ``.``.
"""Skills projects render ``/speckit-<cmd>``; commands use ``.``.
Copilot is dual-layout, so — like Bob — the command-reference
separator depends on the persisted ``ai_skills`` state rather than a
@@ -171,7 +254,7 @@ class CopilotIntegration(IntegrationBase):
Copilot skills project consistent with ``build_command_invocation``
(which emits ``/speckit-<stem>``).
"""
return "-" if skills_enabled else self.invoke_separator
return "-" if skills_enabled else "."
@classmethod
def options(cls) -> list[IntegrationOption]:
@@ -184,7 +267,22 @@ class CopilotIntegration(IntegrationBase):
"--skills",
is_flag=True,
default=False,
help="Scaffold commands as agent skills (speckit-<name>/SKILL.md) instead of .agent.md files",
help=(
"Force the default skills layout (.github/skills/), "
"overriding on-disk auto-detection"
),
),
)
opts.append(
IntegrationOption(
"--commands",
is_flag=True,
default=False,
help=(
"Scaffold .github/agents/*.agent.md commands with companion "
".github/prompts/*.prompt.md files instead of the default "
"skills layout"
),
),
)
return opts
@@ -228,8 +326,8 @@ class CopilotIntegration(IntegrationBase):
def build_command_invocation(self, command_name: str, args: str = "") -> str:
"""Build the native invocation for a Copilot command.
Default mode: agents are not slash-commands — return args as prompt.
Skills mode: ``/speckit-<stem>`` slash-command dispatch.
Commands mode: agents are not slash-commands — return args as prompt.
Skills mode (default): ``/speckit-<stem>`` slash-command dispatch.
"""
if self._skills_mode:
stem = command_name
@@ -266,15 +364,11 @@ class CopilotIntegration(IntegrationBase):
if stem.startswith("speckit."):
stem = stem[len("speckit."):]
# Detect skills mode from project layout when not set via setup()
skills_mode = self._skills_mode
if not skills_mode and project_root:
skills_dir = project_root / ".github" / "skills"
if skills_dir.is_dir():
skills_mode = any(
d.is_dir() and (d / "SKILL.md").is_file()
for d in skills_dir.glob("speckit-*")
)
skills_mode = (
self.is_skills_mode(project_root=project_root)
if project_root
else self._skills_mode
)
if skills_mode:
prompt = "/speckit-" + stem.replace(".", "-")
@@ -366,20 +460,18 @@ class CopilotIntegration(IntegrationBase):
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
"""Install copilot commands, companion prompts, and VS Code settings.
"""Install Copilot skills or the opt-in commands layout.
When ``parsed_options["skills"]`` is truthy, delegates to skills
scaffolding (``speckit-<name>/SKILL.md`` under ``.github/skills/``).
Otherwise uses the default ``.agent.md`` + ``.prompt.md`` layout.
Skills are the default. ``parsed_options["commands"]`` selects
``.agent.md`` files, companion prompts, and the VS Code settings merge.
Existing managed command layouts are preserved when no mode is explicit.
"""
parsed_options = parsed_options or {}
self._skills_mode = bool(parsed_options.get("skills"))
self._skills_mode = self.is_skills_mode(parsed_options, project_root)
if self._skills_mode:
created = self._setup_skills(project_root, manifest, parsed_options, **opts)
else:
if "skills" not in parsed_options:
_warn_legacy_markdown_default()
created = self._setup_default(project_root, manifest, parsed_options, **opts)
created = self._setup_commands(project_root, manifest, parsed_options, **opts)
# Install agent runtime events
event_files = self.emit_events(
@@ -388,14 +480,14 @@ class CopilotIntegration(IntegrationBase):
created.extend(event_files)
return created
def _setup_default(
def _setup_commands(
self,
project_root: Path,
manifest: IntegrationManifest,
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
"""Default mode: .agent.md + .prompt.md + VS Code settings merge."""
"""Commands mode: .agent.md + .prompt.md + VS Code settings merge."""
project_root_resolved = project_root.resolve()
if manifest.project_root != project_root_resolved:
raise ValueError(
@@ -48,6 +48,14 @@ class CursorAgentIntegration(SkillsIntegration):
}
events_config_file = ".cursor/hooks.json"
events_format = "json-flat"
# Cursor sessionStart injects a top-level additional_context (snake_case)
# field (C13). beforeSubmitPrompt has no context output field (block/allow
# only), and plain text on any hook fails Cursor's JSON parse — suppress
# everything else.
events_context_envelope = {
"*": "suppress",
"session_start": "additional_context",
}
def build_exec_args(
self,
@@ -44,6 +44,13 @@ class DevinIntegration(SkillsIntegration):
# top-level "hooks" wrapper (U2), unlike the settings.json formats. The
# json-root-nested writer/remover operate directly on the root event keys.
events_format = "json-root-nested"
# Devin's hooks protocol is JSON-stdout; additionalContext is the
# documented injection field for SessionStart/UserPromptSubmit (C13).
events_context_envelope = {
"*": "suppress",
"session_start": "hookSpecificOutput",
"user_prompt_submit": "hookSpecificOutput",
}
def build_exec_args(
self,
@@ -33,6 +33,15 @@ class GeminiIntegration(TomlIntegration):
}
events_config_file = ".gemini/settings.json"
events_format = "json-nested"
# Gemini mandates JSON-only hook stdout ("silence is mandatory"): plain
# text becomes a user-facing systemMessage, never context. Inject via
# hookSpecificOutput.additionalContext on the two context events and
# suppress stdout everywhere else (C13).
events_context_envelope = {
"*": "suppress",
"session_start": "hookSpecificOutput",
"user_prompt_submit": "hookSpecificOutput",
}
# Gemini measures hook timeouts in milliseconds, unlike Claude/Cursor/Codex
# which use seconds. The shared formatter converts via _native_timeout (#7)
# so the default 60s becomes 60000ms instead of terminating the dispatcher
@@ -317,7 +317,7 @@ def _is_speckit_generated_skill(skill_dir: Path) -> bool:
try:
content = skill_file.read_text(encoding="utf-8")
except OSError:
except (OSError, UnicodeError):
return False
if not content.startswith("---"):
@@ -23,7 +23,15 @@ class OpencodeIntegration(MarkdownIntegration):
CANONICAL_TO_NATIVE = {
"pre_tool_use": "tool.execute.before",
"post_tool_use": "tool.execute.after",
"session_start": "session.created",
# session_start maps to the system-prompt transform hook (not the
# session.created event) so the handler's stdout is injected into the
# system prompt — session.created has no output channel. The hook
# fires per LLM request, which keeps the context present across
# compaction at the cost of running the handler per turn.
"session_start": "experimental.chat.system.transform",
# user_prompt_submit maps to chat.message so handler stdout is
# injected as a synthetic text part on the user's message.
"user_prompt_submit": "chat.message",
"session_end": "session.deleted",
}
events_config_file = "opencode.json"
@@ -30,6 +30,12 @@ class QwenIntegration(MarkdownIntegration):
}
events_config_file = ".qwen/settings.json"
events_format = "json-nested"
# Qwen hooks are a JSON stdin/stdout protocol (Gemini-derived) (C13).
events_context_envelope = {
"*": "suppress",
"session_start": "hookSpecificOutput",
"user_prompt_submit": "hookSpecificOutput",
}
# Qwen Code's command hooks measure timeout in milliseconds (default
# 60000), per the Qwen Code hooks documentation. Declaring the unit makes
# the shared formatter convert the 60s default to 60000ms instead of
@@ -33,6 +33,12 @@ class TabnineIntegration(TomlIntegration):
}
events_config_file = ".tabnine/agent/settings.json"
events_format = "json-nested"
# Tabnine is Gemini-hooks-compatible (JSON-only stdout) (C13).
events_context_envelope = {
"*": "suppress",
"session_start": "hookSpecificOutput",
"user_prompt_submit": "hookSpecificOutput",
}
# Tabnine mirrors Gemini's hook schema (BeforeTool/AfterTool) and, like
# Gemini, measures hook timeouts in milliseconds. Declaring the unit makes
# the shared formatter convert the 60s default to 60000ms instead of
+125 -8
View File
@@ -299,11 +299,33 @@ class PresetManifest:
f"(expected {self.SCHEMA_VERSION})"
)
for section in ("preset", "requires", "provides"):
if not isinstance(self.data[section], dict):
raise PresetValidationError(
f"Invalid {section}: expected a mapping"
)
# Validate preset metadata
pack = self.data["preset"]
# Check presence AND type: the format/version checks below feed these
# values straight to ``re.match`` and ``packaging.Version``, both of
# which raise a bare TypeError on a non-string. YAML makes that an easy
# authoring slip -- unquoted ``version: 1.0`` parses as a float and
# ``id: 2`` as an int -- and TypeError is not a PresetValidationError,
# so it escapes every caller that already handles a malformed manifest
# (see list_installed()'s "Corrupted preset" fallback, which catches
# PresetValidationError only, making one bad preset exit ``specify
# preset list`` with a raw traceback and hide the healthy ones).
# Mirrors the sibling IntegrationDescriptor, which already type-checks
# the same four fields.
for field in ["id", "name", "version", "description"]:
if field not in pack:
raise PresetValidationError(f"Missing preset.{field}")
if not isinstance(pack[field], str):
raise PresetValidationError(
f"Invalid preset.{field}: expected a string, "
f"got {type(pack[field]).__name__}"
)
# Validate pack ID format
if not re.match(r'^[a-z0-9-]+$', pack["id"]):
@@ -322,6 +344,25 @@ class PresetManifest:
requires = self.data["requires"]
if "speckit_version" not in requires:
raise PresetValidationError("Missing requires.speckit_version")
# Presence alone is not enough: check_compatibility() feeds this value to
# ``SpecifierSet(required)``, guarded only by ``except InvalidSpecifier``,
# which a non-string escapes two different ways. A float/int/bool/None
# raises TypeError from the constructor, while a list or dict is an
# *iterable*, so SpecifierSet accepts it and the failure surfaces much
# later as ``AttributeError: 'str' object has no attribute 'filter'`` from
# inside .contains(). Neither is a PresetCompatibilityError, so both
# bypass the CLI's "Compatibility Error" handler and exit 1 with a raw
# traceback naming no field. An unquoted ``speckit_version: 1.0`` is an
# easy YAML slip. Mirrors the sibling IntegrationDescriptor, which already
# requires a non-empty string here.
if (
not isinstance(requires["speckit_version"], str)
or not requires["speckit_version"].strip()
):
raise PresetValidationError(
"Invalid requires.speckit_version: expected a non-empty string, "
f"got {type(requires['speckit_version']).__name__}"
)
# Validate provides section
provides = self.data["provides"]
@@ -361,6 +402,19 @@ class PresetManifest:
"Template missing 'type', 'name', or 'file'"
)
# 'name' feeds re.match and 'file' feeds os.path.normpath below;
# both raise a bare TypeError on a non-string, which is not a
# PresetValidationError and so escapes the callers that handle a
# malformed manifest. The sibling extension manifest already
# rejects a non-string command 'file' via
# relative_extension_path_violation().
for field in ("type", "name", "file"):
if not isinstance(tmpl[field], str):
raise PresetValidationError(
f"Invalid template {field}: expected a string, "
f"got {type(tmpl[field]).__name__}"
)
if tmpl["type"] not in VALID_PRESET_TEMPLATE_TYPES:
raise PresetValidationError(
f"Invalid template type '{tmpl['type']}': "
@@ -496,7 +550,12 @@ class PresetRegistry:
if not isinstance(data.get("presets"), dict):
data["presets"] = {}
return data
except (json.JSONDecodeError, FileNotFoundError):
except (json.JSONDecodeError, UnicodeDecodeError, FileNotFoundError):
# Corrupted or missing registry, start fresh. A registry whose
# bytes cannot be decoded as UTF-8 is the same corruption class
# as malformed JSON — only the exception type differs. OSError is
# deliberately not caught: the data may be intact on disk, and
# starting fresh would let a later _save() wipe it.
return {
"schema_version": self.SCHEMA_VERSION,
"presets": {}
@@ -721,6 +780,18 @@ class PresetManager:
PresetCompatibilityError: If pack is incompatible
"""
required = manifest.requires_speckit_version
# Defense in depth: the manifest validator now rejects a non-string
# requires.speckit_version, but this method is public and also reachable
# with a hand-built manifest object. ``InvalidSpecifier`` alone does not
# cover a non-string -- scalars raise TypeError from the constructor, and
# a list/dict is iterable so it constructs here and only breaks inside
# .contains(). Reject up front so this always reports a
# PresetCompatibilityError.
if not isinstance(required, str):
raise PresetCompatibilityError(
"Invalid version specifier: expected a string, got "
f"{type(required).__name__} ({required!r})"
)
try:
SpecifierSet(required) # Just to validate
except InvalidSpecifier:
@@ -2151,7 +2222,10 @@ class PresetManager:
]
if dir_core_ext_names:
self._unregister_skills_in_dir(
dir_core_ext_names, skills_dir, dir_agent
dir_core_ext_names,
skills_dir,
dir_agent,
restore_from_bundled_core=True,
)
for _skill_name, cmd_name, top_layer in override_skills:
@@ -2986,6 +3060,7 @@ class PresetManager:
preset_dir: Union[Path, str],
*,
additional_owned_sources: Optional[Dict[str, str]] = None,
restore_from_bundled_core: bool = False,
) -> Dict[Path, tuple[Optional[str], List[str]]]:
"""Restore original SKILL.md files after a preset is removed.
@@ -2993,6 +3068,17 @@ class PresetManager:
regenerate the skill from the core command template. If no core
template exists, the skill directory is removed.
Args:
restore_from_bundled_core: When True, a missing project-local
core template (the common case — ``specify init`` never
populates ``.specify/templates/commands``) falls back to
the bundled core_pack/repo-root templates so the skill is
restored instead of deleted (#3928). Callers that are
retiring a skill because its command now renders elsewhere
(a command file superseding it) must leave this False so
the skill is removed rather than resurrected with core
content that would duplicate the winning command.
``registered_skills`` records exactly which agent directories this
preset actually wrote to (see :meth:`_register_skills`), so removal
restores precisely those directories rather than guessing at every
@@ -3066,6 +3152,7 @@ class PresetManager:
renderer_agent,
pack_id=pack_id,
additional_owned_sources=additional_owned_sources,
restore_from_bundled_core=restore_from_bundled_core,
)
if mutated_names:
restored[skills_dir] = (
@@ -3098,6 +3185,7 @@ class PresetManager:
selected_ai,
pack_id=pack_id,
additional_owned_sources=additional_owned_sources,
restore_from_bundled_core=restore_from_bundled_core,
)
return (
{skills_dir: (selected_ai, mutated_names)}
@@ -3171,6 +3259,7 @@ class PresetManager:
*,
pack_id: Optional[str] = None,
additional_owned_sources: Optional[Dict[str, str]] = None,
restore_from_bundled_core: bool = False,
) -> List[str]:
"""Restore original SKILL.md files within a single skills directory.
@@ -3181,6 +3270,7 @@ class PresetManager:
placeholder resolution and argument-hint formatting.
additional_owned_sources: Generated non-preset source markers
accepted as owned for specific skill names.
restore_from_bundled_core: See ``_unregister_skills``.
Returns:
Skill names whose files were restored or removed.
@@ -3256,9 +3346,34 @@ class PresetManager:
if current_source not in owned_sources:
continue
# Try to find the core command template
core_file = core_templates_dir / f"{short_name}.md" if core_templates_dir.exists() else None
if core_file and not core_file.exists():
extension_restore = extension_restore_index.get(skill_name)
# Try to find the core command template. Project-local overrides
# in core_templates_dir take precedence, but that directory is
# rarely populated — the real core commands ship in the bundled
# core_pack (wheel install) or the repo-root templates/ tree
# (source checkout). Callers that want a genuine restore (a
# preset was removed outright, not superseded by another
# renderer) opt into that fallback via restore_from_bundled_core
# so the skill is restored instead of deleted (#3928). An
# installed extension providing a core-named command resolves
# ahead of bundled core elsewhere, so skip the bundled fallback
# when an extension restore exists — otherwise it would win
# over the higher-priority extension layer below.
core_file = core_templates_dir / f"{short_name}.md"
if (
not core_file.exists()
and restore_from_bundled_core
and extension_restore is None
):
from .. import _locate_core_pack, _repo_root
_core_pack = _locate_core_pack()
if _core_pack is not None:
core_file = _core_pack / "commands" / f"{short_name}.md"
else:
core_file = _repo_root() / "templates" / "commands" / f"{short_name}.md"
if not core_file.exists():
core_file = None
if core_file:
@@ -3303,7 +3418,6 @@ class PresetManager:
mutated_names.append(skill_name)
continue
extension_restore = extension_restore_index.get(skill_name)
if extension_restore:
content = extension_restore["source_file"].read_text(encoding="utf-8")
frontmatter, body = registrar.parse_frontmatter(content)
@@ -3442,7 +3556,9 @@ class PresetManager:
"registered_skills", registered_skills
)
if persisted_skills:
self._unregister_skills(persisted_skills, dest_dir)
self._unregister_skills(
persisted_skills, dest_dir, restore_from_bundled_core=True
)
try:
if dest_dir.exists():
shutil.rmtree(dest_dir)
@@ -3780,6 +3896,7 @@ class PresetManager:
restorable_skills,
pack_dir,
additional_owned_sources=override_sources,
restore_from_bundled_core=True,
)
try:
from ..agents import CommandRegistrar
@@ -5291,7 +5408,7 @@ class PresetResolver:
fm_strategy = fm_data.get("strategy")
if isinstance(fm_strategy, str) and fm_strategy.lower() in VALID_PRESET_STRATEGIES:
strategy = fm_strategy.lower()
except (yaml.YAMLError, OSError):
except (UnicodeDecodeError, yaml.YAMLError, OSError):
# Best-effort legacy frontmatter parsing: keep default
# strategy ("replace") when content is unreadable/invalid.
pass
+2 -2
View File
@@ -514,7 +514,7 @@ class WorkflowCatalog:
cached = json.load(f)
if isinstance(cached, dict):
return cached
except (json.JSONDecodeError, OSError):
except (UnicodeDecodeError, json.JSONDecodeError, OSError):
# Ignore invalid/unreadable cache and fall back to fetching from source.
pass
@@ -1210,7 +1210,7 @@ class StepCatalog:
cached = json.load(f)
if isinstance(cached, dict):
return cached
except (json.JSONDecodeError, OSError):
except (UnicodeDecodeError, json.JSONDecodeError, OSError):
# Ignore invalid/unreadable cache and fall back to fetching from source.
pass
+59 -20
View File
@@ -308,15 +308,16 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]:
errors.append("Workflow has no steps defined.")
seen_ids: set[str] = set()
# ``input_names`` is the set of declared workflow input names — used by
# ``_validate_steps`` to cross-reference gate ``verdict_input`` bindings.
# ``None`` means the inputs block itself is malformed (already reported
# above); the cross-check is then disabled so one authoring mistake does
# not cascade into N spurious "undeclared" errors.
input_names: set[str] | None = (
set(definition.inputs) if isinstance(definition.inputs, dict) else None
# ``input_defs`` maps declared workflow input names to their definitions —
# used by ``_validate_steps`` to cross-reference gate ``verdict_input``
# bindings (both that the name exists and that its ``enum`` permits the
# reset sentinel). ``None`` means the inputs block itself is malformed
# (already reported above); the cross-check is then disabled so one
# authoring mistake does not cascade into N spurious "undeclared" errors.
input_defs: dict[str, Any] | None = (
dict(definition.inputs) if isinstance(definition.inputs, dict) else None
)
_validate_steps(definition.steps, seen_ids, errors, input_names)
_validate_steps(definition.steps, seen_ids, errors, input_defs)
return errors
@@ -325,15 +326,15 @@ def _validate_steps(
steps: list[dict[str, Any]],
seen_ids: set[str],
errors: list[str],
input_names: set[str] | None = None,
input_defs: dict[str, Any] | None = None,
inside_fan_out: bool = False,
) -> None:
"""Recursively validate a list of steps.
``input_names`` is the set of declared workflow input names (or ``None``
when the inputs block is malformed). ``inside_fan_out`` is threaded
through nested control-flow steps so gate verdict bindings can be rejected
anywhere inside a fan-out template.
``input_defs`` maps declared workflow input names to their definitions (or
is ``None`` when the inputs block is malformed). ``inside_fan_out`` is
threaded through nested control-flow steps so gate verdict bindings can be
rejected anywhere inside a fan-out template.
"""
from . import STEP_REGISTRY
@@ -440,11 +441,39 @@ def _validate_steps(
f"Gate step {step_id!r}: 'verdict_input' is not "
"supported inside fan-out templates."
)
elif input_names is not None and verdict_input not in input_names:
elif input_defs is not None and verdict_input not in input_defs:
errors.append(
f"Gate step {step_id!r}: 'verdict_input' references "
f"undeclared input {verdict_input!r}."
)
elif input_defs is not None:
# ``on_reject: retry`` resets the bound input to "" before
# pausing, and every later resume re-resolves the persisted
# inputs through ``_coerce_input``. If the input declares an
# ``enum`` that omits "", that reset value is instantly
# illegal: the run pauses fine, but the next resume that
# supplies any input raises "value '' not in allowed
# values", and no verdict can be routed through the gate
# again. Require the enum to admit the sentinel so the
# retry cycle the field advertises is actually reachable.
verdict_def = input_defs.get(verdict_input)
enum_values = (
verdict_def.get("enum")
if isinstance(verdict_def, dict)
else None
)
if (
step_config.get("on_reject") == "retry"
and isinstance(enum_values, list)
and "" not in enum_values
):
errors.append(
f"Gate step {step_id!r}: on_reject='retry' resets "
f"verdict input {verdict_input!r} to '' when the "
f"gate is rejected, but that input's 'enum' does "
f"not allow ''. Add '' to the enum or use "
f"on_reject='abort'/'skip'."
)
# Recursively validate nested steps
for nested_key in ("then", "else", "steps"):
@@ -454,7 +483,7 @@ def _validate_steps(
nested,
seen_ids,
errors,
input_names,
input_defs,
inside_fan_out=inside_fan_out,
)
@@ -467,7 +496,7 @@ def _validate_steps(
case_steps,
seen_ids,
errors,
input_names,
input_defs,
inside_fan_out=inside_fan_out,
)
@@ -478,7 +507,7 @@ def _validate_steps(
default,
seen_ids,
errors,
input_names,
input_defs,
inside_fan_out=inside_fan_out,
)
@@ -491,7 +520,7 @@ def _validate_steps(
[fan_step],
set(),
fan_errors,
input_names,
input_defs,
inside_fan_out=True,
)
errors.extend(fan_errors)
@@ -732,6 +761,11 @@ class RunState:
"Invalid run state: missing required field(s): "
+ ", ".join(missing_fields)
)
if state_data["run_id"] != run_id:
raise ValueError(
f"Invalid run state: stored run_id {state_data['run_id']!r} "
f"does not match requested run_id {run_id!r}"
)
workflow_id = state_data["workflow_id"]
if not isinstance(workflow_id, str) or not _ID_PATTERN.fullmatch(
@@ -1676,8 +1710,13 @@ class WorkflowEngine:
continue
state_path = run_dir / "state.json"
if state_path.exists():
with open(state_path, encoding="utf-8") as f:
state_data = json.load(f)
try:
with open(state_path, encoding="utf-8") as f:
state_data = json.load(f)
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
continue
if not isinstance(state_data, dict) or "run_id" not in state_data:
continue
runs.append(state_data)
return runs
@@ -20,9 +20,31 @@ class FanInStep(StepBase):
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
wait_for = config.get("wait_for", [])
output_config = config.get("output") or {}
if not isinstance(output_config, dict):
output_config = config.get("output")
if output_config is None:
output_config = {}
elif not isinstance(output_config, dict):
# ``validate`` rejects a non-mapping ``output`` and its comment says
# why: "execute() silently coerces a non-mapping output to {}, so the
# author's declared aggregation keys would vanish with no error."
# The engine does not auto-validate before ``execute``, so on an
# unvalidated run that is exactly what happened -- and ``x or {}``
# masked the falsy shapes ([], false, 0, '') before the isinstance
# check even ran. Every declared key vanished while the step still
# reported COMPLETED, so downstream ``steps.<id>.output.<key>``
# resolved to None and interpolated as "": the same "silent empty
# result + COMPLETED" wiring bug the ``wait_for`` guard below
# rejects. Fail loudly with validate()'s own message instead. An
# explicit ``output:`` (YAML null) stays valid, matching validate.
return StepResult(
status=StepStatus.FAILED,
error=(
f"Fan-in step {config.get('id', '?')!r}: 'output' must be a "
f"mapping of key -> expression, got "
f"{type(output_config).__name__}."
),
output={"results": []},
)
# The engine does not auto-validate step config, so an unvalidated run
# with a non-list ``wait_for`` reaches here raw. Iterating it then
@@ -75,6 +75,31 @@ class GateStep(StepBase):
},
)
# ``validate`` rejects an ``on_reject`` outside abort/skip/retry, but the
# engine does not auto-validate before ``execute``. The reject branch
# below handles only "abort" and "retry" and then falls through to its
# ``on_reject == "skip"`` case, so on an unvalidated run any other value
# makes a REJECTED gate report COMPLETED and the run walks straight past
# the review the gate exists to enforce. Reachable by a capitalisation
# slip ("Abort"), a guessed verb ("fail", "stop"), a non-string, or the
# ``None`` that a bare ``on_reject:`` yields -- note ``config.get(k,
# default)`` does NOT substitute the default for an explicit null. Fail
# loudly instead, mirroring the ``options``/``verdict_input`` guards here.
if on_reject not in ("abort", "skip", "retry"):
return StepResult(
status=StepStatus.FAILED,
error=(
f"Gate step {config.get('id', '?')!r}: 'on_reject' must be "
f"'abort', 'skip', or 'retry', got {on_reject!r}."
),
output={
"message": message,
"options": options,
"on_reject": on_reject,
"choice": None,
},
)
if has_verdict_input and (
not isinstance(verdict_input, str) or not verdict_input
):
@@ -11,7 +11,10 @@ from __future__ import annotations
import os
from typing import Any
from specify_cli._agent_config import DEFAULT_INIT_INTEGRATION, SCRIPT_TYPE_CHOICES
from specify_cli._agent_config import (
SCRIPT_TYPE_CHOICES,
resolve_default_init_integration,
)
from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus
from specify_cli.workflows.expressions import evaluate_expression
@@ -54,7 +57,8 @@ class InitStep(StepBase):
Initialize in the target directory instead of creating a new one.
``integration``
Integration key (e.g. ``copilot``). Defaults to the workflow's
default integration, then to ``DEFAULT_INIT_INTEGRATION``.
default integration, then to the resolved default init integration
(``SPECKIT_INTEGRATION_DEFAULT`` env var, else ``copilot``).
``integration_options``
Extra options for the integration (e.g. ``"--skills"`` or
``"--commands-dir .myagent/cmds"``).
@@ -81,7 +85,7 @@ class InitStep(StepBase):
# Apply the same default that specify init uses in non-interactive mode
# so that output.integration reflects the actual integration used.
if not integration:
integration = DEFAULT_INIT_INTEGRATION
integration = resolve_default_init_integration()
integration_options = self._resolve(
config.get("integration_options"), context
@@ -91,9 +95,17 @@ class InitStep(StepBase):
force = self._resolve_bool(config.get("force"), context)
# Workflows run unattended; skip the agent CLI presence check by default.
ignore_agent_tools = self._resolve_bool(
config.get("ignore_agent_tools", True), context
)
# ``config.get(key, True)`` applies that default only when the key is
# ABSENT: a bare ``ignore_agent_tools:`` in YAML parses to None, which
# ``_resolve_bool`` then turns into False -- flipping the documented
# default and re-enabling the agent-CLI presence check this step promises
# to skip, so an unattended run fails with "Agent Detection Error" for
# any integration whose CLI is not installed. Normalize an explicit null
# to the default, mirroring the while/do-while ``max_iterations`` handling.
raw_ignore_agent_tools = config.get("ignore_agent_tools")
if raw_ignore_agent_tools is None:
raw_ignore_agent_tools = True
ignore_agent_tools = self._resolve_bool(raw_ignore_agent_tools, context)
argv: list[str] = ["init"]
if here:
@@ -44,6 +44,20 @@ def test_precedence_default_when_unspecified():
assert _resolve_init_integration(None, None) == "copilot"
def test_precedence_default_honors_env_var(monkeypatch):
monkeypatch.setenv("SPECKIT_INTEGRATION_DEFAULT", "gemini")
# With no override and no bundle-declared integration, the env-var default
# applies instead of the hardcoded "copilot".
assert _resolve_init_integration(None, None) == "gemini"
assert _resolve_init_integration(None, _manifest()) == "gemini"
# Explicit override and bundle-declared integration still take precedence.
assert _resolve_init_integration("claude", None) == "claude"
assert (
_resolve_init_integration(None, _manifest(integration={"id": "claude"}))
== "claude"
)
def _build_mini(tmp_path: Path) -> Path:
bundle = tmp_path / "mini"
bundle.mkdir()
+363 -16
View File
@@ -69,8 +69,11 @@ class TestInitIntegrationFlag:
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
assert (project / ".github" / "prompts" / "speckit.plan.prompt.md").exists()
assert (
project / ".github" / "skills" / "speckit-plan" / "SKILL.md"
).exists()
assert not (project / ".github" / "agents").exists()
assert not (project / ".github" / "prompts").exists()
assert (project / ".specify" / "scripts" / "bash" / "common.sh").exists()
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
@@ -78,6 +81,7 @@ class TestInitIntegrationFlag:
opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8"))
assert opts["integration"] == "copilot"
assert opts["ai_skills"] is True
# init must not leave any legacy agent-context keys in init-options.json
assert "context_file" not in opts
@@ -111,11 +115,75 @@ class TestInitIntegrationFlag:
assert result.exit_code == 0, result.output
assert f"defaulting to '{specify_cli.DEFAULT_INIT_INTEGRATION}'" in result.output
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
assert (
project / ".github" / "skills" / "speckit-plan" / "SKILL.md"
).exists()
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["integration"] == specify_cli.DEFAULT_INIT_INTEGRATION
def test_noninteractive_init_honors_default_integration_env_var(
self, tmp_path, monkeypatch
):
from typer.testing import CliRunner
from specify_cli import app
import specify_cli
def fail_select(*_args, **_kwargs):
raise AssertionError("non-interactive init should not open the integration picker")
monkeypatch.setattr(specify_cli, "select_with_arrows", fail_select)
monkeypatch.setenv(
specify_cli.DEFAULT_INIT_INTEGRATION_ENV_VAR, "gemini"
)
runner = CliRunner()
project = tmp_path / "noninteractive_env"
result = runner.invoke(app, [
"init", str(project), "--script", "sh", "--ignore-agent-tools",
], catch_exceptions=False)
assert result.exit_code == 0, result.output
assert "defaulting to 'gemini'" in result.output
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["integration"] == "gemini"
def test_interactive_init_picker_default_honors_env_var(
self, tmp_path, monkeypatch
):
# The interactive integration picker must receive the resolved
# SPECKIT_INTEGRATION_DEFAULT value as its default_key, not the
# hardcoded constant (guards the picker wiring against regression).
from typer.testing import CliRunner
from specify_cli import app
import specify_cli.commands.init as init_mod
monkeypatch.setattr(init_mod, "_stdin_is_interactive", lambda: True)
monkeypatch.setenv("SPECKIT_INTEGRATION_DEFAULT", "gemini")
captured = {}
def fake_select(options, prompt_text=None, default_key=None):
# Only capture the integration picker (not the script picker).
if "Choose your coding agent integration" in (prompt_text or ""):
captured["default_key"] = default_key
return default_key
monkeypatch.setattr(init_mod, "select_with_arrows", fake_select)
runner = CliRunner()
project = tmp_path / "interactive_env"
result = runner.invoke(app, [
"init", str(project), "--script", "sh", "--ignore-agent-tools",
], catch_exceptions=False)
assert result.exit_code == 0, result.output
assert captured.get("default_key") == "gemini"
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["integration"] == "gemini"
def test_init_here_nonempty_noninteractive_errors_with_force_guidance(self, tmp_path):
"""`init --here` on a non-empty directory with no confirmation input (empty
stdin) must fail fast with guidance to use --force, instead of the bare
@@ -188,7 +256,9 @@ class TestInitIntegrationFlag:
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
assert (
project / ".github" / "skills" / "speckit-plan" / "SKILL.md"
).exists()
def test_init_optional_preset_failure_reports_target_and_continues(
self, tmp_path, monkeypatch
@@ -1311,7 +1381,7 @@ class TestSharedInfraCommandRefs:
assert "/speckit.specify" not in script_content
def test_full_init_copilot_resolves_page_templates(self, tmp_path):
"""Full CLI init with Copilot (markdown agent) produces dot refs in page templates."""
"""Default Copilot skills mode produces hyphen refs in page templates."""
from typer.testing import CliRunner
from specify_cli import app
@@ -1333,27 +1403,28 @@ class TestSharedInfraCommandRefs:
plan = project / ".specify" / "templates" / "plan-template.md"
content = plan.read_text(encoding="utf-8")
assert "/speckit.plan" in content, "Copilot (markdown) should use /speckit.plan"
assert "/speckit-plan" in content, "Copilot skills should use /speckit-plan"
assert "/speckit.plan" not in content
assert "__SPECKIT_COMMAND_" not in content
script_content = self._combined_script_content(project, "sh")
assert "/speckit.specify" in script_content
assert "/speckit-specify" not in script_content
assert "/speckit-specify" in script_content
assert "/speckit.specify" not in script_content
def test_full_init_copilot_skills_resolves_page_templates(self, tmp_path):
"""Full CLI init with Copilot --skills produces hyphen refs in page templates."""
def test_full_init_copilot_commands_resolves_page_templates(self, tmp_path):
"""Copilot --commands produces dot refs in page templates."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
project = tmp_path / "init-copilot-skills"
project = tmp_path / "init-copilot-commands"
old_cwd = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(app, [
"init", str(project),
"--integration", "copilot",
"--integration-options", "--skills",
"--integration-options", "--commands",
"--script", "sh",
"--ignore-agent-tools",
], catch_exceptions=False)
@@ -1364,13 +1435,13 @@ class TestSharedInfraCommandRefs:
plan = project / ".specify" / "templates" / "plan-template.md"
content = plan.read_text(encoding="utf-8")
assert "/speckit-plan" in content, "Copilot --skills should use /speckit-plan"
assert "/speckit.plan" not in content, "dot-notation leaked into Copilot skills page template"
assert "/speckit.plan" in content, "Copilot --commands should use /speckit.plan"
assert "/speckit-plan" not in content
assert "__SPECKIT_COMMAND_" not in content
script_content = self._combined_script_content(project, "sh")
assert "/speckit-specify" in script_content
assert "/speckit.specify" not in script_content
assert "/speckit.specify" in script_content
assert "/speckit-specify" not in script_content
class TestIntegrationCatalogDiscoveryCLI:
@@ -2372,3 +2443,279 @@ def test_refresh_shared_templates_preserves_recovered_user_file(tmp_path):
# Recovered user content must survive (fail-before: replaced by bundled body).
assert user_file.read_text(encoding="utf-8") == "# USER CUSTOM CONTENT\n"
class TestExtensionFlag:
"""Tests for the --extension flag on specify init."""
def _run_init(self, tmp_path, args, project_name="ext-test"):
from unittest.mock import patch
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / project_name
project.mkdir(exist_ok=True)
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
# Patch get_speckit_version to return a stable (non-dev) version so that
# the extension compatibility check (SpecifierSet(">=0.2.0")) passes.
with patch(
"specify_cli.commands.init.get_speckit_version",
return_value="0.8.2",
):
result = runner.invoke(app, [
"init", "--here",
"--integration", "copilot",
"--script", "sh",
"--ignore-agent-tools",
] + args, catch_exceptions=False)
finally:
os.chdir(old_cwd)
return project, result
def test_bundled_extension_installed(self, tmp_path):
"""--extension git installs the bundled git extension."""
project, result = self._run_init(tmp_path, ["--extension", "git"], project_name="ext-bundled")
assert result.exit_code == 0, f"init failed:\n{result.output}"
ext_dir = project / ".specify" / "extensions" / "git"
assert ext_dir.exists(), "git extension directory not found"
assert (ext_dir / "extension.yml").exists(), "extension.yml not found"
# Tracker should show extension step as done
normalized = _normalize_cli_output(result.output)
assert "Install extension: git" in normalized
def test_multiple_extensions_installed(self, tmp_path):
"""--extension can be specified multiple times."""
project, result = self._run_init(
tmp_path,
["--extension", "git", "--extension", "selftest"],
project_name="ext-multi",
)
assert result.exit_code == 0, f"init failed:\n{result.output}"
ext_dir_git = project / ".specify" / "extensions" / "git"
ext_dir_selftest = project / ".specify" / "extensions" / "selftest"
assert ext_dir_git.exists(), "git extension not installed"
assert ext_dir_selftest.exists(), "selftest extension not installed"
def test_local_path_extension_installed(self, tmp_path):
"""--extension /abs/path installs from a local absolute directory path."""
from specify_cli import _locate_bundled_extension
# Use the bundled git extension directory as our "local" extension source
bundled_git = _locate_bundled_extension("git")
assert bundled_git is not None, "bundled git extension not found; cannot run test"
# Pass the absolute path directly (starts with "/")
project, result = self._run_init(
tmp_path,
["--extension", str(bundled_git)],
project_name="ext-local",
)
assert result.exit_code == 0, f"init failed:\n{result.output}"
ext_dir = project / ".specify" / "extensions" / "git"
assert ext_dir.exists(), "extension from local path not installed"
def test_unknown_extension_shows_error_in_tracker(self, tmp_path):
"""An unknown extension name records a tracker error but does not abort init."""
project, result = self._run_init(
tmp_path,
["--extension", "nonexistent-xyz-ext"],
project_name="ext-unknown",
)
assert result.exit_code == 0, "init should not abort on unknown extension"
normalized = _normalize_cli_output(result.output)
assert "failed" in normalized.lower(), "expected 'failed' for unknown extension"
def test_extension_flag_works_with_preset(self, tmp_path):
"""--extension and --preset can be combined."""
project, result = self._run_init(
tmp_path,
["--extension", "git", "--preset", "lean"],
project_name="ext-preset",
)
assert result.exit_code == 0, f"init failed:\n{result.output}"
ext_dir = project / ".specify" / "extensions" / "git"
assert ext_dir.exists(), "git extension not installed alongside preset"
@staticmethod
def _zip_bytes_from_dir(source_dir):
"""Build in-memory ZIP bytes from an extension directory (yml at root)."""
import io
import zipfile
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for path in sorted(source_dir.rglob("*")):
if path.is_file():
zf.write(path, arcname=str(path.relative_to(source_dir)))
return buf.getvalue()
def test_url_extension_rejects_non_https(self, tmp_path):
"""A non-HTTPS URL is rejected before any download; init is not aborted."""
project, result = self._run_init(
tmp_path,
["--extension", "http://example.com/ext.zip", "--trust-extension-urls"],
project_name="ext-http",
)
assert result.exit_code == 0, "init should not abort on a rejected URL"
normalized = _normalize_cli_output(result.output)
assert "failed" in normalized.lower()
# No extension directory should have been created for the bad URL.
assert not (project / ".specify" / "extensions" / "ext").exists()
def test_url_extension_skipped_without_trust(self, tmp_path):
"""Non-interactive URL install without --trust-extension-urls is denied."""
from unittest.mock import patch
with patch(
"specify_cli.commands.init._stdin_is_interactive", return_value=False
), patch("specify_cli.authentication.http.open_url") as mock_open:
project, result = self._run_init(
tmp_path,
["--extension", "https://example.com/git.zip"],
project_name="ext-url-denied",
)
assert result.exit_code == 0, f"init failed:\n{result.output}"
# Default-deny: no download attempted, nothing installed.
mock_open.assert_not_called()
normalized = _normalize_cli_output(result.output)
assert "untrusted url" in normalized.lower()
assert not (project / ".specify" / "extensions" / "git").exists()
def test_url_extension_interactive_confirm_installs(self, tmp_path):
"""An interactive 'yes' to the trust prompt allows the URL install."""
import io
from unittest.mock import patch
from specify_cli import _locate_bundled_extension
bundled_git = _locate_bundled_extension("git")
assert bundled_git is not None, "bundled git extension not found"
zip_bytes = self._zip_bytes_from_dir(bundled_git)
class FakeResponse(io.BytesIO):
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def _cache_dir_stand_in(project_root):
d = project_root / ".specify" / "extensions" / ".cache" / "downloads"
d.mkdir(parents=True, exist_ok=True)
return d
def _open_download_zip(project_root, download_dir, zip_filename):
target = download_dir / zip_filename
o_temporary = getattr(os, "O_TEMPORARY", 0)
if o_temporary:
return os.open(
target, os.O_RDWR | os.O_CREAT | os.O_EXCL | o_temporary, 0o600
)
fd = os.open(target, os.O_RDWR | os.O_CREAT | os.O_EXCL, 0o600)
try:
os.unlink(target)
except OSError:
os.close(fd)
raise
return fd
with patch(
"specify_cli.commands.init._stdin_is_interactive", return_value=True
), patch("typer.confirm", return_value=True), patch(
"specify_cli.authentication.http.open_url",
return_value=FakeResponse(zip_bytes),
), patch(
"specify_cli.extensions._commands._validate_safe_cache_dir",
side_effect=_cache_dir_stand_in,
), patch(
"specify_cli.extensions._commands._safe_open_download_zip",
side_effect=_open_download_zip,
):
project, result = self._run_init(
tmp_path,
["--extension", "https://example.com/git.zip"],
project_name="ext-url-confirm",
)
assert result.exit_code == 0, f"init failed:\n{result.output}"
assert (project / ".specify" / "extensions" / "git").exists()
def test_url_extension_installs_zip(self, tmp_path):
"""A successful HTTPS ZIP download installs via the shared hardened path."""
import io
from unittest.mock import patch
from specify_cli import _locate_bundled_extension
bundled_git = _locate_bundled_extension("git")
assert bundled_git is not None, "bundled git extension not found"
zip_bytes = self._zip_bytes_from_dir(bundled_git)
class FakeResponse(io.BytesIO):
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def _cache_dir_stand_in(project_root):
d = project_root / ".specify" / "extensions" / ".cache" / "downloads"
d.mkdir(parents=True, exist_ok=True)
return d
def _open_download_zip(project_root, download_dir, zip_filename):
target = download_dir / zip_filename
o_temporary = getattr(os, "O_TEMPORARY", 0)
if o_temporary:
return os.open(
target, os.O_RDWR | os.O_CREAT | os.O_EXCL | o_temporary, 0o600
)
fd = os.open(target, os.O_RDWR | os.O_CREAT | os.O_EXCL, 0o600)
try:
os.unlink(target)
except OSError:
os.close(fd)
raise
return fd
with patch(
"specify_cli.authentication.http.open_url",
return_value=FakeResponse(zip_bytes),
), patch(
"specify_cli.extensions._commands._validate_safe_cache_dir",
side_effect=_cache_dir_stand_in,
), patch(
"specify_cli.extensions._commands._safe_open_download_zip",
side_effect=_open_download_zip,
):
project, result = self._run_init(
tmp_path,
["--extension", "https://example.com/git.zip", "--trust-extension-urls"],
project_name="ext-url",
)
assert result.exit_code == 0, f"init failed:\n{result.output}"
ext_dir = project / ".specify" / "extensions" / "git"
assert ext_dir.exists(), "extension from URL not installed"
assert (ext_dir / "extension.yml").exists()
# Transient download archive must not linger in the cache.
cache_dir = project / ".specify" / "extensions" / ".cache" / "downloads"
leftover = list(cache_dir.glob("*.zip")) if cache_dir.exists() else []
assert not leftover, f"download cache not cleaned: {leftover}"
+243 -2
View File
@@ -128,6 +128,23 @@ class TestResolveEvents:
)
assert result == {}
def test_unreadable_yaml_override_keeps_prior_layers(self, tmp_path):
"""An unreadable override is ignored like malformed YAML."""
override_file = tmp_path / ".specify" / "integration-events.yml"
override_file.parent.mkdir(parents=True, exist_ok=True)
override_file.write_bytes(b"\xff\xfe")
result = resolve_events(
"claude",
{"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}},
tmp_path,
None,
)
assert result == {
"post_tool_use": [{"command": "speckit.tdd.validate"}]
}
def test_no_config_no_events(self, tmp_path):
"""Safe fallback with empty config/options."""
result = resolve_events("claude", None, tmp_path, None)
@@ -164,6 +181,13 @@ class TestCollectExtensionEvents:
(ext_dir / "extension.yml").write_text("invalid: - - -", encoding="utf-8")
assert collect_extension_events(tmp_path) == {}
def test_non_utf8_manifest_skipped(self, tmp_path):
ext_dir = tmp_path / ".specify" / "extensions" / "my-ext"
ext_dir.mkdir(parents=True)
(ext_dir / "extension.yml").write_bytes(b"\xff\xfe")
assert collect_extension_events(tmp_path) == {}
def test_event_command_ref_canonicalized_via_manifest(self, tmp_path):
"""R1: events are read from a validated ExtensionManifest, so an
obsolete command ref (e.g. my-ext.boot) is canonicalized
@@ -227,6 +251,8 @@ class TestCanonicalEventMapping:
integration = OpencodeIntegration()
assert integration.supports_events() is True
assert integration.CANONICAL_TO_NATIVE["pre_tool_use"] == "tool.execute.before"
assert integration.CANONICAL_TO_NATIVE["session_start"] == "experimental.chat.system.transform"
assert integration.CANONICAL_TO_NATIVE["user_prompt_submit"] == "chat.message"
assert "stop" not in integration.CANONICAL_TO_NATIVE
def test_copilot_mapping(self):
@@ -647,6 +673,104 @@ class TestCopilotAgentStop:
# -- Shell quoting & matcher escaping (R2, R4) -------------------------------
class TestContextInjectionEnvelopes:
"""C13: context-injection envelope resolution and emission."""
def test_emit_event_stdout_wrapping(self, capsys):
from specify_cli.events import _emit_event_stdout
_emit_event_stdout("hello ctx", "plain")
assert capsys.readouterr().out == "hello ctx"
# hookSpecificOutput without native_event: no hookEventName (backward
# compat for callers that don't pass it).
_emit_event_stdout("hello ctx", "hookSpecificOutput")
assert json.loads(capsys.readouterr().out.strip()) == {
"hookSpecificOutput": {"additionalContext": "hello ctx"}
}
# hookSpecificOutput with native_event: hookEventName included
# (required by Qwen's hooks spec; derived from Claude Code's).
_emit_event_stdout("hello ctx", "hookSpecificOutput", "SessionStart")
assert json.loads(capsys.readouterr().out.strip()) == {
"hookSpecificOutput": {
"additionalContext": "hello ctx",
"hookEventName": "SessionStart",
}
}
_emit_event_stdout("hello ctx", "additionalContext")
assert json.loads(capsys.readouterr().out.strip()) == {
"additionalContext": "hello ctx"
}
_emit_event_stdout("hello ctx", "additional_context")
assert json.loads(capsys.readouterr().out.strip()) == {
"additional_context": "hello ctx"
}
_emit_event_stdout("hello ctx", "suppress")
assert capsys.readouterr().out == ""
# Empty output emits nothing under any envelope.
_emit_event_stdout("", "additionalContext")
assert capsys.readouterr().out == ""
def test_envelope_resolution_and_command_formatting(self):
from specify_cli.events import _dispatcher_command, _context_envelope_for
from specify_cli.integrations.gemini import GeminiIntegration
from specify_cli.integrations.qwen import QwenIntegration
from specify_cli.integrations.copilot import CopilotIntegration
from specify_cli.integrations.cursor_agent import CursorAgentIntegration
from specify_cli.integrations.claude import ClaudeIntegration
from specify_cli.integrations.codex import CodexIntegration
gemini = GeminiIntegration()
assert _context_envelope_for(gemini, "session_start") == "hookSpecificOutput"
assert _context_envelope_for(gemini, "user_prompt_submit") == "hookSpecificOutput"
assert _context_envelope_for(gemini, "pre_tool_use") == "suppress"
# hookSpecificOutput appends the native event name as a 6th dispatcher
# argument so the dispatcher can populate hookEventName. The default
# timeout (60s) is always emitted as the 4th arg to keep positional
# alignment (R3).
cmd_gemini_start = _dispatcher_command(gemini, Path("/proj"), "speckit.boot", "session_start")
assert cmd_gemini_start.endswith(" 60 hookSpecificOutput SessionStart")
cmd_gemini_prompt = _dispatcher_command(gemini, Path("/proj"), "speckit.prompt", "user_prompt_submit")
assert cmd_gemini_prompt.endswith(" 60 hookSpecificOutput BeforeAgent")
cmd_gemini_tool = _dispatcher_command(gemini, Path("/proj"), "speckit.guard", "pre_tool_use")
assert cmd_gemini_tool.endswith(" 60 suppress")
# Qwen uses the same hookSpecificOutput protocol with its own native
# event names; verify hookEventName threading for Qwen's CamelCase names.
qwen = QwenIntegration()
cmd_qwen_start = _dispatcher_command(qwen, Path("/proj"), "speckit.boot", "session_start")
assert cmd_qwen_start.endswith(" 60 hookSpecificOutput SessionStart")
cmd_qwen_prompt = _dispatcher_command(qwen, Path("/proj"), "speckit.prompt", "user_prompt_submit")
assert cmd_qwen_prompt.endswith(" 60 hookSpecificOutput UserPromptSubmit")
copilot = CopilotIntegration()
assert _context_envelope_for(copilot, "session_start") == "additionalContext"
assert _context_envelope_for(copilot, "user_prompt_submit") == "additionalContext"
cmd_copilot_start = _dispatcher_command(copilot, Path("/proj"), "speckit.boot", "session_start")
assert cmd_copilot_start.endswith(" 60 additionalContext")
cmd_copilot_prompt = _dispatcher_command(copilot, Path("/proj"), "speckit.prompt", "user_prompt_submit")
assert cmd_copilot_prompt.endswith(" 60 additionalContext")
cursor = CursorAgentIntegration()
assert _context_envelope_for(cursor, "session_start") == "additional_context"
assert _context_envelope_for(cursor, "user_prompt_submit") == "suppress"
cmd_cursor_start = _dispatcher_command(cursor, Path("/proj"), "speckit.boot", "session_start")
assert cmd_cursor_start.endswith(" 60 additional_context")
claude = ClaudeIntegration()
codex = CodexIntegration()
assert _context_envelope_for(claude, "session_start") is None
assert _context_envelope_for(codex, "session_start") is None
class TestDispatcherCommandQuoting:
"""R2: dispatcher command components are shell-quoted so spaces and shell
metacharacters are passed as single arguments, not reinterpreted."""
@@ -744,6 +868,59 @@ class TestTomlMatcherEscaping:
assert group["matcher"] == 'Ba"sh'
class TestTomlUnreadableConfig:
"""An undecodable user config.toml must not crash install or teardown.
Every JSON merge/remove path goes through ``_load_user_json``, which
skips on an unreadable or malformed file to preserve user content (#22).
The TOML merge and remove read the user's config.toml with no boundary,
so a non-UTF-8 (or otherwise unreadable) file crashed
``install_integration_events``/``remove_integration_events`` with a raw
``UnicodeDecodeError`` — and the merge path would have regenerated the
file, discarding the user's bytes, had it not crashed first.
"""
def test_merge_skips_unreadable_config_and_preserves_bytes(self, tmp_path):
from specify_cli.integrations.codex import CodexIntegration
integration = CodexIntegration()
manifest = _claude_manifest(tmp_path)
config_path = tmp_path / ".codex" / "config.toml"
config_path.parent.mkdir(parents=True)
user_bytes = b"# codex config \xff\xfe not utf-8\n"
config_path.write_bytes(user_bytes)
install_integration_events(
integration, tmp_path, manifest,
{"pre_tool_use": [{"command": "speckit.tdd.validate"}]},
)
# User bytes preserved and the skipped file is not tracked (S5).
assert config_path.read_bytes() == user_bytes
manifest.record_existing.assert_not_called()
def test_teardown_skips_unreadable_config_and_preserves_bytes(self, tmp_path):
from specify_cli.integrations.codex import CodexIntegration
integration = CodexIntegration()
manifest = _claude_manifest(tmp_path)
install_integration_events(
integration, tmp_path, manifest,
{"pre_tool_use": [{"command": "speckit.tdd.validate"}]},
)
config_path = tmp_path / ".codex" / "config.toml"
assert config_path.is_file()
# The user (or another tool) rewrites the config as non-UTF-8
# between install and uninstall.
user_bytes = b"# rewritten \xff\xfe not utf-8\n"
config_path.write_bytes(user_bytes)
remove_integration_events(integration, tmp_path, manifest)
assert config_path.read_bytes() == user_bytes
# -- Opencode TS Plugin merging ---------------------------------------------
class TestOpencodePluginMerging:
@@ -759,6 +936,7 @@ class TestOpencodePluginMerging:
events = {
"pre_tool_use": [{"command": "speckit.tdd.validate", "matcher": "Edit"}],
"session_start": [{"command": "speckit.agent-context.update"}],
"session_end": [{"command": "speckit.agent-context.teardown"}],
}
install_integration_events(integration, tmp_path, manifest, events)
@@ -767,13 +945,50 @@ class TestOpencodePluginMerging:
content = plugin_path.read_text()
assert "runEvent" in content
assert "tool.execute.before" in content
assert "session.created" in content
assert "experimental.chat.system.transform" in content
assert "speckit.tdd.validate" in content
assert "speckit.agent-context.update" in content
# #13: failures must propagate via throw, not process.exit(2) which
# would kill the OpenCode host process.
assert "process.exit(2)" not in content
assert "throw new Error" in content
# session_start (experimental.chat.system.transform) must be guarded
# so canonical session-start handlers only run when a session is
# present — OpenCode fires this hook for non-session operations
# (e.g. agent generation) with no sessionID.
assert "if (!input.sessionID) return;" in content
# session_start handler output is cached per sessionID so non-idempotent
# handlers run once per session instead of on every LLM request.
assert "sessionStartCache" in content
assert "sessionStartCache.get(input.sessionID)" in content
assert "sessionStartCache.set(input.sessionID" in content
# Cache is evicted on session.deleted (session_end).
assert "sessionStartCache.delete(event.sessionID)" in content
def test_opencode_ts_plugin_chat_message_part_injection(self, tmp_path):
"""user_prompt_submit emits chat.message pushing a synthetic TextPart.
The part ID derives from output.parts[last].id (prt_ brand preserved)
with a prt_ fallback to prevent OpenCode session schema crashes."""
integration = OpencodeIntegration()
manifest = MagicMock(spec=IntegrationManifest)
manifest.files = {}
manifest.record_file = MagicMock()
manifest.record_existing = MagicMock()
events = {
"user_prompt_submit": [{"command": "speckit.discover"}],
}
install_integration_events(integration, tmp_path, manifest, events)
plugin_path = tmp_path / ".opencode/plugin/speckit-events.ts"
assert plugin_path.is_file()
content = plugin_path.read_text()
assert "chat.message" in content
assert "output.parts.push" in content
assert "synthetic: true" in content
assert 'type: "text"' in content
assert "output.parts[output.parts.length - 1]?.id" in content
assert '?? "prt_"' in content
def test_opencode_ts_plugin_resolves_interpreter_and_directory_at_load(self, tmp_path):
"""C8/C9: the dispatcher + interpreter are resolved per-project at
@@ -1024,6 +1239,32 @@ class TestCommandRunner:
assert PurePath(argv[1]).as_posix().endswith(".specify/scripts/python/boot.py")
assert ".specify" in argv[1]
def test_unparseable_script_command_returns_none(self, tmp_path):
"""A ``scripts:`` value shlex cannot tokenize must resolve to no argv.
The generated dispatcher's ``_resolve_argv`` twin wraps its
``shlex.split`` in ``except ValueError: return None``, but the
CLI-side resolver did not: an unclosed quote in a ``scripts:``
frontmatter value raised a raw ``ValueError: No closing quotation``
through ``resolve_and_run_event_command`` instead of degrading to
"no runnable script" like every other malformed-input case here.
"""
from specify_cli.events import _resolve_event_command_argv
cmd_dir = tmp_path / ".specify" / "templates" / "commands"
cmd_dir.mkdir(parents=True)
(cmd_dir / "boot.md").write_text(
"---\n"
"description: \"Boot\"\n"
"scripts:\n"
" sh: scripts/bash/boot.sh \"unclosed\n"
"---\nBody\n",
encoding="utf-8",
)
argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None)
assert argv is None
def test_ps_variant_prefixed_with_powershell_launcher(self, tmp_path):
"""S6: the ps variant prefixes argv with pwsh/powershell -File so
subprocess.run(shell=False) can execute the .ps1 script."""
@@ -1102,7 +1343,7 @@ class TestCommandRunner:
content = (tmp_path / EVENTS_DISPATCHER_REL).read_text()
# Delegates to specify_cli when importable.
assert "from specify_cli.events import resolve_and_run_event_command" in content
assert "except ImportError" in content
assert "except (ImportError, TypeError):" in content
# Inline stdlib fallback resolver for one-time/temporary installs.
assert "_run_inline" in content
assert "_find_command_template" in content
+4 -4
View File
@@ -426,7 +426,7 @@ class _RunCapture:
return _Result()
def test_copilot_dispatch_command_includes_extra_args(monkeypatch):
def test_copilot_commands_dispatch_includes_extra_args(monkeypatch):
"""Locks the bypass fix: `CopilotIntegration.dispatch_command`
must honour `SPECKIT_INTEGRATION_COPILOT_EXTRA_ARGS`, not just `build_exec_args`.
"""
@@ -441,9 +441,9 @@ def test_copilot_dispatch_command_includes_extra_args(monkeypatch):
"SPECKIT_INTEGRATION_COPILOT_EXTRA_ARGS", "--allow-tool 'shell(echo)'"
)
CopilotIntegration().dispatch_command(
"speckit.plan", args="body", stream=False
)
integration = CopilotIntegration()
integration._skills_mode = False
integration.dispatch_command("speckit.plan", args="body", stream=False)
assert capture.captured_args is not None
# Hook inserted between `-p prompt` and the canonical Copilot flags.
+199 -120
View File
@@ -2,16 +2,16 @@
import json
import os
import warnings
import pytest
import typer
import yaml
from specify_cli.integrations import get_integration
from specify_cli.integrations.manifest import IntegrationManifest
class TestCopilotIntegration:
class TestCopilotCommandsMode:
def test_copilot_key_and_config(self):
copilot = get_integration("copilot")
assert copilot is not None
@@ -28,7 +28,7 @@ class TestCopilotIntegration:
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
created = copilot.setup(tmp_path, m)
created = copilot.setup(tmp_path, m, parsed_options={"commands": True})
assert len(created) > 0
agent_files = [f for f in created if ".agent." in f.name]
assert len(agent_files) > 0
@@ -36,36 +36,11 @@ class TestCopilotIntegration:
assert f.parent == tmp_path / ".github" / "agents"
assert f.name.endswith(".agent.md")
def test_setup_warns_legacy_markdown_default_is_deprecated(self, tmp_path):
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
with pytest.warns(UserWarning, match="Copilot legacy markdown mode is deprecated"):
created = copilot.setup(tmp_path, m)
assert any(f.name.endswith(".agent.md") for f in created)
def test_skills_setup_does_not_warn_about_legacy_default(self, tmp_path):
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
created = copilot.setup(tmp_path, m, parsed_options={"skills": True})
assert not any(
"Copilot legacy markdown mode is deprecated" in str(item.message)
for item in caught
)
assert any(f.name == "SKILL.md" for f in created)
def test_setup_creates_companion_prompts(self, tmp_path):
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
created = copilot.setup(tmp_path, m)
created = copilot.setup(tmp_path, m, parsed_options={"commands": True})
prompt_files = [f for f in created if f.parent.name == "prompts"]
assert len(prompt_files) > 0
for f in prompt_files:
@@ -77,7 +52,7 @@ class TestCopilotIntegration:
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
created = copilot.setup(tmp_path, m)
created = copilot.setup(tmp_path, m, parsed_options={"commands": True})
agents = [f for f in created if ".agent.md" in f.name]
prompts = [f for f in created if ".prompt.md" in f.name]
assert len(agents) == len(prompts)
@@ -87,7 +62,7 @@ class TestCopilotIntegration:
copilot = CopilotIntegration()
assert copilot._vscode_settings_path() is not None
m = IntegrationManifest("copilot", tmp_path)
created = copilot.setup(tmp_path, m)
created = copilot.setup(tmp_path, m, parsed_options={"commands": True})
settings = tmp_path / ".vscode" / "settings.json"
assert settings.exists()
assert settings in created
@@ -101,7 +76,7 @@ class TestCopilotIntegration:
existing = {"editor.fontSize": 14, "custom.setting": True}
(vscode_dir / "settings.json").write_text(json.dumps(existing, indent=4), encoding="utf-8")
m = IntegrationManifest("copilot", tmp_path)
created = copilot.setup(tmp_path, m)
created = copilot.setup(tmp_path, m, parsed_options={"commands": True})
settings = tmp_path / ".vscode" / "settings.json"
data = json.loads(settings.read_text(encoding="utf-8"))
assert data["editor.fontSize"] == 14
@@ -119,7 +94,7 @@ class TestCopilotIntegration:
settings.write_bytes(original)
m = IntegrationManifest("copilot", tmp_path)
copilot.setup(tmp_path, m)
copilot.setup(tmp_path, m, parsed_options={"commands": True})
assert settings.read_bytes() == original
assert "Could not parse" in caplog.text
@@ -128,7 +103,7 @@ class TestCopilotIntegration:
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
created = copilot.setup(tmp_path, m)
created = copilot.setup(tmp_path, m, parsed_options={"commands": True})
for f in created:
rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
assert rel in m.files, f"Created file {rel} not tracked in manifest"
@@ -137,7 +112,9 @@ class TestCopilotIntegration:
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
created = copilot.install(tmp_path, m)
created = copilot.install(
tmp_path, m, parsed_options={"commands": True}
)
assert len(created) > 0
m.save()
for f in created:
@@ -150,7 +127,9 @@ class TestCopilotIntegration:
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
created = copilot.install(tmp_path, m)
created = copilot.install(
tmp_path, m, parsed_options={"commands": True}
)
m.save()
modified_file = created[0]
modified_file.write_text("user modified this", encoding="utf-8")
@@ -162,7 +141,7 @@ class TestCopilotIntegration:
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
copilot.setup(tmp_path, m)
copilot.setup(tmp_path, m, parsed_options={"commands": True})
agents_dir = tmp_path / ".github" / "agents"
assert agents_dir.is_dir()
agent_files = sorted(agents_dir.glob("speckit.*.agent.md"))
@@ -178,7 +157,7 @@ class TestCopilotIntegration:
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
copilot.setup(tmp_path, m)
copilot.setup(tmp_path, m, parsed_options={"commands": True})
agents_dir = tmp_path / ".github" / "agents"
for agent_file in agents_dir.glob("speckit.*.agent.md"):
content = agent_file.read_text(encoding="utf-8")
@@ -193,7 +172,7 @@ class TestCopilotIntegration:
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
copilot.setup(tmp_path, m)
copilot.setup(tmp_path, m, parsed_options={"commands": True})
specify_file = tmp_path / ".github" / "agents" / "speckit.specify.agent.md"
content = specify_file.read_text(encoding="utf-8")
@@ -209,7 +188,7 @@ class TestCopilotIntegration:
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
copilot.setup(tmp_path, m)
copilot.setup(tmp_path, m, parsed_options={"commands": True})
specify_file = tmp_path / ".github" / "agents" / "speckit.specify.agent.md"
content = specify_file.read_text(encoding="utf-8")
@@ -233,7 +212,7 @@ class TestCopilotIntegration:
encoding="utf-8",
)
copilot.setup(tmp_path, m)
copilot.setup(tmp_path, m, parsed_options={"commands": True})
specify_file = tmp_path / ".github" / "agents" / "speckit.specify.agent.md"
content = specify_file.read_text(encoding="utf-8")
@@ -246,14 +225,14 @@ class TestCopilotIntegration:
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
copilot.setup(tmp_path, m)
copilot.setup(tmp_path, m, parsed_options={"commands": True})
plan_file = tmp_path / ".github" / "agents" / "speckit.plan.agent.md"
assert plan_file.exists()
content = plan_file.read_text(encoding="utf-8")
assert "__CONTEXT_FILE__" not in content
def test_complete_file_inventory_sh(self, tmp_path):
"""Every file produced by specify init --integration copilot --script sh."""
"""Every file produced by Copilot commands mode with shell scripts."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "inventory-sh"
@@ -262,7 +241,8 @@ class TestCopilotIntegration:
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot", "--script", "sh",
"init", "--here", "--integration", "copilot",
"--integration-options", "--commands", "--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
@@ -315,7 +295,7 @@ class TestCopilotIntegration:
)
def test_complete_file_inventory_ps(self, tmp_path):
"""Every file produced by specify init --integration copilot --script ps."""
"""Every file produced by Copilot commands mode with PowerShell scripts."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "inventory-ps"
@@ -324,7 +304,8 @@ class TestCopilotIntegration:
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot", "--script", "ps",
"init", "--here", "--integration", "copilot",
"--integration-options", "--commands", "--script", "ps",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
@@ -376,54 +357,8 @@ class TestCopilotIntegration:
f"Extra: {sorted(set(actual) - set(expected))}"
)
def test_default_cli_init_warns_legacy_markdown_is_deprecated(self, tmp_path):
"""Default Copilot init should warn users about the future skills default."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "default-warning"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
with pytest.warns(
UserWarning,
match="Copilot legacy markdown mode is deprecated",
):
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot", "--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
def test_skills_cli_init_does_not_warn_about_legacy_markdown(self, tmp_path):
"""Explicit Copilot skills mode should not warn about the legacy default."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "skills-no-warning"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot",
"--integration-options", "--skills", "--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
assert not any(
"Copilot legacy markdown mode is deprecated" in str(item.message)
for item in caught
)
class TestCopilotSkillsMode:
"""Tests for Copilot integration in --skills mode."""
"""Tests for Copilot's default skills mode."""
_SKILL_COMMANDS = [
"analyze", "clarify", "constitution", "converge", "implement",
@@ -436,7 +371,7 @@ class TestCopilotSkillsMode:
def _setup_skills(self, copilot, tmp_path):
m = IntegrationManifest("copilot", tmp_path)
created = copilot.setup(tmp_path, m, parsed_options={"skills": True})
created = copilot.setup(tmp_path, m)
return created, m
# -- Options ----------------------------------------------------------
@@ -449,6 +384,137 @@ class TestCopilotSkillsMode:
assert skills_opts[0].is_flag is True
assert skills_opts[0].default is False
def test_options_include_commands_flag(self):
copilot = get_integration("copilot")
commands_opts = [o for o in copilot.options() if o.name == "--commands"]
assert len(commands_opts) == 1
assert commands_opts[0].is_flag is True
assert commands_opts[0].default is False
def test_default_is_skills_mode(self):
copilot = self._make_copilot()
assert copilot.is_skills_mode() is True
assert copilot.is_skills_mode({}) is True
def test_commands_flag_disables_skills_mode(self):
copilot = self._make_copilot()
assert copilot.is_skills_mode({"commands": True}) is False
def test_existing_commands_layout_is_preserved(self, tmp_path):
copilot = self._make_copilot()
agents_dir = tmp_path / ".github" / "agents"
agents_dir.mkdir(parents=True)
(agents_dir / "speckit.plan.agent.md").write_text(
"# plan\n", encoding="utf-8"
)
assert copilot.is_skills_mode(project_root=tmp_path) is False
def test_setup_preserves_existing_commands_without_stored_options(
self, tmp_path
):
copilot = self._make_copilot()
agents_dir = tmp_path / ".github" / "agents"
agents_dir.mkdir(parents=True)
(agents_dir / "speckit.plan.agent.md").write_text(
"# old plan\n", encoding="utf-8"
)
manifest = IntegrationManifest("copilot", tmp_path)
created = copilot.setup(tmp_path, manifest)
assert any(path.name.endswith(".agent.md") for path in created)
assert not (tmp_path / ".github" / "skills").exists()
assert copilot._skills_mode is False
def test_existing_skills_layout_stays_in_skills_mode(self, tmp_path):
copilot = self._make_copilot()
(tmp_path / ".github" / "skills" / "speckit-plan").mkdir(parents=True)
assert copilot.is_skills_mode(project_root=tmp_path) is True
def test_commands_manifest_wins_over_untracked_skill(self, tmp_path):
copilot = self._make_copilot()
manifest = IntegrationManifest("copilot", tmp_path)
copilot.setup(
tmp_path, manifest, parsed_options={"commands": True}
)
manifest.save()
stale_skill = (
tmp_path
/ ".github"
/ "skills"
/ "speckit-plan"
/ "SKILL.md"
)
stale_skill.parent.mkdir(parents=True)
stale_skill.write_text("# user-authored skill\n", encoding="utf-8")
assert copilot.is_skills_mode(project_root=tmp_path) is False
def test_skills_manifest_wins_over_untracked_command(self, tmp_path):
copilot = self._make_copilot()
manifest = IntegrationManifest("copilot", tmp_path)
copilot.setup(tmp_path, manifest)
manifest.save()
stale_agent = (
tmp_path
/ ".github"
/ "agents"
/ "speckit.plan.agent.md"
)
stale_agent.parent.mkdir(parents=True)
stale_agent.write_text("# stale command\n", encoding="utf-8")
assert copilot.is_skills_mode(project_root=tmp_path) is True
def test_explicit_skills_forces_migration_from_commands(self, tmp_path):
copilot = self._make_copilot()
agents_dir = tmp_path / ".github" / "agents"
agents_dir.mkdir(parents=True)
(agents_dir / "speckit.plan.agent.md").write_text(
"# plan\n", encoding="utf-8"
)
assert (
copilot.is_skills_mode({"skills": True}, project_root=tmp_path)
is True
)
def test_skills_and_commands_flags_are_mutually_exclusive(self):
copilot = self._make_copilot()
with pytest.raises(typer.Exit):
copilot.is_skills_mode({"skills": True, "commands": True})
def test_cli_rejects_skills_and_commands_together(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "conflicting-modes"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(
app,
[
"init",
"--here",
"--integration",
"copilot",
"--integration-options",
"--skills --commands",
"--script",
"sh",
],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 1
assert "--skills and --commands are mutually exclusive" in result.output
assert not (project / ".github" / "skills").exists()
assert not (project / ".github" / "agents").exists()
# -- Skills directory structure ---------------------------------------
def test_skills_creates_skill_files(self, tmp_path):
@@ -623,16 +689,16 @@ class TestCopilotSkillsMode:
def test_skills_mode_invoke_separator(self):
"""Copilot effective_invoke_separator should reflect skills mode."""
copilot = self._make_copilot()
assert copilot.effective_invoke_separator() == "."
assert copilot.effective_invoke_separator() == "-"
assert copilot.effective_invoke_separator({"skills": True}) == "-"
assert copilot.effective_invoke_separator({"skills": False}) == "."
assert copilot.effective_invoke_separator({"commands": True}) == "."
def test_invoke_separator_for_mode_tracks_persisted_state(self):
"""Regression (review #3415): registration paths (preset/extension
command refs) must resolve the separator from the persisted ai_skills
state. A Copilot skills project renders ``/speckit-<cmd>`` (hyphen),
matching ``build_command_invocation``; the default markdown layout
renders ``/speckit.<cmd>`` (dot).
matching ``build_command_invocation``; commands mode renders
``/speckit.<cmd>`` (dot).
"""
copilot = self._make_copilot()
assert copilot.invoke_separator_for_mode(True) == "-"
@@ -673,7 +739,7 @@ class TestCopilotSkillsMode:
def test_install_uninstall_roundtrip(self, tmp_path):
copilot = self._make_copilot()
m = IntegrationManifest("copilot", tmp_path)
created = copilot.install(tmp_path, m, parsed_options={"skills": True})
created = copilot.install(tmp_path, m)
assert len(created) > 0
m.save()
for f in created:
@@ -685,7 +751,7 @@ class TestCopilotSkillsMode:
def test_modified_file_survives_uninstall(self, tmp_path):
copilot = self._make_copilot()
m = IntegrationManifest("copilot", tmp_path)
created = copilot.install(tmp_path, m, parsed_options={"skills": True})
created = copilot.install(tmp_path, m)
m.save()
modified_file = created[0]
modified_file.write_text("user modified this", encoding="utf-8")
@@ -710,6 +776,12 @@ class TestCopilotSkillsMode:
def test_build_command_invocation_default_mode(self):
copilot = self._make_copilot()
assert copilot.build_command_invocation("plan", "my args") == "/speckit-plan my args"
assert copilot.build_command_invocation("plan") == "/speckit-plan"
def test_build_command_invocation_commands_mode(self):
copilot = self._make_copilot()
copilot._skills_mode = False
assert copilot.build_command_invocation("plan", "my args") == "my args"
assert copilot.build_command_invocation("plan") == ""
@@ -725,8 +797,8 @@ class TestCopilotSkillsMode:
# -- CLI integration test ---------------------------------------------
def test_init_with_integration_options_skills(self, tmp_path):
"""specify init --integration copilot --integration-options='--skills' scaffolds skills."""
def test_init_defaults_to_skills(self, tmp_path):
"""specify init --integration copilot scaffolds skills by default."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "copilot-skills"
@@ -736,7 +808,6 @@ class TestCopilotSkillsMode:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot",
"--integration-options", "--skills",
"--script", "sh",
], catch_exceptions=False)
finally:
@@ -752,7 +823,7 @@ class TestCopilotSkillsMode:
assert not (project / ".vscode" / "settings.json").exists()
def test_complete_file_inventory_skills_sh(self, tmp_path):
"""Every file produced by specify init --integration copilot --integration-options='--skills' --script sh."""
"""Every file produced by default Copilot init with shell scripts."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "inventory-skills-sh"
@@ -762,7 +833,6 @@ class TestCopilotSkillsMode:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot",
"--integration-options", "--skills",
"--script", "sh",
], catch_exceptions=False)
finally:
@@ -802,36 +872,46 @@ class TestCopilotSkillsMode:
# -- Singleton leak: _skills_mode must reset --------------------------
def test_skills_mode_resets_on_default_setup(self, tmp_path):
"""setup() with skills=True then without must reset _skills_mode."""
def test_skills_mode_resets_between_layouts(self, tmp_path):
"""setup() must reset the singleton mode for each selected layout."""
copilot = self._make_copilot()
# First call: skills mode
# First call: default skills mode
(tmp_path / "proj1").mkdir()
m1 = IntegrationManifest("copilot", tmp_path / "proj1")
copilot.setup(tmp_path / "proj1", m1, parsed_options={"skills": True})
copilot.setup(tmp_path / "proj1", m1)
assert copilot._skills_mode is True
# Second call: default mode (no skills option)
# Second call: explicit commands mode
(tmp_path / "proj2").mkdir()
m2 = IntegrationManifest("copilot", tmp_path / "proj2")
copilot.setup(tmp_path / "proj2", m2)
copilot.setup(
tmp_path / "proj2", m2, parsed_options={"commands": True}
)
assert copilot._skills_mode is False
# build_command_invocation must use default (dotted) mode
assert copilot.build_command_invocation("plan", "args") == "args"
# -- Auto-detection must ignore unrelated .github/skills/ -------------
# Third call: a fresh default project must switch back to skills.
(tmp_path / "proj3").mkdir()
m3 = IntegrationManifest("copilot", tmp_path / "proj3")
copilot.setup(tmp_path / "proj3", m3)
assert copilot._skills_mode is True
assert copilot.build_command_invocation("plan") == "/speckit-plan"
def test_dispatch_ignores_unrelated_skills_directory(self, tmp_path):
"""dispatch_command() must not treat unrelated .github/skills/ as skills mode."""
# -- Auto-detection must preserve managed commands --------------------
def test_dispatch_preserves_commands_with_unrelated_skills(self, tmp_path):
"""Unrelated skills must not migrate a managed commands layout."""
copilot = self._make_copilot()
# Create a .github/skills/ with non-speckit content (e.g. GitHub Skills training)
agents_dir = tmp_path / ".github" / "agents"
agents_dir.mkdir(parents=True)
(agents_dir / "speckit.plan.agent.md").write_text(
"# plan\n", encoding="utf-8"
)
unrelated = tmp_path / ".github" / "skills" / "introduction-to-github"
unrelated.mkdir(parents=True)
(unrelated / "README.md").write_text("# GitHub Skills training\n")
# Should NOT detect skills mode — cli_args should contain --agent
import unittest.mock as mock
with mock.patch("subprocess.run") as mock_run:
mock_run.return_value = mock.Mock(returncode=0, stdout="", stderr="")
@@ -868,7 +948,7 @@ class TestCopilotSkillsMode:
# -- Next-steps display for Copilot skills mode -----------------------
def test_init_skills_next_steps_show_skill_syntax(self, tmp_path):
"""specify init --integration copilot --integration-options='--skills' shows /speckit-plan not /speckit.plan."""
"""Default Copilot init shows /speckit-plan, not /speckit.plan."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "copilot-nextsteps"
@@ -878,7 +958,6 @@ class TestCopilotSkillsMode:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot",
"--integration-options", "--skills",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
@@ -199,6 +199,24 @@ class TestKimiTeardownLegacyCleanup:
assert user_skill.exists()
def test_teardown_preserves_non_utf8_user_skill(self, tmp_path):
i = get_integration("kimi")
user_skill = (
tmp_path
/ ".kimi"
/ "skills"
/ "speckit-user-owned"
/ "SKILL.md"
)
user_skill.parent.mkdir(parents=True)
user_skill.write_bytes(b"\xff\xfe")
m = IntegrationManifest("kimi", tmp_path)
i.teardown(tmp_path, m)
assert user_skill.read_bytes() == b"\xff\xfe"
class TestKimiCommandInvocation:
"""Kimi dispatch must use the native ``/skill:`` slash command."""
+7 -7
View File
@@ -89,10 +89,10 @@ def test_write_integration_json_strips_integration_key(tmp_path):
def test_with_integration_setting_recomputes_separator_from_retained_options():
"""Updating only script_type must not drop an options-dependent separator.
Copilot resolves the command-ref separator to '-' when '--skills' options
are stored and '.' otherwise. A second call that changes only script_type
Copilot resolves the command-ref separator to '.' when '--commands' is
stored and '-' by default. A second call that changes only script_type
(parsed_options=None, raw_options=None) retains the stored parsed_options,
so invoke_separator must stay '-', not be recomputed from the None argument.
so invoke_separator must stay '.', not be recomputed from the None argument.
"""
from specify_cli.integrations import get_integration
from specify_cli.integration_runtime import with_integration_setting
@@ -100,15 +100,15 @@ def test_with_integration_setting_recomputes_separator_from_retained_options():
copilot = get_integration("copilot")
settings = with_integration_setting(
{}, "copilot", copilot, parsed_options={"skills": True}
{}, "copilot", copilot, parsed_options={"commands": True}
)
assert settings["copilot"]["invoke_separator"] == "-"
assert settings["copilot"]["invoke_separator"] == "."
settings2 = with_integration_setting(
{"integration_settings": settings}, "copilot", copilot, script_type="ps"
)
# parsed_options are retained (only script_type changed) ...
assert settings2["copilot"]["parsed_options"] == {"skills": True}
assert settings2["copilot"]["parsed_options"] == {"commands": True}
assert settings2["copilot"]["script"] == "ps"
# ... so the separator must reflect them, not the (None) argument.
assert settings2["copilot"]["invoke_separator"] == "-"
assert settings2["copilot"]["invoke_separator"] == "."
@@ -2224,15 +2224,99 @@ class TestIntegrationSwitch:
# Old claude files removed
assert not (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
# New copilot files created
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
assert "/speckit.specify" in shared_script.read_text(encoding="utf-8")
assert "/speckit-specify" not in shared_script.read_text(encoding="utf-8")
# New default Copilot skills created
assert (
project / ".github" / "skills" / "speckit-plan" / "SKILL.md"
).exists()
assert "/speckit-specify" in shared_script.read_text(encoding="utf-8")
assert "/speckit.specify" not in shared_script.read_text(encoding="utf-8")
# integration.json updated
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["integration"] == "copilot"
def test_switch_rejects_conflicting_copilot_modes_before_uninstall(
self, tmp_path
):
project = _init_project(tmp_path, "claude")
claude_skill = (
project / ".claude" / "skills" / "speckit-plan" / "SKILL.md"
)
before_state = json.loads(
(project / ".specify" / "integration.json").read_text(
encoding="utf-8"
)
)
result = _run_in_project(
project,
[
"integration",
"switch",
"copilot",
"--integration-options",
"--skills --commands",
"--script",
"sh",
],
)
assert result.exit_code == 1
assert "--skills and --commands are mutually exclusive" in result.output
assert claude_skill.exists()
assert not (project / ".github" / "skills").exists()
assert not (project / ".github" / "agents").exists()
after_state = json.loads(
(project / ".specify" / "integration.json").read_text(
encoding="utf-8"
)
)
assert after_state == before_state
def test_switch_preserves_target_options_with_fallback_integration(
self, tmp_path
):
project = _init_project(tmp_path, "claude")
install = _run_in_project(
project,
[
"integration",
"install",
"opencode",
"--script",
"sh",
"--force",
],
)
assert install.exit_code == 0, install.output
result = _run_in_project(
project,
[
"integration",
"switch",
"copilot",
"--integration-options",
"--commands",
"--script",
"sh",
],
)
assert result.exit_code == 0, result.output
assert (
project / ".github" / "agents" / "speckit.plan.agent.md"
).exists()
assert not (project / ".github" / "skills").exists()
state = json.loads(
(project / ".specify" / "integration.json").read_text(
encoding="utf-8"
)
)
assert state["integration_settings"]["copilot"]["parsed_options"] == {
"commands": True
}
def test_switch_migrates_extension_commands(self, tmp_path):
"""Switching should migrate extension commands to the new agent directory."""
project = _init_project(tmp_path, "kimi")
@@ -2492,6 +2576,7 @@ class TestIntegrationSwitch:
os.chdir(project)
result = runner.invoke(app, [
"integration", "switch", "copilot",
"--integration-options", "--commands",
"--script", "sh",
], catch_exceptions=False)
finally:
@@ -2530,6 +2615,7 @@ class TestIntegrationSwitch:
os.chdir(project)
result = runner.invoke(app, [
"integration", "switch", "copilot",
"--integration-options", "--commands",
"--script", "sh",
], catch_exceptions=False)
finally:
@@ -2558,6 +2644,7 @@ class TestIntegrationSwitch:
os.chdir(project)
result = runner.invoke(app, [
"integration", "switch", "copilot",
"--integration-options", "--commands",
"--script", "sh",
], catch_exceptions=False)
finally:
@@ -2582,6 +2669,7 @@ class TestIntegrationSwitch:
os.chdir(project)
result = runner.invoke(app, [
"integration", "switch", "copilot",
"--integration-options", "--commands",
"--script", "sh",
"--refresh-shared-infra",
], catch_exceptions=False)
@@ -2894,7 +2982,9 @@ class TestIntegrationUpgrade:
assert manifest_path.read_text(encoding="utf-8") == before_manifest
def test_upgrade_default_refreshes_shared_script_refs_for_option_separator_change(self, tmp_path):
project = _init_project(tmp_path, "copilot")
project = _init_project(
tmp_path, "copilot", integration_options="--commands"
)
template = project / ".specify" / "templates" / "plan-template.md"
managed_script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
customized_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
@@ -2916,6 +3006,46 @@ class TestIntegrationUpgrade:
assert "/speckit.specify" not in managed_content
assert customized_script.read_text(encoding="utf-8") == customized_before
def test_upgrade_preserves_historical_copilot_commands_without_options(
self, tmp_path
):
"""A command manifest restores missing files instead of migrating."""
project = _init_project(
tmp_path, "copilot", integration_options="--commands"
)
state_path = project / ".specify" / "integration.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
copilot_settings = state["integration_settings"]["copilot"]
copilot_settings.pop("raw_options", None)
copilot_settings.pop("parsed_options", None)
state_path.write_text(json.dumps(state), encoding="utf-8")
for path in (project / ".github" / "agents").glob(
"speckit.*.agent.md"
):
path.unlink()
for path in (project / ".github" / "prompts").glob(
"speckit.*.prompt.md"
):
path.unlink()
result = _run_in_project(
project,
["integration", "upgrade", "copilot", "--script", "sh", "--force"],
)
assert result.exit_code == 0, result.output
assert (
project / ".github" / "agents" / "speckit.plan.agent.md"
).exists()
assert not (project / ".github" / "skills").exists()
init_options = json.loads(
(project / ".specify" / "init-options.json").read_text(
encoding="utf-8"
)
)
assert init_options.get("ai_skills") is not True
def test_upgrade_non_default_keeps_default_template_invocations(self, tmp_path):
project = _init_project(tmp_path, "gemini")
template = project / ".specify" / "templates" / "plan-template.md"
@@ -3721,7 +3851,9 @@ class TestIntegrationUpgrade:
tracking it, so without ``stale_cleanup_exclusions()`` the Phase 2
stale cleanup would delete it (destroying the user's settings).
"""
project = _init_project(tmp_path, "copilot")
project = _init_project(
tmp_path, "copilot", integration_options="--commands"
)
settings = project / ".vscode" / "settings.json"
assert settings.is_file(), "init should create .vscode/settings.json"
before = json.loads(settings.read_text(encoding="utf-8"))
+48
View File
@@ -50,3 +50,51 @@ def test_init_command_registered():
cmd.callback.__name__ for cmd in app.registered_commands if cmd.callback
]
assert "init" in callback_names
def test_resolve_default_init_integration_unset(monkeypatch):
from specify_cli._agent_config import (
DEFAULT_INIT_INTEGRATION,
DEFAULT_INIT_INTEGRATION_ENV_VAR,
resolve_default_init_integration,
)
monkeypatch.delenv(DEFAULT_INIT_INTEGRATION_ENV_VAR, raising=False)
assert resolve_default_init_integration() == DEFAULT_INIT_INTEGRATION
def test_resolve_default_init_integration_valid_override(monkeypatch):
from specify_cli._agent_config import (
DEFAULT_INIT_INTEGRATION_ENV_VAR,
resolve_default_init_integration,
)
monkeypatch.setenv(DEFAULT_INIT_INTEGRATION_ENV_VAR, "gemini")
assert resolve_default_init_integration() == "gemini"
def test_resolve_default_init_integration_whitespace_trimmed(monkeypatch):
from specify_cli._agent_config import (
DEFAULT_INIT_INTEGRATION_ENV_VAR,
resolve_default_init_integration,
)
monkeypatch.setenv(DEFAULT_INIT_INTEGRATION_ENV_VAR, " gemini ")
assert resolve_default_init_integration() == "gemini"
def test_resolve_default_init_integration_invalid_warns_and_falls_back(
monkeypatch, capsys
):
from specify_cli._agent_config import (
DEFAULT_INIT_INTEGRATION,
DEFAULT_INIT_INTEGRATION_ENV_VAR,
resolve_default_init_integration,
)
monkeypatch.setenv(DEFAULT_INIT_INTEGRATION_ENV_VAR, "not-a-real-agent")
assert resolve_default_init_integration() == DEFAULT_INIT_INTEGRATION
captured = capsys.readouterr()
assert "not-a-real-agent" in captured.err
assert DEFAULT_INIT_INTEGRATION_ENV_VAR in captured.err
def test_resolve_default_init_integration_re_exported_from_init():
from specify_cli import resolve_default_init_integration
assert callable(resolve_default_init_integration)
+2 -2
View File
@@ -2043,7 +2043,7 @@ class TestExtensionSkillRegistration:
assert skill_file.exists(), "sanity: skills mode should write SKILL.md"
# Toggle ai_skills off for the same active agent (copilot) and
# rescaffold, mirroring `integration upgrade copilot` (no --skills).
# rescaffold, mirroring `integration upgrade copilot --commands`.
_create_init_options(project_dir, ai="copilot", ai_skills=False)
manager.register_enabled_extensions_for_agent("copilot")
@@ -2117,7 +2117,7 @@ class TestExtensionSkillRegistration:
)
# Toggle copilot to command mode (mirroring `integration upgrade
# copilot` with no --skills) — copilot's mirror is now stale.
# copilot --commands`) — copilot's mirror is now stale.
_create_init_options(project_dir, ai="copilot", ai_skills=False)
manager.register_enabled_extensions_for_agent("copilot")
+635 -4
View File
@@ -17,6 +17,7 @@ import platform
import tempfile
import shutil
import tomllib
import yaml
from contextlib import contextmanager
from pathlib import Path
from datetime import datetime, timezone
@@ -410,6 +411,55 @@ class TestExtensionManifest:
with pytest.raises(ValidationError, match="Invalid version"):
ExtensionManifest(manifest_path)
@pytest.mark.parametrize(
"bad",
[
1.0, # unquoted YAML float -- the likeliest authoring slip
5, # unquoted int
True, # YAML `yes`/`true`
None, # `speckit_version:` written but left empty
[">=0.1.0"], # iterable: slips past SpecifierSet() entirely
{"min": "0.1"}, # iterable: same
],
)
def test_non_string_speckit_version(self, temp_dir, valid_manifest_data, bad):
"""A non-string requires.speckit_version must be a ValidationError.
It was presence-checked only, so it reached ``SpecifierSet(required)`` in
check_compatibility(), which is guarded by ``except InvalidSpecifier``
alone. A non-string escapes that guard two ways: scalars raise TypeError
from the constructor, and a list/dict is iterable so SpecifierSet accepts
it and the failure surfaces later as ``AttributeError: 'str' object has no
attribute 'filter'`` from inside .contains().
"""
import yaml
valid_manifest_data["requires"]["speckit_version"] = 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="Invalid requires.speckit_version"
):
ExtensionManifest(manifest_path)
def test_empty_speckit_version(self, temp_dir, valid_manifest_data):
"""A blank requires.speckit_version must be rejected, not treated as any."""
import yaml
valid_manifest_data["requires"]["speckit_version"] = " "
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 requires.speckit_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
@@ -671,6 +721,102 @@ class TestExtensionManifest:
with pytest.raises(ValidationError, match=f"Invalid {section}"):
ExtensionManifest(manifest_path)
@pytest.mark.parametrize("field", ["id", "name", "version", "description"])
@pytest.mark.parametrize("bad", [1.0, 5, None, ["a"], {"a": 1}, True])
def test_extension_metadata_field_not_string_rejected(
self, temp_dir, valid_manifest_data, field, bad
):
"""A non-string extension.<field> must raise ValidationError, not a raw
TypeError.
The loop over these four fields only checked key PRESENCE, then fed the
values to ``re.match`` (id) and ``packaging.Version`` (version), both of
which raise a bare TypeError on a non-string. YAML makes that an easy
authoring slip: unquoted ``version: 1.0`` parses as a float and ``id: 2``
as an int. TypeError is not a ValidationError, so it escaped
list_installed()'s "Corrupted extension" fallback and made
`specify extension list` exit 1 with a raw traceback, hiding every
healthy extension too. The sibling IntegrationDescriptor already
type-checks the same four fields.
"""
import yaml
valid_manifest_data["extension"][field] = 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 extension.{field}"):
ExtensionManifest(manifest_path)
@pytest.mark.parametrize("bad", [1.0, 5, None, ["a"], {"a": 1}, True])
def test_command_name_not_string_rejected(
self, temp_dir, valid_manifest_data, bad
):
"""A non-string command name must raise ValidationError, not a raw
TypeError from the name-pattern match.
The sibling ``file`` field was already covered, since
relative_extension_path_violation() rejects a non-string value; ``name``
went straight into EXTENSION_COMMAND_NAME_PATTERN.match().
"""
import yaml
valid_manifest_data["provides"]["commands"][0]["name"] = 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="Invalid command name"):
ExtensionManifest(manifest_path)
def test_one_bad_manifest_does_not_hide_healthy_extensions(self, temp_dir):
"""End-to-end guard for the symptom: an unquoted ``version: 1.0`` in one
installed extension must degrade to "Corrupted extension" and still let
list_installed() report the healthy ones, instead of raising TypeError
out of the whole call.
"""
ext_root = temp_dir / ".specify" / "extensions"
for ext_id, version in (("good-ext", '"1.0.0"'), ("bad-ext", "1.0")):
ext_path = ext_root / ext_id
ext_path.mkdir(parents=True, exist_ok=True)
(ext_path / "extension.yml").write_text(
f"""schema_version: "1.0"
extension:
id: {ext_id}
name: {ext_id}
version: {version}
description: desc
requires:
speckit_version: ">=0.1.0"
provides:
commands:
- name: speckit.{ext_id}.hello
file: commands/hello.md
""",
encoding="utf-8",
)
(ext_root / ".registry").write_text(
json.dumps(
{
"schema_version": "1.0",
"extensions": {
"good-ext": {"version": "1.0.0", "enabled": True},
"bad-ext": {"version": "1.0", "enabled": True},
},
}
),
encoding="utf-8",
)
listed = {row["id"]: row for row in ExtensionManager(temp_dir).list_installed()}
assert set(listed) == {"good-ext", "bad-ext"}
assert "Corrupted" not in listed["good-ext"]["description"]
assert "Corrupted" in listed["bad-ext"]["description"]
def test_empty_provides_mapping_is_still_accepted_with_hooks(
self, temp_dir, valid_manifest_data
):
@@ -1169,6 +1315,28 @@ class TestExtensionManager:
with pytest.raises(CompatibilityError, match="Extension requires spec-kit"):
manager.check_compatibility(manifest, "0.0.1")
@pytest.mark.parametrize(
"bad",
[1.0, 5, True, None, [">=0.1.0"], {"min": "0.1"}],
)
def test_check_compatibility_non_string_specifier(self, project_dir, bad):
"""check_compatibility() must report a non-string as CompatibilityError.
Defense in depth for the validator check above: this method is public and
reachable with a hand-built manifest, and ``except InvalidSpecifier`` does
not cover a non-string. Without the guard, scalars raise a bare TypeError
and iterables construct fine only to break inside .contains() -- neither
is a CompatibilityError, so both bypass the CLI's "Compatibility Error"
handler and exit 1 with a raw traceback naming no field.
"""
from types import SimpleNamespace
manager = ExtensionManager(project_dir)
manifest = SimpleNamespace(requires_speckit_version=bad)
with pytest.raises(CompatibilityError, match="Invalid version specifier"):
manager.check_compatibility(manifest, "0.15.2")
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)
@@ -1505,6 +1673,57 @@ class TestExtensionManager:
assert external_target.read_text() == "model: linked-model\n"
assert not manager.registry.is_installed("test-ext")
def test_reinstall_with_unreadable_kept_config_aborts_with_guidance(
self, extension_dir, project_dir, monkeypatch
):
"""An unreadable kept config must abort reinstall, not crash it.
The sibling symlink guard four lines above raises ``ValidationError``
with resolution guidance, but the rescue read itself
(``cfg_file.read_bytes()``/``stat()``) had no boundary, so a kept
config that cannot be read (permission or I/O error) crashed the
reinstall with a raw ``OSError``. It must reject the reinstall while
dest_dir is untouched so the preserved bytes are never rescued
half-read or lost to the rmtree below.
"""
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")
kept_bytes = config_file.read_bytes()
manager.remove("test-ext", keep_config=True)
assert not manager.registry.is_installed("test-ext")
assert config_file.is_file()
# Simulate a kept config that can no longer be read (e.g. a
# permission or I/O error) without touching real permissions so the
# test also runs on platforms where chmod is a no-op.
original_read_bytes = Path.read_bytes
def failing_read_bytes(self_path, *args, **kwargs):
if self_path == config_file:
raise PermissionError(13, "Permission denied")
return original_read_bytes(self_path, *args, **kwargs)
monkeypatch.setattr(Path, "read_bytes", failing_read_bytes)
with pytest.raises(ValidationError, match="cannot be read"):
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
# The kept config survives untouched; nothing was rescued half-read.
monkeypatch.undo()
assert config_file.read_bytes() == kept_bytes
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
):
@@ -1995,6 +2214,87 @@ class TestExtensionManager:
assert (staging_dir / "test-ext-config.yml").read_bytes() == staged_bytes
assert not manager.registry.is_installed("test-ext")
def test_retry_with_unreadable_staged_config_aborts_and_preserves_both(
self, extension_dir, project_dir, monkeypatch
):
"""An unreadable staged backup must abort the retry, not crash it.
Every sibling read in the retry path (the live twin, the packaged
baseline check, the mode sidecar) already catches ``OSError``, but the
staged file's own ``stat()``/``read_bytes()`` had no boundary, so a
staged config that cannot be read crashed the reinstall with a raw
``OSError`` instead of the conflict guidance. It must be treated like
an uncomparable live config: preserve both copies and abort while
dest_dir is untouched.
"""
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")
live_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()
staged_file = staging_dir / "test-ext-config.yml"
assert staged_file.is_file()
# Simulate a staged backup that can no longer be read (e.g. a
# permission or I/O error) without touching real permissions so the
# test also runs on platforms where chmod is a no-op.
original_read_bytes = Path.read_bytes
def failing_read_bytes(self_path, *args, **kwargs):
if self_path == staged_file:
raise PermissionError(13, "Permission denied")
return original_read_bytes(self_path, *args, **kwargs)
monkeypatch.setattr(Path, "read_bytes", failing_read_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 live config and the staged backup.
monkeypatch.undo()
assert config_file.read_bytes() == live_bytes
assert staging_dir.exists()
assert staged_file.is_file()
assert not manager.registry.is_installed("test-ext")
@pytest.mark.parametrize(
"failure_mode",
[
@@ -2697,6 +2997,30 @@ Real body starts here.
assert "Prüfe Konformität" in output
assert "\\u" not in output
def test_render_frontmatter_keeps_long_description_on_one_line(self):
"""A long description must not be folded across lines.
PyYAML wraps plain scalars at ~80 columns by default, which splits a
long ``description`` onto a continuation line. The YAML stays valid,
but the rendered frontmatter then differs in shape from the
hand-written core command templates, where ``description`` is always a
single line -- and consumers that read frontmatter line-wise see a
truncated description followed by a stray line.
"""
long_description = (
"Execute the implementation plan by processing and executing all "
"tasks defined in tasks.md"
)
frontmatter = {"name": "speckit-implement", "description": long_description}
registrar = CommandRegistrar()
output = registrar.render_frontmatter(frontmatter)
assert f"description: {long_description}\n" in output
body = output.split("---\n")[1]
assert yaml.safe_load(body)["description"] == long_description
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
@@ -5003,9 +5327,9 @@ class TestExtensionCatalog:
catalog = self._make_catalog(temp_dir)
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(
mock_response.read.side_effect = io.BytesIO(json.dumps(
{"schema_version": "1.0", "extensions": {}}
).encode()
).encode()).read
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "http://evil.test/catalog.json"
@@ -5051,9 +5375,9 @@ class TestExtensionCatalog:
catalog = self._make_catalog(temp_dir)
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(
mock_response.read.side_effect = io.BytesIO(json.dumps(
{"schema_version": "1.0", "extensions": {}}
).encode()
).encode()).read
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "http://evil.test/catalog.json"
@@ -10454,3 +10778,310 @@ def test_forge_extension_info_hyphenates_command_names(
# not the manifest's dotted name.
assert "speckit-test-ext-hello" in output, output
assert "speckit.test-ext.hello" not in output, output
# ===== Extension Config Scaffolding Tests =====
class TestExtensionConfigScaffolding:
"""Test automatic config scaffolding during add/enable lifecycle."""
def _make_extension(self, ext_dir, config_entries=None):
"""Create a minimal extension with optional config templates."""
ext_dir.mkdir(parents=True, exist_ok=True)
manifest = {
"schema_version": "1.0",
"extension": {
"id": "test-ext",
"name": "Test Extension",
"version": "1.0.0",
"description": "Test extension",
"author": "Test",
"repository": "https://github.com/test/test",
"license": "MIT",
"homepage": "https://github.com/test/test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [{
"name": "speckit.test-ext.example",
"file": "commands/example.md",
"description": "Example command",
}],
},
"tags": ["test"],
}
if config_entries:
manifest["provides"]["config"] = config_entries
import yaml
(ext_dir / "extension.yml").write_text(yaml.dump(manifest, default_flow_style=False))
# Create command file so validation passes
(ext_dir / "commands").mkdir(exist_ok=True)
(ext_dir / "commands" / "example.md").write_text("# Example")
return manifest
def test_scaffold_config_deploys_template(self, tmp_path):
"""Config template lands where ConfigManager reads it, not in .specify/ root."""
from specify_cli.extensions import ExtensionManager
project = tmp_path / "project"
specify_dir = project / ".specify"
specify_dir.mkdir(parents=True)
ext_dir = specify_dir / "extensions" / "test-ext"
self._make_extension(ext_dir, config_entries=[{
"name": "test-config.yml",
"template": "config-template.yml",
"description": "Test config",
"required": True,
}])
(ext_dir / "config-template.yml").write_text("setting: default")
manager = ExtensionManager(project)
deployed, skipped, failed = manager.scaffold_config("test-ext")
assert deployed == ["test-config.yml"]
assert skipped == []
assert failed == []
# ConfigManager._get_project_config() reads
# .specify/extensions/<id>/<name>, so that is where scaffolding must
# put it. Deploying to the .specify/ root left the file somewhere the
# extension never looks.
assert (ext_dir / "test-config.yml").exists()
assert (ext_dir / "test-config.yml").read_text() == "setting: default"
assert not (specify_dir / "test-config.yml").exists()
def test_scaffold_config_preserves_existing(self, tmp_path):
"""Existing config files should never be overwritten."""
from specify_cli.extensions import ExtensionManager
project = tmp_path / "project"
specify_dir = project / ".specify"
specify_dir.mkdir(parents=True)
ext_dir = specify_dir / "extensions" / "test-ext"
self._make_extension(ext_dir, config_entries=[{
"name": "test-config.yml",
"template": "config-template.yml",
"description": "Test config",
"required": True,
}])
(ext_dir / "config-template.yml").write_text("setting: default")
(ext_dir / "test-config.yml").write_text("setting: custom")
manager = ExtensionManager(project)
deployed, skipped, failed = manager.scaffold_config("test-ext")
assert deployed == []
assert skipped == ["test-config.yml"]
assert failed == []
assert (ext_dir / "test-config.yml").read_text() == "setting: custom"
def test_scaffold_config_no_config_section(self, tmp_path):
"""Extensions without config section should return empty list."""
from specify_cli.extensions import ExtensionManager
project = tmp_path / "project"
specify_dir = project / ".specify"
specify_dir.mkdir(parents=True)
ext_dir = specify_dir / "extensions" / "test-ext"
self._make_extension(ext_dir)
manager = ExtensionManager(project)
deployed, skipped, failed = manager.scaffold_config("test-ext")
assert deployed == []
assert skipped == []
assert failed == []
def test_scaffold_config_missing_template_file(self, tmp_path):
"""Missing template files should be reported as failed."""
from specify_cli.extensions import ExtensionManager
project = tmp_path / "project"
specify_dir = project / ".specify"
specify_dir.mkdir(parents=True)
ext_dir = specify_dir / "extensions" / "test-ext"
self._make_extension(ext_dir, config_entries=[{
"name": "test-config.yml",
"template": "nonexistent.yml",
"description": "Test config",
}])
manager = ExtensionManager(project)
deployed, skipped, failed = manager.scaffold_config("test-ext")
assert deployed == []
assert skipped == []
assert failed == ["test-config.yml"]
def test_scaffold_config_rejects_path_traversal(self, tmp_path):
"""Config names with path traversal should be rejected."""
from specify_cli.extensions import ExtensionManager
project = tmp_path / "project"
specify_dir = project / ".specify"
specify_dir.mkdir(parents=True)
ext_dir = specify_dir / "extensions" / "test-ext"
self._make_extension(ext_dir, config_entries=[
{"name": "../etc/passwd", "template": "config.yml"},
{"name": "safe.yml", "template": "../../secrets.yml"},
{"name": "/absolute/path.yml", "template": "config.yml"},
])
(ext_dir / "config.yml").write_text("safe: true")
manager = ExtensionManager(project)
deployed, skipped, failed = manager.scaffold_config("test-ext")
assert deployed == []
assert skipped == []
assert failed == ["../etc/passwd", "safe.yml", "/absolute/path.yml"]
def test_scaffold_config_rejects_directory_template(self, tmp_path):
"""Directory templates should be rejected (must be regular files)."""
from specify_cli.extensions import ExtensionManager
project = tmp_path / "project"
specify_dir = project / ".specify"
specify_dir.mkdir(parents=True)
ext_dir = specify_dir / "extensions" / "test-ext"
self._make_extension(ext_dir, config_entries=[{
"name": "test-config.yml",
"template": "config-dir",
}])
(ext_dir / "config-dir").mkdir()
manager = ExtensionManager(project)
deployed, skipped, failed = manager.scaffold_config("test-ext")
assert deployed == []
assert skipped == []
assert failed == ["test-config.yml"]
def test_scaffold_config_rejects_symlink_template(self, tmp_path):
"""Symlink templates should not be copied."""
from specify_cli.extensions import ExtensionManager
project = tmp_path / "project"
specify_dir = project / ".specify"
specify_dir.mkdir(parents=True)
ext_dir = specify_dir / "extensions" / "test-ext"
self._make_extension(ext_dir, config_entries=[{
"name": "test-config.yml",
"template": "config-link.yml",
}])
real_template = ext_dir / "config-template.yml"
real_template.write_text("setting: default")
(ext_dir / "config-link.yml").symlink_to(real_template)
manager = ExtensionManager(project)
deployed, skipped, failed = manager.scaffold_config("test-ext")
assert deployed == []
assert skipped == []
assert failed == ["test-config.yml"]
assert not (specify_dir / "test-config.yml").exists()
def test_scaffold_config_malformed_manifest(self, tmp_path):
"""Malformed config sections should not crash."""
from specify_cli.extensions import ExtensionManager, ExtensionManifest
import yaml
project = tmp_path / "project"
specify_dir = project / ".specify"
specify_dir.mkdir(parents=True)
ext_dir = specify_dir / "extensions" / "test-ext"
manifest_data = self._make_extension(ext_dir)
manifest_data["provides"]["config"] = "not-a-list"
(ext_dir / "extension.yml").write_text(yaml.dump(manifest_data))
manifest = ExtensionManifest(ext_dir / "extension.yml")
assert manifest.config == []
manager = ExtensionManager(project)
deployed, skipped, failed = manager.scaffold_config("test-ext")
assert deployed == []
assert skipped == []
assert failed == ["provides.config"]
def test_scaffold_config_missing_manifest_returns_consistent_result(self, tmp_path):
"""A missing extension manifest should return the documented tuple."""
from specify_cli.extensions import ExtensionManager
project = tmp_path / "project"
(project / ".specify").mkdir(parents=True)
manager = ExtensionManager(project)
assert manager.scaffold_config("missing") == ([], [], [])
def test_scaffold_config_rejects_symlinked_config_root(self, tmp_path):
"""A symlinked .specify must not become the containment root.
Resolving .specify first and trusting the result lets a symlink point
anywhere: every target then satisfies relative_to and copy2 writes
outside the project.
"""
from specify_cli.extensions import ExtensionManager
project = tmp_path / "project"
project.mkdir()
outside = tmp_path / "outside"
outside.mkdir()
(project / ".specify").symlink_to(outside, target_is_directory=True)
ext_dir = outside / "extensions" / "test-ext"
self._make_extension(ext_dir, config_entries=[{
"name": "test-config.yml",
"template": "config-template.yml",
"description": "Test config",
"required": True,
}])
(ext_dir / "config-template.yml").write_text("setting: default")
manager = ExtensionManager(project)
deployed, skipped, failed = manager.scaffold_config("test-ext")
assert deployed == []
assert skipped == []
assert failed == ["provides.config"]
assert not (outside / "extensions" / "test-ext" / "test-config.yml").exists()
def test_scaffold_config_rejects_targets_removal_would_not_preserve(self, tmp_path):
"""Only top-level *-config.yml targets are scaffolded.
remove(keep_config=True) rmtree's every subdirectory and keeps only
top-level -config.yml / -config.local.yml files, and the backup path
globs the same pattern. Scaffolding anything else would hand the user a
file that `extension add --force` silently replaces with the template
default.
"""
from specify_cli.extensions import ExtensionManager
for target in ("nested/test-config.yml", "settings.yml", "test-config.yaml"):
project = tmp_path / f"project-{target.replace('/', '_')}"
specify_dir = project / ".specify"
specify_dir.mkdir(parents=True)
ext_dir = specify_dir / "extensions" / "test-ext"
self._make_extension(ext_dir, config_entries=[{
"name": target,
"template": "config-template.yml",
"description": "Test config",
"required": True,
}])
(ext_dir / "config-template.yml").write_text("setting: default")
manager = ExtensionManager(project)
deployed, skipped, failed = manager.scaffold_config("test-ext")
assert deployed == [], target
assert skipped == [], target
assert failed == [target], target
def test_scaffold_config_accepts_local_override_name(self, tmp_path):
"""*-config.local.yml is preserved by removal, so it may be scaffolded."""
from specify_cli.extensions import ExtensionManager
project = tmp_path / "project"
specify_dir = project / ".specify"
specify_dir.mkdir(parents=True)
ext_dir = specify_dir / "extensions" / "test-ext"
self._make_extension(ext_dir, config_entries=[{
"name": "test-config.local.yml",
"template": "config-template.yml",
"description": "Test config",
"required": True,
}])
(ext_dir / "config-template.yml").write_text("setting: default")
manager = ExtensionManager(project)
deployed, skipped, failed = manager.scaffold_config("test-ext")
assert deployed == ["test-config.local.yml"]
assert failed == []
+26
View File
@@ -274,3 +274,29 @@ class TestClineRealPostProcess:
# _rewrite_handoff_references rewrote the dotted agent handoff
assert "agent: speckit-foo" in content
assert "agent: speckit.foo" not in content
def test_register_commands_propagates_programming_errors(tmp_path):
"""Regression: narrowed exception must not swallow TypeError/AttributeError.
The invoke separator resolution narrowed from bare 'except Exception' to
'except (ImportError, ValueError, KeyError)'. Programming errors like
TypeError must propagate instead of being silently swallowed.
"""
registrar = CommandRegistrar()
commands = [{"name": "test.cmd", "file": "commands/test.md"}]
ext_dir = tmp_path / "ext"
ext_dir.mkdir()
def _broken_get_integration(name):
raise TypeError("intentional programming error")
import specify_cli.integrations as integ_mod
original = integ_mod.get_integration
integ_mod.get_integration = _broken_get_integration
try:
with pytest.raises(TypeError, match="intentional programming error"):
registrar.register_commands("bob", commands, "ext", ext_dir, tmp_path)
finally:
integ_mod.get_integration = original
+302
View File
@@ -198,6 +198,121 @@ class TestPresetManifest:
with pytest.raises(PresetValidationError, match="YAML mapping"):
PresetManifest(manifest_path)
@pytest.mark.parametrize("section", ["preset", "requires", "provides"])
@pytest.mark.parametrize("bad_value", [None, [], "text"])
def test_required_section_not_mapping_raises_validation_error(
self, temp_dir, valid_pack_data, section, bad_value
):
"""Required manifest sections reject null, list, and scalar values."""
valid_pack_data[section] = bad_value
manifest_path = temp_dir / "preset.yml"
manifest_path.write_text(
yaml.safe_dump(valid_pack_data),
encoding="utf-8",
)
with pytest.raises(
PresetValidationError,
match=rf"Invalid {section}: expected a mapping",
):
PresetManifest(manifest_path)
@pytest.mark.parametrize("field", ["id", "name", "version", "description"])
@pytest.mark.parametrize("bad", [1.0, 5, None, ["a"], {"a": 1}, True])
def test_preset_metadata_field_not_string_raises_validation_error(
self, temp_dir, valid_pack_data, field, bad
):
"""A non-string preset.<field> raises PresetValidationError, not a raw
TypeError.
The loop over these four fields only checked key PRESENCE, then fed the
values to ``re.match`` (id) and ``packaging.Version`` (version), both of
which raise a bare TypeError on a non-string. YAML makes that an easy
authoring slip: unquoted ``version: 1.0`` parses as a float and ``id: 2``
as an int. TypeError is not a PresetValidationError, so it escaped
list_installed()'s "Corrupted preset" fallback and made
`specify preset list` exit 1 with a raw traceback, hiding every healthy
preset too. The sibling IntegrationDescriptor already type-checks the
same four fields.
"""
valid_pack_data["preset"][field] = bad
manifest_path = temp_dir / "preset.yml"
manifest_path.write_text(yaml.safe_dump(valid_pack_data), encoding="utf-8")
with pytest.raises(
PresetValidationError,
match=rf"Invalid preset\.{field}: expected a string",
):
PresetManifest(manifest_path)
@pytest.mark.parametrize("field", ["name", "file"])
@pytest.mark.parametrize("bad", [1.0, 5, None, ["a"], {"a": 1}, True])
def test_template_entry_field_not_string_raises_validation_error(
self, temp_dir, valid_pack_data, field, bad
):
"""A non-string template ``name``/``file`` raises PresetValidationError.
``name`` reaches ``re.match`` and ``file`` reaches ``os.path.normpath``;
both raise a bare TypeError on a non-string. The sibling extension
manifest already rejects a non-string command ``file`` via
relative_extension_path_violation().
"""
valid_pack_data["provides"]["templates"][0][field] = bad
manifest_path = temp_dir / "preset.yml"
manifest_path.write_text(yaml.safe_dump(valid_pack_data), encoding="utf-8")
with pytest.raises(
PresetValidationError,
match=rf"Invalid template {field}: expected a string",
):
PresetManifest(manifest_path)
def test_one_bad_manifest_does_not_hide_healthy_presets(self, temp_dir):
"""End-to-end guard for the symptom: an unquoted ``version: 1.0`` in one
installed preset must degrade to "Corrupted preset" and still let
list_installed() report the healthy ones, instead of raising TypeError
out of the whole call.
"""
preset_root = temp_dir / ".specify" / "presets"
for pack_id, version in (("good-pack", '"1.0.0"'), ("bad-pack", "1.0")):
pack_path = preset_root / pack_id
pack_path.mkdir(parents=True, exist_ok=True)
(pack_path / "preset.yml").write_text(
f"""schema_version: "1.0"
preset:
id: {pack_id}
name: {pack_id}
version: {version}
description: desc
requires:
speckit_version: ">=0.1.0"
provides:
templates:
- type: template
name: spec
file: templates/spec.md
""",
encoding="utf-8",
)
(preset_root / ".registry").write_text(
json.dumps(
{
"schema_version": "1.0",
"presets": {
"good-pack": {"version": "1.0.0", "enabled": True},
"bad-pack": {"version": "1.0", "enabled": True},
},
}
),
encoding="utf-8",
)
listed = {row["id"]: row for row in PresetManager(temp_dir).list_installed()}
assert set(listed) == {"good-pack", "bad-pack"}
assert "Corrupted" not in listed["good-pack"]["description"]
assert "Corrupted" in listed["bad-pack"]["description"]
@pytest.mark.parametrize(
"bad",
[
@@ -291,6 +406,37 @@ class TestPresetManifest:
with pytest.raises(PresetValidationError, match="Missing requires.speckit_version"):
PresetManifest(manifest_path)
@pytest.mark.parametrize(
"bad",
[
1.0, # unquoted YAML float -- the likeliest authoring slip
5, # unquoted int
True, # YAML `yes`/`true`
None, # `speckit_version:` written but left empty
[">=0.1.0"], # iterable: slips past SpecifierSet() entirely
{"min": "0.1"}, # iterable: same
" ", # blank string must not mean "any version"
],
)
def test_non_string_speckit_version(self, temp_dir, valid_pack_data, bad):
"""A non-string requires.speckit_version must be a PresetValidationError.
It was presence-checked only, so it reached ``SpecifierSet(required)`` in
check_compatibility(), which is guarded by ``except InvalidSpecifier``
alone. A non-string escapes that guard two ways: scalars raise TypeError
from the constructor, and a list/dict is iterable so SpecifierSet accepts
it and the failure surfaces later as ``AttributeError: 'str' object has no
attribute 'filter'`` from inside .contains().
"""
valid_pack_data["requires"]["speckit_version"] = bad
manifest_path = temp_dir / "preset.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_pack_data, f)
with pytest.raises(
PresetValidationError, match="Invalid requires.speckit_version"
):
PresetManifest(manifest_path)
def test_no_templates_provided(self, temp_dir, valid_pack_data):
"""Test pack with no templates."""
valid_pack_data["provides"]["templates"] = []
@@ -369,6 +515,27 @@ class TestPresetRegistry:
assert registry.list() == {}
assert not registry.is_installed("test-pack")
def test_load_starts_fresh_for_non_utf8_registry(self, temp_dir):
"""A registry file with undecodable bytes must start fresh, not raise.
``_load()`` already treats malformed JSON as "corrupted registry,
start fresh", but a registry whose *bytes* cannot be decoded as UTF-8
raised a raw ``UnicodeDecodeError`` from the same boundary — the same
corruption class reaching a different exception type.
"""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
(packs_dir / PresetRegistry.REGISTRY_FILE).write_bytes(
b"\xff\xfe not utf-8 \xc3\x28"
)
registry = PresetRegistry(packs_dir)
assert registry.data == {
"schema_version": PresetRegistry.SCHEMA_VERSION,
"presets": {},
}
def test_add_and_get(self, temp_dir):
"""Test adding and retrieving a pack."""
packs_dir = temp_dir / "packs"
@@ -849,6 +1016,26 @@ class TestPresetManager:
with pytest.raises(PresetCompatibilityError, match="Invalid version specifier"):
manager.check_compatibility(manifest, "0.1.5")
@pytest.mark.parametrize(
"bad",
[1.0, 5, True, None, [">=0.1.0"], {"min": "0.1"}],
)
def test_check_compatibility_non_string_specifier(self, pack_dir, temp_dir, bad):
"""check_compatibility() must report a non-string as a compatibility error.
Defense in depth for the validator check: this method is public and the
specifier is read back out of mutable manifest data, and ``except
InvalidSpecifier`` does not cover a non-string. Without the guard, scalars
raise a bare TypeError and iterables construct fine only to break inside
.contains() -- neither is a PresetCompatibilityError, so both bypass the
CLI's "Compatibility Error" handler and exit 1 with a raw traceback.
"""
manager = PresetManager(temp_dir)
manifest = PresetManifest(pack_dir / "preset.yml")
manifest.data["requires"]["speckit_version"] = bad
with pytest.raises(PresetCompatibilityError, match="Invalid version specifier"):
manager.check_compatibility(manifest, "0.1.5")
def test_install_with_priority(self, project_dir, pack_dir):
"""Test installing a pack with custom priority."""
manager = PresetManager(project_dir)
@@ -4906,6 +5093,103 @@ class TestPresetSkills:
assert "templates/commands/specify.md" in content, "Should reference core template"
assert "disable-model-invocation: false" in content
def test_skill_restored_on_preset_remove_without_project_core_templates(self, project_dir):
"""Removing a preset must restore core skills even when the project
has no ``.specify/templates/commands`` directory of its own — which
is the normal case, since ``specify init`` never populates it. The
real core commands live in the bundled core_pack/repo-root templates
tree, and restoration must fall back there instead of deleting the
skill outright (#3928).
"""
self._write_init_options(project_dir, ai="claude")
skills_dir = project_dir / ".claude" / "skills"
self._create_skill(skills_dir, "speckit-specify")
# The project_dir fixture's commands dir is empty, matching a real
# project — specify init never populates project-local overrides
# for unmodified core commands.
core_cmds = project_dir / ".specify" / "templates" / "commands"
assert core_cmds.exists() and not any(core_cmds.iterdir())
manager = PresetManager(project_dir)
install_self_test_preset(manager)
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
assert "preset:self-test" in skill_file.read_text(encoding="utf-8")
manager.remove("self-test")
assert skill_file.exists(), "Core skill must be restored, not deleted"
content = skill_file.read_text(encoding="utf-8")
assert "preset:self-test" not in content
assert "templates/commands/specify.md" in content
assert "Create or update the feature specification" in content
def test_extension_wins_over_bundled_core_on_preset_remove(
self, project_dir, monkeypatch
):
"""When an installed extension owns the same skill name as a core
command, removing a preset that overrode that skill must restore it
from the extension, not silently from the bundled core template.
Extensions are resolved ahead of bundled core elsewhere, and the
bundled-core fallback added for #3928 must not replace that
higher-priority layer.
The extension-command namespace rules (``speckit.<ext>.<command>``)
make a genuine end-to-end name collision with a core command
cumbersome to construct through real manifests, so this stubs
``_build_extension_skill_restore_index`` to exercise the priority
ordering in ``_unregister_skills_in_dir`` directly -- the code path
under test doesn't care how the index entry was produced, only that
it wins over the bundled-core fallback when present.
"""
self._write_init_options(project_dir, ai="claude")
skills_dir = project_dir / ".claude" / "skills"
self._create_skill(skills_dir, "speckit-specify")
# No project-local core template override — the normal case, and
# the one that makes the bundled-core fallback kick in at all.
core_cmds = project_dir / ".specify" / "templates" / "commands"
assert core_cmds.exists() and not any(core_cmds.iterdir())
extension_dir = project_dir / ".specify" / "extensions" / "fakeext"
(extension_dir / "commands").mkdir(parents=True, exist_ok=True)
ext_specify_file = extension_dir / "commands" / "specify.md"
ext_specify_file.write_text(
"---\ndescription: Extension specify command\n---\n\n"
"extension:fakeext specify body\n"
)
manager = PresetManager(project_dir)
install_self_test_preset(manager)
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
assert "preset:self-test" in skill_file.read_text(encoding="utf-8")
fake_restore_index = {
"speckit-specify": {
"command_name": "speckit.fakeext.specify",
"source_file": ext_specify_file,
"source": "extension:fakeext",
"extension_id": "fakeext",
"extension_dir": extension_dir,
}
}
monkeypatch.setattr(
manager,
"_build_extension_skill_restore_index",
lambda: fake_restore_index,
)
manager.remove("self-test")
assert skill_file.exists()
content = skill_file.read_text(encoding="utf-8")
assert "preset:self-test" not in content
assert "source: extension:fakeext" in content
assert "extension:fakeext specify body" in content
assert "templates/commands/specify.md" not in content
def test_skill_restored_on_remove_resolves_script_placeholders(self, project_dir):
"""Core restore should resolve {SCRIPT}/{ARGS} placeholders like other skill paths."""
self._write_init_options(project_dir, ai="claude", ai_skills=True, script="sh")
@@ -11607,6 +11891,24 @@ class TestResolveContent:
class TestCollectAllLayers:
"""Test PresetResolver.collect_all_layers() method."""
def test_non_utf8_legacy_command_keeps_replace_strategy(self, project_dir):
presets_dir = project_dir / ".specify" / "presets"
command_path = (
presets_dir / "legacy-pack" / "commands" / "speckit.legacy.md"
)
command_path.parent.mkdir(parents=True)
command_path.write_bytes(b"\xff\xfe")
PresetRegistry(presets_dir).add(
"legacy-pack", {"version": "1.0.0", "priority": 10}
)
layers = PresetResolver(project_dir).collect_all_layers(
"speckit.legacy", "command"
)
assert layers[0]["path"] == command_path
assert layers[0]["strategy"] == "replace"
def test_single_core_layer(self, project_dir):
"""Test collecting layers with only core template."""
resolver = PresetResolver(project_dir)
+452
View File
@@ -2176,6 +2176,59 @@ class TestInitStep:
assert "--ignore-agent-tools" in argv
assert (tmp_path / ".specify").is_dir()
def test_explicit_null_ignore_agent_tools_keeps_documented_default(
self, tmp_path
):
"""A bare ``ignore_agent_tools:`` must keep the documented default.
The class docstring says "Because workflows run unattended, the step
defaults to ``--ignore-agent-tools``" and the field docs say "defaults to
``true``". But ``config.get(key, True)`` applies the default only when the
key is ABSENT — a bare ``ignore_agent_tools:`` in YAML parses to None,
which ``_resolve_bool`` turned into False, dropping the flag and
re-enabling the agent-CLI presence check for an unattended run.
"""
from specify_cli.workflows.steps.init import InitStep
from specify_cli.workflows.base import StepContext, StepStatus
step = InitStep()
ctx = StepContext(
project_root=str(tmp_path), default_integration="copilot"
)
result = step.execute(
{
"id": "bootstrap",
"here": True,
"script": "sh",
"ignore_agent_tools": None,
},
ctx,
)
assert result.status == StepStatus.COMPLETED
assert "--ignore-agent-tools" in result.output["argv"]
def test_explicit_false_ignore_agent_tools_is_honoured(self, tmp_path):
"""An explicit ``false`` must still opt in to the agent-CLI check."""
from specify_cli.workflows.steps.init import InitStep
from specify_cli.workflows.base import StepContext
step = InitStep()
ctx = StepContext(
project_root=str(tmp_path), default_integration="copilot"
)
result = step.execute(
{
"id": "bootstrap",
"here": True,
"script": "sh",
"ignore_agent_tools": False,
},
ctx,
)
assert "--ignore-agent-tools" not in result.output["argv"]
def test_default_integration_falls_back_to_workflow_default(self, tmp_path):
from specify_cli.workflows.steps.init import InitStep
from specify_cli.workflows.base import StepContext, StepStatus
@@ -2190,6 +2243,24 @@ class TestInitStep:
assert result.status == StepStatus.COMPLETED
assert result.output["integration"] == "copilot"
def test_default_integration_honors_env_var(self, tmp_path, monkeypatch):
# With no step-level and no workflow-level default, the resolved
# SPECKIT_INTEGRATION_DEFAULT value must drive both output.integration
# and the argv passed to init (guards against reverting to the constant).
from specify_cli.workflows.steps.init import InitStep
from specify_cli.workflows.base import StepContext, StepStatus
monkeypatch.setenv("SPECKIT_INTEGRATION_DEFAULT", "gemini")
step = InitStep()
ctx = StepContext(project_root=str(tmp_path))
result = step.execute(
{"id": "bootstrap", "here": True, "script": "sh"}, ctx
)
assert result.status == StepStatus.COMPLETED
assert result.output["integration"] == "gemini"
argv = result.output["argv"]
assert "--integration" in argv and "gemini" in argv
def test_project_name_creates_subdirectory(self, tmp_path):
from specify_cli.workflows.steps.init import InitStep
from specify_cli.workflows.base import StepContext, StepStatus
@@ -2552,6 +2623,36 @@ steps:
})
assert any("on_reject" in e for e in errors)
@pytest.mark.parametrize(
"bad_on_reject", ["Abort", "fail", "stop", "SKIP", None, 5, ["abort"]]
)
def test_execute_invalid_on_reject_fails_loudly(self, bad_on_reject):
"""An unrecognised ``on_reject`` must not silently complete a rejection.
``validate`` rejects anything outside abort/skip/retry, but the engine
does not auto-validate before ``execute``. The reject branch handles only
"abort" and "retry", then falls through to its ``"skip"`` case — so a
REJECTED gate reported COMPLETED and the run continued past the review
the gate exists to enforce. Reachable by a capitalisation slip, a guessed
verb, a non-string, or a bare ``on_reject:`` (which yields None, since
``config.get(k, default)`` does not replace an explicit null).
"""
from specify_cli.workflows.steps.gate import GateStep
from specify_cli.workflows.base import StepContext, StepStatus
result = GateStep().execute(
{
"id": "review",
"message": "Review the spec.",
"options": ["approve", "reject"],
"on_reject": bad_on_reject,
"verdict_input": "spec_verdict",
},
StepContext(inputs={"spec_verdict": "reject"}),
)
assert result.status == StepStatus.FAILED
assert "'on_reject' must be" in (result.error or "")
def test_validate_non_string_options_does_not_raise(self):
"""Non-string options with on_reject=abort/retry must be REPORTED as an
error, not crash: the reject-choice check calls o.lower() on each option,
@@ -3629,6 +3730,44 @@ class TestFanInStep:
assert "'wait_for' must be a list" in (result.error or "")
assert result.output["results"] == []
@pytest.mark.parametrize(
"bad_output", [[], False, 0, "", ["a"], "oops", 5]
)
def test_execute_non_mapping_output_fails_loudly(self, bad_output):
"""A non-mapping ``output`` must fail the step, not drop every key.
``validate`` rejects it and says why: "execute() silently coerces a
non-mapping output to {}, so the author's declared aggregation keys would
vanish with no error." The engine does not auto-validate before
``execute``, so that is exactly what happened — and ``x or {}`` masked
the falsy shapes (``[]``, ``false``, ``0``, ``''``) before the isinstance
check even ran. The step still returned COMPLETED, so downstream
``steps.<id>.output.<key>`` resolved to None and interpolated as "".
"""
from specify_cli.workflows.steps.fan_in import FanInStep
from specify_cli.workflows.base import StepContext, StepStatus
step = FanInStep()
ctx = StepContext(steps={"a": {"output": {"x": 1}}})
result = step.execute(
{"id": "collect", "wait_for": ["a"], "output": bad_output}, ctx
)
assert result.status == StepStatus.FAILED
assert "'output' must be a mapping" in (result.error or "")
assert result.output["results"] == []
def test_execute_explicit_null_output_stays_valid(self):
"""An explicit ``output:`` (YAML null) is valid, matching ``validate``."""
from specify_cli.workflows.steps.fan_in import FanInStep
from specify_cli.workflows.base import StepContext, StepStatus
step = FanInStep()
ctx = StepContext(steps={"a": {"output": {"x": 1}}})
result = step.execute(
{"id": "collect", "wait_for": ["a"], "output": None}, ctx
)
assert result.status == StepStatus.COMPLETED
@pytest.mark.parametrize("bad_entry", [["a", "b"], {"a": 1}, 123, None])
def test_execute_non_string_wait_for_entry_fails_loudly(self, bad_entry):
"""A ``wait_for`` list with a non-string entry must fail the step, not
@@ -4673,6 +4812,149 @@ steps:
# No undeclared-input error (123 is not a string, so cross-check skips)
assert not any("undeclared input" in e for e in errors)
def test_retry_verdict_enum_must_allow_reset_sentinel(self):
# on_reject: retry resets the bound input to "" before pausing, and
# every resume re-resolves persisted inputs through _coerce_input. An
# enum that omits "" makes that reset value instantly illegal, so the
# next resume supplying any input dies with "value '' not in allowed
# values" and no verdict can reach the gate again.
errors = self._errors("""
workflow:
id: wf
name: wf
version: "1.0.0"
inputs:
spec_verdict:
type: string
enum: [approve, reject]
steps:
- id: review
type: gate
message: "Review?"
options: [approve, reject]
on_reject: retry
verdict_input: spec_verdict
""")
assert any(
"on_reject='retry' resets verdict input 'spec_verdict'" in e
for e in errors
), errors
def test_retry_verdict_enum_including_sentinel_passes(self):
errors = self._errors("""
workflow:
id: wf
name: wf
version: "1.0.0"
inputs:
spec_verdict:
type: string
enum: ["", approve, reject]
default: ""
steps:
- id: review
type: gate
message: "Review?"
options: [approve, reject]
on_reject: retry
verdict_input: spec_verdict
""")
assert not any("on_reject='retry'" in e for e in errors), errors
def test_verdict_enum_without_sentinel_passes_when_not_retry(self):
# abort/skip never reset the input, so the enum need not admit "".
for on_reject in ("abort", "skip"):
errors = self._errors(f"""
workflow:
id: wf
name: wf
version: "1.0.0"
inputs:
spec_verdict:
type: string
enum: [approve, reject]
steps:
- id: review
type: gate
message: "Review?"
options: [approve, reject]
on_reject: {on_reject}
verdict_input: spec_verdict
""")
assert not any("on_reject='retry'" in e for e in errors), (
on_reject,
errors,
)
def test_retry_verdict_without_enum_passes(self):
# No enum means _coerce_input accepts "" — the documented shape.
errors = self._errors("""
workflow:
id: wf
name: wf
version: "1.0.0"
inputs:
spec_verdict:
type: string
default: ""
steps:
- id: review
type: gate
message: "Review?"
options: [approve, reject]
on_reject: retry
verdict_input: spec_verdict
""")
assert not any("on_reject='retry'" in e for e in errors), errors
def test_retry_verdict_enum_wedge_is_reachable_end_to_end(self, tmp_path):
"""The validation error above guards a real, unrecoverable run state.
Without the guard this workflow installs and runs fine, then wedges:
the retry reset writes "" into the persisted inputs, and the next
resume that supplies *any* input re-resolves them and dies on the
enum. Only a resume with no inputs at all still works, so the bound
verdict can never be delivered.
"""
import pytest
import yaml as _yaml
from specify_cli.workflows.engine import WorkflowEngine
definition_data = {
"schema_version": "1.0",
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
"inputs": {
"spec_verdict": {"type": "string", "enum": ["approve", "reject"]},
"note": {"type": "string", "default": "a"},
},
"steps": [
{
"id": "review",
"type": "gate",
"message": "Review?",
"options": ["approve", "reject"],
"on_reject": "retry",
"verdict_input": "spec_verdict",
}
],
}
wf_dir = tmp_path / ".specify" / "workflows" / "wf"
wf_dir.mkdir(parents=True)
(wf_dir / "workflow.yml").write_text(
_yaml.safe_dump(definition_data), encoding="utf-8"
)
engine = WorkflowEngine(tmp_path)
definition = engine.load_workflow("wf")
state = engine.execute(definition, inputs={"spec_verdict": "reject"})
assert state.status.value == "paused"
# The retry reset persisted a value the input's own enum forbids.
assert state.inputs["spec_verdict"] == ""
with pytest.raises(ValueError, match="not in allowed values"):
engine.resume(state.run_id, inputs={"note": "b"})
def test_verdict_input_in_switch_case(self):
# Recursion coverage: bad reference inside a switch case must surface.
errors = self._errors("""
@@ -6771,6 +7053,35 @@ class TestRunState:
with pytest.raises(FileNotFoundError):
RunState.load("nonexistent", project_dir)
def test_load_rejects_stored_run_id_mismatch(self, project_dir):
"""The state payload cannot redirect later writes to another run."""
from specify_cli.workflows.engine import RunState
run_dir = (
project_dir
/ ".specify"
/ "workflows"
/ "runs"
/ "requested-run"
)
run_dir.mkdir(parents=True)
(run_dir / "state.json").write_text(
json.dumps(
{
"run_id": "other-run",
"workflow_id": "test-workflow",
"status": "created",
}
),
encoding="utf-8",
)
with pytest.raises(
ValueError,
match="stored run_id 'other-run' does not match requested run_id 'requested-run'",
):
RunState.load("requested-run", project_dir)
@pytest.mark.parametrize(
("installed_workflow_id", "installed_registry_root"),
[
@@ -7021,6 +7332,94 @@ steps:
assert len(runs) == 1
assert runs[0]["workflow_id"] == "list-test"
def test_list_skips_malformed_json(self, project_dir):
from specify_cli.workflows.engine import WorkflowEngine
runs_dir = project_dir / ".specify" / "workflows" / "runs"
bad_dir = runs_dir / "bad-run"
bad_dir.mkdir(parents=True)
(bad_dir / "state.json").write_text("{invalid json", encoding="utf-8")
engine = WorkflowEngine(project_dir)
assert engine.list_runs() == []
def test_list_skips_unreadable_file(self, project_dir):
import sys
import subprocess
from specify_cli.workflows.engine import WorkflowEngine
runs_dir = project_dir / ".specify" / "workflows" / "runs"
bad_dir = runs_dir / "bad-run"
bad_dir.mkdir(parents=True)
state_file = bad_dir / "state.json"
state_file.write_text('{"run_id": "x"}', encoding="utf-8")
if sys.platform == "win32":
subprocess.run(["attrib", "+R", str(state_file)], check=True)
else:
state_file.chmod(0o000)
try:
engine = WorkflowEngine(project_dir)
if sys.platform == "win32":
assert engine.list_runs() == [{"run_id": "x"}]
else:
assert engine.list_runs() == []
finally:
if sys.platform == "win32":
subprocess.run(["attrib", "-R", str(state_file)], check=True)
else:
state_file.chmod(0o644)
def test_list_skips_non_dict_payload(self, project_dir):
from specify_cli.workflows.engine import WorkflowEngine
runs_dir = project_dir / ".specify" / "workflows" / "runs"
bad_dir = runs_dir / "bad-run"
bad_dir.mkdir(parents=True)
(bad_dir / "state.json").write_text('["not", "a", "dict"]', encoding="utf-8")
engine = WorkflowEngine(project_dir)
assert engine.list_runs() == []
def test_list_skips_empty_dict_payload(self, project_dir):
from specify_cli.workflows.engine import WorkflowEngine
runs_dir = project_dir / ".specify" / "workflows" / "runs"
bad_dir = runs_dir / "bad-run"
bad_dir.mkdir(parents=True)
(bad_dir / "state.json").write_text('{}', encoding="utf-8")
engine = WorkflowEngine(project_dir)
assert engine.list_runs() == []
def test_list_skips_bad_file_with_valid_sibling(self, project_dir):
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
runs_dir = project_dir / ".specify" / "workflows" / "runs"
bad_dir = runs_dir / "bad-run"
bad_dir.mkdir(parents=True)
(bad_dir / "state.json").write_text("{bad", encoding="utf-8")
yaml_str = """
schema_version: "1.0"
workflow:
id: "good-run"
name: "Good Run"
version: "1.0.0"
steps:
- id: step-one
type: shell
run: "echo test"
"""
definition = WorkflowDefinition.from_string(yaml_str)
engine = WorkflowEngine(project_dir)
engine.execute(definition)
runs = engine.list_runs()
assert len(runs) == 1
assert runs[0]["workflow_id"] == "good-run"
# ===== Workflow Registry Tests =====
@@ -7249,6 +7648,59 @@ class TestWorkflowCatalog:
assert catalog._fetch_single_catalog(entry) == payload
@pytest.mark.parametrize("catalog_type", ["workflow", "step"])
def test_non_utf8_cached_catalog_is_refetched(
self, project_dir, monkeypatch, catalog_type
):
import io
from specify_cli.authentication import http as auth_http
from specify_cli.workflows.catalog import (
StepCatalog,
StepCatalogEntry,
WorkflowCatalog,
WorkflowCatalogEntry,
)
catalog_cls = WorkflowCatalog if catalog_type == "workflow" else StepCatalog
entry_cls = (
WorkflowCatalogEntry
if catalog_type == "workflow"
else StepCatalogEntry
)
payload_key = "workflows" if catalog_type == "workflow" else "steps"
url = f"https://example.com/{catalog_type}.json"
catalog = catalog_cls(project_dir)
cache_path, metadata_path = catalog._get_cache_paths(url)
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_bytes(b"\xff\xfe")
metadata_path.write_text(
json.dumps({"fetched_at": 4_102_444_800}),
encoding="utf-8",
)
payload = {"schema_version": "1.0", payload_key: {}}
class _FakeResponse(io.BytesIO):
def geturl(self):
return url
monkeypatch.setattr(
auth_http,
"open_url",
lambda url, timeout=30, redirect_validator=None: _FakeResponse(
json.dumps(payload).encode("utf-8")
),
)
entry = entry_cls(
url=url,
name="test",
priority=1,
install_allowed=True,
)
assert catalog._fetch_single_catalog(entry) == payload
assert json.loads(cache_path.read_text(encoding="utf-8")) == payload
def test_non_mapping_stale_workflow_catalog_is_rejected(
self, project_dir, monkeypatch
):
+12
View File
@@ -104,6 +104,18 @@ def test_fetch_rejects_malformed_source_url_cleanly(url):
fetcher(_source(url))
@pytest.mark.parametrize("use_file_url", [False, True], ids=["path", "file-url"])
def test_local_catalog_decode_errors_are_wrapped(tmp_path, use_file_url):
catalog_path = tmp_path / "catalog.json"
catalog_path.write_bytes(b"\xff\xfe")
url = catalog_path.as_uri() if use_file_url else str(catalog_path)
fetcher = adapters.make_catalog_fetcher(allow_network=False)
with pytest.raises(BundlerError, match="Could not read"):
fetcher(_source(url))
def test_builtin_community_catalog_fetches_repository_catalog_online(monkeypatch):
captured: dict = {}
+26
View File
@@ -207,4 +207,30 @@ def test_executable_bit_preserved_in_artifact(tmp_path: Path):
}
# Executable source -> 0755; plain text files -> 0644.
assert modes["scripts/hook.sh"] == 0o755
def test_toctou_stat_read_consistency(tmp_path: Path):
"""Regression: stat() and read() must use the same file descriptor.
The old implementation called file_path.stat() then file_path.read_bytes()
as separate syscalls. Between the two, another process could replace the
file. The fix opens the file once and uses os.fstat() + fh.read() on the
same handle. This test verifies the archived bytes and mode are consistent.
"""
bundle = _make_bundle(tmp_path / "b")
target = bundle / "assets" / "data.bin"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(b"\x00\x01\x02\x03")
target.chmod(0o644)
result = build_bundle(bundle, output_dir=tmp_path / "out")
with zipfile.ZipFile(result.artifact_path) as archive:
content = archive.read("assets/data.bin")
modes = {
info.filename: (info.external_attr >> 16) & 0o777
for info in archive.infolist()
}
assert content == b"\x00\x01\x02\x03"
assert modes["assets/data.bin"] == 0o644
assert modes["README.md"] == 0o644