Compare commits

..

76 Commits

Author SHA1 Message Date
github-actions[bot]
09b99f351a chore: bump version to 0.14.4 2026-07-29 12:08:59 +00:00
Ali jawwad
be33d2a5f6 fix(bundler): degrade non-UTF-8 config reads into BundlerError (#3784)
yamlio.py is the single chokepoint for every bundler read, and its module
docstring states the contract: "All reads/writes go through these functions so
that IO failures degrade into actionable BundlerError rather than raw
tracebacks."

Both readers catch only OSError, but `Path.read_text(encoding="utf-8")` and
`json.load()` raise UnicodeDecodeError on a non-UTF-8 file --
`issubclass(UnicodeDecodeError, OSError)` is False (its MRO is UnicodeError ->
ValueError). So the decode error escaped uncaught:

    load_yaml: LEAKED UnicodeDecodeError -> 'utf-8' codec can't decode byte 0xff
    load_json: LEAKED UnicodeDecodeError -> 'utf-8' codec can't decode byte 0xff

In load_json, json.JSONDecodeError does not help: it is a *sibling* of
UnicodeDecodeError, not a parent.

This is realistic rather than theoretical -- on Windows, PowerShell 5.1's
`Out-File` and `>` default to UTF-16, so a hand-edited
`.specify/bundle-catalogs.yml` or records file hits it.

Widen both read clauses to `(OSError, UnicodeError)`, matching the sibling
catalog readers (catalogs.py:101, workflows/catalog.py:336). JSONDecodeError
deliberately stays FIRST so malformed-but-decodable JSON keeps its more
specific "Invalid JSON" message; a regression test locks that ordering.

Write paths are unaffected -- verified that dump_yaml/dump_json do not leak
UnicodeEncodeError (both escape unencodable input), so this stays scoped to the
two read paths.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 17:29:27 -05:00
Ali jawwad
751eae727e fix(workflows): escape the step-progress line so step ids render (and / stops failing the run) (#3783)
`workflow run` and `workflow resume` both print the step-progress line as
`f"  ▸ [{sid}] {label} …"`. Rich parses the bracketed step id as a style tag,
which produces three failures on main:

1. The id is SILENTLY SWALLOWED on every run -- the only identifying content on
   the line. `id: greet` prints "  ▸  shell …"; "[greet]" is absent.
2. An id that forms a closing tag FAILS THE WHOLE RUN. `validate_workflow`
   places no charset restriction on step ids, so `id: "/"` is a valid workflow;
   the callback then raises MarkupError, which propagates into execute()'s
   handler -> run persisted as `failed` with empty `step_results`, the step
   never executed, exit 1 with a Rich internals error.
3. An id that is a real style (`bold`, `red`) is applied as FORMATTING to the
   rest of the line.

The unescaped `label` (from `step_config["command"]`) compounds it.

Escape the literal bracket with `\[` and escape both interpolated values, at
both sites. This mirrors the `\[<type>]` step-graph precedent already in this
file (workflow_info). Escaping only the values is NOT sufficient -- the
f-string's own brackets are what Rich consumes.

Verified through the real CLI: ids `greet`/`bold`/`a]b` now render verbatim, and
`id: "/"` goes from a failed run to `Status: completed`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 17:28:22 -05:00
github-actions[bot]
4ad7ef2b42 Update Agent Parity Governance preset to v0.4.1 (#3830)
Update agent-parity-governance preset submitted by @hindermath to:
- presets/catalog.community.json (version, download_url, documentation, description, updated_at)
- docs/community/presets.md community presets table

Closes #3829

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-07-28 17:25:54 -05:00
Ali jawwad
6ef96373e2 fix(integrations): reject empty --commands-dir in generic raw_options (#3714)
* fix(integrations): reject empty --commands-dir in generic raw_options

GenericIntegration._resolve_commands_dir has a parity gap: the parsed-options
branch guards emptiness (`if commands_dir:`), but the raw_options fallback
returned the value verbatim with no check. So `--integration-options=
"--commands-dir="` (or `--commands-dir ""`) resolves to `""`, which makes
setup() compute `dest = project_root / "" == project_root` and write every
speckit command file (specify.md, plan.md, ...) directly into the PROJECT
ROOT — silently bypassing the documented "--commands-dir is required"
contract and polluting the repo root.

Apply the same non-empty guard to the raw_options branch so an empty value
falls through to the existing "required" ValueError on every input form.
Non-empty values resolve exactly as before.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* fix(integrations): reject a BLANK --commands-dir, not just an empty one

Self-review follow-up: bare truthiness only closes the empty-string subset. A
whitespace-only value passed both branches (verified: raw "--commands-dir ' '"
returned ' ', parsed {"commands_dir": "  "} returned '  '), so command files
still landed in a directory literally named " " instead of failing with the
documented "required" error.

Require a non-BLANK value and normalize the padding, in the parsed branch as
well as raw_options so the two cannot drift apart -- a padded but real value
(" .myagent/cmds ") now resolves to ".myagent/cmds" rather than being rejected,
matching how other padded config references are normalized.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* fix(integrations): use strip() only to test blankness, return the value verbatim

Address review feedback: normalizing with strip() changed EXISTING valid values,
contrary to this PR's "no behaviour change for valid usage" claim -- a quoted
`--commands-dir ' commands '` previously targeted the literal ` commands `
directory and would have started writing to `commands` instead.

The blankness test still uses strip(), but the accepted value is now returned
unchanged, so the fix stays limited to empty/blank input. Test updated
accordingly: a padded non-blank value must round-trip verbatim (quoted in
raw_options, since shlex.split() consumes unquoted padding before this code
sees it).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 17:25:04 -05:00
Ali jawwad
4bc79fe243 fix(presets): guard non-list/non-mapping provides.templates in PresetManifest (#3712)
* fix(presets): guard non-list/non-mapping provides.templates in PresetManifest

PresetManifest._validate iterated provides["templates"] with no shape guards,
unlike the sibling ExtensionManifest. A malformed third-party preset.yml
crashed with a raw TypeError that escapes the install handler's
PresetValidationError/PresetError catch and dumps an unhandled traceback:

  templates: 5       -> "'int' object is not iterable"
  templates: [null]  -> "argument of type 'NoneType' is not iterable"
  templates: [5]     -> "argument of type 'int' is not iterable"

(and a string/list entry raised the misleading "Template missing 'type',
'name', or 'file'"). Add a container list-guard and a per-entry mapping-guard
that raise a clean PresetValidationError, mirroring ExtensionManifest's
provides.commands guards. Valid manifests (list of mappings) are unaffected.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* fix(presets): check provides.templates type before emptiness

Address review feedback: the new shape guard sat behind the existing truthiness
check, so a FALSY non-list (templates: 0/false/null/''/{}) still reported the
misleading "Preset must provide at least one template" instead of the type
error. Only truthy non-lists (5, "oops", {"a": 1}) reached the guard, which is
why the original test (templates: 5) passed.

Split the checks: presence -> container type -> emptiness. A falsy non-list now
reports "expected a list"; an EMPTY LIST keeps the "at least one template"
message, since that genuinely is a well-typed container with no templates.
Parametrize the non-list test over truthy AND falsy values, and add a
regression guard for the empty-list message.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* test(presets): drop the redundant empty-list templates test

Address review feedback: the added test duplicated the pre-existing
test_no_templates_provided -- both set provides.templates to [] and assert the
same "must provide at least one template" error. That test already guards the
empty-list result of the type-before-emptiness ordering, so keeping mine only
added maintenance. Left a pointer comment where it was.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 17:24:01 -05:00
Ali jawwad
596a31ee0f fix(auth): resolve az via shutil.which so azure-cli token works on Windows (#3709)
* fix(auth): resolve az via shutil.which so azure-cli token works on Windows

AzureDevOpsAuth._acquire_via_az_cli runs subprocess.run with a bare "az".
On Windows the Azure CLI is installed as az.cmd, and subprocess.run calls
CreateProcess, which does not consult PATHEXT -- so a bare "az" fails with
WinError 2 even after `az login`, and azure-cli token acquisition silently
returns None (the OSError is swallowed).

Resolve the executable with shutil.which("az") (which honors PATHEXT) before
the call, mirroring the maintainer's own fix in integrations/base.py for the
same CreateProcess/.cmd issue. `or "az"` preserves prior behavior (and the
existing not-installed OSError path) when az is absent. POSIX is unaffected.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* fix(auth): require an absolute az path so the CWD cannot hijack the lookup

Self-review catch on my own change: resolving with a bare
`shutil.which("az") or "az"` widened an execution surface. On Windows
shutil.which prepends the CURRENT DIRECTORY to the search path (unless
NoDefaultCurrentDirectoryInExePath is set) AND honors PATHEXT, so a stray
.\az.cmd / .\az.bat in the working directory resolves ahead of the real Azure
CLI -- for a credential operation. Verified: with the real az scrubbed from
PATH, shutil.which("az") returns '.\az.CMD'.

Accept the resolution only when it is absolute; otherwise fall back to the bare
"az" (which also preserves the existing not-installed OSError path). A
legitimate install always resolves absolutely, so the Windows .cmd fix this PR
exists for is unaffected. The not-installed and PATHEXT tests are extended with
relative-result cases, all of which fail before this commit.

Note: integrations/base.py resolves executables the same way; hardening that
shared path is a separate concern and is left untouched here.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* test(auth): build the mocked az path with the host's path rules

Fixes the macOS CI failure. The test hardcoded a Windows absolute path, but the
production code calls os.path.isabs() -- on POSIX runners "C:\Program
Files\..." reads as RELATIVE, so the fallback branch ran and argv[0] was "az"
instead of the resolved path.

Construct the path with os.path.join(os.path.abspath(os.sep), ...) so it is
absolute under the host's rules, and assert against that value. The fallback
test's inputs (".\az.CMD", "az.cmd", "./az") are relative under both ntpath
and posixpath, so they were already portable.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 17:21:42 -05:00
Ali jawwad
52a6514e38 fix(workflows): reject falsy non-mapping workflow-catalogs.yml top level (#3707)
* fix(workflows): reject falsy non-mapping workflow-catalogs.yml top level

WorkflowCatalog._load_catalog_config parsed the config with
`yaml.safe_load(...) or {}`, then checked `isinstance(data, dict)`. The
`or {}` coerces a FALSY non-mapping top level (`[]`, `false`, `0`, `''`) to
`{}` *before* the guard runs, so those are silently swallowed as "empty
config" and fall back to the built-in defaults -- while a TRUTHY non-mapping
(`5`, a bare list) correctly raises. Same silent-swallow inconsistency the
bundler catalog reader fixed for its own config.

Drop the `or {}` and branch on `None` (empty document / explicit `null`)
explicitly: `None` stays a valid no-op, every non-mapping (falsy or truthy)
now raises the same actionable error. Correct configs are unaffected.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* docs(workflows): describe the catalog-config fallthrough accurately

The comment said a None return means "no project catalogs, fall back to the
built-in defaults". Both halves were imprecise: _load_catalog_config serves the
project AND user configs, and get_active_catalogs falls through env -> project
-> user -> built-in, so a None from the project layer moves on to the USER
config; the built-in defaults apply only once every layer returned None.

Reword the loader comment and the mirror test docstring. Comments only -- no
behaviour change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* fix(workflows): close the same falsy-mask gap in 'catalogs' and StepCatalog

Self-review follow-up: the top-level fix left the identical asymmetry live five
lines below, and again in this file's twin loader.

1. WorkflowCatalog._load_catalog_config: the ``catalogs`` shape check sat behind
   an emptiness check, so a FALSY non-list (``catalogs: {}``/``''``/``0``/
   ``false``) was silently swallowed as "no catalogs" while ``catalogs: 5``
   raised. Verified before this commit: ``catalogs: {}`` -> None (no error).
   Shape now checked first; absent/explicit-null and empty-list stay no-ops
   (matching the bundler's reader).

2. StepCatalog._load_catalog_config -- the step-catalog twin, read the same way
   -- still had ``yaml.safe_load(...) or {}``, so falsy non-mappings bypassed its
   isinstance guard (``[]`` -> None while ``5`` raised). Same two guards applied,
   keeping the two loaders in lockstep.

Eight new parametrized cases, all failing before this commit.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* test(workflows): move StepCatalog guards into TestStepCatalog and add the nested case

Address three review points:

1. The StepCatalog regression tests sat inside TestWorkflowCatalog, so a
   targeted `pytest ...::TestStepCatalog` run skipped them entirely. Moved into
   that class, where the duplicated twin loader belongs.
2. StepCatalog had no nested-value coverage (only top-level). Added the
   parametrized falsy ``catalogs:`` case, plus the absent/null/empty no-op
   cases. Verified against upstream/main's catalog.py: 8 fail there, pass here.
3. Dropped the inaccurate parity parenthetical. src/specify_cli/catalogs.py
   RAISES for missing/empty ``catalogs`` and coerces a null document to {}, so
   it is not the behavior this loader matches -- the comment now just states
   what changed (only the misreported shapes) without claiming parity.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 17:20:21 -05:00
Ali jawwad
3dad624e5d fix(integrations): render hyphenated /speckit-<name> for Droid (always-slash agent) (#3688)
* fix(integrations): render hyphenated /speckit-<name> for Droid (always-slash agent)

DroidIntegration is an always-skills agent: it installs commands as
.factory/skills/speckit-<name>/SKILL.md and its build_command_invocation
returns the hyphenated /speckit-<name>. But "droid" was missing from every
_invocation_style set, so is_slash_skills_agent("droid", True) returned False
and both HookExecutor._render_hook_invocation and `specify init` next-steps
fell through to the dotted /speckit.<name> form — a command Droid never
registers.

Add "droid" to ALWAYS_SLASH_AGENTS, matching its always-skills siblings
grok/trae/zed/devin (each added there by their own integration PR; droid's
#3587 omitted it).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* test(integrations): assert Droid is ALWAYS-slash (disabled case too)

Address review: the test only covered ai_skills=True, which would also pass
if Droid were miscategorized as CONDITIONAL_SLASH. Add the ai_skills=False
assertion — True there is what distinguishes an ALWAYS_SLASH agent from a
conditional one.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 17:14:31 -05:00
github-actions[bot]
0231ee056b [preset] Update A11Y Governance preset to v0.4.2 (#3828)
* Update A11Y Governance preset to v0.4.2

Update a11y-governance preset submitted by @hindermath to:
- presets/catalog.community.json (version, download_url, documentation, description, updated_at)
- docs/community/presets.md community presets table

Closes #3827

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

* Potential fix for pull request finding

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

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-28 16:55:37 -05:00
github-actions[bot]
ca2b494335 [preset] Update Parallel Autonomous Run Governance to v0.2.4 (#3825)
* Update Parallel Autonomous Run Governance preset to v0.2.4

Update parallel-autonomous-run-governance preset submitted by @hindermath:
- presets/catalog.community.json (version, download_url, documentation, description, requires.extensions, tags, updated_at)
- docs/community/presets.md community presets table

Closes #3824

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

* Potential fix for pull request finding

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

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-28 16:23:34 -05:00
Quratulain-bilal
86c4610b7d fix: correct Optional type annotation for _resolved_dir parameter (#3801) 2026-07-28 16:07:27 -05:00
Quratulain-bilal
a2b0d0d3c1 fix: add timeout to prompt step subprocess execution (#3768)
The prompt step subprocess.run() had no timeout, allowing a hung
LLM invocation to block the entire workflow engine indefinitely.
The shell step already defaults to 300s timeout.

Add timeout parameter (defaulting to 300s, matching shell step)
and handle subprocess.TimeoutExpired gracefully.
2026-07-28 15:49:45 -05:00
Quratulain-bilal
56b1839fba fix: handle tags containing / in GitHub release asset URL resolution (#3767)
The tag extraction in resolve_github_release_asset_api_url split the
URL path on / and assumed the tag was a single segment at index 4.
Tags containing literal / (e.g. feature/v1) would be split across
multiple segments, causing the tag to be truncated to only the first
part and the asset name to include leftover tag segments.

Fix by reconstructing the tag as all segments between 'download' and
the final asset segment: tag = '/'.join(parts[4:-1]), asset = parts[-1].
2026-07-28 15:45:38 -05:00
Marsel Safin
98b2551ade fix(presets): escape catalog metadata in discovery output (#3773)
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-28 15:36:57 -05:00
github-actions[bot]
9bb7206d3b Update Autonomous Run Governance preset to v0.3.3 (#3823)
Update autonomous-run-governance preset submitted by @hindermath to:
- presets/catalog.community.json (version, download_url, documentation, description, tags, updated_at)
- docs/community/presets.md community presets table

Closes #3821

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-07-28 15:18:57 -05:00
Quratulain-bilal
89e204ca3c fix: use bounded read for integration catalog HTTP responses (#3763)
* fix(skills): match closing frontmatter delimiter on its own line

SkillsIntegration.setup parsed each command template's frontmatter with
raw.split("---", 2). A bare substring split stops at the first `---`
*anywhere*, so a template whose description embeds `---` (e.g.
"Separate sections with --- markers") truncated the parsed frontmatter:
later keys were dropped, the description fell back to the generic default,
and the leftover frontmatter spilled into the skill body.

Scan for the closing `---` on its own line instead, for both the
description parse and the body strip. The frontmatter block is parsed
unstripped so trailing newlines in literal (|) block scalars still survive,
and the body slice keeps the newline after the marker so output stays
byte-for-byte identical to the old split for well-formed templates.

Adds regression tests covering the dashed-description truncation and the
frontmatter-spilled-into-body cases.

* fix: use bounded read for integration catalog HTTP responses

The integration catalog fetch used unbounded resp.read() to read
HTTP responses into memory. A malicious or misconfigured catalog
server could return an arbitrarily large response causing OOM.

Replace with read_response_limited() capped at MAX_JSON_METADATA_BYTES
(1 MiB), consistent with how other JSON fetch paths in the codebase
(_version.py, _github_http.py, authentication/azure_devops.py) already
enforce bounded reads.

Pass error_type=IntegrationCatalogError so oversized catalogs are
caught by the existing per-entry recovery path in
_get_merged_integrations() rather than aborting the entire merge.

Add regression test verifying oversized responses are rejected as
IntegrationCatalogError and that healthy catalogs remain usable.
2026-07-28 14:32:31 -05:00
21Silva
88e997306f docs: add Simplified Chinese translation of README (#3740)
Add README.zh-CN.md with a hand-crafted (non-machine) Chinese
translation of the project README, and add a language switcher
link at the top of both README files.

Code blocks, command names, badges, and links are kept identical to
the English source; only prose is translated.

Co-authored-by: yifosheng001 <yifosheng001@ke.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 13:59:39 -05:00
github-actions[bot]
186ca25c99 Update Intake Sequencing Governance preset to v0.2.2 (#3809)
Update intake-sequencing-governance preset submitted by @hindermath to:
- presets/catalog.community.json (version, download_url, documentation, description, provides)
- docs/community/presets.md community presets table

Closes #3807

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-07-28 13:18:10 -05:00
Ali jawwad
a9c9905400 fix(workflows): reject non-string/non-boolean 'condition' in if/while/do-while steps (#3706)
* fix(workflows): reject non-string 'condition' in if/while/do-while steps

`if_then`, `while_loop`, and `do_while` validate() confirm `condition` is
present but never that it is a string. execute() feeds it to
`evaluate_condition()`, which returns a non-string as-is and takes `bool()`
of it -- so `condition: [1, 2]` (a list authoring mistake) silently resolves
to `True`, branching wrongly / spinning the loop to `max_iterations`, with no
error reported.

Reject a present-but-non-string `condition` at validation, mirroring the
existing prompt/shell/command 'must be a string' guards. `"true"`/`"false"`
and expressions like `"{{ ... }}"` are strings, so they stay valid.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* docs(workflows): describe the evaluate_expression/evaluate_condition split accurately

Address review feedback: the guard comments attributed the non-string
pass-through to evaluate_condition(), which always returns a bool. It is
evaluate_expression() (called by evaluate_condition) that returns a non-string
unchanged; evaluate_condition then applies bool() to that value.

Reword all four sites (if/while/do-while guards + the mirror test comment) to
name the two stages correctly. Comments only -- no behaviour change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* fix(workflows): keep a literal bool 'condition' valid

Self-review catch: the guard rejected EVERY non-string, which broke an input
that previously worked. An unquoted ``condition: false`` is idiomatic YAML and
resolves exactly today -- evaluate_expression passes the bool through and
evaluate_condition's bool() is a no-op (verified: evaluate_condition(False) is
False, (True) is True). The if/while steps even default ``condition`` to the
bool ``False`` themselves, so bool is the field's natural type, not an
authoring mistake.

Accept (str, bool) and reject only the genuinely silent-coercion types
(list/dict/int/float, e.g. condition: [1, 2] is always True). Message updated
to "must be a string or boolean"; the bad-value tests drop True and gain 1.5,
and each step gains a positive bool case.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 11:40:36 -05:00
Marsel Safin
054fb7723d fix(bundle): escape catalog metadata in discovery output (#3774)
* fix(bundle): escape catalog metadata in discovery output

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(bundle): escape provides fallback values

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

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-28 11:35:49 -05:00
Noor ul ain
8bfe6e14d1 fix(workflows,extensions): tolerate non-list catalog tags in search/info display (#3770)
`workflow search`, `workflow info`, `extension search` and `extension info`
crashed with `TypeError: 'int' object is not iterable` when a catalog entry
carried a scalar `tags:` value (e.g. `tags: 5`). Catalog payloads are
user-editable YAML/JSON, so this shape reaches the display unvalidated.

Both backends already guard their tag *filter* with
`isinstance(raw_tags, list)` — `WorkflowCatalog.search` and
`ExtensionCatalog.search` skip a non-list `tags` cleanly. Only the display
paths were unguarded: they tested truthiness (`if info.get("tags"):`) and
then iterated. A scalar is truthy but not iterable, so `--tag` filtering
survived while plain `search`/`info` rendering blew up.

Note this is distinct from the non-string *element* handling added in
#3746/#3747: coercing elements with `str(t) for t in ...` does not help when
`tags` is not a sequence at all. The fix is the guard the sibling
integration commands already use — `integrations/_query_commands.py:332,402`
gate on `isinstance(tags, list) and tags`. This aligns workflows and
extensions with that reference pattern, leaving all four tag-join display
sites in these modules consistent.

Regression tests drive the full CLI via CliRunner and cover search + info in
one case per module; both fail before the fix with the exact TypeError.


Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 11:24:19 -05:00
Marsel Safin
a482fb2fce fix: correct nullable resolved directory annotation (#3771)
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-28 11:23:02 -05:00
Noor ul ain
05cb3cba34 fix(presets): tolerate non-string and non-list catalog fields in preset search/info (#3769)
* fix(presets): tolerate non-string and non-list catalog fields in preset search/info

`preset search` and `preset info` crashed with a raw traceback on catalog
payloads that are valid YAML/JSON but not string-typed. Catalog files are
user-editable, so these shapes reach the code unvalidated.

`PresetCatalog.search` had three unguarded assumptions:

- `--author` called `.lower()` on the raw value → `AttributeError: 'int'
  object has no attribute 'lower'` for `author: 789`.
- the query searchable-text join passed raw `name`/`description` through →
  `TypeError: sequence item 0: expected str instance, int found`.
- the `--tag` filter iterated `tags` without a list check, so a scalar
  `tags: 5` (truthy, not iterable) raised `TypeError: 'int' object is not
  iterable`.

PR #3743 fixed only the non-string *elements* of `tags` here; a non-list
`tags` and the `author`/`name`/`description` fields were still unguarded.
The sibling catalogs already handle all of these — `extensions/__init__.py`
and `integrations/catalog.py` coerce with `str(...)` and gate on
`isinstance(raw_tags, list)`. This aligns presets with them.

The same scalar-`tags` crash reached the four display sites in
`presets/_commands.py`, so those now gate on `isinstance(tags, list)`,
matching `integrations/_query_commands.py`. Note `PresetManifest.tags`
returns `self.data.get("tags", [])` and manifest validation does not
enforce list-ness, so a local `preset.yaml` with `tags: 5` validates
successfully and then crashed `preset info` — hence the guard on the
local-manifest branch too.

While here, `preset search` printed tags unescaped, so a tag containing
`[bold]` was silently swallowed as a Rich style tag; it now routes through
`_escape_markup` like the `preset list` line directly above it.

Regression tests in `TestPresetTagsNonString` drive the full CLI path via
CliRunner. All five fail before the fix, each with the exact exception it
targets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Assisted-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>

* chore: regenerate security audit requirements (annotated-doc 0.0.5)

The Security Audit workflow's "Check committed audit requirements are
current" step regenerates requirements with `uv pip compile --upgrade`,
which now resolves annotated-doc==0.0.5. Re-sync the committed snapshot
so the check passes. No pyproject dependency changes; upgrade drift only.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b3d0333b-4a36-4f69-9273-3ac0c3f46481

---------

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>
Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b3d0333b-4a36-4f69-9273-3ac0c3f46481
2026-07-28 11:07:51 -05:00
Marsel Safin
2e44ed60e8 fix(integrations): escape catalog metadata in discovery output (#3772)
* fix(integrations): escape catalog metadata in discovery output

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(integrations): escape unknown query IDs

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

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-28 10:20:31 -05:00
github-actions[bot]
999f8e6497 Update Verify Review Ship extension to v0.4.2 (#3792)
Update verify-review-ship extension submitted by @cadugevaerd to:
- extensions/catalog.community.json (version, download_url, sha256, updated_at)
- docs/community/extensions.md community extensions table

Closes #3791

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-07-28 10:20:07 -05:00
Ben Buttigieg
655a3cb8ca fix(integrations): preserve native skill invocation prefixes (#3663)
* fix(integrations): use native dollar skill invocations

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

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

Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19

* fix(integrations): preserve skill post-process idempotence

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

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

Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19

* fix(integrations): preserve literal skill invocations

Resolve generated command references with the active agent prefix instead of rewriting all slash-form text during post-processing.

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

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

Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19

* fix(integrations): preserve shared invocation prefix

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

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

Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19

* fix(integrations): preserve install invocation prefix

Pass dollar-style skill prefixes through bare-project integration installation and cover the shared template output.

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

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

Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19

* fix(integrations): preserve dollar refs everywhere

Use agent-native invocation prefixes in extension command registration and dynamic shared-script command hints.

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

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

Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19

* fix(shared-infra): preserve dollar command hints

Escape dollar-prefixed commands embedded in Bash strings and propagate the native prefix into installed Python command helpers.

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

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

Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19

* fix(shared-infra): render native helper prefixes

Rewrite installed Bash and PowerShell formatter return expressions so direct callers receive the selected integration's native prefix.

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

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

Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19

* fix(skills): use invocation-neutral hook guidance

Describe hook-derived references as command invocations so dollar-prefixed skills do not receive contradictory slash-command terminology.

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

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

Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19

* test(integrations): expect native fallback invocation

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

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

Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19

* refactor(integrations): centralize invocation prefix selection

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

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

Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19

* fix(integrations): add Kimi /skill: prefix and fix docstrings

- Add SKILL_COLON_AGENTS frozenset and get_invocation_prefix() to
  _invocation_style.py so Kimi resolves to '/skill:' in skills mode
- Switch invoke_prefix_for_integration() to use get_invocation_prefix()
  instead of the binary dollar/slash check
- Update post_process_skill_content docstring (base.py) to cover both
  slash and dollar native invocation forms
- Update _resolve_command_refs_in_skill docstring (presets/__init__.py)
  to document the dollar-prefixed result alongside slash forms

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28
Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

* fix(agents): use get_invocation_prefix for Kimi in register_commands

Replace the binary is_dollar_skills_agent ternary with get_invocation_prefix
so that __SPECKIT_COMMAND_*__ tokens in Kimi skill files resolve to
/skill:speckit-<name> rather than /speckit-<name>.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28
Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

* fix(agents): remove unused is_dollar_skills_agent import

Leftover from replacing the inline ternary with get_invocation_prefix.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28
Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

* fix(integrations): use get_invocation_prefix in post_process_skill_content

Replaces the binary is_dollar_skills_agent ternary with get_invocation_prefix
so that Kimi's hook-command note is injected as /skill:speckit-git-commit from
the start. This keeps _inject_hook_command_note idempotent for Kimi: the
previous note with its native prefix now matches on repeated passes, preventing
duplicate note injection.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28
Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

* fix(presets): use get_invocation_prefix in _resolve_skill_command_refs

Replace the binary is_dollar_skills_agent ternary with get_invocation_prefix
so Kimi tokens resolve to /skill:speckit-* directly rather than /speckit-*
(which previously relied on the broad post-process body replacement).

Also fix test_restore_skill_preserves_dollar_command_refs to write raw_core
with the unresolved __SPECKIT_COMMAND_PLAN__ token, exercising the resolver
rather than bypassing it with a pre-resolved string.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28
Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

* docs(presets): document /skill: form in _resolve_skill_command_refs

Add /skill:speckit-<cmd> to the docstring so the contract covers all
three native prefix forms returned by get_invocation_prefix.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28
Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

* test(integrations): add Kimi /skill: prefix coverage

- test_skill_colon_prefix_core_command: resolve_command_refs with /skill: prefix
- test_get_invocation_prefix_skill_colon: get_invocation_prefix returns /skill:
  for kimi (skills), / for kimi (non-skills), $ for codex, / for claude
- test_kimi_skill_post_processing_is_idempotent: verifies Kimi's hook-command
  note is injected with /skill: prefix and does not duplicate on re-runs
- test_installed_bash_formatter_uses_skill_colon_prefix: shared-infra bash
  formatter outputs /skill:speckit-plan when installed with /skill: prefix

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28
Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

* fix(kimi): use get_invocation_prefix in process_template, remove broad replacement

process_template() was still using a binary is_dollar_skills_agent ternary
to select between dollar and slash prefix, so Kimi tokens were emitted as
/speckit-* and then corrected by a broad .replace('/speckit-', '/skill:speckit-')
in KimiIntegration.post_process_skill_content(). That broad replacement would
also rewrite any literal /speckit-* text in generated skill content, contrary
to the PR's token-only behavior.

- Use get_invocation_prefix(agent_name, invoke_separator == '-') in
  process_template() so Kimi tokens are emitted as /skill:speckit-* directly.
- Remove the broad .replace() from KimiIntegration.post_process_skill_content();
  it is now a no-op (tokens are already correctly prefixed at source).
- Add test_process_template_kimi_uses_skill_colon_prefix to guard the fix.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28
Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19
Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28
2026-07-28 15:48:40 +01:00
github-actions[bot]
809b4c5e26 Update Intake Review Governance preset to v0.2.0 (#3796)
Update intake-review-governance preset submitted by @hindermath:
- presets/catalog.community.json (version, download_url, documentation, description, scripts, tags)
- docs/community/presets.md community presets table

Closes #3794

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-07-28 09:37:09 -05:00
github-actions[bot]
1354eade99 fix(constitution): stop propagating guidance into templates (#3737) (#3790)
Issue #3737 asked the /constitution command to synchronize constitutional
guidance into every effective task/plan/spec template, including active
preset-provided replacements. This changes the fix's direction: rather than
teach the command to discover and edit more template layers, it removes the
template-propagation behavior entirely.

Why this is the correct fix:

- The governed templates do not embed constitutional content. plan-template
  carries a runtime placeholder ("[Gates determined based on constitution
  file]") and spec-template/tasks-template reference no principles at all.
- The consuming commands read .specify/memory/constitution.md at runtime and
  derive their Constitution Check gates live (plan, tasks), and analyze is the
  dedicated drift checker that validates spec/plan/tasks against the
  constitution. Enforcement is therefore already automatic and always current.
- Statically editing template files fights the preset/override composition
  system: a replace preset shadows an edited core template entirely, and a
  hand-edited versioned preset file is clobbered on its next update. Presets and
  extensions are formalized, versioned artifacts the command must not mutate.

So the original bug (constitution edits missing active preset templates) is
resolved by not propagating at all: the runtime read is the single source of
truth. The /constitution command is scoped to its own artifact — it drafts and
writes the constitution and reports a Sync Impact Report changelog, and no
longer reads, edits, or reports on plan/spec/tasks/preset/extension templates.

Refs #3737

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, supervised)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5b80589c-74e8-42e5-b2cb-7a7e0d69a964
2026-07-28 09:17:32 -05:00
Manfred Riem
2a29b534ae chore: release 0.14.3, begin 0.14.4.dev0 development (#3795)
* chore: bump version to 0.14.3

* chore: begin 0.14.4.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-28 09:15:50 -05:00
github-actions[bot]
d8d62756fe Update Intake Authoring Governance preset to v0.3.0 (#3788)
Update intake-authoring-governance preset submitted by @hindermath to:
- presets/catalog.community.json (version, download_url, documentation, description, provides, tags, updated_at)
- docs/community/presets.md community presets table

Closes #3780

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-07-28 08:34:17 -05:00
Faqeha Noor
9602ad2edf fix(copilot): honor preset command template overrides (#3592)
* fix(copilot): honor preset command template overrides

* fix(copilot): resolve canonical preset command names

---------

Co-authored-by: Faqeha Noor <faqehanoor022@gmail.com>
2026-07-28 08:26:12 -05:00
orize
39f2ac3c63 clarify: require real interrogatives, ban topic-label questions (#3745)
* clarify: require real interrogatives, ban topic-label questions

Agents often present topic labels or bare requirement ids as "questions",
which are not answerable on their own. Require a full interrogative under
**Question:**, a plain-language stake sentence, then Recommended/options.

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

* Update templates/commands/clarify.md

* clarify: allow requirement ids only after the ?

Resolves Copilot feedback: an interrogative ending in ? cannot also have
a parenthesized id "at the end of the question." Exact format is now
`**Question:** <interrogative>?` or `**Question:** <interrogative>? (FR-023)`.

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

---------

Co-authored-by: Lorin O'Brien <lorin@pronto.net>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 07:58:12 -05:00
Eric X Engstfeld
7fc5b236c8 feat: Add Alquimia AI integration (#2734)
* Add alquimia-ai as new integration: https://alquimia.ai

* Fix test cases for alquimia-ai integration. Add alquimia-ai to workflow.yml

* Add install url to alquimia-ai integration

* Renamed alquimia-ai to alquimia (cli native denomination)

* Fix unit tests for alquimia integration

* Minor fix in alquimia integration

* Fix typos and copilot findings

* Potential fix for pull request finding

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

* Potential fix for pull request finding

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

* Update tests cases and lint formatting

* Final fixes

* Rename alquimia_ai to alquimia module integration

* Make cli optional for alquimiia integration

* resolve review comments

* Fix copilot review

* Minor fixes: naming, remove unused code

* Update tests cases. Fix issues

* Fix unit tests

* Add alquimia context to default agent-context extension. Update cli requirment to support workflows

* Fix hints (suggestion)

* Add Alquimia AI as agent in github issue template. Fix unit tests

* Address review comments. Update docs

* Update test cases

---------

Co-authored-by: Eric Engstfeld <ericengstfeld@Erics-MacBook-Pro.local>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-28 07:54:13 -05:00
Pascal THUET
118062eac4 harden: secure extension and preset archive downloads (#3141)
* harden: secure extension and preset archive downloads

Adopt the shared download-security primitives from #3140 across extension and
preset catalog, package, direct-URL, and ZIP-install flows:

- bound catalog, package, and inline manifest reads;
- verify catalog SHA-256 values when present;
- replace path-only extraction with bounded traversal/symlink-safe extraction;
- validate malformed hosts and ports before opening download URLs;
- handle normalized trailing-backslash directory entries consistently.

Redirect enforcement and checksum verification remain owned by the shared
helpers already on main; this commit wires them into extension and preset
behavior.

Assisted-by: OpenAI Codex (model: GPT-5, autonomous)

* harden: close archive and catalog download edge cases

Preflight ZIP central directories before ZipFile allocates them, bound both
declared and actual payload sizes, and reject ambiguous or non-portable archive
paths before extraction.

Keep extension update manifest selection consistent with extraction, reject
unsafe catalog-derived output filenames and malformed URL types, and escape
untrusted values in download errors.

Add regression coverage for parser differentials, collisions, platform-specific
filenames, bounded call sites, and failure ordering.

Assisted-by: OpenAI Codex (model: GPT-5, autonomous)

* harden: address download security review feedback

Assisted-by: OpenAI Codex (model: GPT-5, autonomous)

* harden: close ZIP preflight review gaps

Assisted-by: OpenAI Codex (model: GPT-5, autonomous)

* fix: harden extension update preflight and rollback

Assisted-by: OpenAI Codex (model: GPT-5, autonomous)

* fix: harden extension update rollback

Assisted-by: OpenAI Codex (model: GPT-5, autonomous)
2026-07-28 07:52:07 -05:00
Quratulain-bilal
0117a7b977 fix: correct Optional type annotation for context_note parameter (#3765)
The context_note parameter in CommandRegistrar methods was annotated as
\str = None\ which is a type lie — the default is None but the type
hint says str. Static type checkers (mypy/pyright) would flag this as
an error. Changed to \Optional[str] = None\ for correctness, consistent
with how extension_id (same class) is already typed.
2026-07-27 16:12:20 -05:00
Noor ul ain
2355fcb350 Update AGENTS.md (#2626)
* docs: layer contributor-onboarding sections onto AGENTS.md

Rebased onto current main and reworked so the additions match the
current architecture rather than the stale base this branch was written
against. The original revision documented the retired Windsurf
integration and a CLI-managed `context_file` field that no longer
exists (context files are now owned by the opt-in agent-context
extension), and described the manifest at the wrong path with a
non-existent API.

This version keeps all current AGENTS.md content unchanged and adds four
onboarding-focused sections, verified against the code:

- Quickstart — Add a New Integration in 5 Steps (links into the existing
  step-by-step section; notes context files are extension-owned)
- IntegrationManifest — File Tracking (correct path
  .specify/integrations/<key>.manifest.json and real API:
  record_file / record_existing / hash-guarded uninstall)
- Error Handling and Debugging (symptom/cause/fix table + debug tips)
- Contribution Checklist

Purely additive (+88 lines, no deletions); all internal anchors resolve.

Assisted-by: Claude Opus 4.8 (model: claude-opus-4-8, autonomous)
Co-Authored-By: Claude Opus 4.8 (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>

* 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 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-27 15:17:38 -05:00
Noor ul ain
98c9e67ce2 fix(extensions): tolerate non-string catalog name in display-name lookup (#3747)
* fix(extensions): tolerate non-string catalog name in display-name lookup

_resolve_catalog_extension() filters catalog search results by display
name with `ext["name"].lower() == argument.lower()`. Extension catalog
JSON is user-editable, so a hand-authored non-string name (e.g.
`name: 123`) crashes the filter with `AttributeError: 'int' object has
no attribute 'lower'`, taking down `extension info <name>` and
`extension add <name>`. A missing `name` key would likewise KeyError.

Coerce defensively with `str(ext.get("name", "")).lower()`, matching the
ambiguous-match display block just below (which already str()-coerces
name for the same reason). A bad-named entry simply doesn't match,
yielding a clean not-found error instead of a traceback.

Adds a regression test invoking `extension info <name>` against a
mocked catalog whose search result has `name: 123`; it fails pre-fix
with AttributeError.

Co-Authored-By: Claude Opus 4.8 (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>

* 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 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-27 15:00:26 -05:00
Noor ul ain
6136706ef3 fix(presets): coerce non-string catalog tags before joining (#3743)
* fix(presets): coerce non-string catalog tags before joining

Preset catalog payloads are user-editable YAML/JSON, so a `tags:` list
can legitimately contain non-strings (e.g. numeric tags). The preset
list/search/info display paths and the catalog search backend joined
tags with a raw `", ".join(...)` / used `t.lower()`, which raised
`TypeError: sequence item N: expected str instance, int found` (or
`AttributeError` on `.lower()`) and crashed the command.

Sibling command surfaces already guard this — extensions, integrations,
and workflows coerce with `str(t) for t in ...`. This aligns presets:

- `_commands.py`: `preset list`, `preset search`, and both `preset info`
  branches now join `str(t) for t in ...`.
- `__init__.py` `PresetCatalog.search`: tag filter uses `str(t).lower()`
  and the searchable-text join coerces tags to `str`.

Adds regression tests driving `preset search` and `preset info` through
CliRunner with numeric tags; both fail before the fix with the TypeError.

Co-Authored-By: Claude Opus 4.8 (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 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-27 14:14:59 -05:00
Marsel Safin
683bfd00c9 fix: register extensions for the active integration only (#3459)
* fix: register extensions for the active integration only

extension add registered commands for every detected agent, and
integration upgrade back-filled enabled extensions for non-active
integrations. Maintainer direction on #2948: treat the project as
single-active. Only the active integration gets extension artifacts;
use/switch rescaffold the target when the user selects it.

- extension add now routes through the all-agents pass restricted to
  the active integration (only_agent), keeping detection and
  missing-skills-dir recovery safeguards. Projects without recorded
  init-options fall back to detection-based registration.
- integration upgrade re-registers extensions only when upgrading the
  active integration, reversing the #2886 back-fill for non-active
  targets at maintainer request.

Fixes #2948

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

* fix: address review feedback on active-only extension registration

- Restrict the extension-add active-integration fallback to projects
  with no recorded active key at all. A recorded but unsupported key
  (e.g. "generic", deliberately excluded from AGENT_CONFIGS) no longer
  falls back to registering every detected agent.
- Apply the same single-active rule to preset command overrides:
  PresetManager._register_commands now scopes registration to the
  active integration via only_agent.
- Add PresetManager.register_enabled_presets_for_agent, mirroring
  ExtensionManager.register_enabled_extensions_for_agent, and call it
  from integration use/switch/upgrade (active only) alongside the
  existing extension re-registration so presets are rescaffolded on
  activation instead of being written for inactive integrations.

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

* fix: address second round of review feedback (priority order, fail-closed, docs)

- register_enabled_presets_for_agent now processes presets in reverse
  priority order (lowest-precedence first) so the highest-precedence
  preset is written last and actually wins after `integration use`
  rescaffolds two overlapping preset command overrides. Verified this
  reproduces the previously reported reversed-priority bug and that the
  fix resolves it.
- _register_commands_for_active_agent now checks for the "ai" key's
  presence separately from its value: a missing key still falls back to
  detection-based registration for all agents, but a recorded, malformed
  value (non-string or empty, e.g. [] or null) now fails closed
  (registers nothing) instead of being treated as "no active
  integration" or reaching AGENT_CONFIGS.get() with an unhashable key
  and raising TypeError.
- Updated docs/reference/presets.md and docs/reference/integrations.md
  to describe active-only preset/extension registration and clarify
  that `integration use`/`switch` is the activation point for
  installed extensions and presets, and that `upgrade` only
  re-registers them for the active integration.

Adds regression tests: two enabled presets overriding the same command
with different priorities (priority winner must survive `use`
rescaffolding), and a malformed recorded `ai` value ([]) for
`extension add`.

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

* fix: address third round of review feedback (multi-integration semantics)

Fixes five deeper active-only registration bugs surfaced by Copilot review
after 2486c08, all in the presets/extensions single-active integration
rule (#2948):

1. presets: _reconcile_composed_commands (run after install/remove)
   bypassed the active-only filter entirely, writing composition-winner
   command files for every detected non-skill agent via
   register_commands_for_non_skill_agents. Added an only_agent param to
   that registrar method (mirroring register_commands_for_all_agents)
   and threaded it through all 5 reconciliation call sites.

2. presets: `integration use copilot` with --skills (ai_skills: true)
   wrote both the static .agent.md command file AND the SKILL.md
   mirror for the same override. Mirrored the extension path's
   ai_skills guard in both _register_commands and the reconciliation
   pass: a command-backed active agent running in skills mode is
   excluded from non-skill command registration.

3. presets: registered_skills was a flat list, so switching between
   two skill-mode agents (e.g. Claude -> Codex) and then removing the
   preset only restored the currently active agent's directory,
   permanently orphaning the other. _unregister_skills now restores
   every existing skill-mode agent directory instead of only the
   active one.

4. extensions: load_init_options() collapses "no file" and "corrupted
   file" into the same {}, so the round-2 fail-closed fix didn't
   actually distinguish them. Added a shared
   resolve_active_agent_for_registration() helper in _init_options.py
   that checks file existence separately from parse success, returning
   a distinct sentinel for "file absent" vs None for "corrupted or
   invalid". extensions/__init__.py now uses this helper.

5. presets: same corruption-collapsing bug in _register_commands's
   active_agent resolution. Now uses the same shared helper as (4).

Adds regression tests for all five: reconciliation active-only
filtering, copilot --skills dual-write prevention, multi-skill-agent
switch+remove, and corrupted init-options fail-closed behavior for
both extension add and preset add. Each test was verified to fail
against the pre-fix code and pass with the fix.

Targeted (883) and full (3923 passed, 109 skipped) suites pass; ruff
check clean.

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

* fix: address fourth round of review feedback (skill registration provenance)

Replace the "enumerate every skill-mode directory and restore all of them"
approach from the previous round with precise per-agent provenance
tracking, per reviewer feedback that the enumerate-and-restore-everything
design was unsound:

- registered_skills changes from a flat List[str] to Dict[str, List[str]]
  (agent name -> skill names actually written), mirroring the shape
  registered_commands already uses. _register_skills now returns this
  per-agent mapping instead of a bare list, and every call site
  (register_enabled_presets_for_agent, install_from_directory, the
  _reconcile_skills "was this skill previously managed" check) is updated
  to read/merge the new shape. Legacy flat-list registry entries from
  before this change are still readable: writes self-migrate the format,
  and _normalize_registered_skills() handles the transitional read paths.

- _unregister_skills now restores exactly the agent directories recorded
  for a preset instead of guessing at every skill-mode integration that
  happens to exist on disk. This fixes two problems with the old
  enumerate-everything design: (1) it could silently overwrite or delete
  another preset's (or a user's) override in an agent directory the
  current preset never actually touched, and (2) it depended on
  transient per-process integration state (_skills_mode), which is unset
  in a fresh CLI invocation for mode-selectable integrations like Copilot
  --skills, permanently orphaning their overrides after a process
  restart. Registries written before this change (flat list, no agent
  provenance) fall back to best-effort restoration under only the
  currently active agent, matching the pre-existing guarantee level.

- Every directory resolved from persisted provenance is now validated
  through the project's shared symlink/containment guard
  (_ensure_safe_shared_directory) before any file in it is read, written,
  or removed, since restoration may target an agent that isn't currently
  active and its directory can't be assumed safe just because a name was
  recorded for it.

- _tracked_skill_agent_dirs() (the enumeration helper introduced last
  round) is removed; it's superseded by the provenance-based design.

Adds regression tests: a symlinked skills directory is rejected during
removal; removing one preset does not disturb a different preset's
override in another agent's directory; and a Copilot --skills
registration installed, then removed after switching agents in a fresh
PresetManager instance (simulating a new process), is still correctly
restored. Updates existing skill-registration assertions across
test_presets.py and test_integration_claude.py for the new per-agent
registry shape.

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

* fix: address fifth round of review feedback (symlink presence, rescaffold reconciliation, shared skills dir)

- _init_options.py: resolve_active_agent_for_registration() now treats a
  dangling init-options.json symlink as present (path.is_symlink() check
  alongside path.exists()), since Path.exists() follows symlinks and
  returns False for a broken one. Previously a broken symlink fell back
  to the legacy "no file" path and registered every detected agent
  instead of failing closed.
- presets/__init__.py (register_enabled_presets_for_agent): the
  integration use/switch rescaffold path now collects affected command
  names across all presets processed and runs
  _reconcile_composed_commands/_reconcile_skills once after the loop,
  matching install/remove. Previously rescaffolding wrote each preset's
  raw content directly with no follow-up reconciliation, so a
  project-level override (the highest-priority layer) could be clobbered
  by a lower-precedence preset after switching agents.
- presets/__init__.py (_unregister_skills): multiple integrations can
  share one physical skills directory (agy/codex/zed all resolve to
  .agents/skills). Provenance restoration now groups recorded agent
  entries by resolved directory and restores each physical directory
  exactly once, preferring the currently active agent's renderer when it
  owns that directory (otherwise any recorded owner, chosen
  deterministically). Previously each recorded agent key triggered its
  own restore pass against the same directory, with whichever agent was
  iterated last silently winning regardless of which agent was active.

Adds regression tests for each: a dangling init-options.json symlink
failing closed for both preset resolution and extension add; integration
use rescaffold preserving a project override over a lower-priority
preset; and a codex/agy shared-directory removal restoring the directory
exactly once in the active agent's format.

Targeted (tests/integrations/test_integration_subcommand.py,
tests/test_presets.py, tests/test_extensions.py,
tests/test_extension_skills.py,
tests/integrations/test_integration_opencode.py,
tests/integrations/test_integration_claude.py): 930 passed.
Full suite: 3930 passed, 109 skipped.
ruff check: clean on files touched by this change.

Refs #2948

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

* fix: guard skill subdirectories and active-agent scoping in preset reconciliation

Fix 4 issues from round-6 review of the active-only integration
registration work (#2948):

- remove(): removed_cmd_names only collected primary command names from
  registered_commands + manifest aliases, missing commands that were
  only ever registered via skills mode (ai_skills guard returns no
  command names for command-backed integrations in skills mode). This
  skipped reconciliation entirely when removing a higher-priority
  skills-mode preset, causing _unregister_skills() to fall back to
  core/extension content instead of the surviving lower-priority
  preset's override. Now every command template's primary name is
  added to removed_cmd_names unconditionally.

- _reconcile_composed_commands(): the "composed is None" branch (fires
  when no replace-strategy layer remains for a command, e.g. after
  removing a wrap/append preset's base) called unregister_commands()
  across every configured non-skill agent, ignoring only_agent. This
  deleted historical artifacts from integrations that were never active
  for the preset. Now filtered by only_agent like the rest of the file.

- Added _validate_skill_subdir() helper (reusing
  _ensure_safe_shared_directory/_validate_safe_shared_directory from
  shared_infra.py) and applied it at every site that reads or writes an
  individual skill subdirectory (_register_skills,
  _unregister_skills_in_dir, _reconcile_skills' override_skills
  restoration loop). _safe_skills_dir_for_agent only validated the
  parent skills directory; a symlinked leaf subdirectory (e.g.
  .claude/skills/speckit-specify) would slip past that check since
  is_dir()/exists() follow symlinks, letting write_text/rmtree operate
  through it to an arbitrary location outside the project.

Added regression tests: removing a higher-priority skills-only preset
restores the surviving lower-priority preset's content; composed-is-None
unregistration only touches the active agent; symlinked skill subdirectory
rejected on restore; symlinked skill subdirectory rejected on write.

Targeted (934) and full (3934 passed, 109 skipped) test suites and ruff
check pass clean.

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

* fix: persist command registration before fallible skills phase on rescaffold

Fix remaining round-6 review findings on the active-only integration
registration work (#2948):

- register_enabled_presets_for_agent(): registered_commands and
  registered_skills were merged and persisted together in a single
  registry.update() call after both the commands and skills phases ran.
  If _register_skills() raised, the per-preset try/except swallowed it
  before that update() call was reached, even though _register_commands()
  had already written a real command file to disk. That file became
  untracked, so preset removal could no longer clean it up.
  install_from_directory() already persists registered_commands
  immediately after the commands phase, before starting the independently
  fallible skills phase; rescaffold now does the same.

- test_presets.py: renamed a misleading claude_dir variable (pointing at
  Gemini's command directory) in
  test_composed_none_unregister_respects_active_agent to reuse the
  existing gemini_commands_dir variable already defined earlier in the
  same test.

Added regression test
test_rescaffold_persists_commands_before_fallible_skills_phase:
simulates a skills-phase failure during rescaffold and asserts the
command file already written to disk is still tracked in
registered_commands.

Verified all other round-6 findings (preset active-integration scoping,
preset reconciliation/remove paths, skills-mode switching, override
precedence during rescaffold, skill-subdirectory symlink safety) are
already addressed by prior commits in this branch; re-checked each
against current code before concluding no further change was needed.

Targeted (tests/test_presets.py, tests/test_extensions.py: 689 passed)
and full (3935 passed, 109 skipped) suites and ruff check on changed
files pass clean.

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

* fix: unregister stale opposite-mode preset artifact on same-agent skills toggle

Fix an Important gap in register_enabled_presets_for_agent() surfaced by
quality review (#2948): toggling ai_skills for the *same already-active*
command-backed agent (e.g. `integration upgrade copilot` after flipping
ai_skills, with copilot staying active throughout) left a stale artifact
from the previous mode behind, violating the command/skill mutual-
exclusion invariant this PR otherwise enforces.

- command -> skills: _register_commands()'s ai_skills guard makes the
  commands phase a no-op, but the previously-written command file (e.g.
  .agent.md) and its registered_commands[agent] entry were never cleaned
  up, so it lingered alongside the newly written SKILL.md.
- skills -> command: _get_skills_dir() stops resolving a skills directory
  once ai_skills is off, making the skills phase a no-op, but the
  previously-written SKILL.md and its registered_skills[agent] entry were
  never cleaned up, so it lingered alongside the newly (re)written command
  file.

register_enabled_presets_for_agent() now resolves once per call whether
agent_name is a command-backed integration (extension != "/SKILL.md") and
the current ai_skills state, then narrowly unregisters the stale opposite-
mode entry for that agent via the existing _unregister_commands /
_unregister_skills helpers before persisting updated tracking — mirroring
the same per-agent, per-preset isolation already used elsewhere in this
method. Native skill-only agents (claude, codex, ...) are unaffected:
they have no command/skill toggle, so registered_commands and
registered_skills legitimately co-exist for them by design. The trailing
reconciliation pass, project-override precedence, and per-preset
partial-failure isolation are all unchanged.

Added red-first regression tests exercising the real install +
register_enabled_presets_for_agent rescaffold path in both toggle
directions:
- test_rescaffold_toggle_command_to_skills_removes_stale_command_file
- test_rescaffold_toggle_skills_to_command_removes_stale_skill_file

Both failed against the prior code (stale artifact persisted / registry
still tracked it) and pass after the fix.

Targeted (tests/test_presets.py, tests/test_extensions.py: 691 passed)
and full (3937 passed, 109 skipped) suites and ruff check on changed
files pass clean.

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

* fix: migrate legacy flat-list registered_skills on rescaffold even when unchanged

Fix a valid finding from GitHub Copilot's review of HEAD b9d9053 (#2948):
register_enabled_presets_for_agent() normalizes a legacy flat-list
registered_skills value (predating per-agent provenance) to the
{agent_name: [...]} dict shape in memory via _normalize_registered_skills,
but the persistence check only compared the two *normalized* forms. When
the freshly rescaffolded skill names are identical to what the legacy
list already held — the common case, since nothing about the preset or
skill actually changed — that comparison is a no-op and registry.update()
is skipped, leaving the *raw* on-disk value as the un-migrated flat list.

A later switch to a different skill-mode agent and removal then follows
_unregister_skills's legacy best-effort path (restore only the currently
active agent's directory) instead of the per-agent provenance path,
permanently orphaning the first agent's override.

Fix: track the raw (pre-normalization) existing value and force
persistence whenever it's a non-empty list, independent of whether the
normalized content changed. Traced registered_commands for the same
class of bug: its registry value has always been Dict[str, List[str]]
(no legacy flat-list format ever existed for it — the existing
`if not isinstance(existing_commands, dict): existing_commands = {}`
guard is not a lossy migration path), so this fix stays scoped to
registered_skills only.

Added red-first regression test
test_rescaffold_migrates_legacy_flat_list_registered_skills: installs a
preset, overwrites its registry entry with a raw legacy flat list,
rescaffolds the *same* active agent with unchanged skill names, and
asserts the raw registry is migrated to per-agent dict form. Extends the
scenario with a switch to a second skill-mode agent and preset removal
to prove both agents' directories restore cleanly instead of orphaning
the first. Failed against the prior code (raw value stayed a list) and
passes after the fix.

Targeted (tests/test_presets.py, tests/test_extensions.py: 692 passed)
and full (3938 passed, 109 skipped) suites and ruff check on changed
files pass clean.

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

* fix: reconcile before fallible skills phase, infer legacy skill provenance, and unregister stale extension artifacts on toggle

Three findings from the Copilot review on HEAD b9d9053/3a1e749:

1. `register_enabled_presets_for_agent()` only recorded a preset's command
   names into `affected_cmd_names` (the set later passed to
   `_reconcile_composed_commands`/`_reconcile_skills`) in the loop that ran
   *after* `_register_skills()`, inside the same per-preset `try` block. If
   `_register_skills` raised, the `except` caught it and `continue`d before
   that loop ever ran — so a preset whose commands phase already wrote real
   content to disk never got reconciled against the full priority stack,
   leaving its raw content in place instead of a project override or
   higher-precedence preset's content. Fix: record the manifest's command
   names immediately after the commands phase succeeds and persists, before
   calling the independently fallible `_register_skills()`.

2. The legacy flat-list `registered_skills` migration (added for the
   previous review round) attributed every name in the list to whichever
   agent was currently being (re)activated. If the first operation after
   upgrading from a pre-#2948 registry was a direct switch to a *different*
   skill-mode agent (e.g. a legacy Claude override, then `integration use
   codex` with no intervening Claude rescaffold), the migrated dict only
   recorded `{"codex": [...]}`, permanently losing Claude's actual
   provenance and orphaning its override on later removal. Fix: added
   `_infer_legacy_skill_provenance()`, which probes every configured
   skill-mode agent's directory (via the same safe, symlink-validated
   helpers already used for restore/removal) for a `SKILL.md` whose
   frontmatter records this exact preset as the owner
   (`metadata.source == "preset:<pack_id>"`). A name found under more than
   one directory is attributed to every matching agent (the preset may have
   been active while the user switched between several skill-mode agents
   before provenance tracking existed); names that can't be matched to any
   directory still fall back to the previously-active best-effort
   behaviour. Directory grouping for shared-path aliases (e.g.
   agy/codex/zed all resolving to `.agents/skills`) intentionally does not
   call `.resolve()` on the path, since doing so diverges from
   `project_root`'s own resolution state on platforms where a path
   component is itself a symlink (e.g. macOS's `/var` -> `/private/var`)
   and made every subsequent containment check spuriously fail.

3. `register_enabled_extensions_for_agent()` has the same command/skill
   mutual-exclusion gap the preset path had (fixed in a previous round):
   toggling `ai_skills` for the *same active* agent left the opposite
   mode's artifact behind. Command -> skills left the extension's
   `.agent.md` file and its `registered_commands[agent]` entry in place
   once `skills_mode_active` made the commands phase a no-op. Skills ->
   command left the extension's `SKILL.md` file in place, since an empty
   `_register_extension_skills()` result (because this agent's skills
   directory no longer resolves once `ai_skills` is off) was treated as
   "nothing to register" rather than "this was rendered here before and is
   now stale". This diverges from the preset path in one respect:
   `registered_skills` for extensions has always been a flat list with no
   per-agent provenance (extension skills are only ever rendered for the
   active agent, never per-preset-per-agent tracked), so the fix resolves
   ownership by checking which of the extension's tracked skill names
   still exist as directories under this specific agent's directory before
   removing them — mirroring the same technique `unregister_agent_artifacts`
   already uses for full agent deactivation, but scoped narrowly to firing
   only when a toggle is actually detected (`skills_mode_active` /
   `command_mode_active`), so a same-mode re-run never disturbs
   already-correct artifacts or a user's manual customizations.

Regression tests (all confirmed red before their respective fix, green
after):
- tests/test_presets.py::TestPresetSkills::test_rescaffold_reconciles_override_even_when_skills_phase_fails
- tests/test_presets.py::TestPresetSkills::test_rescaffold_legacy_flat_list_direct_switch_preserves_original_agent
- tests/test_extension_skills.py::TestExtensionSkillRegistration::test_rescaffold_toggle_command_to_skills_removes_stale_extension_command_file
- tests/test_extension_skills.py::TestExtensionSkillRegistration::test_rescaffold_toggle_skills_to_command_removes_stale_extension_skill_file

Verification: tests/test_presets.py + tests/test_extensions.py +
tests/test_extension_skills.py (753 passed), tests/integrations/ (1768
passed, 1 skipped), full suite `pytest tests -q` (3942 passed, 109
skipped), `ruff check` on changed files clean.

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

* fix: broaden legacy skill provenance inference to command-backed agents

_infer_legacy_skill_provenance() only probed agents whose registrar
config statically declares extension == "/SKILL.md", excluding
command-backed agents (e.g. Copilot) that can also render preset
overrides as SKILL.md files when ai_skills is enabled. A real
preset-owned .github/skills/.../SKILL.md written while Copilot was the
active skills-mode agent was therefore never probed and got
misattributed entirely to whichever agent activated first after the
upgrade, permanently orphaning Copilot's override on later removal.

Broaden the candidate set to every configured integration
(CommandRegistrar.AGENT_CONFIGS), reusing the existing safe-path
helper (_safe_skills_dir_for_agent, itself built on the shared
_get_skills_dir resolver) rather than inventing new path-construction
logic. The existing preset-marker match (metadata.source ==
"preset:<pack_id>") continues to gate every attribution, so
command-mode agents that never rendered this preset's skill are not
falsely attributed.

Add red-first regression tests: a legacy flat-list entry owned by
Copilot in skills mode, switched directly to Claude with no
intervening Copilot rescaffold, now migrates to a per-agent dict
covering both agents, and removal restores both agents' files instead
of orphaning Copilot's override; plus a negative-case test confirming
a command-mode Copilot with no preset-owned skill marker is not
falsely attributed during the same migration.

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

* fix: preserve extension skill tracking for mirrors in other agent dirs

The skills -> command toggle cleanup in
register_enabled_extensions_for_agent() recomputed the remaining
tracked registered_skills names by checking only the toggling agent's
own skills directory. Since registered_skills is a single flat list
shared across every agent an extension has ever been activated under
(skills are only ever rendered for the active agent, so there is no
per-agent registry key), a name whose mirror still existed under a
*different*, previously-active agent's directory was incorrectly
dropped from tracking as soon as the current agent's own copy was
removed. A later full removal only iterates registered_skills, so the
orphaned mirror under the other agent's directory was never found or
cleaned up.

Add _extension_owned_skill_names(), which re-verifies ownership across
every configured agent's skills directory (deduped by shared path) the
same way the existing _unregister_extension_skills() fallback scan
already does, keeping a name only when a SKILL.md with a matching
metadata.source == "extension:<id>" marker is found somewhere -
read-only, no directory creation, no symlink escape. Use it instead of
re-checking only the toggling agent's own directory when recomputing
what remains tracked after narrow stale-mirror cleanup.

Add a red-first regression test: Auggie is activated in skills mode
first (writing a mirror), then Copilot is activated in skills mode
(writing its own mirror for the same names), then Copilot toggles to
command mode. Before the fix, registered_skills lost both names
entirely even though Auggie's mirrors were untouched on disk; after
the fix tracking is preserved and a subsequent full removal correctly
cleans up Auggie's remaining mirrors too.

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

* fix: reject symlinked skills-directory escape in extension skill scans

_extension_owned_skill_names() and the fast/fallback paths of its
sibling _unregister_extension_skills() called skills_candidate.resolve()
and then checked children relative to that already-resolved candidate.
If the candidate directory itself (e.g. .gemini/skills) was a symlink
pointing outside the project root, both the resolve() call and the
subsequent containment check silently passed through the symlink
instead of rejecting it:

- _extension_owned_skill_names() would falsely attribute ownership to
  a marker-matching SKILL.md living outside the project.
- _unregister_extension_skills()'s fast path (an explicit skills_dir,
  as passed by the toggle-cleanup call site) and its fallback scan
  (used during full extension removal) would both shutil.rmtree() the
  external directory, deleting unrelated content outside the project.

Fix by validating the candidate directory itself with the existing
_validate_safe_shared_directory() shared-infra helper before any probe
or delete: it rejects a symlink at any path component (walking down
from the project root, including the final component) without ever
resolving through it, and is already used elsewhere in the codebase for
the same class of shared-directory containment check. Unsafe
candidates are skipped/refused rather than followed.

Add red-first security regression tests reproducing each of the three
call sites with a `.gemini/skills` symlink pointing at an external
directory containing a marker-matching SKILL.md and an unrelated
precious_file.txt: provenance inference must not attribute the name,
and both the explicit-skills_dir fast path and the None-skills_dir
fallback scan must leave the external directory and file untouched.
Existing valid shared/deduped directory tests (e.g. agy/amp/codex/zed
sharing .agents/skills) continue to pass, confirming legitimate shared
directories still clean up correctly.

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

* Fix unscoped extension-skill removal and legacy preset provenance on direct remove

- _unregister_extension_skills(): omitting skills_dir now always triggers
  the full multi-directory fallback scan instead of narrowing to the
  currently active agent's directory. Previously, remove() (the only
  caller that omits skills_dir) would resolve the active agent's dir and
  take the scoped fast path, orphaning a previously-active second agent's
  extension skill mirror during full removal.

- PresetManager.remove(): infer legacy flat-list registered_skills
  provenance (reusing _infer_legacy_skill_provenance from the prior
  rescaffold fix) before invoking _unregister_skills, so a direct
  `preset remove` with no intervening rescaffold/switch also restores
  every previously-active agent's directory instead of only the
  currently active one.

Added regression tests:
- test_remove_while_second_agent_still_in_skills_mode_cleans_up_first_agent_mirror
- test_remove_infers_legacy_flat_list_provenance_without_prior_rescaffold

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

* Keep unregister_agent_artifacts scoped to its agent when directory is absent

ExtensionManager.unregister_agent_artifacts() converted its resolved
agent_skills_dir to None whenever that directory didn't exist, before
calling _unregister_extension_skills(). After 1d8f9e3, omitting
skills_dir means "genuinely unscoped removal": scan every configured
agent's directory, reserved for ExtensionManager.remove()'s full
project cleanup. Since unregister_agent_artifacts is agent-scoped (used
by switch to clean up the previous integration's artifacts), this
caused it to delete every other agent's live extension skill mirrors
whenever the target agent's own directory happened to be absent, e.g.
unregistering an agent that was never activated.

Fix: always pass the explicit, agent-scoped skills_dir, even when it
doesn't exist on disk, so the fast path is a safe no-op for an absent
directory instead of falling back to the all-agents scan. Registry
reconciliation (dropping removed names from the flat registered_skills
list) now only runs when the agent's directory actually exists, so an
absent directory can't be misread as "these names were removed
everywhere" and wipe tracking for mirrors that still legitimately live
under other agents' directories.

Added regression test:
- test_unregister_agent_artifacts_stays_scoped_when_agent_dir_absent

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

* Preserve global skill tracking across agents in unregister_agent_artifacts

The present-directory branch of ExtensionManager.unregister_agent_artifacts()
recomputed "remaining" registered_skills only by checking whether each name
still existed under the just-cleaned agent's own directory. registered_skills
is a single flat list shared across every agent an extension was ever
activated under (skills are only ever rendered for the currently active
agent, so there's no per-agent registry key). Repro: auggie and copilot both
have mirrors for the same extension; unregister_agent_artifacts("auggie")
correctly removes auggie's own mirror, sees the names absent from auggie's
(now empty) directory, and stores an empty registered_skills list - even
though copilot's mirror is still live on disk and now untracked. A later full
remove() then reads an empty registry and leaves copilot's mirror orphaned.

Fix: after the agent-scoped cleanup, recompute remaining names with
_extension_owned_skill_names(), which scans every safe, configured agent
skills directory (not just the one just cleaned) and keeps a name only if a
marker-verified SKILL.md for this extension still exists somewhere. This is
the same helper already used for the analogous same-agent toggle-cleanup
case, so no new abstraction was introduced. Explicit per-agent cleanup,
marker ownership verification, and symlink/containment safety are unchanged.

Added regression test:
- test_unregister_agent_artifacts_preserves_tracking_for_other_agent_mirror

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

* Reconcile every historical agent on preset removal; validate child skill dirs

Fixes 3 findings from the Copilot review on HEAD 31c9b97 (#2948):

1. presets/__init__.py: remove()'s command reconciliation only recreated
   the surviving preset's content for the currently active agent, even
   though the removed preset's registered_commands could span multiple
   historical (now-inactive) agents recorded via prior rescaffolds. Now
   remove() captures every historical agent registered_commands actually
   targeted (before mutation) and passes it as extra_agents through
   _reconcile_composed_commands -> _register_for_non_skill_agents /
   _register_command_from_path -> registrar.register_commands_for_non_
   skill_agents, so the active-only restriction for install/use is
   preserved while post-removal reconciliation restores every touched
   directory.

2. presets/__init__.py: the analogous gap existed for skills. _unregister_
   skills() now returns {skills_dir: renderer_agent} for every directory it
   actually restored, and _reconcile_skills() accepts extra_skills_dirs to
   reconcile each of those directories (via a new apply_to_dir() helper),
   not only the currently active skills directory. _register_skills() gained
   optional target_dir/target_agent overrides (forcing
   create_missing_skills off for non-active directories) so a historical
   directory is only ever restored, never seeded with brand-new skills.

3. extensions/__init__.py: _extension_owned_skill_names() and both the
   fast and fallback paths of _unregister_extension_skills() validated only
   the parent skills_dir for symlink escape, then resolved
   skills_dir / skill_name and checked containment relative to that
   already-resolved parent. A per-skill child that is itself a symlink to
   a different, legitimate skill directory within the same (safe) root
   passed that containment check, so deleting/attributing through the
   symlink name could destroy or misattribute an unrelated skill reached
   only via the alias. All three call sites now run the shared
   _validate_safe_shared_directory() component-wise check against the full
   skills_dir / skill_name path (not just the parent) before any read or
   delete, rejecting a symlinked child outright rather than following it,
   even when its resolved target remains in-bounds.

Regression tests added (all confirmed red against pre-fix code, green
after):
- test_remove_reconciles_command_for_every_historical_agent
- test_remove_reconciles_skill_for_every_historical_agent
- test_extension_owned_skill_names_rejects_symlinked_child_skill_dir
- test_unregister_extension_skills_explicit_dir_rejects_symlinked_child
- test_unregister_extension_skills_fallback_rejects_symlinked_child

Tests: tests/test_presets.py (361), tests/test_extension_skills.py (69),
tests/test_extensions.py (338) all pass; tests/integrations (1768 passed,
1 skipped) pass; full suite 3902 passed / 74 skipped (90 pre-existing,
environment-only git-signing tests deselected — confirmed failing
identically on the pre-change baseline due to local 1Password SSH-agent
signing, unrelated to this change). ruff check clean on all changed files.

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

* Persist historical reconciliation ownership; defer destructive toggle cleanup; validate registry-provided skill names

Round 11 review findings (5 comments on HEAD ab6c28c), three root causes:

A) Historical-agent reconciliation wrote surviving content to disk but
   discarded the returned per-agent write map, so the preset's own
   registered_commands/registered_skills never learned about directories
   reconciliation restored on its behalf. A later removal of that same
   preset then orphaned those directories. Added
   _merge_pack_registered_commands/_merge_pack_registered_skills and wired
   them into _reconcile_composed_commands and _reconcile_skills's
   apply_to_dir so every actual write is merged back into the winning
   preset's registry metadata.

B) Command<->skills toggle on an already-active agent deleted the old
   artifact before the replacement registration ran, in both
   presets/__init__.py's register_enabled_presets_for_agent and
   extensions/__init__.py's register_enabled_extensions_for_agent. If the
   replacement step raised, both artifacts were lost. Deferred the
   destructive cleanup until after the replacement phase completes
   without raising (register-new-then-remove-old ordering); the mirror
   skills->command direction was already safe since the new command file
   is always registered unconditionally before any cleanup runs.

C) _unregister_skills_in_dir and _infer_legacy_skill_provenance joined a
   registry-provided (untrusted) skill name directly onto a directory
   before any name-shape validation. An absolute in-project name discards
   the intended parent directory entirely (Path's "/" operator drops the
   left side for an absolute right side), letting a corrupted registry
   entry escape the intended skills subtree while still resolving inside
   the project root - passing the existing containment/symlink check.
   Added a centralized _is_safe_registry_skill_name guard (rejecting
   non-strings, empty strings, absolute paths, multi-component paths, and
   "."/".." ) and applied it before every path join derived from
   registry-provided skill names in both functions. Also fixed
   _infer_legacy_skill_provenance's unmatched-name fallback, which
   previously still attributed rejected names to fallback_agent even
   after the loop skipped them.

Added red-first regressions for all three root causes, covering: a
two-preset historical-command-agent survivor scenario, an analogous
skill-agent survivor scenario, injected skills-phase failure during a
preset command->skills toggle and the extension equivalent, a direct
unit test of the new name-safety guard, an absolute-path escape attempt
against _unregister_skills_in_dir, and a false-attribution attempt
against _infer_legacy_skill_provenance.

Tests: tests/test_presets.py (367 passed), tests/test_extension_skills.py
+ tests/test_extensions.py (408 passed), tests/integrations (1768
passed, 1 skipped), full suite tests -q deselecting the pre-existing
1Password-signing-affected tests/extensions/git/test_git_extension.py
(3909 passed, 74 skipped, 90 deselected). ruff check clean on all
changed files.

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

* Verify replacement actually landed before retiring stale toggle artifacts

The command<->skills toggle cleanup added for #2948 deferred destructive
removal of the old-mode artifact until after the replacement registration
call completed without raising. That was necessary but not sufficient:
none of _register_skills(), _register_commands(),
register_commands_for_agent(), or _register_extension_skills() raise on
a missing source template, a safety-validation skip, or a corrupted
manifest entry — they simply return an empty or partial result. Treating
"did not raise" as "fully replaced" meant a stale artifact could still be
deleted (or its tracking dropped) even though its specific replacement
never actually landed, leaving neither artifact in place for that logical
command/skill.

Fix all four affected toggle directions by checking the replacement
call's actual return value before allowing any destructive step:

- presets command->skills (register_enabled_presets_for_agent): only
  unregister a stale command name once its corresponding skill name
  (via the existing _skill_names_for_command() helper) is confirmed
  present in the skills call's returned names for that agent; the
  remainder stays tracked and on disk.
- presets skills->command (register_enabled_presets_for_agent): only
  unregister a stale skill name once its corresponding command name is
  confirmed present in the commands call's returned names for that
  agent, using the same helper.
- extensions skills->command (register_enabled_extensions_for_agent):
  only remove a skill mirror once the matching command (mapped via the
  existing HookExecutor._skill_name_from_command() helper) is confirmed
  present in register_commands_for_agent's returned names.
- extensions command->skills (register_enabled_extensions_for_agent):
  only remove a deferred stale command once its matching skill name is
  confirmed present in _register_extension_skills()'s returned names.

All four reuse the existing command<->skill name-derivation helpers
rather than inventing new mapping logic. Registry tracking is updated to
retain exactly the unreplaced subset rather than being popped wholesale,
so partially-successful toggles leave correct, minimal tracking behind.

Added 8 new regression tests (4 presets, 4 extensions) covering both the
fully-empty and genuinely-partial result cases for each of the four
toggle directions, using real missing-source-file scenarios (not mocked
return values) to exercise the actual code paths. Confirmed red before
the fix and green after for all 8.

Focused (test_presets.py, test_extension_skills.py, test_extensions.py,
tests/integrations): 2551 passed, 1 skipped.
Full suite (tests, excluding the pre-existing environment-local
1Password-signing git-extension failures): 3917 passed, 74 skipped, 90
deselected.
ruff check: clean.

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

* Retire alias command groups on toggle; scope preset cleanup to switched-away agent (#2948)

Fixes three current Copilot review findings on HEAD d0d152e:

1. Command->skills toggle cleanup only matched a stale command's own
   name against the returned replacement skill name. Aliases
   (CommandRegistrar tracks and returns primary + alias names flattened
   into one list) never have their own skill rendered -- only the
   primary command's skill is rendered -- so an alias's name could never
   match, leaving its command artifact and tracking behind forever even
   after the primary's replacement landed. Fixed identically in both
   presets (register_enabled_presets_for_agent) and extensions
   (register_enabled_extensions_for_agent): build a primary->alias
   mapping from the manifest, group stale names by primary, and
   retire/keep the whole group together based solely on whether the
   primary's skill replacement actually landed.

2. `integration switch` to a not-yet-installed target unregistered the
   old agent's extension artifacts but had no preset equivalent, so a
   preset's command overrides (including custom preset commands) and
   skill mirrors for the deactivated agent lingered as orphans. Added
   `PresetManager.unregister_agent_artifacts()`, mirroring
   `ExtensionManager.unregister_agent_artifacts()`: scoped strictly to
   the given agent, migrates a legacy flat-list `registered_skills`
   entry via existing on-disk provenance inference before removing
   anything (so other agents' real ownership is preserved rather than
   guessed or dropped), and guards against double-processing an
   artifact through both the commands and skills paths for native
   SKILL.md agents. Wired via a new `_unregister_presets_for_agent()`
   helper into the integration switch command's existing old-agent
   cleanup phase.

Added red-first regression tests:
- tests/test_presets.py: alias-group retire/keep/partial-multi-group
  tests for the command->skills toggle; unregister_agent_artifacts
  scoping tests for commands and legacy-list skill provenance.
- tests/test_extension_skills.py: alias-group retire/keep tests for the
  extension command->skills toggle.
- tests/integrations/test_integration_subcommand.py: end-to-end switch
  test proving a preset's custom command override is cleaned up when
  switching to a not-yet-installed integration, with tracking updated
  correctly and the new agent's registration unaffected.

All new tests confirmed red (AttributeError / orphaned file assertions)
before the fix and green after. Full suite: 3980 passed, 109 skipped.
ruff check clean on all changed files.

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

* fix: track reconciled extension artifacts

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

* Fix native skill preset reconciliation

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

* Fix shared native skill cleanup

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

* Fix partial preset rescaffold tracking

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

* Fix preset agent skill lifecycle

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

* Clarify preset removal reconciliation

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

* fix(integrations): address upgrade review feedback

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

* fix(presets): reconcile partial command writes

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

* fix: address active artifact cleanup review

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

* fix: defer preset skill cleanup to winning command

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

* fix: track reconciled and partial preset skills

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

* fix: reconcile project overrides to legacy skills

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

* fix: harden preset skill writes and rollback

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

* fix(presets): harden legacy skill restoration

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

* fix(presets): preserve non-owned legacy skills

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

* fix: validate reconciled skill paths

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

* fix(presets): preserve reconciled skill ownership

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

* fix(presets): clean reconciled agent skills

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

* fix: keep legacy cleanup project-local

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

* fix(presets): keep active agent's artifacts in its current mode on remove

A partially failed command<->skills toggle leaves stale tracking
(registered_commands or registered_skills) for the active agent, and
remove() replayed that history regardless of the agent's current mode:

- extra_agents re-admitted the active skills-mode agent into command
  reconciliation, recreating its command file from a surviving lower
  preset even though only_agent excluded it.
- _unregister_skills restored (and _reconcile_skills reapplied) a skill
  artifact for the active command-mode agent instead of deleting the
  preset-owned leftover.

The active agent's participation is now decided exclusively by its
current mode: reconciliation strips it from extra_agents, and removal
routes its stale skills through _delete_agent_preset_skills. Historical
replay still applies to inactive agents only (#2948).

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

* fix: filter uninstalled-extension commands in reconciliation; allow active-agent layout change with presets

Two follow-ups to the upstream-main merge:

- Preset reconciliation (_reconcile_composed_commands) now skips
  extension-scoped commands (speckit.<ext>.<cmd>) whose extension is not
  installed, at the single chokepoint every install/remove/rescaffold
  pass funnels through. Registration already refused them, so
  reconciliation could materialize files no registry entry tracks. The
  duplicated per-call-site filters collapse into one
  _extension_installed_for_command helper.

- The #3415 layout-change guard predates this PR's agent-scoped preset
  rescaffold: for the active integration, _register_presets_for_agent
  now re-registers enabled presets in the new layout and retires the
  old layout's stale files, so an active-agent command<->skills toggle
  proceeds and reconciles instead of being rejected. The guard still
  rejects non-active agents (no rescaffold runs for them) and still
  fails closed on an unreadable registry.
  _installed_presets_affecting_agent also understands the per-agent
  dict shape of registered_skills this PR writes, instead of raising
  'malformed'.

Regression tests: rescaffold with an uninstalled extension's command,
CLI-level legacy<->skills toggle with an installed preset (both
directions), secondary-agent rejection, and dict-shaped
registered_skills in the guard helper.

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

* fix: reject active layout change while a disabled preset owns artifacts

The post-upgrade preset rescaffold iterates enabled presets only, and a
disabled preset's artifacts are deliberately frozen until removal, so an
active-agent command<->skills layout change cannot reconcile them.
_installed_presets_affecting_agent now reports each preset's enabled
state and the guard rejects the migration while any affected preset is
disabled, with re-enable/remove guidance. Enabled presets and non-active
rejection behave as before.

Regression test: disabled preset blocks the toggle untouched; re-enabling
unblocks it and reconciles.

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

* docs: replace placeholder prefix in two safety comments

Comment-only: spell out why skill deletion is restricted to
project-local directories (flat/legacy provenance cannot prove
home-directory ownership) instead of an undefined placeholder word.

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

* docs: correct guard-helper docstring to active-only registration model

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

* fix: fail closed on non-list values in per-agent preset provenance

A dict-shaped registered_skills/registered_commands entry with a
non-list value (e.g. null) left ownership undecidable but read as "no
artifacts", letting a layout-changing upgrade proceed on a malformed
registry. Validate values are lists and raise
_PresetRegistryUnreadableError otherwise, matching the guard's
fail-closed contract. Unit test covers both fields.

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

* fix: drop eager extension unregister on layout-changing upgrade

Unregistering the agent's extension artifacts before re-registration
deleted files and registry tracking up front, so a failed or partial
re-registration left the extension with no artifacts at all. Retirement
of each opposite-mode artifact already belongs to
register_enabled_extensions_for_agent's deferred toggle cleanup, which
removes an old artifact only after its replacement is confirmed. Also
keeps disabled extensions consistent with disabled presets: artifacts
stay frozen in place with intact tracking.

Regression test corrupts the installed extension manifest so
re-registration fails, then asserts the old-layout artifacts and their
registry tracking survive the upgrade.

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

* fix: rescaffold fallback integration after failed switch rollback

When Phase 2 of a switch fails, rollback restores another installed
integration as the default via _set_default_integration but never
re-registered extensions or presets for it. Under active-only
registration the fallback may never have received any artifacts (it
was installed while another integration was active), and Phase 1
already unregistered the outgoing agent's artifacts — leaving the
restored default unusable. Rescaffold both extensions and presets
(best-effort) after the fallback default is successfully restored.

Regression test: secondary codex install with the git extension, a
failing switch to generic, then asserts codex ends up with registered
extension artifacts after rollback.

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

* docs: explain load-bearing pre-create loop in _reconcile_skills

The per-skill _validate_skill_subdir(create=True) loop looks like dead
code (its result is unused), but it re-creates the tracked skill
subdirectories that _unregister_skills just deleted so
_register_skills's only-overwrite-existing gate passes during a
historical-directory restore. Removing it fails
test_skill_reconciliation_preserves_per_directory_names.

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

* fix: preserve dashed-description skill tracking

Use the shared frontmatter parser when verifying surviving extension skill mirrors so delimiter substrings cannot hide provenance metadata.

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

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

* fix: skip absent extension skills during reconciliation

Filter extension-scoped commands before skill reconciliation so historical preset tracking and project overrides cannot recreate artifacts for uninstalled extensions.

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

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

* fix: preserve partial native skill cleanup

Coordinate native-skill command cleanup with registered skill coverage per agent and command so partial rescaffolds cannot orphan preset artifacts.

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

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-27 14:09:40 -05:00
Noor ul ain
42c7230aa9 fix(extensions): tolerate non-string tags in catalog search (#3746)
* fix(extensions): tolerate non-string tags in catalog search

ExtensionCatalog.search() assumed catalog `tags` were always strings:
the tag filter called `t.lower()` and the query path did
`" ".join([...] + tags)`. Extension catalog JSON is user-editable, so a
hand-authored `tags: [1, 2]` crashed search with AttributeError (tag
filter) or TypeError (query join).

Coerce defensively by filtering to `isinstance(t, str)` and guarding the
tags value as a list, matching the reference-correct sibling in
integrations/catalog.py. Non-string tags are now skipped rather than
raising.

Adds a regression test driving search(tag=...) and search(query=...)
against a catalog with mixed string/int tags; both fail pre-fix.

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

* fix(extensions): also coerce non-string author/name in catalog search

The same ExtensionCatalog.search() method had two more string
assumptions on user-editable catalog fields: the author filter called
`ext_data.get("author", "").lower()` (AttributeError on a numeric
author) and the query searchable-text joined `name`/`description`
uncoerced (TypeError on a numeric name). Coerce both defensively,
matching the reference-correct integrations/catalog.py::search.

Extends the regression test with non-string author/name coverage;
fails pre-fix with AttributeError at the author filter.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 11:58:23 -05:00
Quratulain-bilal
e6a3ccfb27 fix(extensions): hyphenate command names in 'extension info' listing (#3744)
The 'Commands:' section of 'specify extension info' for a locally installed
extension printed each command in its manifest dotted form
(e.g. speckit.jira.sync). Cline and Forge register hyphenated command names
(/speckit-jira-sync), so on those projects the displayed names did not match
what the user actually invokes.

Format each name through the active agent's command-name formatter, mirroring
the parity 'extension add' already applies to its 'Provided commands' listing
(#3669) and completing the Forge/Cline command-name parity from #3641/#3642.

Adds a regression test asserting the hyphenated form appears (and the dotted
form does not) for a Forge project.
2026-07-27 11:56:21 -05:00
Noor ul ain
962f9f0765 fix(workflows): escape remaining untrusted fields in workflow info (#3731)
* fix(workflows): escape remaining untrusted fields in `workflow info`

Follow-up to #3690, which escaped only the step-graph brackets. Every
other metadata field `workflow info` prints is untrusted content
(workflow.yml or catalog JSON), and console.print has Rich markup
enabled, so an unescaped `[...]` in any of them is parsed as a style tag
and silently swallowed:

- definition path: name, version, author, description, integration, and
  each input's name/type
- catalog path: name, version, description, tags, and the "not found"
  workflow id

A description of `Does [stuff] nicely` rendered as `Does  nicely`; an
integration of `claude [code]` rendered as `claude `. Route every field
through _escape_markup, matching the sibling `workflow list` / catalog
`search` commands, so bracketed text renders literally.

Add two regression tests covering the definition and catalog paths; both
fail on the pre-fix source (fields with brackets come back truncated).

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

* test: cover version + not-found-id escapes in workflow info

Addresses Copilot review feedback on the workflow-info markup-escape
tests:

- The definition-path and catalog-path regression tests left `version`
  bracket-free and never asserted it, so the version escapes could be
  removed without failing. Use bracketed version values and assert they
  survive verbatim.
- The newly escaped not-found identifier is a separate output path that
  no test reached. Add a case where local load raises FileNotFoundError
  and catalog lookup returns None, invoke `workflow info` with a
  bracketed ID, and assert the literal ID is preserved in the error.

Verified each new assertion fails when its source escape is removed
(test-the-test).

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 11:55:00 -05:00
Ali jawwad
c1028e5506 fix(extensions): guard non-numeric catalog downloads in search/info rendering (#3710)
* fix(extensions): guard non-numeric catalog downloads in search/info rendering

`specify extension search` and `specify extension info <id>` format a catalog
entry's `downloads` field with the `:,` thousands separator, guarded only by
`is not None`. Catalog payloads are only shape-validated -- individual fields
are never type-checked and `_get_merged_extensions` returns raw catalog dicts
-- so an entry with a non-numeric `downloads` (e.g. the JSON string "1500",
realistic from a community / SPECKIT_CATALOG_URL / project catalog) makes the
`:,` format raise `ValueError: Cannot specify ',' with 's'`, aborting the
whole command with an uncaught traceback.

Group-format `downloads` only when it is actually numeric; otherwise render it
as-is. Numeric values (int/float, incl. bool) format identically, so correct
catalogs are byte-for-byte unchanged. Every other field in these two renderers
is already `str()`-wrapped; this closes the one unguarded field.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* fix(extensions): escape the non-numeric downloads fallback for Rich markup

Address review feedback: the fallback interpolated the untrusted catalog value
straight into a Rich-rendered string, so guarding the ``:,`` ValueError just
traded it for a MarkupError -- a catalog entry with downloads "[/red]foo" still
aborted `extension search`/`info`, and balanced tags could restyle the output.

Wrap the fallback in _escape_markup(str(...)) at both sites, matching how every
other catalog field in these renderers is already escaped. Numeric values keep
the identical ``:,`` branch, so correct catalogs are unchanged.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* fix(extensions): escape 'stars' too, in the same stats string

Follow-up to the downloads escaping: `stars` is the other catalog-controlled
value joined into the same Rich-rendered stats line, and it was still raw --
verified that stars "[/red]x" raises the same MarkupError and aborts
`extension info`/`search`. Hardening one of the two adjacent values would have
left the reported defect reachable through the sibling field.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 11:25:01 -05:00
Mateus Cardoso
9e150cd3b2 fix(agent-context): apply default markers when config markers are blank (bash) (#3736)
When the extension config omits context_markers (or sets them blank),
relying on the built-in defaults, the Bash port aborted with "malformed
config parser output" and never updated the context file, while the
Python (`or DEFAULT_*`) and PowerShell (default-initialized) ports handled
it correctly.

The config parser prints three lines (context_files JSON, marker_start,
marker_end), captured via `_raw_opts="$(...)"`. Command substitution strips
trailing newlines, so blank marker lines collapse the output to fewer than
three, tripping the `(( ${#_opts_lines[@]} < 3 ))` guard and making the
DEFAULT_START/END substitution unreachable — the exact case it was written
for.

Require only the context_files line and default the marker lines to empty
(`${_opts_lines[1]:-}` / `${_opts_lines[2]:-}`) so the existing
DEFAULT_START/END fallback fills them in. Add a parity regression test with
blank markers (it fails on the old guard and passes with the fix).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 10:33:56 -05:00
Noor ul ain
99dc915ae3 fix: escape Rich markup in catalog list output (#3738)
The `catalog list` subcommands for workflows, workflow steps, presets,
and integrations printed user-editable catalog fields (name/url/
description from the `*-catalogs.yml` files) through `console.print`
with Rich markup enabled. Any bracketed content such as a description
`Does [stuff] nicely` was parsed as a style tag and silently swallowed,
and a malformed tag could raise while rendering.

Route each untrusted field through the module's already-imported
`escape` helper, matching the pattern already used by
`extension catalog list`.

Adds regression tests for all four commands that inject bracketed
name/url/description and assert the brackets survive verbatim in the
output.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 10:15:37 -05:00
Ali jawwad
103ad73775 fix(workflows): guard non-mapping 'workflow:' block in WorkflowDefinition (#3694)
* fix(workflows): guard non-mapping 'workflow:' block in WorkflowDefinition

A present-but-non-mapping top-level `workflow:` block (bare `workflow:` ->
YAML null, or `workflow: <str>` / `workflow: [..]`) crashed
WorkflowDefinition.__init__ with AttributeError: the `{}` default of
`data.get("workflow", {})` only applies when the key is ABSENT, so a non-dict
value reached `workflow.get("id", ...)`. This fires inside from_yaml/
from_string — before validate_workflow can report the malformed shape — and
in the CLI escapes as a raw traceback (load_workflow is wrapped to catch only
FileNotFoundError/ValueError).

Normalize the local `workflow` to {} when it is not a mapping (self.data keeps
the raw value so validate_workflow still reports it), mirroring the adjacent
default_options guard.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* test(workflows): assert self.data preserves the raw non-mapping workflow value

Address review: the previous assertion only proved the key stayed present; it
would pass even if construction replaced the malformed value with {}. Assert
definition.data["workflow"] equals the original parsed value and is still a
non-mapping, proving the guard normalizes only the local variable.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 09:10:51 -05:00
Ali jawwad
59e63699b8 fix(bundler): reject unsupported schema_version in _merge_config (align readers) (#3711)
bundle-catalogs.yml has two readers that are meant to agree: commands_impl/
catalog_config._read (bundle catalog list/add/remove) and models/catalog.
_merge_config (the resolution path feeding bundle search/info/install via
load_source_stack). _read rejects an unsupported MAJOR schema_version;
_merge_config never checked it, so a file written by a newer/incompatible
Spec Kit (e.g. schema_version '2.0') was silently parsed under v1 assumptions
on the exact path where install_policy governs trust — the two readers
disagreed. #3623 (non-list catalogs) and #3659 (top-level non-mapping) already
aligned these two readers guard-by-guard; this is the last unaligned guard.

Add the same forward-compatible major-version check to _merge_config. Promote
CONFIG_SCHEMA_VERSION to models/catalog.py as the single source of truth and
import it in catalog_config.py (was a local duplicate) so the two cannot drift.
Absent schema_version stays valid (backward compatible); matching major stays
valid.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 08:19:01 -05:00
github-actions[bot]
015d125667 Update Linear Weave extension to v1.0.1 (#3762)
Update linear-weave extension submitted by @tonydwoodhouse:
- extensions/catalog.community.json (version, download_url, documentation, updated_at)

Closes #3758

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-07-27 08:17:41 -05:00
github-actions[bot]
3ca0eb169e Add Intake Sequencing Governance preset to community catalog (#3761)
Add intake-sequencing-governance preset submitted by @hindermath to:
- presets/catalog.community.json (alphabetical order)
- docs/community/presets.md community presets table

Closes #3742

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-07-27 08:16:57 -05:00
github-actions[bot]
c6cb25cb4a Update Quality Gates (Enforcement Layer) extension to v0.3.3 (#3760)
Update gates extension submitted by @schwichtgit:
- extensions/catalog.community.json (version, download_url, updated_at)
- docs/community/extensions.md community extensions table (no changes needed)

Closes #3755

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-07-27 08:15:33 -05:00
github-actions[bot]
eb8108e7b3 Update Verify Review Ship extension to v0.4.1 (#3759)
Update verify-review-ship extension submitted by @cadugevaerd to:
- extensions/catalog.community.json (version, download_url, sha256, description, requires, provides, tags, updated_at)
- docs/community/extensions.md community extensions table

Closes #3751

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-07-27 08:00:08 -05:00
Mateus Cardoso
403fcdc6fd fix(agent-context): discover nested plans in Python port mtime fallback (#3734)
The Python port of update-agent-context reintroduced a one-level plan
scan (specs/*/plan.md) in its mtime fallback, while the Bash and
PowerShell ports search recursively (specs/**/plan.md) per the fix for
issue #3024. The three ports were therefore not in parity: for nested
scoped layouts such as specs/<scope>/<feature>/plan.md, the Python port
found no plan and omitted the plan link from the managed context section.

Switch the fallback to `(root / "specs").rglob("plan.md")` and update the
module docstring to match the documented recursive-discovery contract.
Add a parity regression test covering the nested layout (it fails on the
one-level glob and passes with the recursive scan).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 07:41:20 -05:00
Oscar
2fb94e0f9c fix(extensions): make shipped scripts executable after install (#3723)
Extension archives are unpacked with zipfile.extractall and directory installs
are copied; neither restores a stripped Unix mode. A bundled *.sh therefore
lands non-executable, so a documented `.specify/extensions/<id>/scripts/bash/foo.sh`
invocation fails with "Permission denied" — e.g. a CI step that runs an
extension's gate. It only worked incidentally, after a later `specify init`.

Restore permissions at the shared sink. Every extension install route funnels
through ExtensionManager.install_from_directory (install_from_zip delegates to
it; extension add, extension update, and bundle installs all reach it), so
calling the existing ensure_executable_scripts() there covers every route —
present and future — by construction rather than by patching each command.

The helper already makes .specify scripts executable (init, migrate, and
integration-install all call it); it is called plainly, re-establishing the same
idempotent "scripts are executable" invariant those flows restore. Deliberately
the whole-project call rather than a scoped one: a scan-scope argument would only
spare re-walking already-correct files — negligible beside the copy/extract just
performed — while widening a simple, widely-used interface for a single caller.
Existing callers were audited: init's end-of-init call still covers core
.specify/scripts and is untouched; integration-install and migrate do no manager
install. Nothing is removed. No-op on Windows; best-effort per file; does not
change which files are executable or their mode.

Tests: a manager-level regression test asserts a mode-0644 script comes out
executable via both install_from_directory and install_from_zip(force=True) (the
latter also covering the remove-then-reinstall shape of extension update), plus
an end-to-end `extension add --dev` test. Both fail without the change; skipped
on Windows.

Fixes #3722.
2026-07-27 07:40:43 -05:00
Manfred Riem
446ee329b1 docs(assess): clarify the pipeline works on an empty project (#3732)
* docs(assess): clarify the pipeline works on an empty project

State explicitly in the README and intake command that the assess
pipeline requires no existing source code. An empty, freshly
initialized project and an existing codebase are equally valid
starting points — the input is just an idea (pasted text, a URL, a
ticket, or a codebase pointer).

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9df2615e-6a99-4cdc-b4b2-fc72029bc1d9

* docs(assess): distinguish empty project from no project

Clarify that assess still runs inside an initialized Spec Kit project
(writing under .specify/assessments/) — only existing source code is
optional. Reword 'no repo at all'/'need no repo' to 'need no existing
codebase' so users don't expect intake to work outside a Spec Kit
project.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9df2615e-6a99-4cdc-b4b2-fc72029bc1d9

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9df2615e-6a99-4cdc-b4b2-fc72029bc1d9
2026-07-27 06:42:35 -05:00
Manfred Riem
c0fe0e43cd chore: release 0.14.2, begin 0.14.3.dev0 development (#3730)
* chore: bump version to 0.14.2

* chore: begin 0.14.3.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-24 15:42:56 -05:00
github-actions[bot]
ae0b8ca2b0 Update Intake Review Governance preset to v0.1.1 (#3729)
Update intake-review-governance preset submitted by @hindermath to:
- presets/catalog.community.json (version, download_url, documentation, description)
- docs/community/presets.md community presets table

Closes #3727


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-07-24 15:33:14 -05:00
github-actions[bot]
2fe35bf898 Update Verify Review Ship extension to v0.3.0 (#3728)
Update verify-review-ship extension submitted by @cadugevaerd:
- extensions/catalog.community.json (version, download_url, description, effect, tags, sha256, updated_at)
- docs/community/extensions.md community extensions table

Closes #3726

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-07-24 15:28:25 -05:00
Dhruv Rastogi
73908f798a Update Architecture Guard extension to v1.13.1 (#3724)
Update architecture-guard extension submitted by @DyanGalih:
- extensions/catalog.community.json (version 1.8.17 -> 1.13.1, download_url,
  provides.commands 10 -> 14, tags: add hygiene, updated_at)

Closes #3564

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

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-07-24 13:10:55 -05:00
Ali jawwad
ffe2a7ffd6 docs(upgrade): Claude Code files live in .claude/skills, not .claude/commands (#3708)
The Claude Code integration installs skills into `.claude/skills` (see
integrations/claude: `"dir": ".claude/skills"`), and the "what gets kept"
list earlier in this same doc already says `.claude/skills/`. But three
troubleshooting/reference spots still point users at `.claude/commands/`,
which does not exist for a Claude Code install -- so the "verify files
exist" checks list an empty/missing directory. Correct all three to
`.claude/skills/`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 11:51:40 -05:00
WOLIKIMCHENG
ae82c74339 fix(kilocode): install commands under .kilo/commands (#3672)
* fix(kilocode): write commands to .kilo/commands

* fix: guard Kilo legacy command migration

---------

Co-authored-by: root <kinsonnee@gmail.com>
2026-07-24 11:05:32 -05:00
Ali jawwad
71e6201790 fix(auth): normalize whitespace in auth-config env-var/id references at store time (#3691)
* fix(auth): normalize whitespace in auth-config env-var/id references at store time

token_env, client_secret_env, tenant_id, and client_id were VALIDATED on
their .strip()ed form but STORED raw, so an accidentally padded value passed
validation yet silently broke the downstream verbatim os.environ.get(name) /
OAuth-URL lookups — load_auth_config succeeded but resolve_token returned
None and the request quietly downgraded to unauthenticated (401/403) with no
diagnostic.

Normalize these whitespace-insignificant string references with a _norm
helper at store time, mirroring how `hosts` is already normalized
(h.strip().lower()). `token` is unchanged (already stripped at resolve time).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* test(auth): cover tenant_id/client_id/client_secret_env normalization

Address review: the regression test only covered token_env, but the fix also
normalizes tenant_id, client_id, and client_secret_env. Add a padded
azure-ad entry asserting all three are stored stripped.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 11:03:06 -05:00
Ali jawwad
ea6843c1fe fix(workflows): guard non-mapping 'inputs:' block in engine._resolve_inputs (#3696)
execute()/resume() run UNVALIDATED definitions (load_workflow does not
validate). WorkflowDefinition stores `inputs` raw, so a non-mapping
`inputs:` block (bare `inputs:` -> None, or `inputs: []`) crashed
_resolve_inputs at `for name, input_def in definition.inputs.items()` with
AttributeError, aborting the whole run.

Return {} when inputs is not a mapping, mirroring validate_workflow's own
`isinstance(definition.inputs, dict)` check. Protects both call sites
(execute and resume); normal dict resolution is unchanged.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 10:52:28 -05:00
github-actions[bot]
b0850c97e6 Update Intake Authoring Governance preset to v0.2.0 (#3721)
Update intake-authoring-governance preset submitted by @hindermath to:
- presets/catalog.community.json (version, download_url, documentation, description, provides, tags)
- docs/community/presets.md community presets table

Closes #3720


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-07-24 10:51:23 -05:00
Manfred Riem
781a14a6d2 docs: clarify shell-step interpolation safety (#3719)
* docs: clarify shell-step interpolation safety

Shell step `run` fields are executed by the system shell and `{{ ... }}`
expressions are substituted as raw, unquoted text. Document that untrusted
sources — workflow `inputs.*` and prior-step output, including AI-generated
`prompt` output — must be quoted, enum-constrained, validated, or gated before
they reach a `run` field.

- docs/reference/workflows.md: add an "Interpolation and shell safety" section.
- workflows/README.md: add a warning under the Shell Steps example, link to the
  new section, and quote the `inputs.project_dir` example.
- workflows/PUBLISHING.md: strengthen the interpolation guidance and call out
  prior-step/agent output as untrusted.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: c0fd6900-69bf-4fcb-b377-de37f98f5835

* docs: correct shell-step interpolation guidance

Address review feedback that the previous wording over-promised. Clarify that
none of the mitigations neutralise a hostile interpolated value:

- Quoting is not a security boundary — there is no shell-escaping filter, and a
  value containing the matching quote can break out. Present quoting as
  correctness handling for already-constrained values only.
- Remove the "pass data via environment or files" guidance: ShellStep has no
  `env` mapping (it only copies the process environment and sets
  SPECKIT_WORKFLOW_DIR), so that transport does not exist.
- Drop the claim that routing through a command/prompt step validates or safely
  binds a value; it does not.
- Correct the gate guidance: a gate renders only its own message/show_file and
  does not inspect, resolve, or sanitise the following step. Authors must
  surface the exact command/data in the gate themselves, and approval does not
  neutralise an injectable interpolation.

Frame constraining values at the source (enum/allowlist) as the only reliable
control, and keeping unconstrained values out of `run` fields entirely.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: c0fd6900-69bf-4fcb-b377-de37f98f5835

* docs: remove unsafe interpolation from example and gate guidance

Address further review feedback:

- workflows/README.md: the shell example interpolated an unconstrained path
  into shell source, which contradicted the warning beneath it. Shell steps
  already run from the project root, so drop the `cd '{{ inputs.project_dir }}'`
  prefix and model a plain `run: "npm test"` with no interpolation.
- docs/reference/workflows.md: GateStep prints `message` verbatim with no
  control-character stripping (stripping applies only to `show_file` path and
  contents), so recommending that authors surface untrusted data in `message`
  was itself unsafe — agent/caller output could inject terminal escapes to
  alter or hide the prompt. Direct authors to keep `message` to trusted text
  and surface untrusted material via `show_file`, whose contents are stripped.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: c0fd6900-69bf-4fcb-b377-de37f98f5835

* docs: use correct prompt-step output key in example

A `prompt` step stores agent-generated text under `output.stdout`, not
`output.value`, so the example expression `{{ steps.plan.output.value }}`
would resolve to None. Reference `output.stdout` so the example correctly
demonstrates untrusted agent output flowing into a shell step.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: c0fd6900-69bf-4fcb-b377-de37f98f5835

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: c0fd6900-69bf-4fcb-b377-de37f98f5835
2026-07-24 10:40:26 -05:00
github-actions[bot]
be0c741ebb [extension] Add Blueprint Index — Living Architecture Map extension to community catalog (#3718)
* Add Blueprint Index extension to community catalog

Add blueprint-index extension submitted by @ogil109 to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes #3628

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

* Potential fix for pull request finding

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

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-24 10:24:19 -05:00
Noor ul ain
36754522f7 fix(github-http): return None on malformed host in resolve_github_release_asset_api_url (#3715)
Accessing the parsed authority (via urlparse/.hostname) raises ValueError
on a malformed bracketed host, e.g. https://[not-an-ip]/..., mirroring
the existing .port guard below. download_url is server-controlled (a
catalog download_url payload), so the function's resolve-or-return-None
contract must hold rather than leaking a raw traceback to the caller.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 09:58:24 -05:00
Ali jawwad
391cc0dff8 fix(integrations): declare PiIntegration multi_install_safe (#3652)
* fix(integrations): declare PiIntegration multi_install_safe

PiIntegration writes only to its isolated, static root .pi/prompts,
disjoint from every other integration, yet never declared
multi_install_safe — so it inherited the IntegrationBase default False,
leaving `specify integration status` in a permanent unsafe-multi-install
ERROR state when pi is co-installed alongside another agent.

Add `multi_install_safe = True`, mirroring the isolated MarkdownIntegration
cohort (qwen, shai, qodercli) and the kiro-cli #3471 fix. The parametrized
registry isolation contracts auto-include pi and pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* docs(integrations): list pi in the multi-install-safe reference table

Declaring PiIntegration multi_install_safe means the reference table in
docs/reference/integrations.md (which states it lists all currently
declared multi-install-safe integrations) should include it. Add the
alphabetized pi row with its .pi/prompts isolation path.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 09:48:30 -05:00
Manfred Riem
1631c0a50f harden: remove shell parameter from run_command() (#3716)
run_command() enforces a list[str] argv contract, so a shell parameter
served no purpose beyond keeping an unnecessary shell-injection surface
that a future refactor could re-enable. Remove the parameter (and its
now-dead ValueError guard) so shell=False is the only possible behavior.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 74a1bd02-f6cd-412a-b5a8-a7767a5e058d
2026-07-24 09:46:36 -05:00
dependabot[bot]
6385250264 chore(deps): bump github/codeql-action/init from 4.37.1 to 4.37.3 (#3699)
* chore(deps): bump github/codeql-action/init from 4.37.1 to 4.37.3

Bumps [github/codeql-action/init](https://github.com/github/codeql-action) from 4.37.1 to 4.37.3.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](7188fc3636...e4fba868fa)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.37.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps): bump github/codeql-action/analyze to 4.37.3

Keep the analyze step in sync with the init step bumped by Dependabot
so both CodeQL action references point to the same v4.37.3 commit.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b55c9f98-ef3d-4aee-a68b-c544fc82ae73

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b55c9f98-ef3d-4aee-a68b-c544fc82ae73
2026-07-24 09:18:18 -05:00
Daniel Graham
bfe4772b79 fix: auto-correct conflicting feature prefixes (#1829)
Treat an explicit feature number as a preference when an existing spec directory already uses that prefix. Advance to the next available spec prefix and warn without fetching or scanning git branches.

Keep Bash, PowerShell, and Python variants aligned. Preserve 64-bit numbering, timestamp mode, dry-run output, matching-file behavior, and exact-directory reuse through the allow-existing option.

Assisted-by: Codex (model: GPT-5, autonomous)
2026-07-24 09:13:53 -05:00
dependabot[bot]
c0ba81190b chore(deps): bump actions/checkout from 6.0.3 to 7.0.1 (#3703)
Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.3 to 7.0.1.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6.0.3...3d3c42e5aac5ba805825da76410c181273ba90b1)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 08:20:20 -05:00
dependabot[bot]
e53ddcb0cc chore(deps): bump DavidAnson/markdownlint-cli2-action (#3702)
Bumps [DavidAnson/markdownlint-cli2-action](https://github.com/davidanson/markdownlint-cli2-action) from 24.0.0 to 24.1.0.
- [Release notes](https://github.com/davidanson/markdownlint-cli2-action/releases)
- [Commits](8de2aa07ca...6bf21b0778)

---
updated-dependencies:
- dependency-name: DavidAnson/markdownlint-cli2-action
  dependency-version: 24.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 07:59:52 -05:00
dependabot[bot]
769acafcab chore(deps): bump actions/setup-node from 6.4.0 to 7.0.0 (#3701)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6.4.0 to 7.0.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6.4.0...820762786026740c76f36085b0efc47a31fe5020)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 07:59:12 -05:00
dependabot[bot]
2a397aad6c chore(deps): bump astral-sh/setup-uv from 8.3.2 to 9.0.0 (#3700)
Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 8.3.2 to 9.0.0.
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](11f9893b08...c771a70e62)

---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
  dependency-version: 9.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 07:55:50 -05:00
Manfred Riem
4d3a4281bc chore: release 0.14.1, begin 0.14.2.dev0 development (#3698)
* chore: bump version to 0.14.1

* chore: begin 0.14.2.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-23 15:18:04 -05:00
123 changed files with 21099 additions and 1197 deletions

View File

@@ -8,7 +8,7 @@ body:
value: |
Thanks for requesting a new agent! Before submitting, please check if the agent is already supported.
**Currently supported agents**: Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Cursor, Devin for Terminal, Factory Droid, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Grok Build, Hermes Agent, IBM Bob, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, ZCode, Zed
**Currently supported agents**: Alquimia AI, Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Cursor, Devin for Terminal, Factory Droid, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Grok Build, Hermes Agent, IBM Bob, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, ZCode, Zed
- type: input
id: agent-name

View File

@@ -62,6 +62,7 @@ body:
label: AI Agent
description: Which AI agent are you using?
options:
- Alquimia AI
- Amp
- Antigravity
- Auggie CLI

View File

@@ -56,6 +56,7 @@ body:
description: Does this feature relate to a specific AI agent?
options:
- All agents
- Alquimia AI
- Amp
- Antigravity
- Auggie CLI

View File

@@ -1,6 +1,6 @@
annotated-doc==0.0.4 \
--hash=sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320 \
--hash=sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4
annotated-doc==0.0.5 \
--hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101 \
--hash=sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb
# via typer
click==8.4.2 \
--hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \

View File

@@ -33,10 +33,10 @@
# - GITHUB_TOKEN
#
# Custom actions used:
# - actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
# - github/gh-aw-actions/setup@c0338fef4749d08c21f8f975fb0e37efa17dda47 # v0.79.8
#
@@ -162,7 +162,7 @@ jobs:
env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
- name: Checkout .github and .agents folders
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: |
@@ -434,7 +434,7 @@ jobs:
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
} >> "$GITHUB_OUTPUT"
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
@@ -1352,7 +1352,7 @@ jobs:
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- name: Checkout repository for patch context
if: needs.agent.outputs.has_patch == 'true'
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# --- Threat Detection ---
@@ -1419,7 +1419,7 @@ jobs:
mkdir -p /tmp/gh-aw/threat-detection
touch /tmp/gh-aw/threat-detection/detection.log
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
package-manager-cache: false
@@ -1678,7 +1678,7 @@ jobs:
await main();
- name: Checkout repository (trusted default branch for comment events)
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && (github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment')
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.repository.default_branch }}
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
@@ -1686,7 +1686,7 @@ jobs:
fetch-depth: 0
- name: Checkout repository
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && github.event_name != 'issue_comment' && github.event_name != 'pull_request_review_comment'
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ steps.extract-base-branch.outputs.base-branch || github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }}
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}

View File

@@ -33,7 +33,7 @@
# - GITHUB_TOKEN
#
# Custom actions used:
# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@@ -162,7 +162,7 @@ jobs:
env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
- name: Checkout .github and .agents folders
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: |
@@ -434,7 +434,7 @@ jobs:
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
} >> "$GITHUB_OUTPUT"
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
@@ -1332,7 +1332,7 @@ jobs:
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- name: Checkout repository for patch context
if: needs.agent.outputs.has_patch == 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# --- Threat Detection ---
@@ -1658,7 +1658,7 @@ jobs:
await main();
- name: Checkout repository (trusted default branch for comment events)
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && (github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment')
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.repository.default_branch }}
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
@@ -1666,7 +1666,7 @@ jobs:
fetch-depth: 0
- name: Checkout repository
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && github.event_name != 'issue_comment' && github.event_name != 'pull_request_review_comment'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ steps.extract-base-branch.outputs.base-branch || github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }}
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}

View File

@@ -33,7 +33,7 @@
# - GITHUB_TOKEN
#
# Custom actions used:
# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@@ -162,7 +162,7 @@ jobs:
env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
- name: Checkout .github and .agents folders
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: |
@@ -434,7 +434,7 @@ jobs:
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
} >> "$GITHUB_OUTPUT"
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
@@ -1332,7 +1332,7 @@ jobs:
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- name: Checkout repository for patch context
if: needs.agent.outputs.has_patch == 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# --- Threat Detection ---
@@ -1658,7 +1658,7 @@ jobs:
await main();
- name: Checkout repository (trusted default branch for comment events)
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && (github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment')
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.repository.default_branch }}
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
@@ -1666,7 +1666,7 @@ jobs:
fetch-depth: 0
- name: Checkout repository
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && github.event_name != 'issue_comment' && github.event_name != 'pull_request_review_comment'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ steps.extract-base-branch.outputs.base-branch || github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }}
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}

View File

@@ -32,7 +32,7 @@
# - GITHUB_TOKEN
#
# Custom actions used:
# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@@ -161,7 +161,7 @@ jobs:
env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
- name: Checkout .github and .agents folders
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: |
@@ -430,7 +430,7 @@ jobs:
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
} >> "$GITHUB_OUTPUT"
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
@@ -1277,7 +1277,7 @@ jobs:
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- name: Checkout repository for patch context
if: needs.agent.outputs.has_patch == 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# --- Threat Detection ---

12
.github/workflows/bug-fix.lock.yml generated vendored
View File

@@ -33,7 +33,7 @@
# - GITHUB_TOKEN
#
# Custom actions used:
# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@@ -162,7 +162,7 @@ jobs:
env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
- name: Checkout .github and .agents folders
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: |
@@ -434,7 +434,7 @@ jobs:
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
} >> "$GITHUB_OUTPUT"
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
@@ -1338,7 +1338,7 @@ jobs:
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- name: Checkout repository for patch context
if: needs.agent.outputs.has_patch == 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# --- Threat Detection ---
@@ -1664,7 +1664,7 @@ jobs:
await main();
- name: Checkout repository (trusted default branch for comment events)
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && (github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment')
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.repository.default_branch }}
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
@@ -1672,7 +1672,7 @@ jobs:
fetch-depth: 0
- name: Checkout repository
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && github.event_name != 'issue_comment' && github.event_name != 'pull_request_review_comment'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ steps.extract-base-branch.outputs.base-branch || github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }}
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}

View File

@@ -32,7 +32,7 @@
# - GITHUB_TOKEN
#
# Custom actions used:
# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@@ -161,7 +161,7 @@ jobs:
env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
- name: Checkout .github and .agents folders
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: |
@@ -431,7 +431,7 @@ jobs:
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
} >> "$GITHUB_OUTPUT"
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
@@ -1299,7 +1299,7 @@ jobs:
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- name: Checkout repository for patch context
if: needs.agent.outputs.has_patch == 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# --- Threat Detection ---

View File

@@ -19,14 +19,14 @@ jobs:
language: [ 'actions', 'python' ]
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Initialize CodeQL
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
with:
languages: ${{ matrix.language }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
with:
category: "/language:${{ matrix.language }}"

View File

@@ -30,7 +30,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0 # Fetch all history for git info

View File

@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 1
@@ -37,7 +37,7 @@ jobs:
fi
- name: Run markdownlint-cli2
uses: DavidAnson/markdownlint-cli2-action@8de2aa07cae85fd17c0b35642db70cf5495f1d25 # v24.0.0
uses: DavidAnson/markdownlint-cli2-action@6bf21b07787794f89a243495939cd651942aeabe # v24.1.0
with:
globs: |
'**/*.md'
@@ -47,7 +47,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# shellcheck is preinstalled on ubuntu-latest runners.
# Start at --severity=error to block real bugs without flagging style

View File

@@ -27,12 +27,12 @@ jobs:
fi
- name: Checkout release tag
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: refs/tags/${{ inputs.tag }}
- name: Install uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
@@ -74,7 +74,7 @@ jobs:
path: dist/
- name: Install uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
- name: Publish to PyPI
run: uv publish

View File

@@ -16,7 +16,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
token: ${{ secrets.RELEASE_PAT }}

View File

@@ -12,7 +12,7 @@ jobs:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -19,12 +19,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Install uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
@@ -52,10 +52,10 @@ jobs:
python-version: ["3.11", "3.12", "3.13", "3.14"]
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6

View File

@@ -13,10 +13,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
@@ -34,10 +34,10 @@ jobs:
python-version: ["3.13", "3.14"]
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6

View File

@@ -10,6 +10,20 @@ The toolkit supports multiple AI coding assistants, allowing teams to use their
---
## Quickstart — Add a New Integration in 5 Steps
If you are new to the codebase and want to add support for a new AI agent, here is the shortest path from zero to a working integration:
1. **Choose a base class** — most agents only need `MarkdownIntegration`. See [Choose a base class](#1-choose-a-base-class).
2. **Create a subpackage** — add `src/specify_cli/integrations/<package_dir>/__init__.py` with the required `key`, `config`, and `registrar_config` fields.
3. **Register it** — add one import and one `_register()` call in `src/specify_cli/integrations/__init__.py` (both alphabetical).
4. **Write a test file** — create `tests/integrations/test_integration_<key>.py` (hyphens in the key become underscores in the filename).
5. **Run and verify** — use `specify init --integration <key>` to exercise the full install/uninstall cycle.
Each step is expanded under [Adding a New Integration](#adding-a-new-integration). Note that agent **context files** (`CLAUDE.md`, `AGENTS.md`, …) are **not** handled by the integration — that is owned by the opt-in `agent-context` extension; see [Context file behavior](#4-context-file-behavior).
---
## Integration Architecture
Each AI agent is a self-contained **integration subpackage** under `src/specify_cli/integrations/<key>/`. The subpackage exposes a single class that declares all metadata and inherits setup/teardown logic from a base class. Built-in integrations are then instantiated and added to the global `INTEGRATION_REGISTRY` by `src/specify_cli/integrations/__init__.py` via `_register_builtins()`.
@@ -34,6 +48,30 @@ The registry is the **single source of truth for Python integration metadata**.
---
## IntegrationManifest — File Tracking
`manifest.py` provides the `IntegrationManifest` class, which records every file an integration installs. This record is what makes uninstall reliable and safe.
### How it works
`setup()` receives an `IntegrationManifest` and writes files through it rather than touching the filesystem directly:
```python
# Produce a new file and record its hash for later verification.
manifest.record_file("commands/speckit.plan.md", processed_content)
# Adopt a pre-existing file the integration is now responsible for.
manifest.record_existing(".vscode/settings.json")
```
The manifest is persisted at `.specify/integrations/<key>.manifest.json` (one per integration, keyed by `key`) and stores a SHA-256 hash per file. When the user runs `specify integration uninstall <key>`, `teardown()` delegates to `manifest.uninstall()`, which removes only files whose current hash still matches the recorded value — so files the user later edited by hand are skipped, not clobbered (use `specify integration uninstall <key> --force` to remove modified tracked files anyway).
### Why this matters
Without hash-tracked manifests, uninstall would either remove files it should not (destructive) or leave orphans behind (messy). If you write a custom `setup()`, route **every** file you create through `manifest.record_file(...)` (or `record_existing(...)` for files you adopt) so uninstall can reason about them.
---
## Adding a New Integration
### 1. Choose a base class
@@ -64,13 +102,14 @@ class KilocodeIntegration(MarkdownIntegration):
key = "kilocode"
config = {
"name": "Kilo Code",
"folder": ".kilocode/",
"commands_subdir": "workflows",
"folder": ".kilo/",
"commands_subdir": "commands",
"install_url": None,
"requires_cli": False,
}
registrar_config = {
"dir": ".kilocode/workflows",
"dir": ".kilo/commands",
"legacy_dir": ".kilocode/workflows",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
@@ -201,8 +240,8 @@ Only add custom setup logic when the agent needs non-standard behavior. Integrat
specify init my-project --integration <key>
# Verify files were created in the commands directory configured by
# config["folder"] + config["commands_subdir"] (for example, .kilocode/workflows/)
ls -R my-project/.kilocode/workflows/
# config["folder"] + config["commands_subdir"] (for example, .kilo/commands/)
ls -R my-project/.kilo/commands/
# Uninstall cleanly
cd my-project && specify integration uninstall <key>
@@ -510,4 +549,54 @@ Disclosure is **continuous**, not a one-time event. A single AI-disclosure parag
---
## Error Handling and Debugging
### Common Errors and Fixes
| Symptom | Likely Cause | Fix |
|---|---|---|
| `Integration '<key>' not found` | Missing `_register()` call | Add `_register(<Name>Integration())` inside `_register_builtins()` |
| `NameError: name '<Name>Integration' is not defined` at startup | Missing import | Add `from .<package_dir> import <Name>Integration` inside `_register_builtins()` |
| CLI check fails for a `requires_cli: True` agent | `key` does not match the executable name | Set `key` to the exact name `shutil.which(key)` must resolve (e.g. `"cursor-agent"`, not `"cursor"`) |
| Command files have the wrong argument syntax | Wrong `args` value in `registrar_config` | Use `$ARGUMENTS` for Markdown agents, `{{args}}` for TOML/YAML agents, or the agent's custom placeholder |
| `ModuleNotFoundError` on a brand-new subpackage under pytest only | Ambient interpreter with a stale editable `.pth` | Run inside this tree's own venv (see Common Pitfall 6) |
| Uninstall leaves files behind, or skips files you expected removed | Files not recorded via the manifest, or their hash changed after install | Route every created file through `manifest.record_file(...)`; user-edited files are intentionally skipped unless `force=True` |
| Context file (`CLAUDE.md`, etc.) not updated | Expecting the CLI to manage it | Context files are owned by the opt-in `agent-context` extension, not the integration — see [Context file behavior](#4-context-file-behavior) |
### Debugging Tips
**Inspect the manifest** to see what an installed integration tracks:
```bash
cat .specify/integrations/<key>.manifest.json
```
**Verify a CLI tool is detected** before debugging a `requires_cli` agent:
```bash
which <key> # Should print the executable path if installed
```
**Verify the installed output structure** after `specify init`:
```bash
find my-project/<folder> -type f
```
---
## Contribution Checklist
Before opening or merging an integration PR, confirm the following:
- [ ] Added the integration subpackage under `src/specify_cli/integrations/<package_dir>/`.
- [ ] Registered it (import **and** `_register()`) in `src/specify_cli/integrations/__init__.py`, both alphabetical.
- [ ] Added or updated tests in `tests/integrations/test_integration_<key>.py`.
- [ ] Verified the install/uninstall flow with `specify init --integration <key>`.
- [ ] Did **not** add `context_file` handling to the CLI (that belongs to the `agent-context` extension).
- [ ] Updated devcontainer files if the agent needs a VS Code extension or CLI install step.
- [ ] Updated this guide or other relevant docs if the integration has special setup or limitations.
---
*This documentation should be updated whenever new integrations are added to maintain accuracy and completeness.*

View File

@@ -2,6 +2,96 @@
<!-- insert new changelog below this comment -->
## [0.14.4] - 2026-07-29
### Changed
- fix(bundler): degrade non-UTF-8 config reads into BundlerError (#3784)
- fix(workflows): escape the step-progress line so step ids render (and `/` stops failing the run) (#3783)
- Update Agent Parity Governance preset to v0.4.1 (#3830)
- fix(integrations): reject empty --commands-dir in generic raw_options (#3714)
- fix(presets): guard non-list/non-mapping provides.templates in PresetManifest (#3712)
- fix(auth): resolve az via shutil.which so azure-cli token works on Windows (#3709)
- fix(workflows): reject falsy non-mapping workflow-catalogs.yml top level (#3707)
- fix(integrations): render hyphenated /speckit-<name> for Droid (always-slash agent) (#3688)
- [preset] Update A11Y Governance preset to v0.4.2 (#3828)
- [preset] Update Parallel Autonomous Run Governance to v0.2.4 (#3825)
- fix: correct Optional type annotation for _resolved_dir parameter (#3801)
- fix: add timeout to prompt step subprocess execution (#3768)
- fix: handle tags containing / in GitHub release asset URL resolution (#3767)
- fix(presets): escape catalog metadata in discovery output (#3773)
- Update Autonomous Run Governance preset to v0.3.3 (#3823)
- fix: use bounded read for integration catalog HTTP responses (#3763)
- docs: add Simplified Chinese translation of README (#3740)
- Update Intake Sequencing Governance preset to v0.2.2 (#3809)
- fix(workflows): reject non-string/non-boolean 'condition' in if/while/do-while steps (#3706)
- fix(bundle): escape catalog metadata in discovery output (#3774)
- fix(workflows,extensions): tolerate non-list catalog tags in search/info display (#3770)
- fix: correct nullable resolved directory annotation (#3771)
- fix(presets): tolerate non-string and non-list catalog fields in preset search/info (#3769)
- fix(integrations): escape catalog metadata in discovery output (#3772)
- Update Verify Review Ship extension to v0.4.2 (#3792)
- fix(integrations): preserve native skill invocation prefixes (#3663)
- Update Intake Review Governance preset to v0.2.0 (#3796)
- fix(constitution): stop propagating guidance into templates (#3737) (#3790)
- chore: release 0.14.3, begin 0.14.4.dev0 development (#3795)
## [0.14.3] - 2026-07-28
### Changed
- Update Intake Authoring Governance preset to v0.3.0 (#3788)
- fix(copilot): honor preset command template overrides (#3592)
- clarify: require real interrogatives, ban topic-label questions (#3745)
- feat: Add Alquimia AI integration (#2734)
- harden: secure extension and preset archive downloads (#3141)
- fix: correct Optional type annotation for context_note parameter (#3765)
- Update AGENTS.md (#2626)
- fix(extensions): tolerate non-string catalog name in display-name lookup (#3747)
- fix(presets): coerce non-string catalog tags before joining (#3743)
- fix: register extensions for the active integration only (#3459)
- fix(extensions): tolerate non-string tags in catalog search (#3746)
- fix(extensions): hyphenate command names in 'extension info' listing (#3744)
- fix(workflows): escape remaining untrusted fields in `workflow info` (#3731)
- fix(extensions): guard non-numeric catalog downloads in search/info rendering (#3710)
- fix(agent-context): apply default markers when config markers are blank (bash) (#3736)
- fix: escape Rich markup in catalog list output (#3738)
- fix(workflows): guard non-mapping 'workflow:' block in WorkflowDefinition (#3694)
- fix(bundler): reject unsupported schema_version in _merge_config (align readers) (#3711)
- Update Linear Weave extension to v1.0.1 (#3762)
- Add Intake Sequencing Governance preset to community catalog (#3761)
- Update Quality Gates (Enforcement Layer) extension to v0.3.3 (#3760)
- Update Verify Review Ship extension to v0.4.1 (#3759)
- fix(agent-context): discover nested plans in Python port mtime fallback (#3734)
- fix(extensions): make shipped scripts executable after install (#3723)
- docs(assess): clarify the pipeline works on an empty project (#3732)
- chore: release 0.14.2, begin 0.14.3.dev0 development (#3730)
## [0.14.2] - 2026-07-24
### Changed
- Update Intake Review Governance preset to v0.1.1 (#3729)
- Update Verify Review Ship extension to v0.3.0 (#3728)
- Update Architecture Guard extension to v1.13.1 (#3724)
- docs(upgrade): Claude Code files live in .claude/skills, not .claude/commands (#3708)
- fix(kilocode): install commands under .kilo/commands (#3672)
- fix(auth): normalize whitespace in auth-config env-var/id references at store time (#3691)
- fix(workflows): guard non-mapping 'inputs:' block in engine._resolve_inputs (#3696)
- Update Intake Authoring Governance preset to v0.2.0 (#3721)
- docs: clarify shell-step interpolation safety (#3719)
- [extension] Add Blueprint Index — Living Architecture Map extension to community catalog (#3718)
- fix(github-http): return None on malformed host in resolve_github_release_asset_api_url (#3715)
- fix(integrations): declare PiIntegration multi_install_safe (#3652)
- harden: remove shell parameter from run_command() (#3716)
- chore(deps): bump github/codeql-action/init from 4.37.1 to 4.37.3 (#3699)
- fix: auto-correct conflicting feature prefixes (#1829)
- chore(deps): bump actions/checkout from 6.0.3 to 7.0.1 (#3703)
- chore(deps): bump DavidAnson/markdownlint-cli2-action (#3702)
- chore(deps): bump actions/setup-node from 6.4.0 to 7.0.0 (#3701)
- chore(deps): bump astral-sh/setup-uv from 8.3.2 to 9.0.0 (#3700)
- chore: release 0.14.1, begin 0.14.2.dev0 development (#3698)
## [0.14.1] - 2026-07-23
### Changed

View File

@@ -15,6 +15,11 @@
<a href="https://github.github.io/spec-kit/"><img src="https://img.shields.io/badge/docs-GitHub_Pages-blue" alt="Documentation"/></a>
</p>
<p align="center">
<strong>English</strong> ·
<a href="./README.zh-CN.md">简体中文</a>
</p>
---
## Table of Contents

361
README.zh-CN.md Normal file
View File

@@ -0,0 +1,361 @@
<div align="center">
<img src="./media/logo_large.webp" alt="Spec Kit Logo" width="200" height="200"/>
<h1>🌱 Spec Kit</h1>
<h3><em>在动手编码之前,先定义要构建什么 —— 适配任意 AI 编码助手。</em></h3>
</div>
<p align="center">
<strong>一个开源工具套件,帮助你借助任意 AI 编码助手构建高质量软件 —— 内置开箱即用的规范驱动流程(也可自带流程),可无限扩展、由社区驱动,并为整个组织的协作而设计。</strong>
</p>
<p align="center">
<a href="https://github.com/github/spec-kit/releases/latest"><img src="https://img.shields.io/github/v/release/github/spec-kit" alt="Latest Release"/></a>
<a href="https://github.com/github/spec-kit/stargazers"><img src="https://img.shields.io/github/stars/github/spec-kit?style=social" alt="GitHub stars"/></a>
<a href="https://github.com/github/spec-kit/blob/main/LICENSE"><img src="https://img.shields.io/github/license/github/spec-kit" alt="License"/></a>
<a href="https://github.github.io/spec-kit/"><img src="https://img.shields.io/badge/docs-GitHub_Pages-blue" alt="Documentation"/></a>
</p>
<p align="center">
<a href="./README.md">English</a> ·
<strong>简体中文</strong>
</p>
---
## 目录
- [🤔 什么是规范驱动开发?](#-什么是规范驱动开发)
- [⚡ 快速开始](#-快速开始)
- [📽️ 视频概览](#-视频概览)
- [🌍 社区](#-社区)
- [🤖 支持的 AI 编码助手集成](#-支持的-ai-编码助手集成)
- [🔧 Specify CLI 参考](#-specify-cli-参考)
- [🧩 打造你自己的 Spec Kit扩展与预设](#-打造你自己的-spec-kit扩展与预设)
- [📦 捆绑包:面向角色的一键配置](#-捆绑包面向角色的一键配置)
- [📚 核心理念](#-核心理念)
- [🌟 开发阶段](#-开发阶段)
- [🎯 实验目标](#-实验目标)
- [🔧 环境要求](#-环境要求)
- [📖 深入了解](#-深入了解)
- [💬 支持](#-支持)
- [🙏 致谢](#-致谢)
- [📄 许可证](#-许可证)
## 🤔 什么是规范驱动开发?
规范驱动开发Spec-Driven Development**颠覆了**传统软件开发的思路。几十年来,代码一直是核心 —— 规范只是编码这项"正事"开始前搭起、随后就被丢弃的脚手架。规范驱动开发改变了这一点:**规范本身变得可执行**,它不再只是引导实现,而是直接生成可运行的实现。
## ⚡ 快速开始
### 1. 安装 Specify CLI
需要 **[uv](https://docs.astral.sh/uv/)**[安装 uv](./docs/install/uv.md))。将 `vX.Y.Z` 替换为 [Releases](https://github.com/github/spec-kit/releases) 中最新的发布标签 —— 记得保留开头的 `v`(例如 `v0.12.11`,而不是 `0.12.11`
```bash
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX.Y.Z
```
更倾向从 PyPI 安装?`specify-cli` 包同样发布在那里:
```bash
uv tool install specify-cli
```
其他安装方式、安装校验、升级以及故障排查,请参阅[安装指南](./docs/installation.md)。
### 2. 初始化项目
```bash
specify init my-project --integration copilot
cd my-project
```
要检查更新或升级已安装的 CLI可使用自管理命令。更详细的场景和自定义选项请参阅[升级指南](./docs/upgrade.md)。
```bash
# 检查是否有更新版本可用(只读操作 —— 不会修改任何内容)
specify self check
# 预览升级将执行的操作,但不实际升级
specify self upgrade --dry-run
# 就地升级到最新稳定版(自动识别 uv tool 与 pipx 安装方式)
specify self upgrade
# 或锁定到指定的发布标签(将 vX.Y.Z[suffix] 替换为你想要的标签)
specify self upgrade --tag vX.Y.Z[suffix]
```
直接运行 `specify self upgrade` 会立即执行,与 `pip install -U``npm update` 等命令一样无需额外确认。对于 `uv tool` 安装的情况,它在底层会执行 `uv tool install specify-cli --force --from <git ref>`,因此锁定的发布标签同样有效,包括 dev、alpha/beta/rc 或带构建元数据的后缀。`uvx`(临时运行)和源码检出会被自动识别,此时会给出针对具体路径的操作建议,而不会执行安装程序。可通过设置 `SPECIFY_UPGRADE_TIMEOUT_SECS` 来限制安装子进程的最长运行时间(默认无超时限制 —— 必要时用 `Ctrl+C` 中断)。
### 3. 确立项目准则
在项目目录下启动你的编码助手。大多数助手将 spec-kit 暴露为 `/speckit.*` 斜杠命令处于技能skills模式的 Codex CLI 则使用 `$speckit-*`GitHub Copilot CLI 使用 `/agents` 来选择助手,或直接在提示词中指定它。
使用 **`/speckit.constitution`** 命令来创建项目的治理准则和开发指南,它们将指导后续所有开发工作。
```bash
/speckit.constitution Create principles focused on code quality, testing standards, user experience consistency, and performance requirements
```
### 4. 编写规范
使用 **`/speckit.specify`** 命令描述你想构建什么。聚焦于**做什么**和**为什么做**,而不是技术栈。
```bash
/speckit.specify Build an application that can help me organize my photos in separate photo albums. Albums are grouped by date and can be re-organized by dragging and dropping on the main page. Albums are never in other nested albums. Within each album, photos are previewed in a tile-like interface.
```
### 5. 制定技术实现方案
使用 **`/speckit.plan`** 命令提供你的技术栈和架构选择。
```bash
/speckit.plan The application uses Vite with minimal number of libraries. Use vanilla HTML, CSS, and JavaScript as much as possible. Images are not uploaded anywhere and metadata is stored in a local SQLite database.
```
### 6. 拆解为任务
使用 **`/speckit.tasks`** 从实现方案生成一份可执行的任务清单。
```bash
/speckit.tasks
```
### 7. 执行实现
使用 **`/speckit.implement`** 执行所有任务,按方案构建你的功能。
```bash
/speckit.implement
```
详细的分步说明,请参阅我们的[完整指南](./spec-driven.md)。
## 📽️ 视频概览
想看看 Spec Kit 的实际效果?观看我们的[视频概览](https://www.youtube.com/watch?v=a9eR1xsfvHg&pp=0gcJCckJAYcqIYzv)
[![Spec Kit video header](/media/spec-kit-video-header.jpg)](https://www.youtube.com/watch?v=a9eR1xsfvHg&pp=0gcJCckJAYcqIYzv)
## 🌍 社区
在 [Spec Kit 文档站点](https://github.github.io/spec-kit/)上探索由社区贡献的资源:
- [扩展Extensions](https://github.github.io/spec-kit/community/extensions.html) —— 命令、钩子与各类能力
- [预设Presets](https://github.github.io/spec-kit/community/presets.html) —— 模板与术语覆盖
- [捆绑包Bundles](https://github.github.io/spec-kit/community/bundles.html) —— 由现有组件组合而成的角色与团队技术栈
- [实战演练Walkthroughs](https://github.github.io/spec-kit/community/walkthroughs.html) —— 端到端的 SDD 场景
- [伙伴项目Friends](https://github.github.io/spec-kit/community/friends.html) —— 扩展 Spec Kit 或基于它构建的项目
> [!NOTE]
> 社区贡献由各自的作者独立创建和维护。请在安装前审阅源代码,并自行斟酌使用。
想要参与贡献?请参阅[扩展发布指南](extensions/EXTENSION-PUBLISHING-GUIDE.md)、[预设发布指南](presets/PUBLISHING.md)或[社区捆绑包指南](docs/community/bundles.md)。
## 🤖 支持的 AI 编码助手集成
Spec Kit 可与 30 多个 AI 编码助手协作 —— 既包括 CLI 工具,也包括基于 IDE 的助手。完整列表以及相关说明和使用细节,请参阅[支持的 AI 编码助手集成](https://github.github.io/spec-kit/reference/integrations.html)指南。
运行 `specify integration list` 可查看当前安装版本中所有可用的集成。
## 可用的斜杠命令
运行 `specify init` 后,你的 AI 编码助手就能使用这些斜杠命令来进行结构化开发。对于支持技能模式的集成,传入 `--integration <agent> --integration-options="--skills"` 会安装助手技能,而不是斜杠命令的提示词文件。
### 核心命令
规范驱动开发工作流中必不可少的命令:
| 命令 | 助手技能 | 说明 |
| ------------------------ | ---------------------- | ---------------------------------------------------------- |
| `/speckit.constitution` | `speckit-constitution` | 创建或更新项目的治理准则和开发指南 |
| `/speckit.specify` | `speckit-specify` | 定义你想构建什么(需求与用户故事) |
| `/speckit.plan` | `speckit-plan` | 结合所选技术栈制定技术实现方案 |
| `/speckit.tasks` | `speckit-tasks` | 生成可执行的实现任务清单 |
| `/speckit.taskstoissues` | `speckit-taskstoissues`| 将生成的任务清单转换为 GitHub issue便于跟踪与执行 |
| `/speckit.implement` | `speckit-implement` | 执行所有任务,按方案构建功能 |
| `/speckit.converge` | `speckit-converge` | 对照规范/方案/任务评估代码库,并将剩余工作追加为新任务 |
### 可选命令
用于提升质量与做校验的额外命令:
| 命令 | 助手技能 | 说明 |
| -------------------- | ---------------------- | ------------------------------------------------------------------------------------------------- |
| `/speckit.clarify` | `speckit-clarify` | 澄清描述不充分的部分(建议在 `/speckit.plan` 之前使用;旧称 `/quizme` |
| `/speckit.analyze` | `speckit-analyze` | 跨制品的一致性与覆盖度分析(在 `/speckit.tasks` 之后、`/speckit.implement` 之前运行) |
| `/speckit.checklist` | `speckit-checklist` | 生成自定义质量清单,校验需求的完整性、清晰度与一致性(好比"为自然语言写单元测试" |
## 🔧 Specify CLI 参考
完整的命令详情、选项与示例,请参阅 [CLI 参考文档](https://github.github.io/spec-kit/reference/overview.html)。
## 🧩 打造你自己的 Spec Kit扩展与预设
Spec Kit 可通过两套互补的机制进行深度定制 —— **扩展extensions****预设presets** —— 以及面向单个项目的本地覆盖,用于临时性调整:
| 优先级 | 组件类型 | 位置 |
| -----: | ---------------------------------- | -------------------------------- |
| ⬆ 1 | 项目本地覆盖 | `.specify/templates/overrides/` |
| 2 | 预设 —— 定制核心与扩展 | `.specify/presets/templates/` |
| 3 | 扩展 —— 新增能力 | `.specify/extensions/templates/` |
| ⬇ 4 | Spec Kit 核心 —— 内置 SDD 命令与模板 | `.specify/templates/` |
- **模板**在**运行时**解析 —— Spec Kit 从高到低遍历优先级栈,使用第一个匹配项。
- 项目本地覆盖(`.specify/templates/overrides/`)允许对单个项目做一次性调整,无需创建完整的预设。
- **扩展/预设命令**在**安装时**生效 —— 当你运行 `specify extension add``specify preset add` 时,命令文件会被写入助手目录(如 `.claude/commands/`)。
- 若多个预设或扩展提供了同一命令,优先级最高的版本生效。移除时,次优先级的版本会自动恢复。
- 若不存在任何覆盖或自定义Spec Kit 使用核心默认配置。
### 扩展 —— 新增能力
当你需要 Spec Kit 核心之外的功能时,使用**扩展**。扩展可引入新命令和模板 —— 例如添加核心 SDD 命令未覆盖的领域特定工作流、集成外部工具,或新增全新的开发阶段。它们扩展了 *Spec Kit 能做什么*
```bash
# 搜索可用扩展
specify extension search
# 安装扩展
specify extension add <extension-name>
```
举例来说,扩展可以添加 Jira 集成、实现后代码审查、V 模型测试追溯性,或项目健康诊断等功能。
完整命令指南请参阅[扩展参考文档](https://github.github.io/spec-kit/reference/extensions.html)。浏览[社区扩展](https://github.github.io/spec-kit/community/extensions.html)了解现有资源。
### 预设 —— 定制现有工作流
当你想改变 Spec Kit 的*工作方式*而不是新增能力时,使用**预设**。预设会覆盖核心及已安装扩展中附带的模板和命令 —— 例如强制使用面向合规的规范格式、采用领域特定术语,或对方案和任务应用组织规范。预设定制的是 Spec Kit 及其扩展生成的制品与指令。
```bash
# 搜索可用预设
specify preset search
# 安装预设
specify preset add <preset-name>
```
举例来说,预设可以重构规范模板以要求监管追溯性,将工作流适配为你所用的方法论(如敏捷、看板、瀑布、用户任务驱动或领域驱动设计),在方案中添加强制安全审查关卡,强制要求测试优先的任务排序,或将整个工作流本地化为其他语言。[海盗语演示](https://github.com/mnriem/spec-kit-pirate-speak-preset-demo)充分展示了定制的深度。多个预设可按优先级叠加使用。
完整命令指南以及解析顺序和优先级叠加说明,请参阅[预设参考文档](https://github.github.io/spec-kit/reference/presets.html)。
## 📦 捆绑包:面向角色的一键配置
扩展和预设是独立的构建模块。而**捆绑包bundle**将一组精选的扩展、预设、步骤和工作流打包成一个带版本、面向角色的配置,从而可以用一条命令为整个团队角色(产品经理、业务分析师、安全研究员、开发者……)完成配置。
捆绑包由一份手写的 `bundle.yml` 清单描述。它将每个组件锁定到具体版本,并可选择性地面向特定集成;未指定 `integration` 的捆绑包是**中立的**,会沿用项目当前已使用的集成。
```bash
# 在当前激活的目录栈中发现捆绑包
specify bundle search [<query>]
# 查看捆绑包将添加的确切组件集合(与实际安装的内容一致)
specify bundle info <bundle-id>
# 一步安装捆绑包的完整组件集合
specify bundle install <bundle-id>
# 查看已安装内容,然后以非破坏性方式更新或移除
specify bundle list
specify bundle update <bundle-id> # 或 --all
specify bundle remove <bundle-id> # 仅移除此捆绑包的组件
```
捆绑包从一个**按优先级排序的目录栈**(项目 > 用户 > 内置)中解析。每个来源都带有安装策略:`install-allowed` 来源可用于安装,而 `discovery-only` 来源在 `search`/`info` 中可见但拒绝安装。可通过 `specify bundle catalog list|add|remove` 管理目录栈。
作者在本地校验并打包捆绑包。分发方式是托管构建产物并添加一个目录来源;社区捆绑包投稿请使用 [Bundle Submission](https://github.com/github/spec-kit/issues/new?template=bundle_submission.yml) issue 模板,以便对所需的组件目录和安装证据进行审阅:
```bash
specify bundle validate --path ./my-bundle # 结构与引用检查
specify bundle build --path ./my-bundle # 生成带版本的 .zip 产物
```
[`examples/bundles/`](examples/bundles/) 目录下有四份可直接阅读的示例清单(产品经理、业务分析师、安全研究员、开发者)。
关键保证:`info` 展示的内容与 `install` 添加的内容完全一致(透明性);安装是幂等的,且限定在项目根目录内;`remove` 绝不会触碰其他已安装捆绑包仍需要的组件;所有消费/创作命令都能针对本地或锁定的来源**离线**工作。
### 何时用哪个
| 目标 | 使用 |
| --- | --- |
| 添加全新的命令或工作流 | 扩展 |
| 定制规范、方案或任务的格式 | 预设 |
| 集成外部工具或服务 | 扩展 |
| 强制执行组织或监管规范 | 预设 |
| 交付可复用的领域特定模板 | 均可 —— 预设用于模板覆盖,扩展用于随新命令一起打包的模板 |
| 用一条命令完成完整的角色配置 | 捆绑包 |
## 📚 核心理念
规范驱动开发是一套结构化流程,它强调:
- **意图驱动开发** —— 让规范先定义"*做什么*",再谈"*怎么做*"
- **丰富的规范撰写** —— 借助护栏与组织准则来编写规范
- **多步精炼** —— 而非从提示词一次性生成代码
- **充分依赖**先进 AI 模型对规范的解读能力
## 🌟 开发阶段
| 阶段 | 侧重点 | 关键活动 |
| ----------------------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| **从 0 到 1 开发**"绿地/Greenfield" | 从零生成 | <ul><li>从高层需求出发</li><li>生成规范</li><li>规划实现步骤</li><li>构建生产就绪的应用</li></ul> |
| **创意探索** | 并行实现 | <ul><li>探索多样化的解决方案</li><li>支持多种技术栈与架构</li><li>试验不同的用户体验模式</li></ul> |
| **迭代增强**"棕地/Brownfield" | 存量系统现代化 | <ul><li>迭代式添加功能</li><li>现代化改造遗留系统</li><li>调整流程</li></ul> |
对于已有项目,请将 Spec Kit 工具本身的更新与功能制品的演进分开处理:升级时刷新受管理的项目文件,而在预期行为发生变化时更新 `specs/` 制品。[规范演进指南](./docs/guides/evolving-specs.md)介绍了推荐的棕地迭代循环。
## 🎯 实验目标
我们的研究与实验聚焦于:
### 技术无关性
- 使用多样化的技术栈构建应用
- 验证这一假设:规范驱动开发是一套流程,不与特定技术、编程语言或框架绑定
### 企业级约束
- 展示关键业务应用的开发
- 纳入组织层面的约束(云服务商、技术栈、工程实践)
- 支持企业设计系统与合规要求
### 以用户为中心的开发
- 为不同的用户群体和偏好构建应用
- 支持多种开发方式(从"氛围编码"到 AI 原生开发)
### 创意与迭代流程
- 验证并行实现探索的理念
- 提供稳健的迭代式功能开发工作流
- 将流程扩展到升级与现代化改造任务
## 🔧 环境要求
- **Linux/macOS/Windows**
- [受支持的](#-支持的-ai-编码助手集成) AI 编码助手。
- [uv](https://docs.astral.sh/uv/) 用于包管理(推荐),或 [pipx](https://pipx.pypa.io/) 用于持久化安装
- [Python 3.11+](https://www.python.org/downloads/)
- [Git](https://git-scm.com/downloads)
如果你在使用某个助手时遇到问题,欢迎提交 issue以便我们完善相应集成。
## 📖 深入了解
- **[完整的规范驱动开发方法论](./spec-driven.md)** —— 深入了解整个流程
- **[快速上手指南](https://github.github.io/spec-kit/quickstart.html)** —— 分步实现演练
---
## 💬 支持
如需帮助,请提交 [GitHub issue](https://github.com/github/spec-kit/issues/new)。我们欢迎缺陷报告、功能建议,以及关于使用规范驱动开发的各类问题。
## 🙏 致谢
本项目深受 [John Lam](https://github.com/jflam) 的工作与研究的影响,并在其基础上构建。
## 📄 许可证
本项目基于 MIT 开源许可证的条款授权。完整条款请参阅 [LICENSE](./LICENSE) 文件。

View File

@@ -36,6 +36,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) |
| Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) |
| Blueprint | Stay code-literate in AI-driven development: review a complete code blueprint for every task from spec artifacts before /speckit.implement runs | `docs` | Read+Write | [spec-kit-blueprint](https://github.com/chordpli/spec-kit-blueprint) |
| Blueprint Index — Living Architecture Map | A living architecture map for spec-driven projects, kept honest by a deterministic, low-friction, machine-first CI gate (JSON, self-healable) that blocks only when the map contradicts the specs or code. Brownfield or greenfield. | `process` | Read+Write | [spec-kit-blueprint](https://github.com/ogil109/spec-kit-blueprint) |
| Branch Convention | Configurable branch and folder naming conventions for /specify with presets and custom patterns | `process` | Read+Write | [spec-kit-branch-convention](https://github.com/Quratulain-bilal/spec-kit-branch-convention) |
| Brownfield Bootstrap | Bootstrap spec-kit for existing codebases — auto-discover architecture and adopt SDD incrementally | `process` | Read+Write | [spec-kit-brownfield](https://github.com/Quratulain-bilal/spec-kit-brownfield) |
| BrownKit | Evidence-driven capability discovery, security and QA risk assessment for existing codebases | `process` | Read+Write | [BrownKit](https://github.com/MaksimShevtsov/BrownKit) |
@@ -158,7 +159,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Token Economy | Token routing, measured savings, and context audit workflows | `process` | Read+Write | [spec-kit-token-economy](https://github.com/formin/spec-kit-token-economy) |
| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) |
| Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) |
| Verify Review Ship | Adds post-implementation verify, review, and ship readiness gates to Spec Kit workflows | `process` | Read-only | [spec-kit-verify-review-ship](https://github.com/cadugevaerd/spec-kit-verify-review-ship) |
| Verify Review Ship | Post-convergence operational verification, technical review, learning governance, and transactional delivery. | `process` | Read+Write | [spec-kit-verify-review-ship](https://github.com/cadugevaerd/spec-kit-verify-review-ship) |
| Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) |
| Version Guard | Verify tech stack versions against live npm registries before planning and implementation | `process` | Read-only | [spec-kit-version-guard](https://github.com/KevinBrown5280/spec-kit-version-guard) |
| What-if Analysis | Preview the downstream impact (complexity, effort, tasks, risks) of requirement changes before committing to them | `visibility` | Read-only | [spec-kit-whatif](https://github.com/DevAbdullah90/spec-kit-whatif) |

View File

@@ -7,11 +7,11 @@ The following community-contributed presets customize how Spec Kit behaves — o
| Preset | Purpose | Provides | Requires | URL |
|--------|---------|----------|----------|-----|
| A11Y Governance | Adds WCAG 2.2 AA governance, accessible text/JSON status parity, bilingual DE/EN delivery, CEFR-B2 readability, inclusive content, didactic-comment review, and audit-ready evidence | 10 templates, 3 commands | — | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-governance) |
| Agent Parity Governance | Adds shared-guidance and generated-command parity, fleet-completion evidence, secret-free runner/status metadata, audit-ready evidence, and agent-neutral model routing. | 6 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
| A11Y Governance | Adds accessibility (WCAG 2.2 AA), accessible text and JSON status parity, bilingual DE/EN delivery, CEFR-B2 readability, inclusive-content governance, didactic inline-code-comment review, and audit-ready Spec-Kit run evidence to Spec Kit | 10 templates, 3 commands | — | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-governance) |
| Agent Parity Governance | Adds shared-guidance and generated-command parity, repository-fleet completion evidence, secret-free runner/status metadata, audit-ready Spec-Kit run evidence, and agent-neutral model-routing guidance across declared AI-agent surfaces. | 6 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
| Architecture Governance | Adds secure software architecture, resumable remote-transaction boundaries, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence | 13 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) |
| Autonomous Run Governance | Adds permission-bounded, evidence-first governance for complete autonomous Spec Kit delivery, including validated status, stop, explicit resume, exact-head proof, post-merge closeout, retrospective learning, and an optional policy-driven intake-review gate before feature creation. | 13 templates, 5 commands, 4 scripts | — | [spec-kit-preset-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-autonomous-run-governance) |
| Autonomous Run Governance | Adds permission-bounded autonomous delivery, an optional intake-review gate, and preservation of the project's learner and accessibility contract. | 13 templates, 5 commands, 4 scripts | — | [spec-kit-preset-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-autonomous-run-governance) |
| Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) |
| Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) |
| Command Density | Compacts the nine core Spec Kit command prompts while preserving scripts, handoffs, placeholders, hook output blocks, and rule structure | 9 commands | — | [spec-kit-preset-command-density](https://github.com/Xopoko/spec-kit-preset-command-density) |
@@ -19,13 +19,14 @@ The following community-contributed presets customize how Spec Kit behaves — o
| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) |
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose principles. Supports interactive elements like brainstorming, interview, roleplay, and extras like statistics, cover builder, illustration builder, and bio command. Export with templates for KDP, D2D, etc. | 26 templates, 34 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
| Game Narrative Writing | Preset for game narrative design and interactive storytelling. It adapts the Spec-Driven Development workflow for game narratives: features become story mechanics, specs become narrative briefs, plans become story maps, and tasks become dialogue and scene-writing tasks. Supports branching narratives, player agency systems, state machines, and interactive dialogue trees. | 37 templates, 34 commands, 5 scripts | — | [speckit-preset-game-narrative-writing](https://github.com/adaumann/speckit-preset-game-narrative-writing) |
| Intake Authoring Governance | Creates traceable Spec Kit intakes from ordered text sources and now truthfully adopts legacy intakes without inventing predecessor receipts. | 7 templates, 2 commands, 2 scripts | — | [spec-kit-preset-intake-authoring-governance](https://github.com/hindermath/spec-kit-preset-intake-authoring-governance) |
| Intake Review Governance | Adds hash-bound review, repair, and status gates for single, series, and campaign intake files before interactive, autonomous, or parallel Spec Kit execution. | 8 templates, 3 commands, 2 scripts | — | [spec-kit-preset-intake-review-governance](https://github.com/hindermath/spec-kit-preset-intake-review-governance) |
| Intake Authoring Governance | Governs traceable intake CRUD and language-aware requirements collections with atomic migrations, rollback evidence, and safe series authoring. | 12 templates, 5 commands, 7 scripts | — | [spec-kit-preset-intake-authoring-governance](https://github.com/hindermath/spec-kit-preset-intake-authoring-governance) |
| Intake Review Governance | Reviews single, series, campaign, and language-aware requirements collections before Spec Kit execution. | 8 templates, 3 commands, 4 scripts | — | [spec-kit-preset-intake-review-governance](https://github.com/hindermath/spec-kit-preset-intake-review-governance) |
| Intake Sequencing Governance | Manages language-aware intake-series order, typed dependencies, lifecycle, and authority-neutral next-candidate selection. | 11 templates, 6 commands, 8 scripts | — | [spec-kit-preset-intake-sequencing-governance](https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance) |
| iSAQB Architecture Governance | Adds iSAQB/CPSA-F and arc42 architecture governance with audit-ready evidence for goals, views, resumability, partial-failure scenarios, ADRs, risks, and technical debt. | 13 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) |
| Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) |
| Model Driven Engineering | Focuses on streamlined commands, app repository support, cross-spec support, and capability-aware project memory for model-driven engineering workflows | 6 templates, 11 commands | MDE extension | [spec-kit-preset-mde](https://github.com/AI-MDE/spec-kit-preset-mde) |
| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) |
| Parallel Autonomous Run Governance | Coordinates isolated autonomous Spec Kit campaigns with bounded concurrency, mixed agents, resumable consolidation, governed post-merge closeout, schema 1.2, and an optional current intake-review gate before worker scheduling. | 9 templates, 5 commands, 2 scripts | autonomous-run-governance >=0.3.2; optional: intake-review-governance >=0.1.0 | [spec-kit-preset-parallel-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance) |
| Parallel Autonomous Run Governance | Coordinates permission-bounded autonomous campaigns while preserving the project's learner and accessibility contract across workers and consolidation. | 9 templates, 5 commands, 2 scripts | autonomous-run-governance >=0.2.2; optional: intake-review-governance >=0.1.0 | [spec-kit-preset-parallel-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance) |
| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
| Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands, 1 script | — | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) |
| Security Governance | Adds memory-safe-language and secure-coding governance, exact-head and security-gate evidence, provider-failure classification, ASVS, supply-chain transparency, and EU regulatory screening. | 14 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) |

View File

@@ -6,6 +6,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| Agent | Key | Notes |
| ------------------------------------------------------------------------------------ | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| [Alquimia AI](https://docs.alquimia.ai) | `alquimia` | Skills-based integration; installs skills into `.alquimia/skills` and invokes them as `/speckit-<command>` |
| [Amp](https://ampcode.com/) | `amp` | |
| [Antigravity (agy)](https://antigravity.google/) | `agy` | Skills-based integration; skills are installed automatically |
| [Auggie CLI](https://docs.augmentcode.com/cli/overview) | `auggie` | |
@@ -25,7 +26,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [Hermes](https://github.com/NousResearch/hermes-agent) | `hermes` | Skills-based integration; installs skills globally into `~/.hermes/skills/` |
| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | Skills-based integration by default; installs skills as `speckit-<command>/SKILL.md` under `.bob/skills/` and invokes them as `/speckit-<command>`. Pass `--integration-options="--legacy-commands"` to scaffold the deprecated Bob 1.x layout (`.bob/commands/*.md`) instead; that flag will be removed in a future release. Existing legacy installs can migrate with `specify integration upgrade bob --integration-options="--skills"`, which converts them to the skills layout and removes the old command files. If preset overrides are installed, the migration is rejected with an actionable error (preset artifacts cannot yet be reconciled across a layout change) — remove the preset(s), migrate, then reinstall them. |
| [Junie](https://junie.jetbrains.com/) | `junie` | |
| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | |
| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | Installs commands into `.kilo/commands`; legacy `.kilocode/workflows` installs remain supported as a registration fallback |
| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; installs into `.kimi-code/skills/`. `--migrate-legacy` moves old `.kimi/skills/` installs to the new paths |
| [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Kiro CLI does not substitute `$ARGUMENTS` in file-based prompts, so Spec Kit ships a prose fallback at render time (see [Manage prompts](https://kiro.dev/docs/cli/chat/manage-prompts/) and issue [#1926](https://github.com/github/spec-kit/issues/1926)). Alias: `--integration kiro` |
| [Lingma](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically |
@@ -94,6 +95,8 @@ Installs the specified integration into the current project. If another integrat
Installing an additional integration does not change the default integration. Use `specify integration use <key>` to change the default.
Installed extensions and presets are not registered for a non-default integration at install time — they follow the currently active (default) integration only. `specify integration use <key>` (or `switch <key>`) is what rescaffolds them for the newly active integration.
> **Note:** All integration management commands require a project already initialized with `specify init`. To start a new project with a specific agent, use `specify init <project> --integration <key>` instead.
**Version note:** Controlled multi-install support was introduced in Spec Kit 0.8.5. If `specify integration install <key>` says another integration is already installed and only suggests `switch` or `uninstall`, check your local CLI with `specify version` and upgrade it. Running a one-shot command such as `uvx --from git+https://github.com/github/spec-kit.git specify ...` uses a temporary copy for that command only; it does not update the persistent `specify` executable on your `PATH`.
@@ -127,7 +130,7 @@ specify integration switch <key>
| `--refresh-shared-infra` | Also overwrite shared infrastructure files even if you customized them (otherwise customizations are preserved) |
| `--integration-options` | Options for the target integration when it is not already installed |
If the target integration is not already installed, equivalent to running `uninstall` followed by `install` in a single step. In this mode, `--force` controls whether modified files from the removed integration are deleted. If the target integration is already installed, `switch` only changes the default integration, like `use`; in this mode, `--force` controls whether managed shared templates are overwritten while the default changes. `--integration-options` is rejected for already-installed targets because changing integration options requires reinstalling managed files; run `upgrade <key> --integration-options ...` first, then `use <key>`.
If the target integration is not already installed, equivalent to running `uninstall` followed by `install` in a single step. In this mode, `--force` controls whether modified files from the removed integration are deleted. If the target integration is already installed, `switch` only changes the default integration, like `use`; in this mode, `--force` controls whether managed shared templates are overwritten while the default changes. `--integration-options` is rejected for already-installed targets because changing integration options requires reinstalling managed files; run `upgrade <key> --integration-options ...` first, then `use <key>`. Like `use`, `switch` rescaffolds installed extensions and presets for the target integration once it becomes the default.
## Use an Installed Integration
@@ -141,6 +144,8 @@ specify integration use <key>
Sets the default integration without uninstalling any other installed integrations. This also refreshes managed shared templates so command references match the new default integration's invocation style. Modified or untracked shared templates are preserved unless `--force` is used.
`use` is also the activation point for installed extensions and presets: it re-registers every enabled extension's and preset's command overrides (and skills, for skills-mode agents) for the newly active integration, so artifacts installed while a different integration was active are rescaffolded here rather than at install time.
## Upgrade an Integration
```bash
@@ -155,6 +160,10 @@ specify integration upgrade [<key>]
Reinstalls an installed integration with updated templates and commands (e.g., after upgrading Spec Kit). Defaults to the default integration; if a key is provided, it must be one of the installed integrations. Detects locally modified files and blocks the upgrade unless `--force` is used. Stale files from the previous install that are no longer needed are removed automatically. Shared templates stay aligned with the default integration even when upgrading a non-default integration.
Enabled extensions and presets are re-registered only when upgrading the currently active (default) integration. A non-default upgrade still refreshes that integration's core commands, but does not re-register its extension or preset layers — `use`/`switch` that integration afterward to rescaffold them.
If an upgrade would change an integration between command and skills layouts while preset artifacts are registered for it, the upgrade is rejected before changing files. Remove the affected presets, run the layout-changing upgrade, then reinstall them.
## Report Integration Status
```bash
@@ -263,19 +272,23 @@ The currently declared multi-install safe integrations are:
| Key | Command directory |
| --- | ----------------- |
| `alquimia` | `.alquimia/skills` |
| `auggie` | `.augment/commands` |
| `claude` | `.claude/skills` |
| `cline` | `.clinerules/workflows` |
| `codebuddy` | `.codebuddy/commands` |
| `codex` | `.agents/skills` |
| `cursor-agent` | `.cursor/skills` |
| `droid` | `.factory/skills` |
| `firebender` | `.firebender/commands` |
| `gemini` | `.gemini/commands` |
| `grok` | `.grok/skills` |
| `junie` | `.junie/commands` |
| `kilocode` | `.kilocode/workflows` |
| `kilocode` | `.kilo/commands` |
| `kiro-cli` | `.kiro/prompts` |
| `lingma` | `.lingma/skills` |
| `omp` | `.omp/commands` |
| `pi` | `.pi/prompts` |
| `qodercli` | `.qoder/commands` |
| `qwen` | `.qwen/commands` |
| `shai` | `.shai/commands` |
@@ -300,3 +313,7 @@ CLI-based integrations (like Claude Code, Gemini CLI) require the tool to be ins
### When should I use `upgrade` vs `switch`?
Use `upgrade` when you've upgraded Spec Kit and want to refresh an installed integration's managed files. Use `switch` when you want to replace the current default with another integration; if the target is already installed, `switch` behaves like `use`.
### Do extensions and presets I install apply to every installed integration?
No. Extensions (`specify extension add`) and presets (`specify preset add`) register their command overrides for the currently active (default) integration only, even if other integrations are installed. A non-default integration does not receive those artifacts until it becomes the default: `specify integration use <key>` (or `switch <key>`) rescaffolds every enabled extension and preset for the newly active integration. `specify integration upgrade` follows the same rule — it only re-registers extensions and presets when upgrading the active integration.

View File

@@ -139,7 +139,7 @@ catalogs:
Presets can provide command files, template files (like `plan-template.md`), and script files. Each file name is evaluated independently against the priority stack, so different files can come from different layers.
Templates and scripts are looked up from the stack when Spec Kit needs them. Commands use the same stack for replacement and composition, but are materialized into detected agent directories instead of being re-resolved by agents. During preset install, Spec Kit registers command files for the preset being installed; post-install and post-removal reconciliation then recomputes and writes the effective command content for affected command names based on the active stack. Agents do not re-resolve the stack each time they run a command.
Templates and scripts are looked up from the stack when Spec Kit needs them. Commands use the same stack for replacement and composition, but are materialized into the active integration's directory only, instead of being re-resolved by agents or written to every detected agent directory (#2948). During preset install, Spec Kit registers command files for the preset being installed against the currently active integration; post-install and post-removal reconciliation then recomputes and writes the effective command content for affected command names based on the active stack. Install and rescaffold remain active-only, but removal may also update previously targeted inactive directories recorded by the removed preset to restore the surviving command or skill layer. A non-active installed integration does not otherwise receive these command files until it becomes the default — `specify integration use <key>` (or `switch <key>`) rescaffolds enabled presets for the newly active integration. Agents do not re-resolve the stack each time they run a command.
By default, files use a **replace** strategy: the first match in the priority stack wins and is used entirely. Templates and commands can also use composition strategies: **prepend** places preset content before lower-priority content, **append** places it after lower-priority content, and **wrap** replaces `{CORE_TEMPLATE}` with lower-priority content. Scripts support **replace** and **wrap**; script wrappers use `$CORE_SCRIPT` as the placeholder.

View File

@@ -502,6 +502,32 @@ args: "{{ inputs.spec }}"
message: "{{ status | default('pending') }}"
```
### Interpolation and shell safety
Expressions are resolved by **plain string substitution** — the value of `{{ ... }}` is spliced into the surrounding text exactly as-is, with no quoting or escaping added. That is convenient for building `args` and `message` strings, but it has an important consequence for `shell` steps: a `run` field is handed to the system shell (`/bin/sh -c` on POSIX), so any interpolated value is interpreted as **shell syntax**, not just data.
If an interpolated value can contain characters like `;`, `|`, `&`, `$( )`, backticks, or quotes, it can change or extend the command that actually runs. This matters most when the value is not fully under the workflow author's control:
- **Workflow `inputs.*`** — supplied by whoever runs the workflow.
- **A prior step's output**, e.g. `{{ steps.plan.output.stdout }}` — for a `prompt` step this is **text produced by the AI agent**, which can in turn be influenced by files, tickets, or web content the agent read. Treat agent output as untrusted when it flows into a `shell` step.
There is **no shell-escaping filter** in the expression language and **no sandbox** around a `shell` step, so none of the practices below can be treated as a guarantee that a hostile value is neutralised. The only reliable control is to constrain what an interpolated value *can* be, and to keep values you cannot constrain out of `run` fields entirely. Scrutinise every `run` field that interpolates a value you do not control, and at minimum:
- **Constrain the value at the source with `enum`/an allowlist.** When `inputs.*` feeds a `run` field, restrict it to a fixed set of known-safe values so a caller cannot supply arbitrary shell text at all. This is the strongest control the engine offers — prefer it over any downstream mitigation.
```yaml
inputs:
target:
type: string
enum: [staging, production] # caller cannot inject arbitrary text
```
- **Keep unconstrained values out of `run`.** If a value cannot be constrained to an allowlist — most agent/`prompt` output — do not interpolate it into a `run` field. Branch on it with `if`/`switch` against fixed conditions, or act on it in a `command`/`prompt` step rather than a shell command built from it.
- **Quoting is not a security boundary.** Surrounding a substitution with quotes (`'{{ inputs.x }}'`) helps the shell treat a *trusted* value as a single argument and avoids word-splitting on spaces, but a value that itself contains the matching quote character can still break out and inject shell syntax. Quote for correctness on constrained values; never rely on quoting to make an *unconstrained* substitution safe.
- **Gates do not inspect the next step, and `message` is printed verbatim.** A `gate` step renders only its own `message`/`show_file` — it does not display, resolve, or sanitise the command that follows it, and approval never neutralises an injectable interpolation. Do **not** interpolate raw untrusted data into `message`: it is printed as-is with no control-character stripping, so agent or caller output could inject terminal/ANSI escapes that alter or hide the approval prompt. Keep `message` to trusted, constrained text, and surface untrusted material for review via `show_file` instead — its path and contents are control/ANSI-stripped before display.
A `shell` step is an arbitrary-command primitive by design; these practices reduce exposure and keep *which* command runs under the author's control, but they do not eliminate the risk of interpolating values you do not fully control.
## Shell Step Environment Variables
Shell steps automatically receive the following environment variables:

View File

@@ -195,15 +195,13 @@ Some IDE-based agents (like Kilo Code, Cline) may show **duplicate slash command
**Example for Kilo Code:**
```bash
# Navigate to the agent's commands folder
cd .kilocode/workflows/
# List files and identify duplicates
ls -la
# List current and legacy Kilo command folders
ls -la .kilo/commands/
ls -la .kilocode/workflows/
# Delete old versions (example filenames - yours may differ)
rm speckit.specify-old.md
rm speckit.plan-v1.md
rm .kilocode/workflows/speckit.specify-old.md
rm .kilocode/workflows/speckit.plan-v1.md
```
Restart your IDE to refresh the command list.
@@ -248,14 +246,12 @@ specify extension update
This happens with IDE-based agents (Kilo Code, Cline, etc.).
```bash
# Find the agent folder (example: .kilocode/workflows/)
cd .kilocode/workflows/
# List all files
ls -la
# For Kilo Code, inspect both current and legacy command folders
ls -la .kilo/commands/
ls -la .kilocode/workflows/
# Delete old command files
rm speckit.old-command-name.md
rm .kilocode/workflows/speckit.old-command-name.md
# Restart your IDE
```
@@ -307,7 +303,7 @@ Alternatively, run the `/speckit.specify` command which creates `.specify/featur
2. **For CLI-based agents**, verify files exist:
```bash
ls -la .claude/commands/ # Claude Code
ls -la .claude/skills/ # Claude Code
ls -la .gemini/commands/ # Gemini
ls -la .cursor/skills/ # Cursor
ls -la .pi/prompts/ # Pi Coding Agent
@@ -356,7 +352,7 @@ This warning appears when you run `specify init --here` (or `specify init .`) in
Only Spec Kit infrastructure files:
- Agent command files (`.claude/commands/`, `.github/prompts/`, etc.)
- Agent command/skill files (`.claude/skills/`, `.github/prompts/`, etc.)
- Scripts in `.specify/scripts/`
- Templates in `.specify/templates/`
- Missing memory files such as `.specify/memory/constitution.md` may be created from templates; an existing constitution is preserved
@@ -445,7 +441,7 @@ Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/s
ls -la .github/prompts/
# For Claude
ls -la .claude/commands/
ls -la .claude/skills/
# For Pi
ls -la .pi/prompts/

View File

@@ -2,6 +2,7 @@
"_comment": "Default coding agent context file per integration, owned by the agent-context extension. Used to self-seed agent-context-config.yml when it declares no context_file/context_files. Keyed by the Spec Kit integration key recorded in .specify/init-options.json. This mapping is independent of the Specify CLI by design.",
"agents": {
"agy": "AGENTS.md",
"alquimia": "ALQUIMIA.md",
"amp": "AGENTS.md",
"auggie": ".augment/rules/specify-rules.md",
"bob": "AGENTS.md",

View File

@@ -176,13 +176,18 @@ _opts_lines=()
while IFS= read -r _line || [[ -n "$_line" ]]; do
_opts_lines+=("$_line")
done < <(printf '%s\n' "$_raw_opts")
if (( ${#_opts_lines[@]} < 3 )); then
echo "agent-context: malformed config parser output; expected 3 lines (context_files, marker_start, marker_end), got ${#_opts_lines[@]}; skipping update." >&2
if (( ${#_opts_lines[@]} < 1 )); then
echo "agent-context: malformed config parser output; expected at least the context_files line, got ${#_opts_lines[@]}; skipping update." >&2
exit 0
fi
# The marker lines may be absent: the $(...) capture above strips trailing
# newlines, so blank markers (the config omitting context_markers and relying on
# defaults) collapse the 3-line output to fewer lines. Default them to empty here
# and let the DEFAULT_START/END substitution below fill them in, matching the
# Python and PowerShell ports.
CONTEXT_FILES_JSON="${_opts_lines[0]}"
MARKER_START="${_opts_lines[1]}"
MARKER_END="${_opts_lines[2]}"
MARKER_START="${_opts_lines[1]:-}"
MARKER_END="${_opts_lines[2]:-}"
if ! _context_files_raw="$("$_python" - "$CONTEXT_FILES_JSON" <<'PY'
import json

View File

@@ -11,8 +11,9 @@ Usage: update_agent_context.py [plan_path]
When ``plan_path`` is omitted, the script derives it from
``.specify/feature.json`` (written by /speckit-specify). Falls back to the most
recently modified ``specs/*/plan.md`` only when feature.json is absent or its
plan does not exist yet.
recently modified ``plan.md`` anywhere under ``specs/`` (including nested scoped
layouts such as ``specs/<scope>/<feature>/plan.md``) only when feature.json is
absent or its plan does not exist yet.
"""
from __future__ import annotations
@@ -173,7 +174,7 @@ def _resolve_plan_path(project_root: str) -> str:
if not plan_path:
root = Path(project_root).resolve()
plans = sorted(
(root / "specs").glob("*/plan.md"),
(root / "specs").rglob("plan.md"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)

View File

@@ -6,6 +6,8 @@ Discovery answers *"is this worth building?"* Delivery answers *"how do we build
## Overview
`assess` runs inside an initialized Spec Kit project (it writes assessments under `.specify/assessments/`), but that project can be **completely empty of source code** — a freshly initialized project with no code works just as well as an established codebase. The input is just an idea: pasted text, a URL, or a ticket need no existing code, while a codebase pointer lets you assess an idea for code that already exists. Neither starting point is more "correct" than the other.
Each idea lives in its own directory under `.specify/assessments/<slug>/`, with one Markdown artifact per stage:
```

View File

@@ -21,6 +21,8 @@ The user input is the idea and (optionally) a slug. Treat it as one of:
3. **A codebase pointer** — phrasing like "an idea for this repo" or a path. Read enough of the repository to record what the idea relates to.
4. **A mix** of the above.
There is **no requirement for existing source code**: within an initialized Spec Kit project, intake works just as well when the project is empty of code as when it already has a codebase. Pasted text or a URL (options 12) need no existing codebase; a codebase pointer (option 3) targets existing code. Both are equally valid.
If the input is empty, ask the user for the idea (interactive), or stop with a note that there is nothing to intake (automated).
## Slug Resolution

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-07-21T00:00:00Z",
"updated_at": "2026-07-28T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"aide": {
@@ -290,8 +290,8 @@
"id": "architecture-guard",
"description": "Framework-agnostic architecture review extension for validating implementation against governance and architecture constitutions, detecting architectural drift, and generating non-blocking refactor tasks.",
"author": "DyanGalih",
"version": "1.8.17",
"download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.8.17.zip",
"version": "1.13.1",
"download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.13.1.zip",
"repository": "https://github.com/DyanGalih/spec-kit-architecture-guard",
"homepage": "https://github.com/DyanGalih/spec-kit-architecture-guard",
"documentation": "https://github.com/DyanGalih/spec-kit-architecture-guard/blob/main/docs/architecture-overview.md",
@@ -303,7 +303,7 @@
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 10,
"commands": 14,
"hooks": 3
},
"tags": [
@@ -313,13 +313,14 @@
"refactor",
"workflow",
"governance",
"guardrails"
"guardrails",
"hygiene"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-05-05T07:26:00Z",
"updated_at": "2026-06-08T00:00:00Z"
"updated_at": "2026-07-24T00:00:00Z"
},
"archive": {
"name": "Archive Extension",
@@ -489,6 +490,46 @@
"created_at": "2026-04-17T00:00:00Z",
"updated_at": "2026-04-17T00:00:00Z"
},
"blueprint-index": {
"name": "Blueprint Index — Living Architecture Map",
"id": "blueprint-index",
"description": "Living architecture map for brownfield and greenfield projects, with a deterministic CI gate that blocks contradictions between the map, specs, and code while warning on non-blocking drift.",
"author": "ogil109",
"version": "0.2.0",
"download_url": "https://github.com/ogil109/spec-kit-blueprint/releases/download/v0.2.0/blueprint.zip",
"repository": "https://github.com/ogil109/spec-kit-blueprint",
"homepage": "https://github.com/ogil109/spec-kit-blueprint/tree/main",
"documentation": "https://github.com/ogil109/spec-kit-blueprint/blob/main/README.md",
"changelog": "https://github.com/ogil109/spec-kit-blueprint/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "process",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.10.0",
"tools": [
{ "name": "bash", "required": false },
{ "name": "git", "required": false }
]
},
"provides": {
"commands": 4,
"hooks": 0
},
"tags": [
"blueprint",
"architecture",
"coherence",
"drift",
"brownfield",
"autonomous",
"ci"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-24T00:00:00Z",
"updated_at": "2026-07-24T00:00:00Z"
},
"branch-convention": {
"name": "Branch Convention",
"id": "branch-convention",
@@ -1578,8 +1619,8 @@
"id": "gates",
"description": "Deterministic quality enforcement for Spec Kit across agent hooks, git checks, and CI pipelines with one policy file and one verify entrypoint for identical results at every boundary.",
"author": "schwichtgit",
"version": "0.3.2",
"download_url": "https://github.com/schwichtgit/spec-gates/releases/download/v0.3.2/gates-0.3.2.zip",
"version": "0.3.3",
"download_url": "https://github.com/schwichtgit/spec-gates/releases/download/v0.3.3/gates-0.3.3.zip",
"repository": "https://github.com/schwichtgit/spec-gates",
"homepage": "https://github.com/schwichtgit/spec-gates",
"documentation": "https://github.com/schwichtgit/spec-gates/blob/main/docs/how-it-works.md",
@@ -1623,7 +1664,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-07-09T00:00:00Z",
"updated_at": "2026-07-15T00:00:00Z"
"updated_at": "2026-07-27T00:00:00Z"
},
"github-issues": {
"name": "GitHub Issues Integration 1",
@@ -2034,11 +2075,11 @@
"id": "linear-weave",
"description": "Weave Spec Kit into Linear: pull requirements, mirror tasks.md into sub-issues, sync statuses.",
"author": "Tony Woodhouse",
"version": "1.0.0",
"download_url": "https://github.com/tonydwoodhouse/spec-kit-linear-weave/archive/refs/tags/v1.0.0.zip",
"version": "1.0.1",
"download_url": "https://github.com/tonydwoodhouse/spec-kit-linear-weave/archive/refs/tags/v1.0.1.zip",
"repository": "https://github.com/tonydwoodhouse/spec-kit-linear-weave",
"homepage": "https://github.com/tonydwoodhouse/spec-kit-linear-weave",
"documentation": "https://github.com/tonydwoodhouse/spec-kit-linear-weave#readme",
"documentation": "https://github.com/tonydwoodhouse/spec-kit-linear-weave/blob/main/README.md",
"changelog": "https://github.com/tonydwoodhouse/spec-kit-linear-weave/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "integration",
@@ -2061,7 +2102,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-07-21T00:00:00Z",
"updated_at": "2026-07-21T00:00:00Z"
"updated_at": "2026-07-27T00:00:00Z"
},
"loop": {
"name": "Loop Engineering",
@@ -4777,36 +4818,40 @@
"verify-review-ship": {
"name": "Verify Review Ship",
"id": "verify-review-ship",
"description": "Adds post-implementation verify, review, and ship readiness gates to Spec Kit workflows.",
"description": "Post-convergence operational verification, technical review, learning governance, and transactional delivery.",
"author": "Carlos Eduardo Gevaerd Araujo",
"version": "0.1.0",
"download_url": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/archive/refs/tags/v0.1.0.zip",
"version": "0.4.2",
"download_url": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/archive/refs/tags/v0.4.2.zip",
"sha256": "71dceef5bf81d7ac54faa26bb5cf279554815a4928ee8d0c8e9bfb4c3e2bb0ab",
"repository": "https://github.com/cadugevaerd/spec-kit-verify-review-ship",
"homepage": "https://github.com/cadugevaerd/spec-kit-verify-review-ship",
"documentation": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/blob/main/README.md",
"changelog": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "process",
"effect": "read-only",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.1.0"
"speckit_version": ">=0.11.2"
},
"provides": {
"commands": 3,
"hooks": 1
"hooks": 0
},
"tags": [
"quality",
"review",
"shipping",
"workflow",
"testing"
"merge",
"cleanup",
"learning",
"governance",
"agent-skills"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-10T00:00:00Z",
"updated_at": "2026-07-10T00:00:00Z"
"updated_at": "2026-07-28T00:00:00Z"
},
"verify-tasks": {
"name": "Verify Tasks Extension",

View File

@@ -1,8 +1,17 @@
{
"schema_version": "1.0",
"updated_at": "2026-07-17T00:00:00Z",
"updated_at": "2026-07-27T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json",
"integrations": {
"alquimia": {
"id": "alquimia",
"name": "Alquimia AI",
"version": "1.0.0",
"description": "Alquimia AI CLI integration",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["alquimia"]
},
"claude": {
"id": "claude",
"name": "Claude Code",

View File

@@ -1,19 +1,19 @@
{
"schema_version": "1.0",
"updated_at": "2026-07-23T00:00:00Z",
"updated_at": "2026-07-28T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
"presets": {
"a11y-governance": {
"name": "A11Y Governance",
"id": "a11y-governance",
"version": "0.4.1",
"description": "Adds WCAG 2.2 AA governance, accessible text/JSON status parity, bilingual DE/EN delivery, CEFR-B2 readability, inclusive content, didactic-comment review, and audit-ready evidence.",
"version": "0.4.2",
"description": "Adds accessibility (WCAG 2.2 AA), accessible text and JSON status parity, bilingual DE/EN delivery, CEFR-B2 readability, inclusive-content governance, didactic inline-code-comment review, and audit-ready Spec-Kit run evidence to Spec Kit.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-a11y-governance/archive/refs/tags/v0.4.1.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-a11y-governance/archive/refs/tags/v0.4.2.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-a11y-governance/blob/v0.4.1/README.md",
"documentation": "https://github.com/hindermath/spec-kit-preset-a11y-governance/blob/v0.4.2/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.0"
@@ -34,18 +34,18 @@
"didactic-comments"
],
"created_at": "2026-04-27T00:00:00Z",
"updated_at": "2026-07-23T00:00:00Z"
"updated_at": "2026-07-28T00:00:00Z"
},
"agent-parity-governance": {
"name": "Agent Parity Governance",
"id": "agent-parity-governance",
"version": "0.4.0",
"description": "Adds shared-guidance and generated-command parity, fleet-completion evidence, secret-free runner/status metadata, audit-ready evidence, and agent-neutral model routing.",
"version": "0.4.1",
"description": "Adds shared-guidance and generated-command parity, repository-fleet completion evidence, secret-free runner/status metadata, audit-ready Spec-Kit run evidence, and agent-neutral model-routing guidance across declared AI-agent surfaces.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/archive/refs/tags/v0.4.0.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/archive/refs/tags/v0.4.1.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/blob/v0.4.0/README.md",
"documentation": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/blob/v0.4.1/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.0"
@@ -64,7 +64,7 @@
"multi-agent"
],
"created_at": "2026-04-27T00:00:00Z",
"updated_at": "2026-07-23T00:00:00Z"
"updated_at": "2026-07-28T00:00:00Z"
},
"aide-in-place": {
"name": "AIDE In-Place Migration",
@@ -135,13 +135,13 @@
"autonomous-run-governance": {
"name": "Autonomous Run Governance",
"id": "autonomous-run-governance",
"version": "0.3.2",
"description": "Adds permission-bounded, evidence-first governance for complete autonomous Spec Kit delivery, including validated status, stop, explicit resume, exact-head proof, post-merge closeout, retrospective learning, and an optional policy-driven intake-review gate before feature creation.",
"version": "0.3.3",
"description": "Adds permission-bounded autonomous delivery, an optional intake-review gate, and preservation of the project's learner and accessibility contract.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/archive/refs/tags/v0.3.2.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/archive/refs/tags/v0.3.3.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/blob/v0.3.2/README.md",
"documentation": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/blob/v0.3.3/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.3"
@@ -156,11 +156,10 @@
"governance",
"evidence",
"permissions",
"resume",
"intake-review"
"accessibility"
],
"created_at": "2026-07-13T00:00:00Z",
"updated_at": "2026-07-21T00:00:00Z"
"updated_at": "2026-07-28T00:00:00Z"
},
"canon-core": {
"name": "Canon Core",
@@ -368,42 +367,42 @@
"intake-authoring-governance": {
"name": "Intake Authoring Governance",
"id": "intake-authoring-governance",
"version": "0.1.1",
"description": "Creates traceable Spec Kit intakes from ordered text sources and now truthfully adopts legacy intakes without inventing predecessor receipts.",
"version": "0.3.0",
"description": "Governs traceable intake CRUD and language-aware requirements collections with atomic migrations, rollback evidence, and safe series authoring.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/archive/refs/tags/v0.1.1.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/archive/refs/tags/v0.3.0.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/blob/v0.1.1/README.md",
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/blob/v0.3.0/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.3"
},
"provides": {
"templates": 7,
"commands": 2,
"scripts": 2
"templates": 12,
"commands": 5,
"scripts": 7
},
"tags": [
"intake",
"authoring",
"governance",
"traceability",
"legacy-adoption"
"requirements",
"migration"
],
"created_at": "2026-07-22T00:00:00Z",
"updated_at": "2026-07-23T00:00:00Z"
"updated_at": "2026-07-28T00:00:00Z"
},
"intake-review-governance": {
"name": "Intake Review Governance",
"id": "intake-review-governance",
"version": "0.1.0",
"description": "Adds hash-bound review, repair, and status gates for single, series, and campaign intake files before interactive, autonomous, or parallel Spec Kit execution.",
"version": "0.2.0",
"description": "Reviews single, series, campaign, and language-aware requirements collections before Spec Kit execution.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-intake-review-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-review-governance/archive/refs/tags/v0.1.0.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-review-governance/archive/refs/tags/v0.2.0.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-intake-review-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-review-governance/blob/v0.1.0/README.md",
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-review-governance/blob/v0.2.0/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.3"
@@ -411,17 +410,46 @@
"provides": {
"templates": 8,
"commands": 3,
"scripts": 2
"scripts": 4
},
"tags": [
"intake",
"review",
"governance",
"quality-gate",
"autonomous"
"requirements",
"quality-gate"
],
"created_at": "2026-07-21T00:00:00Z",
"updated_at": "2026-07-21T00:00:00Z"
"updated_at": "2026-07-28T00:00:00Z"
},
"intake-sequencing-governance": {
"name": "Intake Sequencing Governance",
"id": "intake-sequencing-governance",
"version": "0.2.2",
"description": "Manages language-aware intake-series order, typed dependencies, lifecycle, and authority-neutral next-candidate selection.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance/archive/refs/tags/v0.2.2.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance/blob/v0.2.2/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.3"
},
"provides": {
"templates": 11,
"commands": 6,
"scripts": 8
},
"tags": [
"intake",
"sequencing",
"governance",
"dag",
"lifecycle"
],
"created_at": "2026-07-27T00:00:00Z",
"updated_at": "2026-07-28T00:00:00Z"
},
"isaqb-architecture-governance": {
"name": "iSAQB Architecture Governance",
@@ -543,16 +571,16 @@
"parallel-autonomous-run-governance": {
"name": "Parallel Autonomous Run Governance",
"id": "parallel-autonomous-run-governance",
"version": "0.2.3",
"description": "Coordinates isolated autonomous Spec Kit campaigns with bounded concurrency, mixed agents, resumable consolidation, governed post-merge closeout, schema 1.2, and an optional current intake-review gate before worker scheduling.",
"version": "0.2.4",
"description": "Coordinates permission-bounded autonomous campaigns while preserving the project's learner and accessibility contract across workers and consolidation.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance/archive/refs/tags/v0.2.3.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance/archive/refs/tags/v0.2.4.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance/blob/v0.2.3/README.md",
"documentation": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance/blob/v0.2.4/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.3"
"speckit_version": ">=0.8.3"
},
"provides": {
"templates": 9,
@@ -563,12 +591,11 @@
"parallel",
"autonomous",
"governance",
"orchestration",
"resume",
"intake-review"
"accessibility",
"orchestration"
],
"created_at": "2026-07-22T00:00:00Z",
"updated_at": "2026-07-22T00:00:00Z"
"updated_at": "2026-07-28T00:00:00Z"
},
"pirate": {
"name": "Pirate Speak (Full)",

View File

@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.14.1"
version = "0.14.4"
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"

View File

@@ -8,6 +8,7 @@ ALLOW_EXISTING=false
SHORT_NAME=""
BRANCH_NUMBER=""
USE_TIMESTAMP=false
NUMBER_EXPLICIT=false
ARGS=()
i=1
while [ $i -le $# ]; do
@@ -48,6 +49,9 @@ while [ $i -le $# ]; do
exit 1
fi
BRANCH_NUMBER="$next_arg"
if [ -n "$BRANCH_NUMBER" ]; then
NUMBER_EXPLICIT=true
fi
;;
--timestamp)
USE_TIMESTAMP=true
@@ -60,7 +64,7 @@ while [ $i -le $# ]; do
echo " --dry-run Compute feature name and paths without creating directories or files"
echo " --allow-existing-branch Reuse an existing feature directory if it already exists"
echo " --short-name <name> Provide a custom short name (2-4 words) for the feature"
echo " --number N Specify branch number manually (overrides auto-detection)"
echo " --number N Prefer a feature number (auto-corrected if its specs prefix exists)"
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
echo " --help, -h Show this help message"
echo ""
@@ -91,6 +95,7 @@ if [ -z "$FEATURE_DESCRIPTION" ]; then
fi
MAX_FEATURE_NUMBER=9223372036854775807
MAX_BRANCH_LENGTH=244
is_feature_number_in_range() {
local value="$1"
@@ -128,12 +133,40 @@ get_highest_from_specs() {
echo "$highest"
}
# Return success when a spec directory owns the given numeric prefix.
spec_prefix_exists() {
local specs_dir="$1"
local feature_num="$2"
for spec_path in "$specs_dir/${feature_num}-"*; do
[ -d "$spec_path" ] && return 0
done
return 1
}
# Function to clean and format a branch name
clean_branch_name() {
local name="$1"
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
}
# Fit a feature prefix and suffix within GitHub's branch-name limit.
fit_branch_name() {
local feature_num="$1"
local branch_suffix="$2"
local branch_name="${feature_num}-${branch_suffix}"
if [ ${#branch_name} -gt $MAX_BRANCH_LENGTH ]; then
local prefix_length=$(( ${#feature_num} + 1 ))
local max_suffix_length=$((MAX_BRANCH_LENGTH - prefix_length))
local truncated_suffix
truncated_suffix=$(printf '%s' "$branch_suffix" | cut -c "1-$max_suffix_length" | sed 's/-$//')
branch_name="${feature_num}-${truncated_suffix}"
fi
printf '%s' "$branch_name"
}
# Quote a value for POSIX shell reuse, byte-identical to Python's shlex.quote
# so the persistence hints match the Python variant exactly (printf %q output
# differs between bash versions and from shlex.quote for spaces/metachars).
@@ -253,26 +286,41 @@ else
# Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal)
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
# Treat an explicit number as a preference when its prefix is already used
# by a feature directory. Auto-detected numbers are already conflict-free.
if [ "$NUMBER_EXPLICIT" = true ]; then
SPEC_CONFLICT=false
REQUESTED_BRANCH_NAME=$(fit_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
REQUESTED_DIR="$SPECS_DIR/$REQUESTED_BRANCH_NAME"
if [ "$ALLOW_EXISTING" != true ] || [ ! -d "$REQUESTED_DIR" ]; then
spec_prefix_exists "$SPECS_DIR" "$FEATURE_NUM" && SPEC_CONFLICT=true
fi
if [ "$SPEC_CONFLICT" = true ]; then
REQUESTED_NUM="$FEATURE_NUM"
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
BRANCH_NUMBER=$HIGHEST
while true; do
if [ "$BRANCH_NUMBER" -eq "$MAX_FEATURE_NUMBER" ]; then
echo "Error: feature number must be between 0 and $MAX_FEATURE_NUMBER, got '9223372036854775808'" >&2
exit 1
fi
BRANCH_NUMBER=$((BRANCH_NUMBER + 1))
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
spec_prefix_exists "$SPECS_DIR" "$FEATURE_NUM" || break
done
>&2 echo "[specify] Warning: --number $REQUESTED_NUM conflicts with an existing spec directory; using $FEATURE_NUM instead"
fi
fi
fi
# GitHub enforces a 244-byte limit on branch names
# Validate and truncate if necessary
MAX_BRANCH_LENGTH=244
if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then
# Calculate how much we need to trim from suffix
# Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4
PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
# Truncate suffix at word boundary if possible
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
# Remove trailing hyphen if truncation created one
TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
ORIGINAL_BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
BRANCH_NAME=$(fit_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
if [ "$BRANCH_NAME" != "$ORIGINAL_BRANCH_NAME" ]; then
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"

View File

@@ -14,6 +14,7 @@ param(
[string[]]$FeatureDescription
)
$ErrorActionPreference = 'Stop'
$maxBranchLength = 244
# Show help if requested
if ($Help) {
@@ -24,7 +25,7 @@ if ($Help) {
Write-Host " -DryRun Compute feature name and paths without creating directories or files"
Write-Host " -AllowExistingBranch Reuse an existing feature directory if it already exists"
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the feature"
Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
Write-Host " -Number N Prefer a feature number (auto-corrected if its specs prefix exists)"
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
Write-Host " -Help Show this help message"
Write-Host ""
@@ -67,11 +68,44 @@ function Get-HighestNumberFromSpecs {
return $highest
}
function Test-SpecPrefixInUse {
param(
[string]$SpecsDir,
[string]$FeatureNum
)
if (-not (Test-Path -LiteralPath $SpecsDir -PathType Container)) {
return $false
}
return $null -ne (Get-ChildItem -LiteralPath $SpecsDir -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.Name -like "$FeatureNum-*" } |
Select-Object -First 1)
}
function ConvertTo-CleanBranchName {
param([string]$Name)
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
}
function Get-FittedBranchName {
param(
[string]$FeatureNum,
[string]$BranchSuffix
)
$fittedName = "$FeatureNum-$BranchSuffix"
if ($fittedName.Length -gt $maxBranchLength) {
$prefixLength = $FeatureNum.Length + 1
$maxSuffixLength = $maxBranchLength - $prefixLength
$truncatedSuffix = $BranchSuffix.Substring(0, [Math]::Min($BranchSuffix.Length, $maxSuffixLength))
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
$fittedName = "$FeatureNum-$truncatedSuffix"
}
return $fittedName
}
# Load common functions (includes Get-RepoRoot and Resolve-Template)
. "$PSScriptRoot/common.ps1"
@@ -176,26 +210,40 @@ if ($Timestamp) {
}
$featureNum = ('{0:000}' -f $resolvedNumber)
$branchName = "$featureNum-$branchSuffix"
# Treat an explicit number as a preference when its prefix is already used
# by a feature directory. Auto-detected numbers are already conflict-free.
$specConflict = $false
if ($hasNumber -and (Test-Path -LiteralPath $specsDir -PathType Container)) {
$requestedBranchName = Get-FittedBranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
$requestedDir = Join-Path $specsDir $requestedBranchName
if (-not $AllowExistingBranch -or -not (Test-Path -LiteralPath $requestedDir -PathType Container)) {
$specConflict = Test-SpecPrefixInUse -SpecsDir $specsDir -FeatureNum $featureNum
}
}
if ($specConflict) {
$requestedNum = $featureNum
$highestNumber = Get-HighestNumberFromSpecs -SpecsDir $specsDir
$resolvedNumber = $highestNumber
do {
if ($resolvedNumber -eq [long]::MaxValue) {
Write-Error "Error: feature number must be between 0 and $([long]::MaxValue), got '9223372036854775808'"
exit 1
}
$resolvedNumber++
$featureNum = ('{0:000}' -f $resolvedNumber)
} while (Test-SpecPrefixInUse -SpecsDir $specsDir -FeatureNum $featureNum)
[Console]::Error.WriteLine("[specify] Warning: -Number $requestedNum conflicts with an existing spec directory; using $featureNum instead")
}
}
# GitHub enforces a 244-byte limit on branch names
# Validate and truncate if necessary
$maxBranchLength = 244
if ($branchName.Length -gt $maxBranchLength) {
# Calculate how much we need to trim from suffix
# Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4
$prefixLength = $featureNum.Length + 1
$maxSuffixLength = $maxBranchLength - $prefixLength
# Truncate suffix
$truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))
# Remove trailing hyphen if truncation created one
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
$originalBranchName = $branchName
$branchName = "$featureNum-$truncatedSuffix"
$originalBranchName = "$featureNum-$branchSuffix"
$branchName = Get-FittedBranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
if ($branchName -ne $originalBranchName) {
[Console]::Error.WriteLine("[specify] Warning: Branch name exceeded GitHub's 244-byte limit")
[Console]::Error.WriteLine("[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)")
[Console]::Error.WriteLine("[specify] Truncated to: $branchName ($($branchName.Length) bytes)")

View File

@@ -76,7 +76,7 @@ Options:
--dry-run Compute feature name and paths without creating directories or files
--allow-existing-branch Reuse an existing feature directory if it already exists
--short-name <name> Provide a custom short name (2-4 words) for the feature
--number N Specify branch number manually (overrides auto-detection)
--number N Prefer a feature number (auto-corrected if its specs prefix exists)
--timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering
--help, -h Show this help message
@@ -204,6 +204,43 @@ def _get_highest_from_specs(specs_dir: Path) -> int:
return highest
def _fit_branch_name(feature_num: str, branch_suffix: str) -> str:
"""Fit a feature prefix and suffix within GitHub's branch-name limit."""
branch_name = f"{feature_num}-{branch_suffix}"
if len(branch_name) <= _MAX_BRANCH_LENGTH:
return branch_name
max_suffix_length = _MAX_BRANCH_LENGTH - (len(feature_num) + 1)
truncated_suffix = re.sub(r"-$", "", branch_suffix[:max_suffix_length])
return f"{feature_num}-{truncated_suffix}"
def _spec_prefix_exists(specs_dir: Path, feature_num: str) -> bool:
"""Return whether a spec directory owns the given numeric prefix."""
try:
return any(
entry.is_dir() and entry.name.startswith(f"{feature_num}-")
for entry in specs_dir.iterdir()
)
except OSError:
# Match Bash globbing and PowerShell's ErrorAction=SilentlyContinue.
return False
def _has_spec_prefix_conflict(
specs_dir: Path,
feature_num: str,
requested_dir: Path,
*,
allow_existing: bool,
) -> bool:
"""Return whether another spec directory owns the requested prefix."""
if allow_existing and requested_dir.is_dir():
return False
return _spec_prefix_exists(specs_dir, feature_num)
def main(argv: list[str] | None = None) -> int:
argv0 = sys.argv[0]
args = _parse_args(list(argv if argv is not None else sys.argv[1:]), argv0)
@@ -261,18 +298,48 @@ def main(argv: list[str] | None = None) -> int:
return 1
feature_num = f"{number:03d}"
# Treat an explicit number as a preference when its prefix is already used
# by a feature directory. Auto-detected numbers are already conflict-free.
if branch_number:
requested_branch_name = _fit_branch_name(feature_num, branch_suffix)
requested_dir = specs_dir / requested_branch_name
spec_conflict = _has_spec_prefix_conflict(
specs_dir,
feature_num,
requested_dir,
allow_existing=args.allow_existing,
)
if spec_conflict:
requested_num = feature_num
number = _get_highest_from_specs(specs_dir)
while True:
number += 1
if number > _MAX_FEATURE_NUMBER:
print(
f"Error: feature number must be between 0 and "
f"{_MAX_FEATURE_NUMBER}, got '{number}'",
file=sys.stderr,
)
return 1
feature_num = f"{number:03d}"
if not _spec_prefix_exists(specs_dir, feature_num):
break
print(
f"[specify] Warning: --number {requested_num} conflicts with "
f"an existing spec directory; using {feature_num} instead",
file=sys.stderr,
)
max_suffix_length = _MAX_BRANCH_LENGTH - (len(feature_num) + 1)
if max_suffix_length <= 0:
print("Error: feature number is too long for a branch name", file=sys.stderr)
return 1
branch_name = f"{feature_num}-{branch_suffix}"
original_branch_name = f"{feature_num}-{branch_suffix}"
branch_name = _fit_branch_name(feature_num, branch_suffix)
# GitHub enforces a 244-byte limit on branch names.
if len(branch_name) > _MAX_BRANCH_LENGTH:
truncated_suffix = re.sub(r"-$", "", branch_suffix[:max_suffix_length])
original_branch_name = branch_name
branch_name = f"{feature_num}-{truncated_suffix}"
if branch_name != original_branch_name:
print(
"[specify] Warning: Branch name exceeded GitHub's 244-byte limit",
file=sys.stderr,

View File

@@ -114,6 +114,7 @@ def _refresh_shared_templates(
project_path: Path,
*,
invoke_separator: str,
invoke_prefix: str = "/",
force: bool = False,
) -> None:
"""Refresh default-sensitive shared templates without touching scripts."""
@@ -124,6 +125,7 @@ def _refresh_shared_templates(
repo_root=_repo_root(),
console=console,
invoke_separator=invoke_separator,
invoke_prefix=invoke_prefix,
force=force,
)
@@ -134,6 +136,7 @@ def _install_shared_infra(
tracker: StepTracker | None = None,
force: bool = False,
invoke_separator: str = ".",
invoke_prefix: str = "/",
refresh_managed: bool = False,
refresh_hint: str | None = None,
) -> bool:
@@ -177,6 +180,7 @@ def _install_shared_infra(
console=console,
force=force,
invoke_separator=invoke_separator,
invoke_prefix=invoke_prefix,
refresh_managed=refresh_managed,
refresh_hint=refresh_hint,
)
@@ -188,6 +192,7 @@ def _install_shared_infra_or_exit(
tracker: StepTracker | None = None,
force: bool = False,
invoke_separator: str = ".",
invoke_prefix: str = "/",
refresh_managed: bool = False,
refresh_hint: str | None = None,
) -> bool:
@@ -198,6 +203,7 @@ def _install_shared_infra_or_exit(
tracker=tracker,
force=force,
invoke_separator=invoke_separator,
invoke_prefix=invoke_prefix,
refresh_managed=refresh_managed,
refresh_hint=refresh_hint,
)

View File

@@ -1,10 +1,19 @@
"""Helpers for bounded HTTP downloads."""
"""Helpers for bounded downloads and archive extraction."""
from __future__ import annotations
import io
import re
import socket
import stat
import struct
import unicodedata
import zipfile
from collections.abc import Iterator
from contextlib import ExitStack, contextmanager
from ipaddress import IPv4Address, IPv6Address, ip_address
from itertools import pairwise
from pathlib import Path, PurePosixPath, PureWindowsPath
from typing import NoReturn, TypeVar
from urllib.parse import ParseResult, urlparse
@@ -12,17 +21,52 @@ from urllib.parse import ParseResult, urlparse
ErrorT = TypeVar("ErrorT", bound=Exception)
MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024
MAX_ZIP_ENTRIES = 512
MAX_ZIP_MEMBER_BYTES = 10 * 1024 * 1024
MAX_ZIP_TOTAL_BYTES = 50 * 1024 * 1024
MAX_ZIP_PATH_BYTES = 4096
MAX_ZIP_COMPONENT_BYTES = 255
# ``ZipFile`` reads this whole structure into memory. Four MiB leaves roughly
# 8 KiB of filename/extra/comment metadata for each of the 512 allowed entries.
MAX_ZIP_CENTRAL_DIRECTORY_BYTES = 4 * 1024 * 1024
READ_CHUNK_SIZE = 64 * 1024
# Tighter ceiling for responses that are read fully into memory and parsed as
# Tighter ceilings for responses that are read fully into memory and parsed as
# JSON. The 50 MiB MAX_DOWNLOAD_BYTES default is sized for archive/payload
# downloads; JSON metadata responses are far smaller, so capping them close to
# their real size shrinks the memory-DoS surface and keeps the "too large"
# error reachable (rather than only triggering on tens of MiB). Pass it
# downloads; JSON responses are far smaller, so capping them close to their real
# size shrinks the memory-DoS surface and keeps the "too large" error reachable
# (rather than only triggering on tens of MiB). Pass the matching constant
# explicitly at each JSON call site so the intended bound is pinned there.
# METADATA covers fixed-shape single-object responses (an OAuth token, one
# release's metadata): a few KiB in practice, 1 MiB is already generous.
# * METADATA - fixed-shape single-object responses (an OAuth token, one
# release's metadata): a few KiB in practice, 1 MiB is already generous.
# * CATALOG - listings that grow with the number of published items. The
# largest bundled catalog is ~130 KiB today, so 8 MiB leaves ~60x headroom
# for growth while staying well under the download ceiling.
MAX_JSON_METADATA_BYTES = 1 * 1024 * 1024
MAX_JSON_CATALOG_BYTES = 8 * 1024 * 1024
_WINDOWS_INVALID_FILENAME_CHARS = frozenset('<>:"|?*')
_WINDOWS_RESERVED_FILENAME = re.compile(
r"^(?:con|prn|aux|nul|conin\$|conout\$|"
r"com[1-9\u00b9\u00b2\u00b3]|lpt[1-9\u00b9\u00b2\u00b3])$",
re.IGNORECASE,
)
_ZIP_EOCD = struct.Struct("<4s4H2LH")
_ZIP_EOCD_SIGNATURE = b"PK\x05\x06"
_ZIP64_LOCATOR_SIGNATURE = b"PK\x06\x07"
_ZIP_CENTRAL_HEADER_SIZE = 46
_ZIP_CENTRAL_SIGNATURE = b"PK\x01\x02"
_ZIP_LOCAL_HEADER_SIZE = 30
_ZIP_LOCAL_SIGNATURE = b"PK\x03\x04"
_ZIP_EXTRA_HEADER = struct.Struct("<HH")
_ZIP64_EXTRA_FIELD_ID = 0x0001
_ZIP64_MIN_EXTRACT_VERSION = 45
_ZIP_UINT16_MAX = (1 << 16) - 1
_ZIP_UINT32_MAX = (1 << 32) - 1
_ZIP_MAX_COMMENT_BYTES = (1 << 16) - 1
_BOUNDED_ZIP_COMPRESSION_METHODS = frozenset(
(zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED)
)
def _ip_address_without_scope(
@@ -179,6 +223,41 @@ def _raise(error_type: type[ErrorT], message: str) -> NoReturn:
raise error_type(message)
def _raise_from(error_type: type[ErrorT], message: str, exc: Exception) -> NoReturn:
raise error_type(message) from exc
class _ReadLimitExceeded(Exception):
"""Internal signal used to keep domain-specific errors at call sites."""
def _validate_non_negative_int(value: int, name: str) -> None:
if isinstance(value, bool) or not isinstance(value, int):
raise TypeError(f"{name} must be an integer")
if value < 0:
raise ValueError(f"{name} must be non-negative")
def _validate_max_bytes(max_bytes: int) -> None:
_validate_non_negative_int(max_bytes, "max_bytes")
def _read_limited(response, max_bytes: int) -> bytes:
"""Read a stream with bounded requests and without retaining fragments."""
output = io.BytesIO()
total = 0
limit = max_bytes + 1
while total < limit:
chunk = response.read(min(READ_CHUNK_SIZE, limit - total))
if not chunk:
break
total += len(chunk)
if total > max_bytes:
raise _ReadLimitExceeded
output.write(chunk)
return output.getvalue()
def read_response_limited(
response,
*,
@@ -199,20 +278,619 @@ def read_response_limited(
explicit value so the intended bound is pinned at the call site rather than
tracking changes to the shared default.
"""
if isinstance(max_bytes, bool) or not isinstance(max_bytes, int):
raise TypeError("max_bytes must be an integer")
if max_bytes < 0:
raise ValueError("max_bytes must be non-negative")
_validate_max_bytes(max_bytes)
try:
return _read_limited(response, max_bytes)
except _ReadLimitExceeded:
_raise(error_type, f"{label!r} exceeds maximum size of {max_bytes} bytes")
output = io.BytesIO()
total = 0
limit = max_bytes + 1
while total < limit:
chunk = response.read(min(READ_CHUNK_SIZE, limit - total))
if not chunk:
break
total += len(chunk)
if total > max_bytes:
_raise(error_type, f"{label} exceeds maximum size of {max_bytes} bytes")
output.write(chunk)
return output.getvalue()
def build_safe_download_path(
target_dir: Path,
identifier: object,
version: object,
*,
error_type: type[ErrorT] = ValueError,
label: str = "archive",
) -> Path:
"""Build a portable single-component archive path inside *target_dir*."""
if not isinstance(identifier, str) or not isinstance(version, str):
_raise(
error_type,
f"Unsafe {label} download filename derived from "
f"{identifier!r} and {version!r}",
)
filename = f"{identifier}-{version}.zip"
try:
filename_too_long = (
len(filename.encode("utf-8")) > MAX_ZIP_COMPONENT_BYTES
)
except UnicodeEncodeError:
filename_too_long = True
posix_path = PurePosixPath(filename)
windows_path = PureWindowsPath(filename)
if (
filename_too_long
or posix_path.name != filename
or windows_path.name != filename
or any(unicodedata.category(character) == "Cc" for character in filename)
or any(
character in _WINDOWS_INVALID_FILENAME_CHARS
for character in filename
)
or filename.endswith((" ", "."))
):
_raise(
error_type,
f"Unsafe {label} download filename derived from "
f"{identifier!r} and {version!r}",
)
return Path(target_dir) / filename
def read_zip_member_limited(
zf: zipfile.ZipFile,
name: str,
*,
max_bytes: int = MAX_ZIP_MEMBER_BYTES,
error_type: type[ErrorT] = ValueError,
label: str | None = None,
) -> bytes:
"""Read a single ZIP member into memory under a hard size cap.
Reading a member with ``zf.open(name).read()`` is unbounded: a crafted
archive can declare a tiny ``file_size`` yet decompress to many gigabytes (a
"zip bomb"), exhausting memory before the caller ever inspects the data.
This rejects members whose *declared* size already exceeds *max_bytes* and,
to defend against headers that lie, also reads in bounded chunks and stops
one byte past the limit.
Use this for any inline manifest/metadata read that happens *before*
:func:`safe_extract_zip` (which already enforces the same per-member bound
during extraction); a raw ``zf.open(...).read()`` bypasses that protection.
"""
_validate_max_bytes(max_bytes)
member_label = label or name
try:
info = zf.getinfo(name)
except KeyError as exc:
_raise_from(error_type, f"ZIP member not found: {name!r}", exc)
if info.file_size > max_bytes:
_raise(
error_type,
f"ZIP member {member_label!r} exceeds maximum size of {max_bytes} bytes",
)
try:
with zf.open(name, "r") as source:
return _read_limited(source, max_bytes)
except _ReadLimitExceeded:
_raise(
error_type,
f"ZIP member {member_label!r} exceeds maximum size of {max_bytes} bytes",
)
except Exception as exc:
_raise_from(
error_type,
f"Failed to read ZIP member {member_label!r}: {exc!r}",
exc,
)
def normalize_zip_member_name(
name: str,
*,
error_type: type[ErrorT] = ValueError,
) -> str:
"""Return a normalized, portable ZIP member name or raise if unsafe."""
if "\x00" in name:
_raise(error_type, f"Unsafe path in ZIP archive: {name!r}")
normalized = name.replace("\\", "/")
try:
encoded_name = normalized.encode("utf-8")
except UnicodeEncodeError:
_raise(error_type, f"Unsafe path in ZIP archive: {name!r}")
if len(encoded_name) > MAX_ZIP_PATH_BYTES:
_raise(
error_type,
f"Unsafe path in ZIP archive: {name!r} "
"(not portable across supported filesystems)",
)
path = PurePosixPath(normalized)
raw_parts = normalized.split("/")
# Strip a single trailing empty segment, i.e. the one-slash directory
# marker that legitimate ZIPs use ("mydir/", "mydir/subdir/"). Anything
# else that produces an empty segment - consecutive slashes ("a//b") or a
# second trailing slash - is left in place and rejected below as malformed.
if raw_parts and raw_parts[-1] == "":
raw_parts = raw_parts[:-1]
has_windows_drive = re.match(r"^[A-Za-z]:", normalized) is not None
if (
not raw_parts
or path.is_absolute()
or has_windows_drive
or any(part in {"", ".", ".."} for part in raw_parts)
):
_raise(
error_type,
f"Unsafe path in ZIP archive: {name!r} (potential path traversal)",
)
for part in raw_parts:
reserved_stem = part.partition(".")[0].partition(":")[0].rstrip(" ")
if (
len(part.encode("utf-8")) > MAX_ZIP_COMPONENT_BYTES
or any(
unicodedata.category(character) == "Cc"
for character in part
)
or any(character in _WINDOWS_INVALID_FILENAME_CHARS for character in part)
or part.startswith(" ")
or part.endswith((" ", "."))
or _WINDOWS_RESERVED_FILENAME.fullmatch(reserved_stem)
):
_raise(
error_type,
f"Unsafe path in ZIP archive: {name!r} "
"(not portable across supported filesystems)",
)
return normalized
def portable_zip_path_key(name: str) -> tuple[str, ...]:
"""Return a comparison key for filesystems with case/Unicode folding."""
normalized_name = name.replace("\\", "/")
return tuple(
unicodedata.normalize("NFC", part.casefold())
for part in normalized_name.removesuffix("/").split("/")
)
def _raise_zip64(error_type: type[ErrorT]) -> NoReturn:
_raise(
error_type,
"ZIP64 archives are not supported by the bounded extractor",
)
def _preflight_zip_entry_features(
extract_version: int,
compression_method: int,
*,
error_type: type[ErrorT],
) -> None:
"""Enforce the formats whose output can be bounded by ``ZipExtFile``.
Python's BZIP2 and LZMA ``ZipExtFile`` paths do not pass the requested
output length to the decompressor; only STORED and DEFLATED preserve this
module's hard memory bound. APPNOTE assigns extract version 4.5 to ZIP64
size extensions. Because this field declares the minimum extractor feature
level, reject 4.5 and every newer level for the supported methods,
independently of the usual size sentinels and extra field.
"""
if compression_method not in _BOUNDED_ZIP_COMPRESSION_METHODS:
_raise(
error_type,
f"Unsupported ZIP compression method {compression_method}; "
"the bounded extractor supports only STORED and DEFLATED",
)
if extract_version >= _ZIP64_MIN_EXTRACT_VERSION:
_raise(
error_type,
"ZIP64 or newer ZIP features requiring extractor version 4.5 or "
"newer are not supported by the bounded extractor",
)
def _reject_zip64_extra_fields(
extra: bytes,
zip_path: Path,
*,
error_type: type[ErrorT],
) -> None:
"""Reject ZIP64 extra fields and malformed complete extra records."""
offset = 0
while offset + _ZIP_EXTRA_HEADER.size <= len(extra):
field_id, field_size = _ZIP_EXTRA_HEADER.unpack_from(extra, offset)
field_end = offset + _ZIP_EXTRA_HEADER.size + field_size
if field_id == _ZIP64_EXTRA_FIELD_ID:
_raise_zip64(error_type)
if field_end > len(extra):
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
offset = field_end
def _preflight_zip_local_header(
archive_file,
zip_path: Path,
*,
error_type: type[ErrorT],
archive_prefix_size: int,
central_directory_start: int,
local_header_offset: int,
) -> None:
"""Reject local-entry ZIP64 indicators before ``ZipFile`` is constructed."""
physical_offset = archive_prefix_size + local_header_offset
if (
physical_offset < archive_prefix_size
or physical_offset + _ZIP_LOCAL_HEADER_SIZE > central_directory_start
):
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
archive_file.seek(physical_offset)
header = archive_file.read(_ZIP_LOCAL_HEADER_SIZE)
if (
len(header) != _ZIP_LOCAL_HEADER_SIZE
or header[:4] != _ZIP_LOCAL_SIGNATURE
):
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
extract_version = struct.unpack_from("<H", header, 4)[0]
compression_method = struct.unpack_from("<H", header, 8)[0]
_preflight_zip_entry_features(
extract_version,
compression_method,
error_type=error_type,
)
compressed_size, uncompressed_size = struct.unpack_from("<LL", header, 18)
if (
compressed_size == _ZIP_UINT32_MAX
or uncompressed_size == _ZIP_UINT32_MAX
):
_raise_zip64(error_type)
filename_size, extra_size = struct.unpack_from("<HH", header, 26)
extra_offset = physical_offset + _ZIP_LOCAL_HEADER_SIZE + filename_size
if extra_offset + extra_size > central_directory_start:
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
archive_file.seek(extra_offset)
extra = archive_file.read(extra_size)
if len(extra) != extra_size:
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
_reject_zip64_extra_fields(extra, zip_path, error_type=error_type)
def _preflight_zip_central_directory(
archive_file,
zip_path: Path,
*,
error_type: type[ErrorT],
max_entries: int,
) -> None:
"""Bound and count the central directory before ``ZipFile`` materializes it."""
archive_file.seek(0, 2)
file_size = archive_file.tell()
tail_size = min(file_size, _ZIP_EOCD.size + _ZIP_MAX_COMMENT_BYTES)
archive_file.seek(file_size - tail_size)
tail = archive_file.read(tail_size)
# ZipFile selects the last EOCD signature in the search window. Inspect
# exactly that record too: falling back to an earlier signature would let
# the preflight validate one central directory while ZipFile materializes
# another.
eocd_index = tail.rfind(_ZIP_EOCD_SIGNATURE)
if eocd_index < 0 or eocd_index + _ZIP_EOCD.size > len(tail):
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
eocd = _ZIP_EOCD.unpack_from(tail, eocd_index)
comment_size = eocd[-1]
if eocd_index + _ZIP_EOCD.size + comment_size != len(tail):
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
eocd_offset = file_size - len(tail) + eocd_index
if eocd_offset >= 20:
archive_file.seek(eocd_offset - 20)
if archive_file.read(4) == _ZIP64_LOCATOR_SIGNATURE:
_raise_zip64(error_type)
(
_signature,
disk_number,
central_directory_disk,
entries_on_disk,
declared_entries,
central_directory_size,
central_directory_offset,
_comment_size,
) = eocd
if (
disk_number != 0
or central_directory_disk != 0
or entries_on_disk != declared_entries
):
_raise(error_type, "Multi-disk ZIP archives are not supported")
if (
declared_entries == _ZIP_UINT16_MAX
or central_directory_size == _ZIP_UINT32_MAX
or central_directory_offset == _ZIP_UINT32_MAX
):
_raise_zip64(error_type)
if declared_entries > max_entries:
_raise(
error_type,
f"ZIP archive contains too many entries "
f"({declared_entries} > {max_entries})",
)
if central_directory_size > MAX_ZIP_CENTRAL_DIRECTORY_BYTES:
_raise(
error_type,
f"ZIP central directory exceeds maximum size of "
f"{MAX_ZIP_CENTRAL_DIRECTORY_BYTES} bytes",
)
central_directory_start = eocd_offset - central_directory_size
if (
central_directory_start < 0
or central_directory_offset > central_directory_start
):
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
archive_prefix_size = central_directory_start - central_directory_offset
consumed = 0
actual_entries = 0
local_header_offsets: list[int] = []
while consumed < central_directory_size:
archive_file.seek(central_directory_start + consumed)
remaining = central_directory_size - consumed
if remaining < _ZIP_CENTRAL_HEADER_SIZE:
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
header = archive_file.read(_ZIP_CENTRAL_HEADER_SIZE)
if (
len(header) != _ZIP_CENTRAL_HEADER_SIZE
or header[:4] != _ZIP_CENTRAL_SIGNATURE
):
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
extract_version = struct.unpack_from("<H", header, 6)[0]
compression_method = struct.unpack_from("<H", header, 10)[0]
_preflight_zip_entry_features(
extract_version,
compression_method,
error_type=error_type,
)
compressed_size, uncompressed_size = struct.unpack_from("<LL", header, 20)
disk_number_start = struct.unpack_from("<H", header, 34)[0]
local_header_offset = struct.unpack_from("<L", header, 42)[0]
if (
compressed_size == _ZIP_UINT32_MAX
or uncompressed_size == _ZIP_UINT32_MAX
or local_header_offset == _ZIP_UINT32_MAX
or disk_number_start == _ZIP_UINT16_MAX
):
_raise_zip64(error_type)
if disk_number_start != 0:
_raise(error_type, "Multi-disk ZIP archives are not supported")
filename_size, extra_size, comment_size = struct.unpack_from(
"<HHH", header, 28
)
variable_size = filename_size + extra_size + comment_size
record_size = _ZIP_CENTRAL_HEADER_SIZE + variable_size
if record_size > remaining:
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
variable_data = archive_file.read(variable_size)
if len(variable_data) != variable_size:
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
extra = variable_data[filename_size : filename_size + extra_size]
_reject_zip64_extra_fields(extra, zip_path, error_type=error_type)
local_header_offsets.append(local_header_offset)
consumed += record_size
actual_entries += 1
if actual_entries > max_entries:
_raise(
error_type,
f"ZIP archive contains too many entries "
f"({actual_entries} > {max_entries})",
)
if actual_entries != declared_entries:
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
for local_header_offset in local_header_offsets:
_preflight_zip_local_header(
archive_file,
zip_path,
error_type=error_type,
archive_prefix_size=archive_prefix_size,
central_directory_start=central_directory_start,
local_header_offset=local_header_offset,
)
@contextmanager
def open_zip_bounded(
zip_path: Path,
*,
error_type: type[ErrorT] = ValueError,
max_entries: int = MAX_ZIP_ENTRIES,
) -> Iterator[zipfile.ZipFile]:
"""Open an untrusted ZIP after a bounded-memory header preflight."""
_validate_non_negative_int(max_entries, "max_entries")
zip_path = Path(zip_path)
with ExitStack() as stack:
try:
archive_file = stack.enter_context(zip_path.open("rb"))
except OSError as exc:
_raise_from(error_type, f"Invalid ZIP archive: {zip_path}", exc)
try:
_preflight_zip_central_directory(
archive_file,
zip_path,
error_type=error_type,
max_entries=max_entries,
)
except OSError as exc:
_raise_from(error_type, f"Invalid ZIP archive: {zip_path}", exc)
try:
archive_file.seek(0)
zf = stack.enter_context(zipfile.ZipFile(archive_file, "r"))
except Exception as exc:
_raise_from(error_type, f"Invalid ZIP archive: {zip_path}", exc)
yield zf
def safe_extract_zip(
zip_path: Path,
target_dir: Path,
*,
error_type: type[ErrorT] = ValueError,
max_entries: int = MAX_ZIP_ENTRIES,
max_member_bytes: int = MAX_ZIP_MEMBER_BYTES,
max_total_bytes: int = MAX_ZIP_TOTAL_BYTES,
) -> None:
"""Extract a ZIP archive after path, symlink, and size validation."""
_validate_non_negative_int(max_member_bytes, "max_member_bytes")
_validate_non_negative_int(max_total_bytes, "max_total_bytes")
try:
target_root = target_dir.resolve()
except OSError as exc:
_raise_from(error_type, f"Invalid ZIP extraction target: {target_dir}", exc)
with open_zip_bounded(
zip_path,
error_type=error_type,
max_entries=max_entries,
) as zf:
try:
members = zf.infolist()
except zipfile.BadZipFile as exc:
_raise_from(error_type, f"Invalid ZIP archive: {zip_path}", exc)
if len(members) > max_entries:
_raise(
error_type,
f"ZIP archive contains too many entries ({len(members)} > {max_entries})",
)
normalized_members: list[tuple[zipfile.ZipInfo, str, bool]] = []
validated_paths: dict[tuple[str, ...], tuple[str, bool]] = {}
total_size = 0
for member in members:
normalized_name = normalize_zip_member_name(
member.filename,
error_type=error_type,
)
is_dir = member.is_dir() or normalized_name.endswith("/")
path_key = portable_zip_path_key(normalized_name)
existing = validated_paths.get(path_key)
if existing is not None:
_raise(
error_type,
f"Conflicting path in ZIP archive: {member.filename} conflicts "
f"with {existing[0]}",
)
validated_paths[path_key] = (member.filename, is_dir)
mode = member.external_attr >> 16
if stat.S_ISLNK(mode):
_raise(error_type, f"Unsafe symlink in ZIP archive: {member.filename}")
member_path = (target_dir / normalized_name).resolve()
try:
member_path.relative_to(target_root)
except ValueError:
_raise(
error_type,
f"Unsafe path in ZIP archive: {member.filename} "
"(potential path traversal)",
)
if not is_dir:
if member.file_size > max_member_bytes:
_raise(
error_type,
f"ZIP member {member.filename} exceeds maximum size "
f"of {max_member_bytes} bytes",
)
total_size += member.file_size
if total_size > max_total_bytes:
_raise(
error_type,
f"ZIP archive exceeds maximum uncompressed size "
f"of {max_total_bytes} bytes",
)
normalized_members.append((member, normalized_name, is_dir))
# Tuple sorting places every path immediately before its descendants.
# One adjacent comparison per entry detects file/directory conflicts
# without repeatedly rebuilding every path prefix.
for (
(path_key, (original, is_dir)),
(next_key, (next_original, _next_is_dir)),
) in pairwise(sorted(validated_paths.items())):
if (
not is_dir
and len(next_key) > len(path_key)
and next_key[: len(path_key)] == path_key
):
_raise(
error_type,
f"Conflicting path in ZIP archive: {original} conflicts "
f"with {next_original}",
)
# The loop above bounds the *declared* total via member.file_size, but a
# crafted archive can understate those headers. Mirror the per-member
# guard below with a cumulative count of the bytes actually written so
# the total-size bound holds even when the headers lie.
total_written = 0
for member, normalized_name, is_dir in normalized_members:
member_path = target_dir / normalized_name
if is_dir:
try:
member_path.mkdir(parents=True, exist_ok=True)
except OSError as exc:
_raise_from(
error_type,
f"Failed to create ZIP directory {member.filename}: {exc}",
exc,
)
continue
try:
member_path.parent.mkdir(parents=True, exist_ok=True)
except OSError as exc:
_raise_from(
error_type,
f"Failed to create parent directory for ZIP member {member.filename}: {exc}",
exc,
)
written = 0
# Raised outside the try below: if error_type subclasses OSError or
# RuntimeError, raising inside would re-wrap the limit error as
# "Failed to extract" and lose the size-bound message.
limit_error: str | None = None
try:
with zf.open(member, "r") as source, member_path.open("wb") as dest:
while True:
chunk = source.read(READ_CHUNK_SIZE)
if not chunk:
break
written += len(chunk)
if written > max_member_bytes:
limit_error = (
f"ZIP member {member.filename} exceeds maximum size "
f"of {max_member_bytes} bytes"
)
break
total_written += len(chunk)
if total_written > max_total_bytes:
limit_error = (
f"ZIP archive exceeds maximum uncompressed size "
f"of {max_total_bytes} bytes"
)
break
dest.write(chunk)
except Exception as exc:
_raise_from(
error_type,
f"Failed to extract ZIP member {member.filename}: {exc}",
exc,
)
if limit_error is not None:
_raise(error_type, limit_error)

View File

@@ -102,8 +102,17 @@ def resolve_github_release_asset_api_url(
from specify_cli._download_security import read_response_limited
parsed = urlparse(download_url)
hostname = (parsed.hostname or "").lower()
# Accessing ``.hostname`` (like ``.port`` below) raises ValueError on a
# malformed authority, e.g. an invalid bracketed IPv6 host
# ``https://[not-an-ip]/...``. The function's contract is to return None for
# anything it can't resolve, not to raise, so guard the read. ``download_url``
# is server-controlled here (a catalog ``download_url`` payload), so a
# malformed value must not leak a raw traceback past the caller.
try:
parsed = urlparse(download_url)
hostname = (parsed.hostname or "").lower()
except ValueError:
return None
parts = [unquote(part) for part in parsed.path.strip("/").split("/")]
is_ghes = (
@@ -150,8 +159,9 @@ def resolve_github_release_asset_api_url(
if len(parts) < 6 or parts[2:4] != ["releases", "download"]:
return None
owner, repo, tag = parts[0], parts[1], parts[4]
asset_name = "/".join(parts[5:])
owner, repo = parts[0], parts[1]
tag = "/".join(parts[4:-1])
asset_name = parts[-1]
encoded_tag = quote(tag, safe="")
release_url = f"{api_base}/repos/{owner}/{repo}/releases/tags/{encoded_tag}"

View File

@@ -3,12 +3,22 @@
import json
from collections.abc import Mapping
from pathlib import Path
from typing import Any
from typing import Any, Union
INIT_OPTIONS_FILE = ".specify/init-options.json"
class _MissingInitOptionsFile:
"""Sentinel: init-options.json does not exist at all (legacy layout)."""
def __repr__(self) -> str: # pragma: no cover - debug aid only
return "MISSING_INIT_OPTIONS_FILE"
MISSING_INIT_OPTIONS_FILE = _MissingInitOptionsFile()
def save_init_options(project_path: Path, options: dict[str, Any]) -> None:
"""Persist the CLI options used during ``specify init``."""
dest = project_path / INIT_OPTIONS_FILE
@@ -34,3 +44,40 @@ def load_init_options(project_path: Path) -> dict[str, Any]:
def is_ai_skills_enabled(opts: Mapping[str, Any] | None) -> bool:
"""Return True only when init options explicitly enable AI skills."""
return isinstance(opts, Mapping) and opts.get("ai_skills") is True
def resolve_active_agent_for_registration(
project_path: Path,
) -> Union[str, None, _MissingInitOptionsFile]:
"""Resolve the active integration key for active-only registration (#2948).
``load_init_options`` collapses "no file", "unreadable/malformed file",
and "valid file with no recorded active agent" into the same ``{}``
result, which previously made corrupted-but-present init-options behave
like a legacy pre-init-options project and fall back to registering
every detected agent. This helper distinguishes those cases explicitly:
- Returns :data:`MISSING_INIT_OPTIONS_FILE` when init-options.json does
not exist at all (pre-init-options layout or direct library use).
Callers should fall back to detection-based registration for all
agents, matching the original pre-#2948 behavior for such projects.
- Returns ``None`` when init-options.json exists but could not provide a
valid non-empty string active agent (malformed/unreadable JSON,
non-object payload, or a non-string/empty ``ai`` value). Callers must
fail closed (register nothing) rather than treat this like "no file"
or pass a non-string key into agent-config lookups.
- Returns the active agent key (a non-empty string) otherwise.
"""
path = project_path / INIT_OPTIONS_FILE
# A dangling symlink's target doesn't exist, so Path.exists() (which
# follows symlinks) returns False even though the path itself is
# present as a broken/corrupted entry. Treat any symlink as "present"
# so a dangling one fails closed via the invalid-file branch below
# instead of being mistaken for "no file at all" (legacy fallback).
if not path.is_symlink() and not path.exists():
return MISSING_INIT_OPTIONS_FILE
active_agent = load_init_options(project_path).get("ai")
if isinstance(active_agent, str) and active_agent:
return active_agent
return None

View File

@@ -12,7 +12,7 @@ from __future__ import annotations
DOLLAR_SKILLS_AGENTS: frozenset[str] = frozenset({"codex", "zcode"})
# Agents that always render /speckit-<name>, regardless of ai_skills.
ALWAYS_SLASH_AGENTS: frozenset[str] = frozenset({"devin", "grok", "trae", "zed"})
ALWAYS_SLASH_AGENTS: frozenset[str] = frozenset({"devin", "droid", "grok", "trae", "zed"})
# Agents that render /speckit-<name> only when ai_skills is enabled.
CONDITIONAL_SLASH_AGENTS: frozenset[str] = frozenset(
@@ -29,6 +29,9 @@ CONDITIONAL_SLASH_AGENTS: frozenset[str] = frozenset(
}
)
# Agents that render /skill:<name> (skill-colon invocation) when in skills mode.
SKILL_COLON_AGENTS: frozenset[str] = frozenset({"kimi"})
def is_dollar_skills_agent(selected_ai: str | None, ai_skills_enabled: bool) -> bool:
"""Return ``True`` if *selected_ai* uses ``$speckit-<name>`` invocations.
@@ -41,6 +44,21 @@ def is_dollar_skills_agent(selected_ai: str | None, ai_skills_enabled: bool) ->
return selected_ai in DOLLAR_SKILLS_AGENTS and ai_skills_enabled
def get_invocation_prefix(selected_ai: str | None, ai_skills_enabled: bool) -> str:
"""Return the native invocation prefix for *selected_ai* in skills mode.
Returns ``"$"`` for dollar-skills agents (Codex, ZCode),
``"/skill:"`` for skill-colon agents (Kimi), and ``"/"`` for all others.
"""
if not isinstance(selected_ai, str):
return "/"
if selected_ai in DOLLAR_SKILLS_AGENTS and ai_skills_enabled:
return "$"
if selected_ai in SKILL_COLON_AGENTS and ai_skills_enabled:
return "/skill:"
return "/"
def is_slash_skills_agent(selected_ai: str | None, ai_skills_enabled: bool) -> bool:
"""Return ``True`` if *selected_ai* uses ``/speckit-<name>`` invocations.

View File

@@ -12,6 +12,7 @@ import yaml
from pathlib import Path, PurePosixPath, PureWindowsPath
from typing import Any
from ._console import console
from ._download_security import normalize_zip_member_name
CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude"
CLAUDE_NPM_LOCAL_PATH = Path.home() / ".claude" / "local" / "node_modules" / ".bin" / "claude"
@@ -27,19 +28,22 @@ def relative_extension_path_violation(value: Any) -> str | None:
``None`` when it is an acceptable relative path within the extension
directory.
Policy: the value must be a non-empty string with no leading/trailing
whitespace, no absolute/anchored form, and no ``..`` traversal. The value is
Policy: the value must be a non-empty, portable file path with no
leading/trailing whitespace, absolute/anchored form, ``..`` traversal,
platform-reserved component, or directory-only suffix. The value is
evaluated under both POSIX and Windows path semantics because a native
``Path`` is OS-dependent (a ``PurePosixPath`` on POSIX does not interpret
Windows drive/UNC forms, and ``C:foo`` is anchored but not ``is_absolute()``
yet resolves against the CWD on its drive). Rejecting any non-empty anchor
covers POSIX-absolute (``/abs``), Windows drive-relative (``C:foo``), Windows
absolute (``C:\\foo``), and UNC/rooted forms.
Windows drive/UNC forms, and ``C:foo`` is anchored but not
``is_absolute()`` yet resolves against the CWD on its drive). Rejecting any
non-empty anchor covers POSIX-absolute (``/abs``), Windows drive-relative
(``C:foo``), Windows absolute (``C:\\foo``), and UNC/rooted forms.
"""
if not isinstance(value, str) or not value:
return "must be a non-empty string"
if value.strip() != value:
return "must not have leading or trailing whitespace"
if "\\" in value:
return "must use forward slashes as path separators"
posix_path = PurePosixPath(value)
win_path = PureWindowsPath(value)
if (
@@ -52,6 +56,15 @@ def relative_extension_path_violation(value: Any) -> str | None:
"must be a relative path within the extension directory "
"(no absolute paths, drive letters, or '..' segments)"
)
if value.endswith(("/", "\\")):
return "must name a file or command, not a directory"
try:
normalize_zip_member_name(value)
except ValueError:
return (
"must use portable path components "
"(no reserved names or platform-invalid characters)"
)
return None
@@ -69,21 +82,14 @@ def run_command(
cmd: list[str],
check_return: bool = True,
capture: bool = False,
shell: bool = False,
) -> str | None:
"""Run a command without invoking a shell and optionally capture output.
The ``shell`` parameter is kept in the signature so existing keyword
callers (and the re-export from ``specify_cli``) don't raise ``TypeError``,
but only the default ``shell=False`` is honoured. ``shell=True`` is
rejected with ``ValueError`` rather than silently ignored, so the
unsupported mode fails loudly instead of running with a different meaning.
Commands are always executed with ``shell=False`` and must be passed as an
argv ``list[str]``. There is deliberately no ``shell`` parameter: the
argv-list contract makes shell interpolation impossible by construction, so
the shell-injection surface cannot be re-enabled at a call site.
"""
if shell:
raise ValueError(
"run_command() does not support shell=True; pass argv as a list"
)
try:
if capture:
result = subprocess.run(cmd, check=check_return, capture_output=True, text=True)

View File

@@ -10,11 +10,12 @@ import os
import re
from copy import deepcopy
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any, Dict, Iterable, List, Optional
import yaml
from ._init_options import is_ai_skills_enabled, load_init_options
from ._invocation_style import get_invocation_prefix
from ._toml_string import escape_toml_basic as _escape_toml_basic
from ._toml_string import has_illegal_toml_control as _has_illegal_toml_control
from ._utils import relative_extension_path_violation
@@ -270,7 +271,7 @@ class CommandRegistrar:
return text
def render_markdown_command(
self, frontmatter: dict, body: str, source_id: str, context_note: str = None
self, frontmatter: dict, body: str, source_id: str, context_note: Optional[str] = None
) -> str:
"""Render command in Markdown format.
@@ -597,8 +598,8 @@ class CommandRegistrar:
source_id: str,
source_dir: Path,
project_root: Path,
context_note: str = None,
_resolved_dir: Path = None,
context_note: Optional[str] = None,
_resolved_dir: Optional[Path] = None,
link_outputs: bool = False,
extension_id: Optional[str] = None,
) -> List[str]:
@@ -659,22 +660,38 @@ class CommandRegistrar:
# correct when a stale ``.bob/skills`` directory coexists with
# ``.bob/commands``.
_sep = agent_config.get("invoke_separator", ".")
registrar_writes_skills = agent_config.get("extension") == "/SKILL.md"
try:
from specify_cli.integrations import get_integration # noqa: PLC0415
_integ = get_integration(agent_name)
if _integ is not None:
registrar_writes_skills = (
agent_config.get("extension") == "/SKILL.md"
)
_sep = _integ.invoke_separator_for_mode(registrar_writes_skills)
except Exception:
pass
_prefix = get_invocation_prefix(agent_name, registrar_writes_skills)
for cmd_info in commands:
cmd_name = cmd_info["name"]
aliases = cmd_info.get("aliases", [])
cmd_file = cmd_info["file"]
name_reason = relative_extension_path_violation(cmd_name)
if name_reason:
raise ValueError(
f"Invalid command name {cmd_name!r}: {name_reason}"
)
if aliases is None:
aliases = []
if not isinstance(aliases, list):
raise ValueError(
f"Aliases for command {cmd_name!r} must be a list"
)
for alias in aliases:
alias_reason = relative_extension_path_violation(alias)
if alias_reason:
raise ValueError(
f"Invalid command alias {alias!r}: {alias_reason}"
)
# Guard against path traversal using the single shared policy in
# relative_extension_path_violation(), so the runtime guard stays
@@ -755,7 +772,7 @@ class CommandRegistrar:
# (base.py itself imports CommandRegistrar lazily).
from specify_cli.integrations.base import IntegrationBase # noqa: PLC0415
body = IntegrationBase.resolve_command_refs(body, _sep)
body = IntegrationBase.resolve_command_refs(body, _sep, _prefix)
output_name = self._compute_output_name(agent_name, cmd_name, agent_config)
@@ -957,10 +974,16 @@ class CommandRegistrar:
project_root: Path to project root
cmd_name: Command name (e.g. 'speckit.my-ext.example')
"""
name_reason = relative_extension_path_violation(cmd_name)
if name_reason:
raise ValueError(
f"Invalid Copilot prompt name {cmd_name!r}: {name_reason}"
)
prompts_dir = project_root / ".github" / "prompts"
prompts_dir.mkdir(parents=True, exist_ok=True)
prompt_file = prompts_dir / f"{cmd_name}.prompt.md"
CommandRegistrar._ensure_inside(prompt_file, prompts_dir)
prompt_file.parent.mkdir(parents=True, exist_ok=True)
prompt_file.write_text(f"---\nagent: {cmd_name}\n---\n", encoding="utf-8")
@staticmethod
@@ -1016,10 +1039,11 @@ class CommandRegistrar:
source_id: str,
source_dir: Path,
project_root: Path,
context_note: str = None,
context_note: Optional[str] = None,
link_outputs: bool = False,
create_missing_active_skills_dir: bool = False,
extension_id: Optional[str] = None,
only_agent: Optional[str] = None,
) -> Dict[str, List[str]]:
"""Register commands for all detected agents in the project.
@@ -1037,6 +1061,8 @@ class CommandRegistrar:
skills directory) and is skipped when safe resolution or
creation fails.
extension_id: Extension id when rendering extension-owned commands.
only_agent: If set, restrict registration to this single agent
while keeping all detection and recovery safeguards (#2948).
Returns:
Dictionary mapping agent names to list of registered commands
@@ -1060,6 +1086,8 @@ class CommandRegistrar:
)
active_created_skills_dir: Optional[Path] = None
for agent_name, agent_config in self.AGENT_CONFIGS.items():
if only_agent is not None and agent_name != only_agent:
continue
active_skills_output = (
agent_name == active_skills_agent
and agent_config.get("extension") == "/SKILL.md"
@@ -1165,6 +1193,8 @@ class CommandRegistrar:
context_note: Optional[str] = None,
link_outputs: bool = False,
extension_id: Optional[str] = None,
only_agent: Optional[str] = None,
extra_agents: Optional[Iterable[str]] = None,
) -> Dict[str, List[str]]:
"""Register commands for all non-skill agents in the project.
@@ -1181,13 +1211,29 @@ class CommandRegistrar:
link_outputs: If True, create dev-mode symlinks for rendered
command files when supported by the OS.
extension_id: Extension id when rendering extension-owned commands.
only_agent: If set, restrict registration to this single agent
(#2948). An agent name that matches no configured agent
(e.g. an empty string) yields no registrations at all.
extra_agents: Additional agent names to register for besides
``only_agent``. Used by post-removal reconciliation to also
restore surviving content into historical agent directories
a just-removed preset actually wrote to, not only the
currently active agent (#2948). Ignored when ``only_agent``
is ``None`` (already unrestricted).
Returns:
Dictionary mapping agent names to list of registered commands
"""
results = {}
self._ensure_configs()
extra_agents_set = frozenset(extra_agents) if extra_agents else frozenset()
for agent_name, agent_config in self.AGENT_CONFIGS.items():
if (
only_agent is not None
and agent_name != only_agent
and agent_name not in extra_agents_set
):
continue
if agent_config.get("extension") == "/SKILL.md":
continue
detect_dir_str = agent_config.get("detect_dir")

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import base64
import json as _json
import os
import shutil
import subprocess
from typing import TYPE_CHECKING
@@ -71,9 +72,27 @@ class AzureDevOpsAuth(AuthProvider):
def _acquire_via_az_cli() -> str | None:
"""Run ``az account get-access-token`` and return the access token."""
try:
# Windows: ``subprocess.run`` calls ``CreateProcess``, which does
# not consult ``PATHEXT``, so a bare ``"az"`` (installed as
# ``az.cmd``) fails with ``WinError 2`` even after ``az login``.
# Resolve via ``shutil.which`` (which honors ``PATHEXT``) so the
# ``.cmd`` shim works. On POSIX this is a harmless lookup that
# returns the same executable.
#
# Require an ABSOLUTE result: on Windows ``shutil.which`` prepends
# the current directory to the search path (unless
# ``NoDefaultCurrentDirectoryInExePath`` is set), so a stray
# ``.\az.cmd`` in the working directory would otherwise be resolved
# ahead of the real Azure CLI and run for a credential operation. A
# legitimate install always resolves to an absolute path, so this
# costs nothing; falling back to the bare ``"az"`` preserves the
# prior behavior (and the existing OSError path) when ``az`` is
# absent.
resolved = shutil.which("az")
az = resolved if resolved and os.path.isabs(resolved) else "az"
result = subprocess.run( # noqa: S603, S607
[
"az",
az,
"account",
"get-access-token",
"--resource",

View File

@@ -13,6 +13,7 @@ import stat
from dataclasses import dataclass
from fnmatch import fnmatch
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
@@ -53,6 +54,19 @@ def _is_valid_host_pattern(pattern: str) -> bool:
return pattern.startswith("*.") and "*" not in pattern[2:]
def _norm(value: Any) -> Any:
"""Strip surrounding whitespace from a whitespace-insignificant string
config reference (env-var names, tenant/client ids) before it is stored.
These fields are validated on their ``.strip()``ed form, so an accidentally
padded value passes validation but then silently breaks the verbatim
``os.environ.get(...)`` / URL lookups downstream. Normalizing at store time
mirrors how ``hosts`` is already handled (``h.strip().lower()``). Non-string
values (e.g. ``None``) pass through unchanged.
"""
return value.strip() if isinstance(value, str) else value
def load_auth_config(
path: Path | None = None,
) -> list[AuthConfigEntry]:
@@ -182,10 +196,10 @@ def load_auth_config(
provider=provider,
auth=auth,
token=token,
token_env=token_env,
tenant_id=entry_raw.get("tenant_id"),
client_id=entry_raw.get("client_id"),
client_secret_env=entry_raw.get("client_secret_env"),
token_env=_norm(token_env),
tenant_id=_norm(entry_raw.get("tenant_id")),
client_id=_norm(entry_raw.get("client_id")),
client_secret_env=_norm(entry_raw.get("client_secret_env")),
)
)

View File

@@ -14,14 +14,13 @@ from .. import BundlerError
from ..lib.yamlio import dump_yaml, ensure_within, load_yaml
from ..models.catalog import (
CONFIG_FILENAME,
CONFIG_SCHEMA_VERSION,
BUILTIN_DEFAULT_STACK,
CatalogSource,
InstallPolicy,
Scope,
)
CONFIG_SCHEMA_VERSION = "1.0"
_BUILTIN_IDS = {raw["id"] for raw in BUILTIN_DEFAULT_STACK}
# Windows absolute paths like ``C:\catalog.json`` parse with a single-letter
@@ -153,6 +152,8 @@ def add_source(
# keeps that ValueError inside the guard instead of leaking a raw
# traceback past the CLI's `except BundlerError`. Reuse the value below.
hostname = parsed.hostname
# Accessing ``port`` performs urllib's syntax/range validation.
_ = parsed.port
except ValueError as exc:
raise BundlerError(f"Invalid catalog url: '{url}'.") from exc
if not (parsed.scheme or parsed.path):

View File

@@ -58,7 +58,12 @@ def load_yaml(path: Path) -> Any:
raise BundlerError(f"File not found: {path}")
try:
text = path.read_text(encoding="utf-8")
except OSError as exc:
except (OSError, UnicodeError) as exc:
# A non-UTF-8 file raises UnicodeDecodeError, which is a ValueError --
# NOT an OSError -- so it escaped this module's "IO failures degrade
# into actionable BundlerError" contract as a raw traceback. Realistic
# on Windows, where PowerShell 5.1's `Out-File`/`>` default to UTF-16.
# Matches the sibling catalog readers (catalogs.py, workflows/catalog.py).
raise BundlerError(f"Could not read {path}: {exc}") from exc
try:
has_node = yaml.compose(text) is not None
@@ -98,9 +103,15 @@ def load_json(path: Path) -> Any:
try:
with path.open("r", encoding="utf-8") as handle:
return json.load(handle)
# JSONDecodeError stays FIRST: it and UnicodeDecodeError are sibling
# ValueError subclasses (neither subsumes the other), so malformed-but-
# decodable JSON keeps its more specific "Invalid JSON" message while a
# decode failure falls through to the read-error clause below.
except json.JSONDecodeError as exc:
raise BundlerError(f"Invalid JSON in {path}: {exc}") from exc
except OSError as exc:
except (OSError, UnicodeError) as exc:
# See load_yaml: a non-UTF-8 file raises UnicodeDecodeError, which is
# not an OSError, and previously escaped as a raw traceback.
raise BundlerError(f"Could not read {path}: {exc}") from exc

View File

@@ -15,6 +15,11 @@ from .. import BundlerError
from ..lib.yamlio import ensure_within, load_yaml
CONFIG_FILENAME = "bundle-catalogs.yml"
# Supported bundle-catalogs.yml schema (major version). Both readers of the
# file — this module's _merge_config and commands_impl/catalog_config._read —
# reject an unsupported major version so a file written by a newer/incompatible
# Spec Kit fails fast instead of being parsed under the wrong assumptions.
CONFIG_SCHEMA_VERSION = "1.0"
class InstallPolicy(str, Enum):
@@ -139,6 +144,7 @@ class CatalogEntry:
license: str
download_url: str
requires_speckit_version: str
sha256: str | None = None
provides: dict[str, int] = field(default_factory=dict)
repository: str | None = None
tags: tuple[str, ...] = ()
@@ -181,6 +187,11 @@ class CatalogEntry:
license=str(data.get("license", "")).strip(),
download_url=str(data.get("download_url", "")).strip(),
requires_speckit_version=str(requires.get("speckit_version", "")).strip(),
sha256=(
None
if data.get("sha256") is None
else str(data["sha256"]).strip()
),
provides=dict(provides_raw),
repository=(str(data["repository"]) if data.get("repository") else None),
tags=_parse_tags(data.get("tags"), entry_id),
@@ -193,6 +204,7 @@ class CatalogEntry:
description=self.description, author=self.author, license=self.license,
download_url=self.download_url,
requires_speckit_version=self.requires_speckit_version,
sha256=self.sha256,
provides=self.provides, repository=self.repository, tags=self.tags,
verified=self.verified, source_id=source.id,
source_policy=source.install_policy,
@@ -267,6 +279,23 @@ def _merge_config(by_id: dict[str, CatalogSource], config_path: Path, scope: Sco
f"Malformed catalog config at {config_path}: expected a mapping at "
f"the top level, got {type(data).__name__}."
)
# Reject an unsupported major schema version, matching the sibling reader
# commands_impl/catalog_config._read. Without this, a file written by a
# newer/incompatible Spec Kit was silently parsed under v1 assumptions on
# the resolution path (bundle search/install), while the other reader
# rejected it — the two readers disagreed. An absent schema_version stays
# valid (backward compatible with configs that omit it).
schema_version = data.get("schema_version")
if schema_version is not None and (
str(schema_version).strip().split(".")[0]
!= CONFIG_SCHEMA_VERSION.split(".")[0]
):
raise BundlerError(
f"Unsupported catalog config schema version "
f"'{str(schema_version).strip()}' at {config_path}; this Spec Kit "
f"understands version {CONFIG_SCHEMA_VERSION}. The file may have been "
"written by a newer version or is corrupt."
)
catalogs = data.get("catalogs")
if catalogs is None:
return

View File

@@ -16,6 +16,7 @@ from urllib.parse import ParseResult, urlparse
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 ..models.catalog import CatalogSource
@@ -76,6 +77,8 @@ def _validate_remote_url(source_id: str, url: str) -> None:
try:
parsed = urlparse(url)
hostname = parsed.hostname
# Accessing ``port`` performs urllib's syntax/range validation.
_ = parsed.port
except ValueError:
raise BundlerError(
f"Catalog '{source_id}' URL is malformed: {url}"
@@ -117,7 +120,15 @@ def make_catalog_fetcher(*, allow_network: bool = True):
def fetch(source: CatalogSource) -> dict:
url = source.url
parsed = urlparse(url)
try:
parsed = urlparse(url)
# Keep malformed authorities and ports inside the BundlerError
# contract even when a config file was edited by hand.
_ = parsed.port
except ValueError:
raise BundlerError(
f"Catalog {source.id!r} URL is malformed: {url!r}"
) from None
scheme = parsed.scheme.lower()
if scheme == "builtin":
@@ -180,7 +191,12 @@ def _http_get_json(source_id: str, url: str) -> dict:
) as response:
final_url = response.geturl()
_validate_remote_url(source_id, final_url)
raw = response.read().decode("utf-8")
raw = read_response_limited(
response,
max_bytes=MAX_JSON_CATALOG_BYTES,
error_type=BundlerError,
label=f"bundle catalog '{source_id}'",
).decode("utf-8")
except BundlerError:
raise
except Exception as exc: # noqa: BLE001

View File

@@ -12,8 +12,10 @@ import re
from pathlib import Path
import typer
from rich.markup import escape as _escape_markup
from ..._console import console, err_console
from ..._download_security import MAX_DOWNLOAD_BYTES, read_response_limited
from ...bundler import BundlerError
from ...bundler.lib.project import (
active_integration,
@@ -184,11 +186,16 @@ def bundle_search(
else ""
)
console.print(
f" [bold]{r.entry.id}[/bold] v{r.entry.version}{r.entry.name} "
f"[dim]({r.entry.role})[/dim] {_trust_badge(r.entry.verified)} {policy}"
f" [bold]{_escape_markup(str(r.entry.id))}[/bold] "
f"v{_escape_markup(str(r.entry.version))} "
f"{_escape_markup(str(r.entry.name))} "
f"[dim]({_escape_markup(str(r.entry.role))})[/dim] "
f"{_trust_badge(r.entry.verified)} {policy}"
)
console.print(f" {_escape_markup(str(r.entry.description))}")
console.print(
f" [dim]source: {_escape_markup(str(r.source.id))}[/dim]"
)
console.print(f" {r.entry.description}")
console.print(f" [dim]source: {r.source.id}[/dim]")
@bundle_app.command("info")
@@ -241,16 +248,31 @@ def bundle_info(
print(_json.dumps(payload, indent=2))
return
console.print(f"\n[bold cyan]{entry.id}[/bold cyan] v{entry.version}{entry.name}")
console.print(f" Role: {entry.role}")
console.print(f" {entry.description}")
console.print(f" Author: {entry.author} License: {entry.license}")
console.print(f" Source: {resolved.source.id} ({resolved.source.install_policy.value})")
console.print(
f"\n[bold cyan]{_escape_markup(str(entry.id))}[/bold cyan] "
f"v{_escape_markup(str(entry.version))}"
f"{_escape_markup(str(entry.name))}"
)
console.print(f" Role: {_escape_markup(str(entry.role))}")
console.print(f" {_escape_markup(str(entry.description))}")
console.print(
f" Author: {_escape_markup(str(entry.author))} "
f"License: {_escape_markup(str(entry.license))}"
)
console.print(
f" Source: {_escape_markup(str(resolved.source.id))} "
f"({resolved.source.install_policy.value})"
)
console.print(f" Trust: {_trust_badge(entry.verified)}")
if entry.requires_speckit_version:
console.print(f" Requires Spec Kit: {entry.requires_speckit_version}")
console.print(
f" Requires Spec Kit: "
f"{_escape_markup(str(entry.requires_speckit_version))}"
)
if manifest and manifest.integration:
console.print(f" Integration: {manifest.integration.id}")
console.print(
f" Integration: {_escape_markup(str(manifest.integration.id))}"
)
if components:
console.print("\n [bold]Components[/bold] (added on install):")
@@ -260,18 +282,22 @@ def bundle_info(
continue
console.print(f" [bold]{kind}:[/bold]")
for item in items:
console.print(f" - {_format_component(item)}")
console.print(
f" - {_escape_markup(_format_component(item))}"
)
else:
console.print("\n [bold]Provides:[/bold]")
for kind in ("extensions", "presets", "steps", "workflows"):
count = entry.provides.get(kind, 0)
if count:
console.print(f" {kind}: {count}")
console.print(f" {kind}: {_escape_markup(str(count))}")
if overlaps:
console.print("\n [yellow]Overlaps with already-installed bundles:[/yellow]")
for overlap in overlaps:
console.print(f" [yellow]-[/yellow] {overlap}")
console.print(
f" [yellow]-[/yellow] {_escape_markup(str(overlap))}"
)
if not resolved.install_allowed:
console.print(
@@ -337,6 +363,10 @@ def bundle_install(
local_manifest = _local_manifest_source(bundle_id)
if local_manifest is not None:
manifest = local_manifest
_validate_manifest_structure(
manifest,
source=f"Local bundle source {bundle_id!r}",
)
else:
stack = _build_stack(project_root or Path.cwd(), offline=offline)
resolved = stack.resolve(bundle_id)
@@ -350,6 +380,16 @@ def bundle_install(
if project_root is None:
init_integration = _resolve_init_integration(integration, manifest)
# Resolve all hard compatibility gates before ``specify init``.
# Otherwise an incompatible but structurally valid bundle would
# initialize a project and only then fail its version/integration
# checks, leaving state behind after a failed install.
resolve_install_plan(
manifest,
speckit_version=_speckit_version(),
active_integration=init_integration,
integration_explicit=True,
)
console.print(
f"[cyan]No Spec Kit project here; initializing with integration "
f"'{init_integration}'…[/cyan]"
@@ -711,17 +751,24 @@ def _local_manifest_source(arg: str):
if candidate.suffix == ".zip":
import io
import zipfile
import yaml as _yaml
with zipfile.ZipFile(candidate) as archive:
from ..._download_security import open_zip_bounded, read_zip_member_limited
with open_zip_bounded(candidate, error_type=BundlerError) as archive:
try:
raw = archive.read("bundle.yml")
archive.getinfo("bundle.yml")
except KeyError as exc:
raise BundlerError(
f"Artifact '{candidate}' does not contain a bundle.yml."
) from exc
raw = read_zip_member_limited(
archive,
"bundle.yml",
error_type=BundlerError,
label="bundle manifest",
)
data = _yaml.safe_load(io.BytesIO(raw))
return BundleManifest.from_dict(data)
@@ -805,7 +852,13 @@ def _download_manifest(resolved, *, offline: bool):
f"Network access disabled; cannot download bundle '{resolved.entry.id}' "
f"from {url}."
)
return _download_remote_manifest(resolved.entry.id, url)
manifest = _download_remote_manifest(
resolved.entry.id,
url,
expected_sha256=getattr(resolved.entry, "sha256", None),
)
_validate_catalog_manifest(resolved.entry, manifest)
return manifest
def _require_https(label: str, url: str) -> None:
@@ -817,6 +870,8 @@ def _require_https(label: str, url: str) -> None:
try:
parsed = urlparse(url)
hostname = parsed.hostname
# Accessing ``port`` performs urllib's syntax/range validation.
_ = parsed.port
except ValueError:
raise BundlerError(
f"Refusing to download {label}: URL is malformed: {url}"
@@ -830,7 +885,12 @@ def _require_https(label: str, url: str) -> None:
raise BundlerError(f"Refusing to download {label} from URL with no host: {url}")
def _download_remote_manifest(entry_id: str, url: str):
def _download_remote_manifest(
entry_id: str,
url: str,
*,
expected_sha256: str | None = None,
):
"""Fetch a remote bundle artifact over HTTPS and extract its manifest."""
import io
import tempfile
@@ -842,6 +902,7 @@ def _download_remote_manifest(entry_id: str, url: str):
from ...authentication.http import github_provider_hosts, open_url
from ..._github_http import resolve_github_release_asset_api_url
from ...bundler.models.manifest import BundleManifest
from ...shared_infra import verify_archive_sha256
def _validate_redirect(old_url: str, new_url: str) -> None:
_require_https(f"bundle '{entry_id}'", new_url)
@@ -879,7 +940,18 @@ def _download_remote_manifest(entry_id: str, url: str):
extra_headers=extra_headers,
) as resp:
_require_https(f"bundle '{entry_id}'", resp.geturl())
raw = resp.read()
raw = read_response_limited(
resp,
max_bytes=MAX_DOWNLOAD_BYTES,
error_type=BundlerError,
label=f"bundle '{entry_id}' download",
)
verify_archive_sha256(
raw,
expected_sha256,
entry_id,
BundlerError,
)
except BundlerError:
raise
except Exception as exc: # noqa: BLE001
@@ -940,6 +1012,38 @@ def _download_remote_manifest(entry_id: str, url: str):
) from exc
def _validate_manifest_structure(manifest, *, source: str) -> None:
"""Reject a malformed manifest before any project mutation can occur."""
from ...bundler.services.validator import validate_manifest
report = validate_manifest(manifest)
if report.ok:
return
raise BundlerError(
f"{source} contains an invalid bundle manifest:\n - "
+ "\n - ".join(report.errors)
)
def _validate_catalog_manifest(entry, manifest) -> None:
"""Bind a downloaded manifest to the catalog identity that selected it."""
if manifest.bundle.id != entry.id:
raise BundlerError(
f"Downloaded bundle id mismatch: catalog entry {entry.id!r} points to "
f"a manifest for {manifest.bundle.id!r}."
)
if manifest.bundle.version != entry.version:
raise BundlerError(
f"Downloaded bundle version mismatch for {entry.id!r}: catalog declares "
f"{entry.version!r}, but the manifest declares "
f"{manifest.bundle.version!r}."
)
_validate_manifest_structure(
manifest,
source=f"Downloaded bundle {entry.id!r}",
)
def register(app: typer.Typer) -> None:
"""Attach the bundle command group to the root Typer app."""
app.add_typer(bundle_app, name="bundle")

View File

@@ -183,6 +183,7 @@ def register(app: typer.Typer) -> None:
save_init_options,
)
from ..integration_runtime import (
invoke_prefix_for_integration as _invoke_prefix_for_integration,
with_integration_setting as _with_integration_setting,
)
from ..integrations._commands import (
@@ -481,6 +482,12 @@ def register(app: typer.Typer) -> None:
invoke_separator=resolved_integration.effective_invoke_separator(
integration_parsed_options, project_root=project_path
),
invoke_prefix=_invoke_prefix_for_integration(
resolved_integration,
resolved_integration.key,
integration_parsed_options,
project_path,
),
)
tracker.complete(
"shared-infra", f"scripts ({selected_script}) + templates"

File diff suppressed because it is too large Load Diff

View File

@@ -8,12 +8,14 @@ which re-fetch from the parent package at call time so test monkeypatching of
"""
from __future__ import annotations
import hashlib
import os
import shutil
import tempfile
import zipfile
from pathlib import Path
from typing import Optional
from uuid import uuid4
import typer
import yaml
@@ -23,6 +25,15 @@ from rich.table import Table
from .._console import console
from .._assets import get_speckit_version
from .._download_security import (
is_https_or_localhost_http,
normalize_zip_member_name,
open_zip_bounded,
portable_zip_path_key,
read_response_limited,
read_zip_member_limited,
)
from .._init_options import is_ai_skills_enabled
extension_app = typer.Typer(
name="extension",
@@ -166,9 +177,17 @@ def _resolve_catalog_extension(
if ext_info:
return (ext_info, None)
# Try by display name - search using argument as query, then filter for exact match
search_results = catalog.search(query=argument)
name_matches = [ext for ext in search_results if ext["name"].lower() == argument.lower()]
# Try by display name - search using argument as query, then filter for exact match.
# Coerce name defensively: catalog JSON is user-editable, so a hand-authored
# non-string/missing name must not crash the match (the ambiguous-match display
# below already str()-coerces name for the same reason).
search_results = catalog.search()
argument_lower = argument.lower()
name_matches = [
ext
for ext in search_results
if str(ext.get("name", "")).lower() == argument_lower
]
if len(name_matches) == 1:
return (name_matches[0], None)
@@ -435,14 +454,17 @@ def extension_add(
# "Invalid URL" message instead of leaking a raw traceback past the
# CLI. Reuse the value below.
hostname = parsed.hostname
parsed.port
except ValueError:
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}")
raise typer.Exit(1)
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
if not hostname:
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}")
raise typer.Exit(1)
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
if not is_https_or_localhost_http(from_url):
console.print("[red]Error:[/red] URL must use HTTPS for security.")
console.print("HTTP is only allowed for localhost URLs.")
console.print("HTTP is only allowed for loopback URLs.")
raise typer.Exit(1)
safe_url = _escape_markup(from_url)
@@ -525,7 +547,11 @@ def extension_add(
with dl_catalog._open_url(
download_url, timeout=60, extra_headers=extra_headers
) as response:
zip_data = response.read()
zip_data = read_response_limited(
response,
error_type=ExtensionError,
label=f"extension {from_url}",
)
if not zipfile.is_zipfile(io.BytesIO(zip_data)):
console.print(
@@ -775,8 +801,9 @@ def extension_search(
# Metadata
console.print(f"\n [dim]Author:[/dim] {_escape_markup(str(ext.get('author', 'Unknown')))}")
if ext.get('tags'):
tags_str = ", ".join(str(t) for t in ext['tags'])
ext_tags = ext.get('tags', [])
if isinstance(ext_tags, list) and ext_tags:
tags_str = ", ".join(str(t) for t in ext_tags)
console.print(f" [dim]Tags:[/dim] {_escape_markup(tags_str)}")
# Source catalog
@@ -790,10 +817,24 @@ def extension_search(
# Stats
stats = []
if ext.get('downloads') is not None:
stats.append(f"Downloads: {ext['downloads']:,}")
if ext.get('stars') is not None:
stats.append(f"Stars: {ext['stars']}")
downloads = ext.get('downloads')
if downloads is not None:
# Catalog fields are untrusted; a non-numeric ``downloads``
# (e.g. the JSON string "1500") would crash the ``:,`` format
# with "Cannot specify ',' with 's'". Only group-format numbers,
# and escape the fallback: the joined stats are rendered as Rich
# markup, so a value like "[/red]foo" would raise MarkupError
# (matching how every other catalog field here is escaped).
stats.append(
f"Downloads: {downloads:,}"
if isinstance(downloads, (int, float))
else f"Downloads: {_escape_markup(str(downloads))}"
)
stars = ext.get('stars')
if stars is not None:
# Same untrusted-value/Rich-markup hazard as `downloads` above,
# in the same joined string.
stats.append(f"Stars: {_escape_markup(str(stars))}")
if stats:
console.print(f" [dim]{' | '.join(stats)}[/dim]")
@@ -879,9 +920,30 @@ def extension_info(
console.print()
if ext_manifest.commands:
# Print each command the way the active agent registers it.
# Cline and Forge hyphenate command names (e.g. Forge invokes
# `/speckit-jira-sync`, not the manifest's dotted
# `speckit.jira.sync`), so mirror the same formatting used by
# `extension add`'s "Provided commands" listing — otherwise the
# names shown here don't match what the user actually types.
selected_ai = load_init_options(project_root).get("ai")
if selected_ai == "cline":
from specify_cli.integrations.cline import (
format_cline_command_name as _format_command_name,
)
elif selected_ai == "forge":
from specify_cli.integrations.forge import (
format_forge_command_name as _format_command_name,
)
else:
_format_command_name = None
console.print("[bold]Commands:[/bold]")
for cmd in ext_manifest.commands:
console.print(f"{_escape_markup(str(cmd['name']))}: {_escape_markup(str(cmd.get('description', '')))}")
cmd_name = cmd['name']
if _format_command_name is not None:
cmd_name = _format_command_name(cmd_name)
console.print(f"{_escape_markup(str(cmd_name))}: {_escape_markup(str(cmd.get('description', '')))}")
console.print()
# Show catalog status
@@ -964,17 +1026,32 @@ def _print_extension_info(ext_info: dict, manager):
console.print()
# Tags
if ext_info.get('tags'):
tags_str = ", ".join(str(t) for t in ext_info['tags'])
info_tags = ext_info.get('tags', [])
if isinstance(info_tags, list) and info_tags:
tags_str = ", ".join(str(t) for t in info_tags)
console.print(f"[bold]Tags:[/bold] {_escape_markup(tags_str)}")
console.print()
# Statistics
stats = []
if ext_info.get('downloads') is not None:
stats.append(f"Downloads: {ext_info['downloads']:,}")
if ext_info.get('stars') is not None:
stats.append(f"Stars: {ext_info['stars']}")
downloads = ext_info.get('downloads')
if downloads is not None:
# Catalog fields are untrusted; a non-numeric ``downloads`` (e.g. the
# JSON string "1500") would crash the ``:,`` format with "Cannot
# specify ',' with 's'". Only group-format numbers, and escape the
# fallback: the joined stats are rendered as Rich markup, so a value
# like "[/red]foo" would raise MarkupError (matching how every other
# catalog field here is escaped).
stats.append(
f"Downloads: {downloads:,}"
if isinstance(downloads, (int, float))
else f"Downloads: {_escape_markup(str(downloads))}"
)
stars = ext_info.get('stars')
if stars is not None:
# Same untrusted-value/Rich-markup hazard as `downloads` above, in the
# same joined string.
stats.append(f"Stars: {_escape_markup(str(stars))}")
if stats:
console.print(f"[bold]Statistics:[/bold] {' | '.join(stats)}")
console.print()
@@ -1022,6 +1099,7 @@ def extension_update(
from . import (
ExtensionManager,
ExtensionCatalog,
ExtensionManifest,
ExtensionError,
ValidationError,
CommandRegistrar,
@@ -1136,9 +1214,17 @@ def extension_update(
console.print(f"📦 Updating {safe_ext_name}...")
# Backup paths
backup_base = manager.extensions_dir / ".backup" / f"{extension_id}-update"
backup_root = manager.extensions_dir / ".backup"
backup_key = hashlib.sha256(
extension_id.encode("utf-8")
).hexdigest()[:16]
backup_base = (
backup_root
/ f"update-{backup_key}-{uuid4().hex}"
)
backup_ext_dir = backup_base / "extension"
backup_commands_dir = backup_base / "commands"
backup_skills_dir = backup_base / "skills"
backup_config_dir = backup_base / "config"
# Store backup state
@@ -1146,14 +1232,125 @@ def extension_update(
backup_installed = UNSET # Original installed list from extensions.yml
backup_hooks = None # None means backup step 4 not yet reached; {} or {...} means backup was captured
backed_up_command_files = {}
backed_up_command_symlinks = {}
backed_up_skill_dirs = {}
new_command_dirs_absent_before_update = []
new_command_paths_absent_before_update = []
new_skill_names = []
new_skill_paths_absent_before_update = []
# Validation failures must not rewrite an untouched installation.
installation_modified = False
zip_cleanup_error = None
backup_created_by_attempt = False
def backup_command_artifact(original_file, backup_file):
"""Back up one command artifact once, preserving its full path."""
nonlocal backup_created_by_attempt
original_key = str(original_file)
if original_key in backed_up_command_files:
return
if original_file.is_symlink():
backed_up_command_symlinks[original_key] = os.readlink(
original_file
)
else:
if original_file.stat().st_nlink > 1:
raise RuntimeError(
"Cannot safely update hard-linked generated "
f"artifact '{original_file}'"
)
backup_created_by_attempt = True
backup_file.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(original_file, backup_file)
backed_up_command_files[original_key] = str(backup_file)
def restore_command_artifact(original_path, backup_path):
"""Restore one regular file or symlink without following it."""
original_key = str(original_path)
original_file = Path(original_path)
backup_file = Path(backup_path)
symlink_state = backed_up_command_symlinks.get(
original_key
)
if symlink_state is not None:
if original_file.is_symlink() or original_file.is_file():
original_file.unlink()
elif original_file.exists():
raise RuntimeError(
"Command rollback found an unexpected directory "
f"at '{original_file}'"
)
original_file.parent.mkdir(parents=True, exist_ok=True)
os.symlink(symlink_state, original_file)
return
if not backup_file.is_file() or backup_file.is_symlink():
raise RuntimeError(
"Command rollback backup is missing for "
f"'{original_file}'"
)
if original_file.is_symlink() or original_file.is_file():
original_file.unlink()
elif original_file.exists():
raise RuntimeError(
"Command rollback found an unexpected directory "
f"at '{original_file}'"
)
original_file.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(backup_file, original_file)
def remember_absent_parent_dirs(artifact_path, root_dir):
"""Remember absent parents a failed renderer may create."""
boundary = root_dir.parent
if root_dir.is_relative_to(project_root):
boundary = project_root
parent = artifact_path.parent
while parent != boundary:
if parent.exists() or parent.is_symlink():
break
new_command_dirs_absent_before_update.append(parent)
parent = parent.parent
def backup_extension_skills(skill_names, *, skills_dir=None):
"""Back up every owned skill directory that remove() may delete."""
nonlocal backup_created_by_attempt
for skill_dir in manager._find_extension_skill_dirs(
skill_names,
extension_id,
skills_dir=skills_dir,
create_skills_dir=False,
):
original_key = str(skill_dir)
if original_key in backed_up_skill_dirs:
continue
backup_created_by_attempt = True
backup_skills_dir.mkdir(parents=True, exist_ok=True)
backup_skill_dir = backup_skills_dir / str(
len(backed_up_skill_dirs)
)
shutil.copytree(skill_dir, backup_skill_dir, symlinks=True)
backed_up_skill_dirs[original_key] = str(backup_skill_dir)
try:
if backup_root.is_symlink():
raise RuntimeError(
"Cannot safely create update backup under symlinked "
f"directory '{backup_root}'"
)
if backup_base.exists() or backup_base.is_symlink():
raise RuntimeError(
"Cannot safely reuse an existing update backup "
f"directory '{backup_base}'"
)
# 1. Backup registry entry (always, even if extension dir doesn't exist)
backup_registry_entry = manager.registry.get(extension_id)
# 2. Backup extension directory
extension_dir = manager.extensions_dir / extension_id
if extension_dir.exists():
backup_created_by_attempt = True
backup_base.mkdir(parents=True, exist_ok=True)
if backup_ext_dir.exists():
shutil.rmtree(backup_ext_dir)
@@ -1177,30 +1374,91 @@ def extension_update(
commands_dir = _AgentReg._resolve_agent_dir(
agent_name, agent_config, project_root
)
dirs_to_backup = [commands_dir]
legacy = agent_config.get("legacy_dir")
if legacy:
legacy_dir = project_root / legacy
if (
legacy_dir.exists()
and legacy_dir != commands_dir
):
dirs_to_backup.append(legacy_dir)
for cmd_name in cmd_names:
output_name = _AgentReg._compute_output_name(agent_name, cmd_name, agent_config)
cmd_file = commands_dir / f"{output_name}{agent_config['extension']}"
if cmd_file.exists():
# Mirror the real on-disk layout under the backup dir.
# Skills agents (extension == "/SKILL.md") name every
# command file "SKILL.md", living in a per-command
# subdir (e.g. speckit-plan/SKILL.md). Using cmd_file.name
# alone would collide all of them onto one backup path and
# break rollback; keep the relative path to stay unique.
backup_cmd_path = backup_commands_dir / agent_name / cmd_file.relative_to(commands_dir)
backup_cmd_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(cmd_file, backup_cmd_path)
backed_up_command_files[str(cmd_file)] = str(backup_cmd_path)
output_name = _AgentReg._compute_output_name(
agent_name, cmd_name, agent_config
)
names_to_backup = [output_name]
if (
output_name != cmd_name
and _AgentReg._is_safe_command_name(cmd_name)
):
names_to_backup.append(cmd_name)
for dir_index, target_dir in enumerate(
dirs_to_backup
):
for name in names_to_backup:
cmd_file = (
target_dir
/ f"{name}{agent_config['extension']}"
)
try:
_AgentReg._ensure_inside(
cmd_file, target_dir
)
except ValueError:
continue
if (
cmd_file.exists()
or cmd_file.is_symlink()
):
# Keep both the directory location and
# relative path unique. unregister_commands()
# removes legacy and canonical copies, and
# skills agents place every SKILL.md in its
# own command subdirectory.
backup_cmd_path = (
backup_commands_dir
/ agent_name
/ f"location-{dir_index}"
/ cmd_file.relative_to(target_dir)
)
backup_command_artifact(
cmd_file, backup_cmd_path
)
# Also backup copilot prompt files
if agent_name == "copilot":
prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md"
if prompt_file.exists():
backup_prompt_path = backup_commands_dir / "copilot-prompts" / prompt_file.name
backup_prompt_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(prompt_file, backup_prompt_path)
backed_up_command_files[str(prompt_file)] = str(backup_prompt_path)
prompts_dir = (
project_root / ".github" / "prompts"
)
prompt_file = (
prompts_dir / f"{cmd_name}.prompt.md"
)
try:
_AgentReg._ensure_inside(
prompt_file, prompts_dir
)
except ValueError:
continue
if prompt_file.exists() or prompt_file.is_symlink():
backup_prompt_path = (
backup_commands_dir
/ "copilot-prompts"
/ prompt_file.relative_to(prompts_dir)
)
backup_command_artifact(
prompt_file, backup_prompt_path
)
raw_registered_skills = (
backup_registry_entry.get("registered_skills", [])
if isinstance(backup_registry_entry, dict)
else []
)
registered_skills = manager._valid_name_list(raw_registered_skills)
backup_extension_skills(registered_skills)
# 4. Backup hooks and installed list from extensions.yml
# get_project_config() always normalizes installed->[] and hooks->{},
@@ -1224,24 +1482,107 @@ def extension_update(
try:
# 6. Validate extension ID from ZIP BEFORE modifying installation
# Handle both root-level and nested extension.yml (GitHub auto-generated ZIPs)
with zipfile.ZipFile(zip_path, "r") as zf:
with open_zip_bounded(zip_path) as zf:
import yaml
manifest_data = None
manifest_bytes = None
namelist = zf.namelist()
# First try root-level extension.yml
if "extension.yml" in namelist:
with zf.open("extension.yml") as f:
parsed_manifest = yaml.safe_load(f)
manifest_data = parsed_manifest if parsed_manifest is not None else {}
else:
# Look for extension.yml in a single top-level subdirectory
# (e.g., "repo-name-branch/extension.yml")
manifest_paths = [n for n in namelist if n.endswith("/extension.yml") and n.count("/") == 1]
if len(manifest_paths) == 1:
with zf.open(manifest_paths[0]) as f:
parsed_manifest = yaml.safe_load(f)
manifest_data = parsed_manifest if parsed_manifest is not None else {}
# Read the manifest under a hard size cap: this happens
# before install_from_zip()'s safe_extract_zip(), so a
# raw zf.open().read() here would bypass that bound and
# let a zip-bomb extension.yml exhaust memory.
# Normalize separators before choosing the manifest so
# this pre-scan cannot approve one entry while extraction
# later overwrites it with a backslash alias.
manifest_candidates = []
archive_entries = []
for name in namelist:
normalized_name = normalize_zip_member_name(name)
parts = normalized_name.removesuffix("/").split(
"/"
)
path_key = portable_zip_path_key(normalized_name)
archive_entries.append(
(normalized_name, parts)
)
if (
len(parts) in {1, 2}
and path_key[-1] == "extension.yml"
):
manifest_candidates.append(
(name, normalized_name, path_key)
)
seen_manifest_keys = {}
for name, _normalized_name, path_key in manifest_candidates:
previous = seen_manifest_keys.get(path_key)
if previous is not None:
raise ValueError(
"Downloaded extension archive contains multiple "
"extension.yml manifests"
)
seen_manifest_keys[path_key] = name
for _name, normalized_name, _path_key in manifest_candidates:
if normalized_name.split("/")[-1] != "extension.yml":
raise ValueError(
"Downloaded extension archive manifest "
"filenames must use canonical "
"'extension.yml' casing"
)
root_manifest = next(
(
name
for name, _normalized_name, path_key
in manifest_candidates
if path_key == ("extension.yml",)
),
None,
)
nested_manifests = [
(name, normalized_name)
for name, normalized_name, path_key
in manifest_candidates
if len(path_key) == 2
and path_key[-1] == "extension.yml"
]
manifest_path = root_manifest
if manifest_path is None and len(nested_manifests) == 1:
manifest_path, normalized_manifest_path = (
nested_manifests[0]
)
manifest_root = normalized_manifest_path.split(
"/", 1
)[0]
top_level_dirs = {
parts[0]
for normalized_name, parts in archive_entries
if (
len(parts) > 1
or normalized_name.endswith("/")
)
}
if top_level_dirs != {manifest_root}:
raise ValueError(
"Downloaded extension archive with a "
"nested extension.yml must contain exactly "
"one top-level directory"
)
if manifest_path is not None:
manifest_bytes = read_zip_member_limited(
zf, manifest_path
)
parsed_manifest = yaml.safe_load(
manifest_bytes
)
manifest_data = (
parsed_manifest
if parsed_manifest is not None
else {}
)
if manifest_data is None:
raise ValueError("Downloaded extension archive is missing 'extension.yml'")
@@ -1255,13 +1596,205 @@ def extension_update(
"Invalid extension manifest in downloaded archive: expected 'extension' mapping"
)
zip_extension_id = extension_data.get("id")
# Run the same manifest and compatibility validation as a
# normal install while the existing extension is still
# untouched. Reuse the exact bounded bytes selected above.
if manifest_bytes is None:
raise ValueError(
"Downloaded extension archive is missing 'extension.yml'"
)
with tempfile.TemporaryDirectory(
prefix="speckit-update-manifest-"
) as manifest_tmpdir:
manifest_file = Path(manifest_tmpdir) / "extension.yml"
manifest_file.write_bytes(manifest_bytes)
preflight_manifest = ExtensionManifest(manifest_file)
manager.check_compatibility(
preflight_manifest, speckit_version
)
zip_extension_id = preflight_manifest.id
if zip_extension_id != extension_id:
raise ValueError(
f"Extension ID mismatch: expected '{extension_id}', got '{zip_extension_id}'"
)
expected_version = pkg_version.Version(update["available"])
archive_version = pkg_version.Version(
preflight_manifest.version
)
if archive_version != expected_version:
raise ValueError(
"Extension version mismatch: "
f"expected '{update['available']}', "
f"got '{preflight_manifest.version}'"
)
# Match the remaining deterministic install validation
# before crossing the destructive boundary. The helper
# excludes this extension's current registry entry while
# still detecting namespace, core, duplicate, and
# cross-extension command conflicts.
manager._validate_install_conflicts(preflight_manifest)
new_command_names = list(
manager._collect_manifest_command_names(
preflight_manifest
)
)
new_skill_names = list(
dict.fromkeys(
manager._skill_name_for_command(command_name)
for command_name in new_command_names
)
)
# Command rendering happens before hook registration and
# registry.add(). Preserve every candidate output that
# already exists, and remember paths that are absent now so
# rollback can remove files created before registry state is
# available. Include aliases and Copilot companion prompts.
for (
agent_name,
commands_dir,
) in manager._command_registration_targets().items():
agent_config = registrar.AGENT_CONFIGS[agent_name]
for command_name in new_command_names:
output_name = _AgentReg._compute_output_name(
agent_name, command_name, agent_config
)
command_file = (
commands_dir
/ f"{output_name}{agent_config['extension']}"
)
_AgentReg._ensure_inside(command_file, commands_dir)
backup_command_path = (
backup_commands_dir
/ agent_name
/ command_file.relative_to(commands_dir)
)
if command_file.exists() or command_file.is_symlink():
backup_command_artifact(
command_file, backup_command_path
)
else:
new_command_paths_absent_before_update.append(
command_file
)
remember_absent_parent_dirs(
command_file, commands_dir
)
if agent_name == "copilot":
prompts_dir = (
project_root / ".github" / "prompts"
)
prompt_file = (
prompts_dir / f"{command_name}.prompt.md"
)
_AgentReg._ensure_inside(
prompt_file, prompts_dir
)
if prompt_file.is_symlink():
raise RuntimeError(
"Cannot safely update symlinked Copilot "
f"prompt artifact '{prompt_file}'"
)
backup_prompt_path = (
backup_commands_dir
/ "copilot-prompts"
/ prompt_file.relative_to(prompts_dir)
)
if (
prompt_file.exists()
or prompt_file.is_symlink()
):
backup_command_artifact(
prompt_file, backup_prompt_path
)
else:
new_command_paths_absent_before_update.append(
prompt_file
)
remember_absent_parent_dirs(
prompt_file, prompts_dir
)
new_command_paths_absent_before_update = list(
dict.fromkeys(
new_command_paths_absent_before_update
)
)
new_command_dirs_absent_before_update = list(
dict.fromkeys(
new_command_dirs_absent_before_update
)
)
# A newly introduced command may reuse an existing
# extension-owned skill directory that was not present in
# the old registry. Back it up before cleanup can touch it.
backup_extension_skills(new_skill_names)
new_skills_dir = manager._get_skills_dir(create=False)
if new_skills_dir is not None:
# Unscoped removal deliberately ignores home-scoped
# outputs because the flat registry cannot establish
# project ownership. The active install can still
# replace a marker-owned skill in its explicit root,
# so back up that exact project/home target separately.
backup_extension_skills(
list(
dict.fromkeys(
registered_skills + new_skill_names
)
),
skills_dir=new_skills_dir,
)
init_options = load_init_options(project_root)
if (
isinstance(init_options, dict)
and is_ai_skills_enabled(init_options)
and isinstance(init_options.get("ai"), str)
and init_options["ai"]
):
# resolve_active_skills_dir() first creates the
# configured project-local skills marker. Some
# agents (notably Hermes) then redirect rendered
# skills to a different global root, so snapshot
# both locations for exact rollback.
from .. import _get_skills_dir
configured_skills_dir = _get_skills_dir(
project_root, init_options["ai"]
)
remember_absent_parent_dirs(
configured_skills_dir / ".update-marker",
configured_skills_dir,
)
new_skills_root = new_skills_dir.resolve()
for skill_name in new_skill_names:
skill_path = new_skills_dir / skill_name
resolved_skill_path = skill_path.resolve(strict=False)
resolved_skill_path.relative_to(new_skills_root)
if not (
skill_path.exists() or skill_path.is_symlink()
):
new_skill_paths_absent_before_update.append(
skill_path
)
remember_absent_parent_dirs(
skill_path / "SKILL.md",
new_skills_dir,
)
new_command_dirs_absent_before_update = list(
dict.fromkeys(
new_command_dirs_absent_before_update
)
)
# 7. Remove old extension (handles command file cleanup and registry removal)
installation_modified = True
manager.remove(extension_id, keep_config=True)
# 8. Install new version
@@ -1311,15 +1844,42 @@ def extension_update(
hook["enabled"] = False
hook_executor.save_project_config(config)
finally:
# Clean up downloaded ZIP
# ZIP cleanup is housekeeping: never replace an install
# error or roll back an already committed update because a
# scanner temporarily locks the download on Windows.
if zip_path.exists():
zip_path.unlink()
try:
zip_path.unlink()
except OSError as error:
zip_cleanup_error = error
# 10. Clean up backup on success
if backup_base.exists():
shutil.rmtree(backup_base)
# 10. Clean up backup on success. The update has committed at
# this point, so a locked backup file must not trigger rollback
# of an otherwise successful installation.
cleanup_error = None
if backup_created_by_attempt and backup_base.exists():
try:
shutil.rmtree(backup_base)
except OSError as error:
cleanup_error = error
console.print(f" [green]✓[/green] Updated to v{update['available']}")
if cleanup_error is not None:
console.print(
" [yellow]Warning:[/yellow] Could not fully remove "
"update backup: "
f"{_escape_markup(str(cleanup_error))}"
)
console.print(
" [dim]Backup may remain at: "
f"{_escape_markup(str(backup_base))}[/dim]"
)
if zip_cleanup_error is not None:
console.print(
" [yellow]Warning:[/yellow] Could not remove "
"downloaded update archive: "
f"{_escape_markup(str(zip_cleanup_error))}"
)
updated_extensions.append(ext_name)
except KeyboardInterrupt:
@@ -1327,6 +1887,24 @@ def extension_update(
except Exception as e:
console.print(f" [red]✗[/red] Failed: {_escape_markup(str(e))}")
failed_updates.append((ext_name, str(e)))
if zip_cleanup_error is not None:
console.print(
" [yellow]Warning:[/yellow] Could not remove "
"downloaded update archive: "
f"{_escape_markup(str(zip_cleanup_error))}"
)
if not installation_modified:
if backup_created_by_attempt and backup_base.exists():
try:
shutil.rmtree(backup_base)
except OSError as cleanup_error:
console.print(
" [yellow]Warning:[/yellow] Could not remove "
"untouched-update backup: "
f"{_escape_markup(str(cleanup_error))}"
)
continue
# Rollback on failure
console.print(f" [yellow]↩[/yellow] Rolling back {safe_ext_name}...")
@@ -1343,13 +1921,28 @@ def extension_update(
shutil.copytree(backup_ext_dir, extension_dir)
# Remove any NEW command files created by failed install
# (files that weren't in the original backup)
# (files that weren't in the original backup). Registration
# writes before registry.add(), so start with the paths that
# were absent at the destructive boundary instead of relying
# only on a possibly missing new registry entry.
for command_path in new_command_paths_absent_before_update:
if command_path.is_symlink() or command_path.is_file():
command_path.unlink()
elif command_path.exists():
raise RuntimeError(
"Command rollback found an unexpected directory "
f"at '{command_path}'"
)
new_registered_skills = []
try:
new_registry_entry = manager.registry.get(extension_id)
if new_registry_entry is None or not isinstance(new_registry_entry, dict):
new_registered_commands = {}
else:
new_registered_commands = new_registry_entry.get("registered_commands", {})
new_registered_skills = manager._valid_name_list(
new_registry_entry.get("registered_skills", [])
)
for agent_name, cmd_names in new_registered_commands.items():
if agent_name not in registrar.AGENT_CONFIGS:
continue
@@ -1373,13 +1966,78 @@ def extension_update(
except KeyError:
pass # No new registry entry exists, nothing to clean up
# Restore backed up command files
# Restore command artifacts that existed before the update
# before extension-skill cleanup inspects ownership. A
# failed skills registrar may have overwritten a user's
# pre-existing SKILL.md with extension metadata; restoring
# it first prevents the conservative skill unregistrar from
# misclassifying and deleting the user's whole directory.
for original_path, backup_path in backed_up_command_files.items():
backup_file = Path(backup_path)
if backup_file.exists():
original_file = Path(original_path)
original_file.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(backup_file, original_file)
restore_command_artifact(
original_path, backup_path
)
# Skill generation happens before hooks and registry.add(),
# so a failed install may have created skills that are not
# recorded in any registry entry yet. Derive names from the
# preflighted manifest as well as any partial new entry.
skills_to_remove = list(
dict.fromkeys(new_skill_names + new_registered_skills)
)
# A write failure can leave a partial skill without valid
# ownership metadata, which the normal conservative
# unregistrar intentionally refuses to delete. Paths that
# were absent at the destructive boundary are safe to
# remove directly during rollback.
for skill_path in new_skill_paths_absent_before_update:
if skill_path.is_symlink() or skill_path.is_file():
skill_path.unlink()
elif skill_path.exists():
shutil.rmtree(skill_path)
manager._unregister_extension_skills(
skills_to_remove, extension_id
)
# Restore all original registered skill artifacts after
# removing skills created by the failed installation.
for original_path, backup_path in backed_up_skill_dirs.items():
backup_skill_dir = Path(backup_path)
if not backup_skill_dir.is_dir():
raise RuntimeError(
"Skill rollback backup is missing for "
f"'{original_path}'"
)
original_skill_dir = Path(original_path)
if (
original_skill_dir.is_symlink()
or original_skill_dir.is_file()
):
original_skill_dir.unlink()
elif original_skill_dir.exists():
shutil.rmtree(original_skill_dir)
original_skill_dir.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(
backup_skill_dir,
original_skill_dir,
symlinks=True,
)
# Remove empty artifact directories that did not exist at
# the destructive boundary. Do this after skill cleanup and
# restoration so newly created skills roots and their
# project-local parents can also be removed exactly.
for command_dir in sorted(
new_command_dirs_absent_before_update,
key=lambda path: len(path.parts),
reverse=True,
):
if command_dir.is_dir() and not command_dir.is_symlink():
try:
command_dir.rmdir()
except OSError:
# Preserve any non-empty directory: other
# content may belong to the user.
pass
# Restore metadata in extensions.yml (hooks and installed list).
# Only run if backup step 4 was reached (backup_hooks is not None);
@@ -1434,10 +2092,26 @@ def extension_update(
if backup_registry_entry:
manager.registry.restore(extension_id, backup_registry_entry)
# Backup cleanup is post-rollback housekeeping. A locked
# file (notably on Windows) must not turn successfully
# restored state into a contradictory "Rollback failed".
cleanup_error = None
if backup_created_by_attempt and backup_base.exists():
try:
shutil.rmtree(backup_base)
except OSError as error:
cleanup_error = error
console.print(" [green]✓[/green] Rollback successful")
# Clean up backup directory only on successful rollback
if backup_base.exists():
shutil.rmtree(backup_base)
if cleanup_error is not None:
console.print(
" [yellow]Warning:[/yellow] Could not fully "
"remove rollback backup: "
f"{_escape_markup(str(cleanup_error))}"
)
console.print(
" [dim]Backup may remain at: "
f"{_escape_markup(str(backup_base))}[/dim]"
)
except Exception as rollback_error:
console.print(f" [red]✗[/red] Rollback failed: {_escape_markup(str(rollback_error))}")
console.print(f" [dim]Backup preserved at: {_escape_markup(str(backup_base))}[/dim]")

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from collections.abc import Callable
from typing import Any
from ._invocation_style import get_invocation_prefix
from .integration_state import integration_setting, integration_settings
@@ -99,3 +100,14 @@ def invoke_separator_for_integration(
return integration.effective_invoke_separator(stored_parsed, project_root)
return integration.effective_invoke_separator(None, project_root)
def invoke_prefix_for_integration(
integration: Any,
key: str,
parsed_options: dict[str, Any] | None = None,
project_root: Any = None,
) -> str:
"""Resolve the native invocation prefix for an integration's output mode."""
skills_mode = integration.is_skills_mode(parsed_options, project_root)
return get_invocation_prefix(key, skills_mode)

View File

@@ -48,6 +48,7 @@ def _register_builtins() -> None:
"""
# -- Imports (alphabetical) -------------------------------------------
from .agy import AgyIntegration
from .alquimia import AlquimiaAIIntegration
from .amp import AmpIntegration
from .auggie import AuggieIntegration
from .bob import BobIntegration
@@ -86,6 +87,7 @@ def _register_builtins() -> None:
# -- Registration (alphabetical) --------------------------------------
_register(AgyIntegration())
_register(AlquimiaAIIntegration())
_register(AmpIntegration())
_register(AuggieIntegration())
_register(BobIntegration())

View File

@@ -11,6 +11,7 @@ from rich.markup import escape
from .._agent_config import SCRIPT_TYPE_CHOICES
from .._console import console
from ..integration_runtime import (
invoke_prefix_for_integration as _invoke_prefix_for_integration,
invoke_separator_for_integration as _invoke_separator_for_integration,
resolve_integration_options as _resolve_integration_options_impl,
with_integration_setting as _with_integration_setting,
@@ -333,6 +334,9 @@ def _set_default_integration(
integration, {"integration_settings": settings}, key, parsed_options,
project_root=project_root,
),
invoke_prefix=_invoke_prefix_for_integration(
integration, key, parsed_options, project_root
),
force=refresh_templates_force,
refresh_managed=True,
refresh_hint=refresh_hint,
@@ -395,19 +399,14 @@ def _register_extensions_for_agent(
"""Register all enabled extensions' commands/skills for ``agent_key``.
``use`` / ``switch`` re-register enabled extensions for the agent they
activate; ``upgrade`` backfills them for the refreshed agent. Plain
``install`` deliberately does not call this helper so adding a secondary
integration has no extension side effects until it is selected or upgraded.
See issue #2886.
activate (rescaffold); ``upgrade`` does so only for the *active*
integration. Plain ``install`` and upgrade of a non-active integration
deliberately skip this helper so a secondary integration has no extension
side effects until it is selected. See issues #2886 and #2948.
Known limitation: extension *skill* rendering is scoped to the active
agent (init-options track a single ``ai`` / ``ai_skills`` pair). A
skills-mode agent registered while it is *not* the active agent (e.g.
Copilot ``--skills`` registered while non-active) therefore
receives command files rather than skills here — matching ``extension
add``'s multi-agent behavior. ``use`` / ``switch`` avoid this because they
make the target the active agent first. Per-agent skills parity is tracked in
#2948.
Callers always pass the active agent (use/switch activate the target
before registering), so extension *skill* rendering — which is scoped to
the active ``ai`` / ``ai_skills`` init-options — matches ``agent_key``.
Best-effort: never aborts the surrounding integration operation. Callers
invoke it *after* the use/upgrade/switch transaction has committed so a
@@ -443,6 +442,91 @@ def _unregister_extensions_for_agent(
)
def _register_presets_for_agent(
project_root: Path,
agent_key: str,
*,
continuing: str,
) -> None:
"""Register all enabled presets' command overrides/skills for ``agent_key``.
Presets follow the same single-active rule as extensions (#2948):
``use`` / ``switch`` re-register enabled presets for the agent they
activate (rescaffold), so a preset installed while a different
integration was active is not left targeting that inactive integration.
Best-effort: never aborts the surrounding integration operation.
"""
try:
from ..presets import PresetManager
preset_mgr = PresetManager(project_root)
preset_mgr.register_enabled_presets_for_agent(agent_key)
except Exception as preset_err:
from .. import _print_cli_warning
_print_cli_warning(
"register preset artifacts for",
"integration",
agent_key,
preset_err,
continuing=continuing,
)
def _unregister_presets_for_agent(
project_root: Path,
agent_key: str,
*,
continuing: str,
) -> None:
"""Best-effort removal of ``agent_key``'s preset command/skill artifacts.
Mirrors ``_unregister_extensions_for_agent``: used by ``switch`` when
uninstalling the previous integration so its preset command overrides
and skill mirrors don't linger as orphans in the old agent's directory
once a different (possibly not-yet-installed) integration becomes
active (#2948).
Best-effort: never aborts the surrounding integration operation.
"""
try:
from ..presets import PresetManager
preset_mgr = PresetManager(project_root)
preset_mgr.unregister_agent_artifacts(agent_key)
except Exception as preset_err:
from .. import _print_cli_warning
_print_cli_warning(
"clean up preset artifacts for",
"integration",
agent_key,
preset_err,
continuing=continuing,
)
def _unregister_enabled_extension_commands_for_agent(
project_root: Path,
agent_key: str,
*,
continuing: str,
) -> None:
"""Best-effort removal of enabled extension command artifacts for ``agent_key``."""
_best_effort_extension_op(
project_root,
agent_key,
lambda mgr, key: mgr.unregister_agent_artifacts(
key,
enabled_only=True,
commands_only=True,
),
phase="clean up enabled extension command artifacts for",
continuing=continuing,
)
# ---------------------------------------------------------------------------
# CLI formatting helpers (re-exported from _commands.py)
# ---------------------------------------------------------------------------

View File

@@ -8,6 +8,7 @@ import typer
from .._console import console
from .._utils import _display_project_path
from ..integration_runtime import (
invoke_prefix_for_integration as _invoke_prefix_for_integration,
invoke_separator_for_integration as _invoke_separator_for_integration,
with_integration_setting as _with_integration_setting,
)
@@ -130,6 +131,9 @@ def integration_install(
infra_integration, current, infra_key, infra_parsed,
project_root=project_root,
),
invoke_prefix=_invoke_prefix_for_integration(
infra_integration, infra_key, infra_parsed, project_root
),
)
if os.name != "nt":
from .. import ensure_executable_scripts

View File

@@ -9,6 +9,7 @@ import typer
from .._console import console
from ..integration_runtime import (
invoke_prefix_for_integration as _invoke_prefix_for_integration,
invoke_separator_for_integration as _invoke_separator_for_integration,
with_integration_setting as _with_integration_setting,
)
@@ -29,13 +30,16 @@ from ._helpers import (
_read_integration_json,
_refresh_init_options_speckit_version,
_register_extensions_for_agent,
_register_presets_for_agent,
_remove_integration_json,
_resolve_integration_options,
_resolve_integration_script_type,
_resolve_script_type,
_set_default_integration,
_set_default_integration_or_exit,
_unregister_enabled_extension_commands_for_agent,
_unregister_extensions_for_agent,
_unregister_presets_for_agent,
_update_init_options_for_integration,
_write_integration_json,
)
@@ -54,6 +58,66 @@ def _manifest_tracks_skill_layout(manifest) -> bool:
return any(str(rel).endswith("/SKILL.md") for rel in manifest.files)
def _manifest_path_under(rel_path: str, root: str) -> bool:
"""Return True when manifest key *rel_path* is inside project-relative *root*."""
normalized_root = PurePath(root).as_posix().strip("/")
normalized_rel = PurePath(rel_path).as_posix().strip("/")
if not normalized_root:
return False
return normalized_rel == normalized_root or normalized_rel.startswith(
f"{normalized_root}/"
)
def _legacy_command_root_changed(
integration,
project_root: Path,
old_manifest,
new_manifest,
) -> bool:
"""Return True when command artifacts moved from legacy_dir to canonical dir."""
config = integration.registrar_config or {}
canonical = config.get("dir")
legacy = config.get("legacy_dir")
if (
not isinstance(canonical, str)
or not canonical.strip()
or not isinstance(legacy, str)
or not legacy.strip()
or PurePath(canonical).as_posix() == PurePath(legacy).as_posix()
):
return False
canonical_dir = project_root / canonical
legacy_dir = project_root / legacy
if not canonical_dir.is_dir() or not legacy_dir.is_dir():
return False
old_had_legacy = any(
_manifest_path_under(rel, legacy) for rel in old_manifest.files
)
new_has_canonical = any(
_manifest_path_under(rel, canonical) for rel in new_manifest.files
)
return old_had_legacy and new_has_canonical
def _legacy_command_root_upgrade_pending(integration, old_manifest) -> bool:
"""Return True when the old manifest tracks command files under legacy_dir."""
config = integration.registrar_config or {}
canonical = config.get("dir")
legacy = config.get("legacy_dir")
if (
not isinstance(canonical, str)
or not canonical.strip()
or not isinstance(legacy, str)
or not legacy.strip()
or PurePath(canonical).as_posix() == PurePath(legacy).as_posix()
):
return False
return any(_manifest_path_under(rel, legacy) for rel in old_manifest.files)
class _PresetRegistryUnreadableError(Exception):
"""Raised when an existing preset registry cannot be read or parsed.
@@ -64,16 +128,21 @@ class _PresetRegistryUnreadableError(Exception):
"""
def _installed_presets_affecting_agent(project_root, agent_key: str) -> list[str]:
def _installed_presets_affecting_agent(
project_root,
agent_key: str,
*,
include_skills: bool = True,
) -> list[str]:
"""Return IDs of installed presets with artifacts registered for *agent_key*.
Presets register command overrides for every detected agent and mirror
skills for the active skills agent, tracking the result in each preset's
``registered_commands`` / ``registered_skills`` metadata. There is no
agent-scoped preset re-registration mechanism, so a command↔skills *layout
change* cannot reconcile those artifacts (see ``integration_upgrade``).
Callers use this to detect the unsafe case and reject the migration rather
than silently orphaning preset files / leaving stale registry entries.
Preset registration is active-agent-only (#2948): command overrides are
written for the active non-skills agent and skills for the active skills
agent, tracked per preset in ``registered_commands`` /
``registered_skills``. Entries for *other* agents may still exist from
when those agents were active. Callers use this to reject command-root or
command↔skills layout migrations before mutation: preset rescaffolding is
best-effort and cannot guarantee every tracked artifact has a replacement.
Fails **closed**: a genuinely absent registry (no presets ever installed)
returns an empty list, but if the registry file exists and cannot be read
@@ -112,22 +181,53 @@ def _installed_presets_affecting_agent(project_root, agent_key: str) -> list[str
f"preset '{preset_id}' entry is malformed"
)
registered_commands = meta.get("registered_commands", {})
if not isinstance(registered_commands, dict):
if not isinstance(registered_commands, dict) or not all(
isinstance(names, list) for names in registered_commands.values()
):
raise _PresetRegistryUnreadableError(
f"preset '{preset_id}' registered_commands is malformed"
)
registered_skills = meta.get("registered_skills", [])
if not isinstance(registered_skills, (list, tuple)):
if isinstance(registered_skills, dict):
# Per-agent provenance ({agent: [skill names]}): only entries for
# *this* agent make the preset affect it. Values must be lists —
# anything else (e.g. null) leaves ownership undecidable, so fail
# closed rather than read it as "no artifacts".
if not all(
isinstance(names, list) for names in registered_skills.values()
):
raise _PresetRegistryUnreadableError(
f"preset '{preset_id}' registered_skills is malformed"
)
has_skills = include_skills and bool(
registered_skills.get(agent_key)
)
elif isinstance(registered_skills, (list, tuple)):
# Legacy flat list: not agent-scoped, so any recorded skill may
# belong to this agent — fail closed and count it as affecting.
has_skills = include_skills and bool(registered_skills)
else:
raise _PresetRegistryUnreadableError(
f"preset '{preset_id}' registered_skills is malformed"
)
has_commands = bool(registered_commands.get(agent_key))
has_skills = bool(registered_skills)
if has_commands or has_skills:
affected.append(preset_id)
return affected
def _installed_command_presets_affecting_agent(
project_root,
agent_key: str,
) -> list[str]:
"""Return installed presets with command artifacts registered for *agent_key*."""
return _installed_presets_affecting_agent(
project_root,
agent_key,
include_skills=False,
)
@integration_app.command("switch")
def integration_switch(
target: str = typer.Argument(help="Integration key to switch to"),
@@ -218,6 +318,14 @@ def integration_switch(
"need re-registration."
),
)
_register_presets_for_agent(
project_root,
target,
continuing=(
"The integration switch succeeded, but installed presets may "
"need re-registration."
),
)
console.print(f"\n[green]✓[/green] Default integration set to [bold]{target}[/bold].")
raise typer.Exit(0)
@@ -275,6 +383,19 @@ def integration_switch(
continuing="Continuing with integration switch; old extension artifacts may need manual cleanup.",
)
# Unregister preset commands/skills for the old agent for the same
# reason: without this, a preset's command overrides (including
# custom preset commands) and skill mirrors rendered for
# installed_key would remain orphaned in its directory once a
# different, possibly not-yet-installed integration becomes active
# (#2948). Scoped strictly to installed_key; other agents' files,
# tracking, and the preset packs themselves are untouched.
_unregister_presets_for_agent(
project_root,
installed_key,
continuing="Continuing with integration switch; old preset artifacts may need manual cleanup.",
)
# Clear metadata so a failed Phase 2 doesn't leave stale references
installed_keys = [installed for installed in installed_keys if installed != installed_key]
_clear_init_options_for_integration(project_root, installed_key)
@@ -327,6 +448,9 @@ def integration_switch(
target_integration, current, target, parsed_options,
project_root=project_root,
),
invoke_prefix=_invoke_prefix_for_integration(
target_integration, target, parsed_options, project_root
),
refresh_hint=(
"To overwrite customizations, re-run with "
"[cyan]specify integration switch ... --refresh-shared-infra[/cyan]."
@@ -396,6 +520,24 @@ def integration_switch(
f"[yellow]Warning:[/yellow] Failed to restore default "
f"integration '{fallback_key}': {restore_err}"
)
else:
# Under active-only registration the fallback may never
# have received any extension/preset artifacts (it was
# installed while another integration was active), and
# Phase 1 already unregistered the outgoing agent's
# artifacts. Rescaffold so the restored default is
# actually usable. Both helpers are best-effort and
# cannot raise past this point.
_register_extensions_for_agent(
project_root,
fallback_key,
continuing="The switch was rolled back; installed extensions may need re-registration.",
)
_register_presets_for_agent(
project_root,
fallback_key,
continuing="The switch was rolled back; installed presets may need re-registration.",
)
else:
_write_integration_json(
project_root, fallback_key, installed_keys, _integration_settings(current)
@@ -416,6 +558,11 @@ def integration_switch(
target,
continuing="The integration switch succeeded, but installed extensions may need re-registration.",
)
_register_presets_for_agent(
project_root,
target,
continuing="The integration switch succeeded, but installed presets may need re-registration.",
)
name = (target_integration.config or {}).get("name", target)
console.print(f"\n[green]✓[/green] Switched to integration '{name}'")
@@ -487,18 +634,65 @@ def integration_upgrade(
integration, current, key, integration_options
)
# Guard: reject a command↔skills layout change while preset overrides are
# installed for this agent (review #3415). A dual-mode agent (e.g. Bob)
# can flip layout across an upgrade (``--skills`` / ``--legacy-commands``).
# Extension artifacts are reconciled after the flip (see below), but preset
# artifacts cannot be: there is no agent-scoped preset re-registration
# anywhere in the CLI, so migrating would delete a preset's old-layout
# files without recreating them in the new layout and leave the preset
# registry claiming artifacts that no longer exist. Detect the intended
# layout (``is_skills_mode`` reflects the resolved flags/disk state, so a
# plain same-layout upgrade is unaffected) and bail out *before* any
# mutation with an actionable error so the project is never left in a
# half-migrated, inconsistent state.
legacy_command_root_upgrade_pending = _legacy_command_root_upgrade_pending(
integration,
old_manifest,
)
# Guard: Kilo's legacy command root moves from .kilocode/workflows to
# .kilo/commands. Preset command artifacts are tracked outside the
# integration manifest, and their agent-scoped rescaffold is best-effort,
# not transactional with command-root cleanup. Refuse before setup writes
# .kilo/commands rather than risking orphaned legacy files or missing
# registry-tracked overrides in the canonical directory.
if key == "kilocode" and legacy_command_root_upgrade_pending:
config = integration.registrar_config or {}
legacy = config.get("legacy_dir", "legacy command directory")
canonical = config.get("dir", "canonical command directory")
try:
affected_presets = _installed_command_presets_affecting_agent(
project_root,
key,
)
except _PresetRegistryUnreadableError as exc:
console.print(
f"[red]Error:[/red] Cannot migrate '{key}' command directory "
f"from [cyan]{legacy}[/cyan] to [cyan]{canonical}[/cyan]: "
"the preset registry could not be read to verify installed presets."
)
console.print(f"[dim]Details:[/dim] {_cli_error_detail(exc)}")
console.print(
"A command directory migration cannot reconcile preset command "
"artifacts while the preset registry state is unknown. Fix or "
"restore [cyan].specify/presets/.registry[/cyan] and retry."
)
raise typer.Exit(1)
if affected_presets:
preset_list = ", ".join(sorted(affected_presets))
console.print(
f"[red]Error:[/red] Cannot migrate '{key}' command directory "
f"from [cyan]{legacy}[/cyan] to [cyan]{canonical}[/cyan] while "
f"preset override(s) are installed: [bold]{preset_list}[/bold]."
)
console.print(
"Preset command artifacts cannot yet be reconciled across this "
"command directory migration, so the upgrade is refused before "
"changing files."
)
console.print(
"Remove the preset(s), run the upgrade, then reinstall them:\n"
f" [cyan]specify preset remove <id>[/cyan]\n"
f" [cyan]specify integration upgrade {key} --script {selected_script} --force[/cyan]\n"
f" [cyan]specify preset add <id>[/cyan]"
)
raise typer.Exit(1)
# Reject command↔skills layout changes while preset artifacts are tracked
# for the integration (review #3415). Preset rescaffolding is best-effort:
# an enabled preset can still have a missing/corrupt manifest or command
# source, or fail during a write. Phase 2 would otherwise delete the
# old-layout file before a replacement is known to exist. Refuse before
# any mutation; same-layout upgrades still rescaffold the active agent.
if _manifest_tracks_skill_layout(old_manifest) != integration.is_skills_mode(
parsed_options, project_root
):
@@ -524,9 +718,9 @@ def integration_upgrade(
f"preset override(s) are installed: [bold]{preset_list}[/bold]."
)
console.print(
"Preset artifacts cannot yet be reconciled across a command↔skills "
"layout change, so the migration would orphan their files and leave "
"the preset registry inconsistent."
"Preset artifacts cannot be safely reconciled across a "
"command↔skills layout change, so the migration is refused "
"before changing files."
)
console.print(
"Remove the preset(s), run the upgrade, then reinstall them:\n"
@@ -557,6 +751,9 @@ def integration_upgrade(
infra_integration, current, infra_key, infra_parsed,
project_root=project_root,
),
invoke_prefix=_invoke_prefix_for_integration(
infra_integration, infra_key, infra_parsed, project_root
),
)
if os.name != "nt":
from .. import ensure_executable_scripts
@@ -592,6 +789,9 @@ def integration_upgrade(
integration, {"integration_settings": settings}, key, parsed_options,
project_root=project_root,
),
invoke_prefix=_invoke_prefix_for_integration(
integration, key, parsed_options, project_root
),
force=force,
refresh_managed=True,
)
@@ -646,66 +846,37 @@ def integration_upgrade(
if stale_removed:
console.print(f" Removed {len(stale_removed)} stale file(s) from previous install")
# Re-register enabled extensions for the upgraded agent so its extension
# commands are (re)created — including agents installed before this
# back-fill existed. Mirrors switch for command registration; see #2886.
# Done after the upgrade has fully settled (Phase 2 included) and outside
# the try/except above so this best-effort step cannot affect upgrade
# success.
#
# Layout-change reconciliation: a dual-mode agent (e.g. Bob) can flip
# between the legacy commands layout and the skills layout across an
# upgrade (``upgrade bob --integration-options "--skills"`` / reverse
# ``--legacy-commands``). Phase 2 above only removes stale files tracked by
# the *integration* manifest (core commands); extension artifacts are
# tracked separately in the extension registry, so the old layout's
# extension command/skill files would otherwise linger as orphans. When the
# layout actually changed, first unregister the agent's extension artifacts
# (removing old-layout files and clearing per-agent registry entries) so the
# re-registration below recreates them in the new layout. ``upgrade``s that
# don't change layout skip this to avoid needless remove/re-add churn.
#
# Only the *active* integration is reconciled this way (``installed_key ==
# key``). ``ExtensionManager.unregister_agent_artifacts`` treats the
# per-extension ``registered_skills`` list as belonging to the passed agent
# and, when that agent's skills directory is absent, falls back to scanning
# every agent's skills directory — so running it for a *secondary*
# (non-active) agent could delete or untrack the *active* agent's extension
# skills. The subsequent re-registration cannot repair that because
# extension skill rendering is intentionally scoped to the active agent
# (#2948). Extension skills only ever exist for the active agent, so
# skipping the unregister for a secondary agent orphans nothing new: a
# secondary agent only has extension *command* files, which the
# re-registration below rewrites in place regardless of layout.
#
# Known limitation: preset command/skill artifacts are NOT reconciled on a
# layout change. There is no agent-scoped preset re-registration mechanism
# anywhere in the CLI — ``use`` / ``switch`` / ``upgrade`` never reconcile
# presets for any agent (presets are only (un)registered at preset
# install/remove time). Rather than silently orphan them, the guard near
# the top of this function rejects a layout-changing upgrade while preset
# overrides are installed, so control only reaches here (with a changed
# layout) when no preset artifacts are at stake. Full preset reconciliation
# would require a new cross-cutting PresetManager subsystem affecting every
# dual-layout agent, which is out of scope for this Bob migration.
if (
installed_key == key
and _manifest_tracks_skill_layout(old_manifest)
!= _manifest_tracks_skill_layout(new_manifest)
):
_unregister_extensions_for_agent(
legacy_command_root_changed = _legacy_command_root_changed(
integration,
project_root,
old_manifest,
new_manifest,
)
if legacy_command_root_changed:
_unregister_enabled_extension_commands_for_agent(
project_root,
key,
continuing=(
"The integration layout changed, but old-layout extension "
"artifacts may need manual cleanup."
"The integration command directory changed, but legacy enabled "
"extension artifacts may need manual cleanup."
),
)
_register_extensions_for_agent(
project_root,
key,
continuing="The integration was upgraded, but installed extensions may need re-registration.",
)
# Re-register enabled extensions and presets only when upgrading the
# active integration. Inactive integrations remain untouched until
# `use` or `switch` activates and rescaffolds them (#2948). This runs
# after the core upgrade transaction, so failures remain best-effort.
if key == installed_key:
_register_extensions_for_agent(
project_root,
key,
continuing="The integration was upgraded, but installed extensions may need re-registration.",
)
_register_presets_for_agent(
project_root,
key,
continuing="The integration was upgraded, but installed presets may need re-registration.",
)
name = (integration.config or {}).get("name", key)
console.print(f"\n[green]✓[/green] Integration '{name}' upgraded successfully")

View File

@@ -18,6 +18,7 @@ from ._commands import integration_app, integration_catalog_app
from ._helpers import (
_read_integration_json,
_register_extensions_for_agent,
_register_presets_for_agent,
_resolve_integration_options,
_set_default_integration_or_exit,
)
@@ -248,6 +249,11 @@ def integration_use(
key,
continuing="The integration was selected, but installed extensions may need re-registration.",
)
_register_presets_for_agent(
project_root,
key,
continuing="The integration was selected, but installed presets may need re-registration.",
)
console.print(f"[green]✓[/green] Default integration set to [bold]{key}[/bold].")
@@ -312,22 +318,26 @@ def integration_search(
console.print(f"\n[green]Found {len(results)} integration(s):[/green]\n")
for integ in sorted(results, key=lambda e: e.get("id", "")):
iid = integ.get("id", "?")
name = integ.get("name", iid)
version = integ.get("version", "?")
iid_value = str(integ.get("id", "?"))
iid = _rich_escape(iid_value)
name = _rich_escape(str(integ.get("name", iid_value)))
version = _rich_escape(str(integ.get("version", "?")))
console.print(f"[bold]{name}[/bold] ({iid}) v{version}")
desc = integ.get("description", "")
if desc:
console.print(f" {desc}")
console.print(f" {_rich_escape(str(desc))}")
console.print(f"\n [dim]Author:[/dim] {integ.get('author', 'Unknown')}")
author_value = _rich_escape(str(integ.get("author", "Unknown")))
console.print(f"\n [dim]Author:[/dim] {author_value}")
tags = integ.get("tags", [])
if isinstance(tags, list) and tags:
console.print(f" [dim]Tags:[/dim] {', '.join(str(t) for t in tags)}")
safe_tags = _rich_escape(", ".join(str(t) for t in tags))
console.print(f" [dim]Tags:[/dim] {safe_tags}")
cat_name = integ.get("_catalog_name", "")
cat_name_value = integ.get("_catalog_name", "")
cat_name = _rich_escape(str(cat_name_value))
install_allowed = integ.get("_install_allowed", True)
if cat_name:
if cat_name_value:
if install_allowed:
console.print(f" [dim]Catalog:[/dim] {cat_name}")
else:
@@ -336,9 +346,9 @@ def integration_search(
"[yellow](discovery only — not installable)[/yellow]"
)
if iid == installed_key:
if iid_value == installed_key:
console.print("\n [green]✓ Installed[/green] (currently active)")
elif iid in INTEGRATION_REGISTRY:
elif iid_value in INTEGRATION_REGISTRY:
console.print(f"\n [cyan]Install:[/cyan] specify integration install {iid}")
elif install_allowed:
console.print(
@@ -368,6 +378,7 @@ def integration_info(
project_root = _require_specify_project()
catalog = IntegrationCatalog(project_root)
installed_key = _default_integration_key(_read_integration_json(project_root))
safe_integration_id = _rich_escape(str(integration_id))
try:
info = catalog.get_integration_info(integration_id)
@@ -380,29 +391,38 @@ def integration_info(
catalog_error = None
if info:
name = info.get("name", integration_id)
version = info.get("version", "?")
console.print(f"\n[bold cyan]{name}[/bold cyan] ({integration_id}) v{version}")
name = _rich_escape(str(info.get("name", integration_id)))
version = _rich_escape(str(info.get("version", "?")))
console.print(
f"\n[bold cyan]{name}[/bold cyan] ({safe_integration_id}) v{version}"
)
if info.get("description"):
console.print(f" {info['description']}")
console.print(f" {_rich_escape(str(info['description']))}")
console.print()
console.print(f" [dim]Author:[/dim] {info.get('author', 'Unknown')}")
author_value = _rich_escape(str(info.get("author", "Unknown")))
console.print(f" [dim]Author:[/dim] {author_value}")
if info.get("license"):
console.print(f" [dim]License:[/dim] {info['license']}")
console.print(
f" [dim]License:[/dim] {_rich_escape(str(info['license']))}"
)
tags = info.get("tags", [])
if isinstance(tags, list) and tags:
console.print(f" [dim]Tags:[/dim] {', '.join(str(t) for t in tags)}")
safe_tags = _rich_escape(", ".join(str(t) for t in tags))
console.print(f" [dim]Tags:[/dim] {safe_tags}")
cat_name = info.get("_catalog_name", "")
cat_name_value = info.get("_catalog_name", "")
cat_name = _rich_escape(str(cat_name_value))
install_allowed = info.get("_install_allowed", True)
if cat_name:
if cat_name_value:
install_note = "" if install_allowed else " [yellow](discovery only)[/yellow]"
console.print(f" [dim]Source catalog:[/dim] {cat_name}{install_note}")
if info.get("repository"):
console.print(f" [dim]Repository:[/dim] {info['repository']}")
console.print(
f" [dim]Repository:[/dim] {_rich_escape(str(info['repository']))}"
)
if integration_id == installed_key:
console.print("\n [green]✓ Installed[/green] (currently active)")
@@ -438,7 +458,7 @@ def integration_info(
else:
console.print("\nTry again when online, or use a built-in integration ID directly.")
else:
console.print(f"[red]Error:[/red] Integration '{integration_id}' not found")
console.print(f"[red]Error:[/red] Integration '{safe_integration_id}' not found")
console.print("\nTry: specify integration search")
raise typer.Exit(1)
@@ -489,13 +509,14 @@ def integration_catalog_list():
display_name = str(raw_name).strip() if raw_name is not None else ""
if not display_name:
display_name = f"catalog-{i + 1}"
safe_name = _rich_escape(display_name)
if env_override or project_configs is None:
console.print(f" - [bold]{display_name}[/bold] — {install_status}")
console.print(f" - [bold]{safe_name}[/bold] — {install_status}")
else:
console.print(f" [{i}] [bold]{display_name}[/bold] — {install_status}")
console.print(f" {cfg.get('url', '')}")
console.print(f" [{i}] [bold]{safe_name}[/bold] — {install_status}")
console.print(f" {_rich_escape(str(cfg.get('url', '')))}")
if cfg.get("description"):
console.print(f" [dim]{cfg['description']}[/dim]")
console.print(f" [dim]{_rich_escape(str(cfg['description']))}[/dim]")
console.print()

View File

@@ -0,0 +1,165 @@
"""Alquimia AI integration."""
from __future__ import annotations
from typing import Any
from ..._utils import dump_frontmatter
from ..base import SkillsIntegration
# Mapping of command template stem → argument-hint text shown inline
# when a user invokes the slash command in Alquimia AI.
ARGUMENT_HINTS: dict[str, str] = {
"specify": "Describe the feature you want to specify",
"plan": "Optional guidance for the planning phase",
"tasks": "Optional task generation constraints",
"implement": "Optional implementation guidance or task filter",
"analyze": "Optional focus areas for analysis",
"clarify": "Optional areas to clarify in the spec",
"constitution": "Principles or values for the project constitution",
"checklist": "Domain or focus area for the checklist",
"taskstoissues": "Optional filter or label for GitHub issues",
}
class AlquimiaAIIntegration(SkillsIntegration):
"""Integration for Alquimia AI skills."""
key = "alquimia"
config = {
"name": "Alquimia AI",
"folder": ".alquimia/",
"commands_subdir": "skills",
"install_url": "https://docs.alquimia.ai",
"requires_cli": True,
}
registrar_config = {
"dir": ".alquimia/skills",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
multi_install_safe = True
def _render_skill(
self, template_name: str, frontmatter: dict[str, Any], body: str
) -> str:
"""Render a processed command template as an Alquimia skill."""
skill_name = f"speckit-{template_name.replace('.', '-')}"
description = frontmatter.get(
"description",
f"Spec-kit workflow command: {template_name}",
)
skill_frontmatter = self._build_skill_fm(
skill_name, description, f"templates/commands/{template_name}.md"
)
frontmatter_text = dump_frontmatter(skill_frontmatter)
return f"---\n{frontmatter_text}\n---\n\n{body.strip()}\n"
def _build_skill_fm(self, name: str, description: str, source: str) -> dict:
from specify_cli.agents import CommandRegistrar
return CommandRegistrar.build_skill_frontmatter(
self.key, name, description, source
)
@staticmethod
def inject_argument_hint(content: str, hint: str) -> str:
"""Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter.
Skips injection if ``argument-hint:`` already exists in the
frontmatter to avoid duplicate keys.
"""
lines = content.splitlines(keepends=True)
# Pre-scan: bail out if argument-hint already present in frontmatter
dash_count = 0
for line in lines:
stripped = line.rstrip("\n\r")
if stripped == "---":
dash_count += 1
if dash_count == 2:
break
continue
if dash_count == 1 and stripped.startswith("argument-hint:"):
return content # already present
out: list[str] = []
in_fm = False
dash_count = 0
injected = False
for line in lines:
stripped = line.rstrip("\n\r")
if stripped == "---":
dash_count += 1
in_fm = dash_count == 1
out.append(line)
continue
if in_fm and not injected and stripped.startswith("description:"):
out.append(line)
# Preserve the exact line-ending style (\r\n vs \n)
if line.endswith("\r\n"):
eol = "\r\n"
elif line.endswith("\n"):
eol = "\n"
else:
eol = ""
escaped = hint.replace("\\", "\\\\").replace('"', '\\"')
out.append(f'argument-hint: "{escaped}"{eol}')
injected = True
continue
out.append(line)
return "".join(out)
@staticmethod
def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str:
"""Insert ``key: value`` before the closing ``---`` if not already present."""
lines = content.splitlines(keepends=True)
# Pre-scan: bail out if already present in frontmatter
dash_count = 0
for line in lines:
stripped = line.rstrip("\n\r")
if stripped == "---":
dash_count += 1
if dash_count == 2:
break
continue
if dash_count == 1 and stripped.startswith(f"{key}:"):
return content
# Inject before the closing --- of frontmatter
out: list[str] = []
dash_count = 0
injected = False
for line in lines:
stripped = line.rstrip("\n\r")
if stripped == "---":
dash_count += 1
if dash_count == 2 and not injected:
if line.endswith("\r\n"):
eol = "\r\n"
elif line.endswith("\n"):
eol = "\n"
else:
eol = ""
out.append(f"{key}: {value}{eol}")
injected = True
out.append(line)
return "".join(out)
def post_process_skill_content(self, content: str) -> str:
"""Inject Alquimia-specific frontmatter flags, hints and hook notes."""
updated = super().post_process_skill_content(content)
updated = self._inject_frontmatter_flag(updated, "user-invocable")
updated = self._inject_frontmatter_flag(
updated, "disable-model-invocation", "false"
)
for line in updated.splitlines():
if line.startswith("name:"):
name = line.removeprefix("name:").strip().strip("\"'")
hint = ARGUMENT_HINTS.get(name.removeprefix("speckit-"))
if hint:
updated = self.inject_argument_hint(updated, hint)
break
return updated

View File

@@ -27,6 +27,7 @@ from typing import TYPE_CHECKING, Any
import yaml
from .._invocation_style import get_invocation_prefix, is_dollar_skills_agent
from .._toml_string import escape_toml_basic as _escape_toml_basic
from .._toml_string import has_illegal_toml_control as _has_illegal_toml_control
@@ -34,7 +35,7 @@ if TYPE_CHECKING:
from .manifest import IntegrationManifest
_HOOK_COMMAND_NOTE = (
"- When constructing slash commands from hook command names, "
"- When constructing command invocations from hook command names, "
"replace dots (`.`) with hyphens (`-`). "
"For example, `speckit.git.commit` → `/speckit-git-commit`.\n"
)
@@ -601,7 +602,9 @@ class IntegrationBase(ABC):
return created
@staticmethod
def resolve_command_refs(content: str, separator: str = ".") -> str:
def resolve_command_refs(
content: str, separator: str = ".", prefix: str = "/"
) -> str:
"""Replace ``__SPECKIT_COMMAND_<NAME>__`` placeholders with invocations.
Each placeholder encodes a command name in upper-case with
@@ -611,10 +614,16 @@ class IntegrationBase(ABC):
* ``separator="."`` → ``/speckit.plan``, ``/speckit.git.commit``
* ``separator="-"`` → ``/speckit-plan``, ``/speckit-git-commit``
*prefix* defaults to ``"/"`` but may be ``"$"`` for agents whose
native skills invocation uses dollar-prefixed chat commands.
"""
return re.sub(
r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__",
lambda m: "/speckit" + separator + m.group(1).lower().replace("_", separator),
lambda m: prefix
+ "speckit"
+ separator
+ m.group(1).lower().replace("_", separator),
content,
)
@@ -838,7 +847,12 @@ class IntegrationBase(ABC):
content = CommandRegistrar.rewrite_project_relative_paths(content)
# 8. Replace __SPECKIT_COMMAND_<NAME>__ with invocation strings
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
invocation_prefix = get_invocation_prefix(
agent_name, invoke_separator == "-"
)
content = IntegrationBase.resolve_command_refs(
content, invoke_separator, invocation_prefix
)
return content
@@ -1520,18 +1534,21 @@ class SkillsIntegration(IntegrationBase):
return project_root / folder / subdir
def build_command_invocation(self, command_name: str, args: str = "") -> str:
"""Skills use ``/speckit-<stem>`` (hyphenated directory name)."""
"""Build the agent's native invocation for a hyphenated skill name."""
stem = command_name
if stem.startswith("speckit."):
stem = stem[len("speckit."):]
invocation = "/speckit-" + stem.replace(".", "-")
prefix = "$" if is_dollar_skills_agent(self.key, True) else "/"
invocation = prefix + "speckit-" + stem.replace(".", "-")
if args:
invocation = f"{invocation} {args}"
return invocation
@staticmethod
def _inject_hook_command_note(content: str) -> str:
def _inject_hook_command_note(
content: str, invocation_prefix: str = "/"
) -> str:
"""Insert a dot-to-hyphen note before each hook output instruction.
Targets the line ``- For each executable hook, output the following``
@@ -1540,6 +1557,11 @@ class SkillsIntegration(IntegrationBase):
above them.
"""
note = _HOOK_COMMAND_NOTE.rstrip("\n")
if invocation_prefix != "/":
note = note.replace(
"`/speckit-git-commit`",
f"`{invocation_prefix}speckit-git-commit`",
)
def repl(m: re.Match[str]) -> str:
indent = m.group(1)
@@ -1573,10 +1595,13 @@ class SkillsIntegration(IntegrationBase):
Called by external skill generators (presets, extensions) to let
the integration inject agent-specific frontmatter or body
transformations. The base implementation injects shared skills
guidance for converting dotted hook command names to hyphenated
slash commands. Subclasses may override — see ``ClaudeIntegration``.
guidance for converting dotted hook command names to the agent-native
hyphenated command invocation (e.g. ``/speckit-git-commit`` or
``$speckit-git-commit``). Subclasses may override -- see
``ClaudeIntegration``.
"""
return self._inject_hook_command_note(content)
invocation_prefix = get_invocation_prefix(self.key, True)
return self._inject_hook_command_note(content, invocation_prefix)
def setup(
self,
@@ -1627,13 +1652,27 @@ class SkillsIntegration(IntegrationBase):
command_name = src_file.stem # e.g. "plan"
skill_name = f"speckit-{command_name.replace('.', '-')}"
# Parse frontmatter for description
# Parse frontmatter for description. Locate the closing ``---`` on
# its own line rather than with ``raw.split("---", 2)`` — a bare
# substring split stops at the first ``---`` *anywhere*, including
# one inside a value such as ``description: Separate sections
# with ---``, which truncates the frontmatter and drops later keys.
# The block between the delimiters is parsed unstripped so trailing
# newlines in literal (``|``) block scalars survive.
frontmatter: dict[str, Any] = {}
if raw.startswith("---"):
parts = raw.split("---", 2)
if len(parts) >= 3:
fm_lines = raw.splitlines(keepends=True)
fm_close = next(
(
i
for i in range(1, len(fm_lines))
if fm_lines[i].rstrip() == "---"
),
None,
)
if fm_close is not None:
try:
fm = yaml.safe_load(parts[1])
fm = yaml.safe_load("".join(fm_lines[1:fm_close]))
if isinstance(fm, dict):
frontmatter = fm
except yaml.YAMLError:
@@ -1648,11 +1687,27 @@ class SkillsIntegration(IntegrationBase):
# Strip the processed frontmatter — we rebuild it for skills.
# Preserve leading whitespace in the body to match release ZIP
# output byte-for-byte (the template body starts with \n after
# the closing ---).
# the closing ---). Scan for the closing ``---`` on its own line
# rather than ``split("---", 2)`` so a ``---`` embedded in a value
# does not truncate the frontmatter and spill it into the body.
if processed_body.startswith("---"):
parts = processed_body.split("---", 2)
if len(parts) >= 3:
processed_body = parts[2]
body_lines = processed_body.splitlines(keepends=True)
close_idx = next(
(
i
for i in range(1, len(body_lines))
if body_lines[i].rstrip() == "---"
),
None,
)
if close_idx is not None:
# Keep whatever trails the ``---`` marker on the closing
# line (normally just the newline) so the body stays
# byte-for-byte identical to ``split("---", 2)[2]``. The
# line-anchored check guarantees ``---`` sits at index 0.
processed_body = body_lines[close_idx][3:] + "".join(
body_lines[close_idx + 1 :]
)
# Select description — use the original template description
# to stay byte-for-byte identical with release ZIP output.

View File

@@ -21,6 +21,7 @@ from typing import Any, Dict, List, Optional, Tuple
import yaml
from packaging import version as pkg_version
from .._download_security import MAX_JSON_METADATA_BYTES, read_response_limited
from ..catalogs import CatalogEntry, CatalogStackBase
@@ -200,7 +201,14 @@ class IntegrationCatalog(CatalogStackBase):
final_url = resp.geturl()
if final_url != entry.url:
self._validate_catalog_url(final_url)
catalog_data = json.loads(resp.read())
catalog_data = json.loads(
read_response_limited(
resp,
max_bytes=MAX_JSON_METADATA_BYTES,
error_type=IntegrationCatalogError,
label=f"catalog from {entry.url}",
)
)
shape_error = _catalog_shape_error(catalog_data)
if shape_error is not None:

View File

@@ -379,6 +379,10 @@ class CopilotIntegration(IntegrationBase):
if not templates:
return []
from ...presets import PresetResolver
preset_resolver = PresetResolver(project_root_resolved)
dest = self.commands_dest(project_root)
dest_resolved = dest.resolve()
try:
@@ -396,7 +400,11 @@ class CopilotIntegration(IntegrationBase):
# 1. Process and write command files as .agent.md
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
resolved_template = preset_resolver.resolve(
f"speckit.{src_file.stem}", template_type="command"
)
source_path = resolved_template or src_file
raw = source_path.read_text(encoding="utf-8")
processed = self.process_template(
raw, self.key, script_type, arg_placeholder,
project_root=project_root,

View File

@@ -53,8 +53,16 @@ class GenericIntegration(MarkdownIntegration):
"""
parsed_options = parsed_options or {}
# Accept a value only when it is non-BLANK. An empty value resolves to
# the project root (``project_root / ""``) and a whitespace-only one to
# a directory literally named " ", so either would silently scatter
# command files instead of failing with the documented "required"
# error. ``strip()`` is used ONLY to decide blankness -- the value
# itself is returned verbatim, so a deliberate (if unusual) padded
# directory name still targets exactly what the user asked for. Both
# branches below apply the same rule so they cannot drift apart.
commands_dir = parsed_options.get("commands_dir")
if commands_dir:
if commands_dir and (not isinstance(commands_dir, str) or commands_dir.strip()):
return commands_dir
# Fall back to raw_options (--integration-options="--commands-dir ...")
@@ -64,9 +72,13 @@ class GenericIntegration(MarkdownIntegration):
tokens = shlex.split(raw)
for i, token in enumerate(tokens):
if token == "--commands-dir" and i + 1 < len(tokens):
return tokens[i + 1]
candidate = tokens[i + 1]
if candidate.strip():
return candidate
if token.startswith("--commands-dir="):
return token.split("=", 1)[1]
candidate = token.split("=", 1)[1]
if candidate.strip():
return candidate
raise ValueError(
"--commands-dir is required for the generic integration"

View File

@@ -7,13 +7,14 @@ class KilocodeIntegration(MarkdownIntegration):
key = "kilocode"
config = {
"name": "Kilo Code",
"folder": ".kilocode/",
"commands_subdir": "workflows",
"folder": ".kilo/",
"commands_subdir": "commands",
"install_url": None,
"requires_cli": False,
}
registrar_config = {
"dir": ".kilocode/workflows",
"dir": ".kilo/commands",
"legacy_dir": ".kilocode/workflows",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",

View File

@@ -59,8 +59,7 @@ class KimiIntegration(SkillsIntegration):
def post_process_skill_content(self, content: str) -> str:
"""Ensure in-skill cross-command references use Kimi's `/skill:` syntax."""
content = super().post_process_skill_content(content)
return content.replace("/speckit-", "/skill:speckit-")
return super().post_process_skill_content(content)
@classmethod
def options(cls) -> list[IntegrationOption]:

View File

@@ -18,3 +18,4 @@ class PiIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
multi_install_safe = True

File diff suppressed because it is too large Load Diff

View File

@@ -19,6 +19,7 @@ from .._console import console
from .._download_security import (
is_https_or_localhost_http,
is_safe_download_redirect,
read_response_limited,
)
preset_app = typer.Typer(
@@ -60,8 +61,9 @@ def preset_list():
pri = pack.get('priority', 10)
console.print(f" [bold]{pack['name']}[/bold] ({pack['id']}) v{pack['version']}{status} — priority {pri}")
console.print(f" {pack['description']}")
if pack.get("tags"):
tags_str = ", ".join(pack["tags"])
tags = pack.get("tags", [])
if isinstance(tags, list) and tags:
tags_str = _escape_markup(", ".join(str(t) for t in tags))
console.print(f" [dim]Tags: {tags_str}[/dim]")
console.print(f" [dim]Templates: {pack['template_count']}[/dim]")
console.print()
@@ -126,15 +128,15 @@ def preset_add(
if not is_https_or_localhost_http(from_url):
console.print(
"[red]Error:[/red] URL must use HTTPS with a hostname, "
"or HTTP for localhost/loopback."
"[red]Error:[/red] URL must use HTTPS with a hostname and be "
"a valid URL with a host. HTTP is only allowed for localhost, "
"127.0.0.1, and ::1."
)
raise typer.Exit(1)
console.print(f"Installing preset from [cyan]{_escape_markup(from_url)}[/cyan]...")
import urllib.error
import tempfile
import shutil
with tempfile.TemporaryDirectory() as tmpdir:
zip_path = Path(tmpdir) / "preset.zip"
@@ -162,16 +164,21 @@ def preset_add(
console.print(
"[red]Error:[/red] Preset URL redirected to a disallowed URL: "
f"{final_url}. Redirect targets must use HTTPS with a hostname, "
"or HTTP for localhost/loopback."
"or HTTP for localhost (127.0.0.1, ::1)."
)
raise typer.Exit(1)
with zip_path.open("wb") as output:
try:
shutil.copyfileobj(response, output)
except TypeError:
output.write(response.read())
except urllib.error.URLError as e:
console.print(f"[red]Error:[/red] Failed to download: {_escape_markup(str(e))}")
zip_path.write_bytes(
read_response_limited(
response,
error_type=PresetError,
label=f"preset {from_url}",
)
)
except (urllib.error.URLError, PresetError) as e:
console.print(
f"[red]Error:[/red] Failed to download: "
f"{_escape_markup(str(e))}"
)
raise typer.Exit(1)
manifest = manager.install_from_zip(zip_path, speckit_version, priority)
@@ -285,10 +292,16 @@ def preset_search(
console.print(f"\n[bold cyan]Presets ({len(results)} found):[/bold cyan]\n")
for pack in results:
console.print(f" [bold]{pack.get('name', pack['id'])}[/bold] ({pack['id']}) v{pack.get('version', '?')}")
console.print(f" {pack.get('description', '')}")
if pack.get("tags"):
tags_str = ", ".join(pack["tags"])
name = _escape_markup(str(pack.get("name", pack["id"])))
pack_id = _escape_markup(str(pack["id"]))
version = _escape_markup(str(pack.get("version", "?")))
console.print(f" [bold]{name}[/bold] ({pack_id}) v{version}")
console.print(
f" {_escape_markup(str(pack.get('description', '')))}"
)
tags = pack.get("tags", [])
if isinstance(tags, list) and tags:
tags_str = _escape_markup(", ".join(str(t) for t in tags))
console.print(f" [dim]Tags: {tags_str}[/dim]")
console.print()
@@ -367,6 +380,7 @@ def preset_info(
from . import PresetCatalog, PresetManager, PresetError
project_root = _require_specify_project()
safe_preset_id = _escape_markup(str(preset_id))
# Check if installed locally first
manager = PresetManager(project_root)
local_pack = manager.get_pack(preset_id)
@@ -378,8 +392,9 @@ def preset_info(
console.print(f" Description: {local_pack.description}")
if local_pack.author:
console.print(f" Author: {local_pack.author}")
if local_pack.tags:
console.print(f" Tags: {', '.join(local_pack.tags)}")
local_tags = local_pack.tags
if isinstance(local_tags, list) and local_tags:
console.print(f" Tags: {', '.join(str(t) for t in local_tags)}")
console.print(f" Templates: {len(local_pack.templates)}")
for tmpl in local_pack.templates:
console.print(f" - {tmpl['name']} ({tmpl['type']}): {tmpl.get('description', '')}")
@@ -408,20 +423,32 @@ def preset_info(
console.print(f"[red]Error:[/red] Preset '{preset_id}' not found (not installed and not in catalog)")
raise typer.Exit(1)
console.print(f"\n[bold cyan]Preset: {pack_info.get('name', preset_id)}[/bold cyan]\n")
console.print(f" ID: {pack_info['id']}")
console.print(f" Version: {pack_info.get('version', '?')}")
console.print(f" Description: {pack_info.get('description', '')}")
name = _escape_markup(str(pack_info.get("name", preset_id)))
console.print(f"\n[bold cyan]Preset: {name}[/bold cyan]\n")
console.print(f" ID: {_escape_markup(str(pack_info['id']))}")
console.print(
f" Version: {_escape_markup(str(pack_info.get('version', '?')))}"
)
console.print(
f" Description: {_escape_markup(str(pack_info.get('description', '')))}"
)
if pack_info.get("author"):
console.print(f" Author: {pack_info['author']}")
if pack_info.get("tags"):
console.print(f" Tags: {', '.join(pack_info['tags'])}")
console.print(
f" Author: {_escape_markup(str(pack_info['author']))}"
)
catalog_tags = pack_info.get("tags", [])
if isinstance(catalog_tags, list) and catalog_tags:
console.print(f" Tags: {', '.join(str(t) for t in catalog_tags)}")
if pack_info.get("repository"):
console.print(f" Repository: {pack_info['repository']}")
console.print(
f" Repository: {_escape_markup(str(pack_info['repository']))}"
)
if pack_info.get("license"):
console.print(f" License: {pack_info['license']}")
console.print(
f" License: {_escape_markup(str(pack_info['license']))}"
)
console.print("\n [yellow]Status: not installed[/yellow]")
console.print(f" Install with: [cyan]specify preset add {preset_id}[/cyan]")
console.print(f" Install with: [cyan]specify preset add {safe_preset_id}[/cyan]")
console.print()
@@ -580,10 +607,10 @@ def preset_catalog_list():
if entry.install_allowed
else "[yellow]discovery only[/yellow]"
)
console.print(f" [bold]{entry.name}[/bold] (priority {entry.priority})")
console.print(f" [bold]{_escape_markup(str(entry.name))}[/bold] (priority {entry.priority})")
if entry.description:
console.print(f" {entry.description}")
console.print(f" URL: {entry.url}")
console.print(f" {_escape_markup(str(entry.description))}")
console.print(f" URL: {_escape_markup(str(entry.url))}")
console.print(f" Install: {install_str}")
console.print()

View File

@@ -272,27 +272,56 @@ _BASH_FORMAT_COMMAND_RE = re.compile(
_POWERSHELL_FORMAT_COMMAND_RE = re.compile(
r"Format-SpecKitCommand\s+-CommandName\s+(['\"])([A-Za-z0-9_.-]+)\1(?:\s+-RepoRoot\s+[^\r\n]+)?"
)
_PYTHON_FORMAT_COMMAND_RETURN_RE = re.compile(
r'return f"/speckit\{separator\}\{name\}"'
)
_BASH_FORMATTER_RETURN_RE = re.compile(
r'''printf '/speckit%s%s\\n' "\$separator" "\$command_name"'''
)
_POWERSHELL_FORMATTER_RETURN_RE = re.compile(
r'return "/speckit\$separator\$name"'
)
def _format_speckit_command(command_name: str, separator: str) -> str:
def _format_speckit_command(
command_name: str, separator: str, prefix: str = "/"
) -> str:
name = command_name.strip().lstrip("/")
if name.startswith("speckit."):
name = name[len("speckit.") :]
elif name.startswith("speckit-"):
name = name[len("speckit-") :]
name = name.replace(".", separator)
return f"/speckit{separator}{name}"
return f"{prefix}speckit{separator}{name}"
def _resolve_dynamic_command_refs(content: str, separator: str) -> str:
def _resolve_dynamic_command_refs(
content: str, separator: str, prefix: str = "/"
) -> str:
"""Render script runtime command helpers for managed shared infra copies."""
bash_prefix = r"\$" if prefix == "$" else prefix
content = _BASH_FORMAT_COMMAND_RE.sub(
lambda match: _format_speckit_command(match.group(2), separator),
lambda match: _format_speckit_command(
match.group(2), separator, bash_prefix
),
content,
)
return _POWERSHELL_FORMAT_COMMAND_RE.sub(
lambda match: f"'{_format_speckit_command(match.group(2), separator)}'",
content = _POWERSHELL_FORMAT_COMMAND_RE.sub(
lambda match: f"'{_format_speckit_command(match.group(2), separator, prefix)}'",
content,
)
content = _BASH_FORMATTER_RETURN_RE.sub(
f'''printf '{prefix}speckit%s%s\\\\n' "$separator" "$command_name"''',
content,
)
powershell_prefix = "`$" if prefix == "$" else prefix
content = _POWERSHELL_FORMATTER_RETURN_RE.sub(
f'return "{powershell_prefix}speckit$separator$name"',
content,
)
return _PYTHON_FORMAT_COMMAND_RETURN_RE.sub(
f'return f"{prefix}speckit{{separator}}{{name}}"',
content,
)
@@ -305,6 +334,7 @@ def refresh_shared_templates(
repo_root: Path,
console: Any,
invoke_separator: str,
invoke_prefix: str = "/",
force: bool = False,
) -> None:
"""Refresh default-sensitive shared templates without touching scripts."""
@@ -336,7 +366,9 @@ def refresh_shared_templates(
continue
content = src.read_text(encoding="utf-8")
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
content = IntegrationBase.resolve_command_refs(
content, invoke_separator, invoke_prefix
)
planned_updates.append((dst, rel, content))
for dst, rel, content in planned_updates:
@@ -363,6 +395,7 @@ def install_shared_infra(
console: Any,
force: bool = False,
invoke_separator: str = ".",
invoke_prefix: str = "/",
refresh_managed: bool = False,
refresh_hint: str | None = None,
) -> bool:
@@ -516,8 +549,12 @@ def install_shared_infra(
if not _ensure_or_bucket_dir(dst_path.parent):
continue
content = src_path.read_text(encoding="utf-8")
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
content = _resolve_dynamic_command_refs(content, invoke_separator)
content = IntegrationBase.resolve_command_refs(
content, invoke_separator, invoke_prefix
)
content = _resolve_dynamic_command_refs(
content, invoke_separator, invoke_prefix
)
planned_copies.append(
(
dst_path,
@@ -566,7 +603,9 @@ def install_shared_infra(
continue
content = src.read_text(encoding="utf-8")
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
content = IntegrationBase.resolve_command_refs(
content, invoke_separator, invoke_prefix
)
planned_templates.append((dst, rel, content))
for dst_path, rel, content, mode in planned_copies:

View File

@@ -12,7 +12,7 @@ import json
import os
import re
import sys
from pathlib import Path
from pathlib import Path, PurePosixPath
from typing import Any
import typer
@@ -401,6 +401,12 @@ def _reject_insecure_download_redirect(old_url: str, new_url: str) -> None:
# a ceiling any legitimate workflow definition should ever approach.
_MAX_WORKFLOW_YAML_BYTES = 5 * 1024 * 1024 # 5 MiB
_DOWNLOAD_CHUNK_SIZE = 65536
# Custom step packages contain executable Python, metadata, and optional helper
# files downloaded one-by-one rather than as an archive. Mirror the archive
# ceilings so a catalog cannot turn individually valid files into an unbounded
# aggregate download.
_MAX_STEP_PACKAGE_FILES = 512
_MAX_STEP_PACKAGE_BYTES = 50 * 1024 * 1024 # 50 MiB
def _read_response_within_limit(response, max_bytes: int | None = None) -> bytes:
@@ -1048,7 +1054,18 @@ def workflow_run(
load_custom_steps(project_root)
engine = WorkflowEngine(project_root)
if not json_output:
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
# Escape the literal bracket (\[) so Rich renders `[<step id>]` instead
# of parsing it as a style tag named after the step id -- which it
# silently swallows (losing the only identifying content on the line),
# applies as formatting when the id happens to be a real style such as
# `bold`, or raises MarkupError when the id forms a closing tag (`/`),
# failing the whole run. Escape the interpolated values too, since both
# come from workflow YAML. Mirrors the `\[<type>]` step-graph precedent
# in workflow_info below.
engine.on_step_start = lambda sid, label: console.print(
f" \u25b8 \\[{_escape_markup(str(sid))}] "
f"{_escape_markup(str(label))} \u2026"
)
err = _error_console(json_output)
@@ -1170,7 +1187,18 @@ def workflow_resume(
load_custom_steps(project_root)
engine = WorkflowEngine(project_root)
if not json_output:
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
# Escape the literal bracket (\[) so Rich renders `[<step id>]` instead
# of parsing it as a style tag named after the step id -- which it
# silently swallows (losing the only identifying content on the line),
# applies as formatting when the id happens to be a real style such as
# `bold`, or raises MarkupError when the id forms a closing tag (`/`),
# failing the whole run. Escape the interpolated values too, since both
# come from workflow YAML. Mirrors the `\[<type>]` step-graph precedent
# in workflow_info below.
engine.on_step_start = lambda sid, label: console.print(
f" \u25b8 \\[{_escape_markup(str(sid))}] "
f"{_escape_markup(str(label))} \u2026"
)
inputs = _parse_input_values(input_values, json_output=json_output)
err = _error_console(json_output)
@@ -2320,7 +2348,7 @@ def workflow_search(
if desc:
console.print(f" {_escape_markup(str(desc))}")
tags = wf.get("tags", [])
if tags:
if isinstance(tags, list) and tags:
safe_tags = _escape_markup(", ".join(str(t) for t in tags))
console.print(f" [dim]Tags: {safe_tags}[/dim]")
console.print()
@@ -2354,14 +2382,25 @@ def workflow_info(
raise typer.Exit(1)
if definition:
console.print(f"\n[bold cyan]{definition.name}[/bold cyan] ({definition.id})")
console.print(f" Version: {definition.version}")
# Escape every user-controlled field: workflow.yml values (name,
# version, author, description, integration, input names/types) are not
# trusted, and console.print has Rich markup enabled, so an unescaped
# `[...]` in any of them is parsed as a style tag and silently swallowed
# (same defect fixed for the step graph below; the sibling workflow_list
# already escapes all of these).
console.print(
f"\n[bold cyan]{_escape_markup(str(definition.name))}[/bold cyan] "
f"({_escape_markup(str(definition.id))})"
)
console.print(f" Version: {_escape_markup(str(definition.version))}")
if definition.author:
console.print(f" Author: {definition.author}")
console.print(f" Author: {_escape_markup(str(definition.author))}")
if definition.description:
console.print(f" Description: {definition.description}")
console.print(f" Description: {_escape_markup(str(definition.description))}")
if definition.default_integration:
console.print(f" Integration: {definition.default_integration}")
console.print(
f" Integration: {_escape_markup(str(definition.default_integration))}"
)
if installed:
console.print(" [green]Installed[/green]")
@@ -2370,7 +2409,10 @@ def workflow_info(
for name, inp in definition.inputs.items():
if isinstance(inp, dict):
req = "required" if inp.get("required") else "optional"
console.print(f" {name} ({inp.get('type', 'string')}) — {req}")
console.print(
f" {_escape_markup(str(name))} "
f"({_escape_markup(str(inp.get('type', 'string')))}) — {req}"
)
if definition.steps:
console.print(f"\n [bold]Steps ({len(definition.steps)}):[/bold]")
@@ -2395,15 +2437,24 @@ def workflow_info(
info = None
if info:
console.print(f"\n[bold cyan]{info.get('name', workflow_id)}[/bold cyan] ({workflow_id})")
console.print(f" Version: {info.get('version', '?')}")
# Catalog-derived fields are untrusted; escape them so bracketed content
# is rendered literally rather than parsed (and swallowed) as Rich markup.
console.print(
f"\n[bold cyan]{_escape_markup(str(info.get('name', workflow_id)))}[/bold cyan] "
f"({_escape_markup(str(workflow_id))})"
)
console.print(f" Version: {_escape_markup(str(info.get('version', '?')))}")
if info.get("description"):
console.print(f" Description: {info['description']}")
if info.get("tags"):
console.print(f" Tags: {', '.join(info['tags'])}")
console.print(f" Description: {_escape_markup(str(info['description']))}")
info_tags = info.get("tags", [])
if isinstance(info_tags, list) and info_tags:
safe_tags = _escape_markup(", ".join(str(t) for t in info_tags))
console.print(f" Tags: {safe_tags}")
console.print(" [yellow]Not installed[/yellow]")
else:
console.print(f"[red]Error:[/red] Workflow '{workflow_id}' not found")
console.print(
f"[red]Error:[/red] Workflow '{_escape_markup(str(workflow_id))}' not found"
)
raise typer.Exit(1)
@@ -2424,10 +2475,10 @@ def workflow_catalog_list():
console.print("\n[bold cyan]Workflow Catalog Sources:[/bold cyan]\n")
for i, cfg in enumerate(configs):
install_status = "[green]install allowed[/green]" if cfg["install_allowed"] else "[yellow]discovery only[/yellow]"
console.print(f" [{i}] [bold]{cfg['name']}[/bold] — {install_status}")
console.print(f" {cfg['url']}")
console.print(f" [{i}] [bold]{_escape_markup(str(cfg['name']))}[/bold] — {install_status}")
console.print(f" {_escape_markup(str(cfg['url']))}")
if cfg.get("description"):
console.print(f" [dim]{cfg['description']}[/dim]")
console.print(f" [dim]{_escape_markup(str(cfg['description']))}[/dim]")
console.print()
@@ -2636,14 +2687,39 @@ def workflow_step_add(
)
raise typer.Exit(1)
step_yml_url = info.get("step_yml_url") or info.get("url")
if not step_yml_url:
declared_step_yml_url = info.get("step_yml_url")
if declared_step_yml_url is not None and not isinstance(
declared_step_yml_url, str
):
console.print(
f"[red]Error:[/red] Catalog entry for '{step_id}' has a malformed "
"step.yml URL; expected a non-empty string"
)
raise typer.Exit(1)
step_yml_url = declared_step_yml_url or info.get("url")
if step_yml_url is None or (
isinstance(step_yml_url, str) and not step_yml_url.strip()
):
console.print(f"[red]Error:[/red] Catalog entry for '{step_id}' has no URL")
raise typer.Exit(1)
if not isinstance(step_yml_url, str):
console.print(
f"[red]Error:[/red] Catalog entry for '{step_id}' has a malformed "
"step.yml URL; expected a non-empty string"
)
raise typer.Exit(1)
# Derive __init__.py URL: replace trailing step.yml with __init__.py
# or use explicit init_url if provided.
init_url = info.get("init_url")
if init_url is not None and (
not isinstance(init_url, str) or not init_url.strip()
):
console.print(
f"[red]Error:[/red] Catalog entry for '{step_id}' has a malformed "
"__init__.py URL; expected a non-empty string"
)
raise typer.Exit(1)
if not init_url:
if step_yml_url.endswith("step.yml"):
init_url = step_yml_url[: -len("step.yml")] + "__init__.py"
@@ -2654,6 +2730,41 @@ def workflow_step_add(
)
raise typer.Exit(1)
# Preflight the declared file count before creating a staging directory or
# issuing any request. The two required files are always part of the package;
# duplicate declarations for them in extra_files are ignored below and do
# not count twice.
extra_files = info.get("extra_files")
if extra_files is not None and not isinstance(extra_files, dict):
console.print(
"[yellow]Warning:[/yellow] Catalog entry 'extra_files' is not a mapping; "
"additional package files will not be downloaded."
)
extra_files = {}
def _is_required_package_file(rel_path: object) -> bool:
"""Match portable path/case aliases of the two required package files."""
if not isinstance(rel_path, str):
return False
parts = PurePosixPath(rel_path.replace("\\", "/")).parts
return len(parts) == 1 and parts[0].casefold() in {
"step.yml",
"__init__.py",
}
declared_extra_count = sum(
1
for rel_path in (extra_files or {})
if not _is_required_package_file(rel_path)
)
package_file_count = 2 + declared_extra_count
if package_file_count > _MAX_STEP_PACKAGE_FILES:
console.print(
f"[red]Error:[/red] Step package declares {package_file_count} files, "
f"exceeding the {_MAX_STEP_PACKAGE_FILES}-file limit"
)
raise typer.Exit(1)
from specify_cli.authentication.http import open_url as _open_url
def _safe_fetch(url: str) -> bytes:
@@ -2710,6 +2821,14 @@ def workflow_step_add(
console.print(f"[red]Error:[/red] Failed to download step files: {exc}")
raise typer.Exit(1)
package_bytes = len(step_yml_content) + len(init_py_content)
if package_bytes > _MAX_STEP_PACKAGE_BYTES:
console.print(
f"[red]Error:[/red] Step package exceeds the "
f"{_MAX_STEP_PACKAGE_BYTES}-byte total size limit"
)
raise typer.Exit(1)
# Validate step.yml
try:
import yaml as _yaml
@@ -2754,13 +2873,6 @@ def workflow_step_add(
# relative-path → URL. step.yml and __init__.py are ignored here (already
# written). Paths are validated to stay within the step package directory to
# prevent path-traversal attacks.
extra_files = info.get("extra_files")
if extra_files is not None and not isinstance(extra_files, dict):
console.print(
"[yellow]Warning:[/yellow] Catalog entry 'extra_files' is not a mapping; "
"additional package files will not be downloaded."
)
extra_files = {}
for rel_path, file_url in (extra_files or {}).items():
if not isinstance(rel_path, str) or not rel_path.strip():
console.print(
@@ -2768,7 +2880,7 @@ def workflow_step_add(
"empty or non-string path key"
)
raise typer.Exit(1)
if rel_path in ("step.yml", "__init__.py"):
if _is_required_package_file(rel_path):
continue # already written above
# Reject dot-path segments ('', '.', '..') that would refer to the
# package directory itself (IsADirectoryError) or escape it.
@@ -2804,6 +2916,13 @@ def workflow_step_add(
f"[red]Error:[/red] Failed to download extra file '{rel_path}': {exc}"
)
raise typer.Exit(1)
package_bytes += len(file_content)
if package_bytes > _MAX_STEP_PACKAGE_BYTES:
console.print(
f"[red]Error:[/red] Step package exceeds the "
f"{_MAX_STEP_PACKAGE_BYTES}-byte total size limit"
)
raise typer.Exit(1)
try:
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(file_content)
@@ -3067,10 +3186,10 @@ def workflow_step_catalog_list():
if cfg["install_allowed"]
else "[yellow]discovery only[/yellow]"
)
console.print(f" [{i}] [bold]{cfg['name']}[/bold] — {install_status}")
console.print(f" {cfg['url']}")
console.print(f" [{i}] [bold]{_escape_markup(str(cfg['name']))}[/bold] — {install_status}")
console.print(f" {_escape_markup(str(cfg['url']))}")
if cfg.get("description"):
console.print(f" [dim]{cfg['description']}[/dim]")
console.print(f" [dim]{_escape_markup(str(cfg['description']))}[/dim]")
console.print()

View File

@@ -22,6 +22,8 @@ from typing import Any
import yaml
from .._download_security import MAX_JSON_CATALOG_BYTES, read_response_limited
# ---------------------------------------------------------------------------
# Errors
@@ -308,7 +310,8 @@ class WorkflowCatalog:
try:
parsed = urlparse(url)
hostname = parsed.hostname
except ValueError:
_ = parsed.port
except (TypeError, ValueError):
raise WorkflowValidationError(
f"Catalog URL is malformed: {url}"
) from None
@@ -332,26 +335,45 @@ class WorkflowCatalog:
if not config_path.exists():
return None
try:
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
except (yaml.YAMLError, OSError, UnicodeError) as exc:
raise WorkflowValidationError(
f"Failed to read catalog config {config_path}: {exc}"
) from exc
# An empty document (or explicit ``null``) parses to None -> this config
# layer contributes nothing, so ``get_active_catalogs`` moves on to the
# next layer (this loader serves both the project and user configs;
# the built-in defaults apply only once every layer has returned None).
# Do NOT coerce with ``or {}`` here: that also turns a FALSY non-mapping
# (top-level ``[]``, ``false``, ``0``, ``''``) into ``{}`` and silently
# swallows it, while a TRUTHY non-mapping (``5``, a bare list) correctly
# raises below -- an inconsistency. Only None means "no document".
if data is None:
return None
if not isinstance(data, dict):
raise WorkflowValidationError(
f"Invalid catalog config: expected a mapping, "
f"got {type(data).__name__}"
)
catalogs_data = data.get("catalogs", [])
if not catalogs_data:
# Empty catalogs list (e.g. after removing last entry)
# is valid — fall back to built-in defaults.
# Same asymmetry as the top level above, one nesting level down: the
# shape check has to run BEFORE the emptiness check, or a FALSY non-list
# (``catalogs: {}``/``''``/``0``/``false``) is silently swallowed as
# "no catalogs" while a TRUTHY non-list (``catalogs: 5``) correctly
# raises. An absent key, an explicit ``catalogs:`` null, and an empty
# list all keep their existing "nothing configured here" behavior --
# only the misreported shapes change.
catalogs_data = data.get("catalogs")
if catalogs_data is None:
return None
if not isinstance(catalogs_data, list):
raise WorkflowValidationError(
f"Invalid catalog config: 'catalogs' must be a list, "
f"got {type(catalogs_data).__name__}"
)
if not catalogs_data:
# Empty catalogs list (e.g. after removing last entry)
# is valid — fall back to built-in defaults.
return None
entries: list[WorkflowCatalogEntry] = []
for idx, item in enumerate(catalogs_data):
@@ -505,7 +527,8 @@ class WorkflowCatalog:
try:
parsed = urlparse(url)
hostname = parsed.hostname
except ValueError:
_ = parsed.port
except (TypeError, ValueError):
raise WorkflowCatalogError(
f"Refusing to fetch catalog from malformed URL: {url}"
) from None
@@ -538,7 +561,14 @@ class WorkflowCatalog:
entry.url, timeout=30, redirect_validator=_validate_redirect
) as resp:
_validate_catalog_url(resp.geturl())
data = json.loads(resp.read().decode("utf-8"))
data = json.loads(
read_response_limited(
resp,
max_bytes=MAX_JSON_CATALOG_BYTES,
error_type=WorkflowCatalogError,
label="workflow catalog",
).decode("utf-8")
)
except Exception as exc:
# Fall back to cache if available
if cache_file.exists():
@@ -982,7 +1012,8 @@ class StepCatalog:
try:
parsed = urlparse(url)
hostname = parsed.hostname
except ValueError:
_ = parsed.port
except (TypeError, ValueError):
raise StepValidationError(
f"Catalog URL is malformed: {url}"
) from None
@@ -1006,24 +1037,33 @@ class StepCatalog:
if not config_path.exists():
return None
try:
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
except (yaml.YAMLError, OSError, UnicodeError) as exc:
raise StepValidationError(
f"Failed to read catalog config {config_path}: {exc}"
) from exc
# Same two guards as WorkflowCatalog._load_catalog_config above, kept in
# lockstep: this is the step-catalog twin of that loader and read the
# same way. Dropping ``or {}`` stops a falsy non-mapping top level from
# being coerced past the isinstance check, and the ``catalogs`` shape
# check runs before the emptiness check for the same reason.
if data is None:
return None
if not isinstance(data, dict):
raise StepValidationError(
f"Invalid catalog config: expected a mapping, "
f"got {type(data).__name__}"
)
catalogs_data = data.get("catalogs", [])
if not catalogs_data:
catalogs_data = data.get("catalogs")
if catalogs_data is None:
return None
if not isinstance(catalogs_data, list):
raise StepValidationError(
f"Invalid catalog config: 'catalogs' must be a list, "
f"got {type(catalogs_data).__name__}"
)
if not catalogs_data:
return None
entries: list[StepCatalogEntry] = []
for idx, item in enumerate(catalogs_data):
@@ -1178,7 +1218,8 @@ class StepCatalog:
try:
parsed = urlparse(url)
hostname = parsed.hostname
except ValueError:
_ = parsed.port
except (TypeError, ValueError):
raise StepCatalogError(
f"Refusing to fetch catalog from malformed URL: {url}"
) from None
@@ -1211,7 +1252,14 @@ class StepCatalog:
entry.url, timeout=30, redirect_validator=_validate_redirect
) as resp:
_validate_url(resp.geturl())
data = json.loads(resp.read().decode("utf-8"))
data = json.loads(
read_response_limited(
resp,
max_bytes=MAX_JSON_CATALOG_BYTES,
error_type=StepCatalogError,
label="step catalog",
).decode("utf-8")
)
except Exception as exc:
if cache_safe and cache_file.exists():
try:

View File

@@ -42,6 +42,17 @@ class WorkflowDefinition:
self.source_path = source_path
workflow = data.get("workflow", {})
# A present-but-non-mapping ``workflow:`` block (bare ``workflow:`` ->
# None, or ``workflow: <str/list>``) would crash the following
# ``workflow.get(...)`` calls with AttributeError, so construction fails
# before any validation can run. Normalize the local to {} instead: the
# header fields fall back to their defaults and ``validate_workflow``
# (which reads those parsed attributes) reports the missing
# ``workflow.id``/``workflow.name``. ``self.data`` is deliberately left
# holding the raw value, since it is what gets written back out when a
# definition is serialized. Mirrors the default_options guard below.
if not isinstance(workflow, dict):
workflow = {}
self.id: str = workflow.get("id", "")
self.name: str = workflow.get("name", "")
self.version: str = workflow.get("version", "0.0.0")
@@ -1400,6 +1411,14 @@ class WorkflowEngine:
) -> dict[str, Any]:
"""Resolve workflow inputs against definitions and provided values."""
resolved: dict[str, Any] = {}
# execute()/resume() accept UNVALIDATED definitions (load_workflow does
# not validate). A non-mapping ``inputs:`` block (bare ``inputs:`` ->
# None, or ``inputs: []``) is stored raw, so iterating ``.items()`` here
# would crash the run with AttributeError. Treat a non-mapping inputs
# block as "no inputs"; validate_workflow reports the malformed shape
# via its own isinstance check.
if not isinstance(definition.inputs, dict):
return {}
for name, input_def in definition.inputs.items():
if not isinstance(input_def, dict):
continue

View File

@@ -70,6 +70,24 @@ class DoWhileStep(StepBase):
f"Do-while step {config.get('id', '?')!r} is missing "
f"'condition' field."
)
elif not isinstance(config["condition"], (str, bool)):
# The engine re-evaluates 'condition' via evaluate_condition() after
# each iteration. That call first delegates to
# evaluate_expression() -- which returns a non-string unchanged --
# and then coerces the result with bool(). So a list/dict/number
# condition silently resolves to its truthiness (e.g.
# condition: [1, 2] is always truthy, looping to max_iterations)
# with no error. Reject those at validation, mirroring the
# prompt/shell/command 'must be a string' checks.
#
# A literal ``bool`` stays valid: an unquoted ``condition: false``
# is idiomatic YAML and evaluate_condition() already resolves it
# exactly (bool passthrough, then a no-op bool()). "true"/"false"
# and an expression like "{{ ... }}" stay valid too.
errors.append(
f"Do-while step {config.get('id', '?')!r}: 'condition' must be a "
f"string or boolean, got {type(config['condition']).__name__}."
)
max_iter = config.get("max_iterations")
if max_iter is not None:
# bool is a subclass of int, so isinstance(True, int) is True and

View File

@@ -61,6 +61,24 @@ class IfThenStep(StepBase):
errors.append(
f"If step {config.get('id', '?')!r} is missing 'condition' field."
)
elif not isinstance(config["condition"], (str, bool)):
# execute() feeds 'condition' to evaluate_condition(), which first
# delegates to evaluate_expression() -- that returns a non-string
# unchanged -- and then coerces the result with bool(). So a
# list/dict/number condition silently resolves to its truthiness
# (e.g. condition: [1, 2] is always True) with no error, branching
# wrongly on an authoring mistake. Reject those at validation,
# mirroring the prompt/shell/command 'must be a string' checks.
#
# A literal ``bool`` stays valid: an unquoted ``condition: false``
# is idiomatic YAML, evaluate_condition() already resolves it
# exactly (bool passthrough, then a no-op bool()), and this step
# itself defaults ``condition`` to ``False``. "true"/"false" and an
# expression like "{{ ... }}" are strings, so they stay valid too.
errors.append(
f"If step {config.get('id', '?')!r}: 'condition' must be a "
f"string or boolean, got {type(config['condition']).__name__}."
)
if "then" not in config:
errors.append(
f"If step {config.get('id', '?')!r} is missing 'then' field."

View File

@@ -89,8 +89,9 @@ class PromptStep(StepBase):
)
# Attempt CLI dispatch
timeout = config.get("timeout", 300)
dispatch_result = self._try_dispatch(
prompt, integration, model, context
prompt, integration, model, context, timeout=timeout
)
output: dict[str, Any] = {
@@ -136,6 +137,7 @@ class PromptStep(StepBase):
integration_key: str | None,
model: str | None,
context: StepContext,
timeout: int = 300,
) -> dict[str, Any] | None:
"""Dispatch *prompt* directly through the integration CLI."""
if not integration_key or not isinstance(integration_key, str) or not prompt:
@@ -178,6 +180,7 @@ class PromptStep(StepBase):
exec_args,
text=True,
cwd=str(project_root),
timeout=timeout,
)
return {
"exit_code": result.returncode,
@@ -190,6 +193,12 @@ class PromptStep(StepBase):
"stdout": "",
"stderr": "Interrupted by user",
}
except subprocess.TimeoutExpired:
return {
"exit_code": -1,
"stdout": "",
"stderr": f"Prompt timed out after {timeout} seconds.",
}
except OSError:
return None

View File

@@ -79,6 +79,24 @@ class WhileStep(StepBase):
f"While step {config.get('id', '?')!r} is missing "
f"'condition' field."
)
elif not isinstance(config["condition"], (str, bool)):
# execute() feeds 'condition' to evaluate_condition(), which first
# delegates to evaluate_expression() -- that returns a non-string
# unchanged -- and then coerces the result with bool(). So a
# list/dict/number condition silently resolves to its truthiness
# (e.g. condition: [1, 2] is always truthy, spinning the loop to
# max_iterations) with no error. Reject those at validation,
# mirroring the prompt/shell/command 'must be a string' checks.
#
# A literal ``bool`` stays valid: an unquoted ``condition: false``
# is idiomatic YAML, evaluate_condition() already resolves it
# exactly (bool passthrough, then a no-op bool()), and this step
# itself defaults ``condition`` to ``False``. "true"/"false" and an
# expression like "{{ ... }}" are strings, so they stay valid too.
errors.append(
f"While step {config.get('id', '?')!r}: 'condition' must be a "
f"string or boolean, got {type(config['condition']).__name__}."
)
max_iter = config.get("max_iterations")
if max_iter is not None:
# bool is a subclass of int, so isinstance(True, int) is True and

View File

@@ -139,6 +139,12 @@ Execution steps:
5. Sequential questioning loop (interactive):
- Present EXACTLY ONE question at a time.
- **Question writing quality (applies to every question, MC or short-answer):**
- Lead with `**Question:**` followed by a full interrogative that ends with `?`. The question text before the `?` must make sense on its own.
- NEVER use a topic label, section heading, or requirement id as the question itself. For example, `Acceptance device/runtime matrix (FR-023)` is INVALID — it is a label, not a question.
- After the `?`, the only permitted suffix is an optional parenthesized requirement/question id. Exact format: `**Question:** <interrogative>?` or `**Question:** <interrogative>? (FR-023)`. Never put the id before the `?`, and never use the id (alone or with a topic label) as the whole prompt.
- Immediately after the question line, add one plain-language "Why it matters" sentence (the stake for acceptance or shipping) before the recommendation/options.
- Use everyday wording; introduce jargon only if defined in the same sentence. Self-check: a reader who does not know Spec Kit must be able to answer from the Question line alone. Terse is fine; cryptic labels are not.
- For multiplechoice questions:
- **Analyze all options** and determine the **most suitable option** based on:
- Best practices for the project type

View File

@@ -1,5 +1,5 @@
---
description: Create or update the project constitution from interactive or provided principle inputs, ensuring all dependent templates stay in sync.
description: Create or update the project constitution from interactive or provided principle inputs.
handoffs:
- label: Build Specification
agent: speckit.specify
@@ -16,8 +16,8 @@ You **MUST** consider the user input before proceeding (if not empty).
## Scope Guard
This command's own work is limited to updating the project constitution and propagating
constitution-driven changes to the dependent artifacts identified in this command.
This command's own work is limited to updating the project constitution itself. Dependent templates
and commands read the constitution at runtime and are not modified here.
- Classify every part of the user input as either constitution content or a separate,
non-governance intent.
@@ -25,7 +25,7 @@ constitution-driven changes to the dependent artifacts identified in this comman
deployment requests, you **MUST NOT** execute them. Extract them as deferred intents instead.
- You **MUST NOT** create, modify, or delete application source files, feature routes,
components, tests, deployment files, or other artifacts unrelated to the constitution
workflow and its required propagation.
workflow.
- If it is unclear whether an instruction is constitution content, ask for clarification before
making changes.
- After completing the constitution update, include a `Next Actions` section for each deferred
@@ -70,7 +70,7 @@ constitution-driven changes to the dependent artifacts identified in this comman
## Outline
You are updating the project constitution at `.specify/memory/constitution.md`. This file is a TEMPLATE containing placeholder tokens in square brackets (e.g. `[PROJECT_NAME]`, `[PRINCIPLE_1_NAME]`). Your job is to (a) collect/derive concrete values, (b) fill the template precisely, and (c) propagate any amendments across dependent artifacts.
You are updating the project constitution at `.specify/memory/constitution.md`. This file is a TEMPLATE containing placeholder tokens in square brackets (e.g. `[PROJECT_NAME]`, `[PRINCIPLE_1_NAME]`). Your job is to (a) collect/derive concrete values and (b) fill the template precisely.
**Note**: If `.specify/memory/constitution.md` does not exist yet, it should have been initialized from `.specify/templates/constitution-template.md` during project setup. If it's missing, copy the template first.
@@ -96,32 +96,24 @@ Follow this execution flow:
- Ensure each Principle section: succinct name line, paragraph (or bullet list) capturing nonnegotiable rules, explicit rationale if not obvious.
- Ensure Governance section lists amendment procedure, versioning policy, and compliance review expectations.
4. Consistency propagation checklist (convert prior checklist into active validations):
- Read `.specify/templates/plan-template.md` and ensure any "Constitution Check" or rules align with updated principles.
- Read `.specify/templates/spec-template.md` for scope/requirements alignment—update if constitution adds/removes mandatory sections or constraints.
- Read `.specify/templates/tasks-template.md` and ensure task categorization reflects new or removed principle-driven task types (e.g., observability, versioning, testing discipline).
- Read each installed Spec Kit command file for your agent (including this one) — named `speckit.*` or `speckit-*` (dot or hyphen depending on the agent), or laid out as `speckit-<name>/SKILL.md` for skills-based integrations, e.g. in `.github/agents/`, `.github/skills/`, `.claude/skills/`, or your agent's equivalent commands directory — to verify no outdated references (CLAUDE-only or other agent-specific names) remain when generic guidance is required.
- Read any runtime guidance docs (e.g., `README.md`, `docs/quickstart.md`, or agent-specific guidance files if present). Update references to principles changed.
5. Produce a Sync Impact Report (prepend as an HTML comment at top of the constitution file after update):
4. Produce a Sync Impact Report (prepend as an HTML comment at top of the constitution file after update):
- Version change: old → new
- List of modified principles (old title → new title if renamed)
- Added sections
- Removed sections
- Templates requiring updates (✅ updated / ⚠ pending) with file paths
- Follow-up TODOs if any placeholders intentionally deferred.
6. Validation before final output:
5. Validation before final output:
- No remaining unexplained bracket tokens.
- Version line matches report.
- Dates ISO format YYYY-MM-DD.
- Principles are declarative, testable, and free of vague language ("should" → replace with MUST/SHOULD rationale where appropriate).
7. Write the completed constitution back to `.specify/memory/constitution.md` (overwrite).
6. Write the completed constitution back to `.specify/memory/constitution.md` (overwrite).
8. Output a final summary to the user with:
7. Output a final summary to the user with:
- New version and bump rationale.
- Any files flagged for manual follow-up.
- Any TODO placeholders or deferred items requiring manual follow-up.
- Suggested commit message (e.g., `docs: amend constitution to vX.Y.Z (principle additions + governance update)`).
- A `Next Actions` section for any deferred non-governance intents.

View File

@@ -17,6 +17,7 @@ from typer.testing import CliRunner
from specify_cli import app
from specify_cli.bundler.services.packager import build_bundle
from tests.conftest import strip_ansi
from tests.bundler_helpers import (
catalog_entry_dict,
valid_manifest_dict,
@@ -25,6 +26,42 @@ from tests.bundler_helpers import (
runner = CliRunner()
MARKUP_BUNDLE_ID = "[red]markup-id[/red]"
MARKUP_SOURCE_ID = "[underline]markup-source[/underline]"
def _configure_markup_catalog(project: Path, **overrides: object) -> dict:
entry = catalog_entry_dict(
MARKUP_BUNDLE_ID,
name="[green]Markup Name[/green]",
version="[blue]1.0.0[/blue]",
role="[magenta]Markup Role[/magenta]",
description="[yellow]Markup Description[/yellow]",
author="[cyan]Markup Author[/cyan]",
license="[bold]Markup License[/bold]",
download_url="https://example.com/markup-bundle.zip",
requires={"speckit_version": "[italic]>=0.1.0[/italic]"},
**overrides,
)
catalog = project / "markup-catalog.json"
write_catalog_file(catalog, {MARKUP_BUNDLE_ID: entry})
config = {
"schema_version": "1.0",
"catalogs": [
{
"id": MARKUP_SOURCE_ID,
"url": str(catalog),
"priority": 1,
"install_policy": "install-allowed",
}
],
}
(project / ".specify" / "bundle-catalogs.yml").write_text(
yaml.safe_dump(config),
encoding="utf-8",
)
return entry
@pytest.fixture()
def project(tmp_path: Path, monkeypatch) -> Path:
@@ -124,6 +161,24 @@ def test_search_works_without_a_project(tmp_path: Path, monkeypatch):
assert result.output.strip().startswith("[")
def test_search_escapes_catalog_markup(project: Path):
entry = _configure_markup_catalog(project)
result = runner.invoke(app, ["bundle", "search", "--offline"])
assert result.exit_code == 0, result.output
output = " ".join(strip_ansi(result.output).split())
for value in (
entry["id"],
entry["name"],
entry["version"],
entry["role"],
entry["description"],
MARKUP_SOURCE_ID,
):
assert value in output
def test_info_unknown_bundle_without_project_reports_not_found(tmp_path: Path, monkeypatch):
monkeypatch.chdir(tmp_path) # no .specify/
result = runner.invoke(app, ["bundle", "info", "does-not-exist", "--offline"])
@@ -261,6 +316,83 @@ def test_info_expands_full_component_set(project: Path, monkeypatch):
assert "Trust" in text.output
def test_info_escapes_catalog_markup(project: Path, monkeypatch):
entry = _configure_markup_catalog(project)
bundle_dir = project / "markup-bundle"
bundle_dir.mkdir()
manifest_data = valid_manifest_dict()
manifest_data["bundle"]["id"] = MARKUP_BUNDLE_ID
manifest_data["integration"] = {
"id": "[conceal]markup-integration[/conceal]"
}
manifest_path = bundle_dir / "bundle.yml"
manifest_path.write_text(yaml.safe_dump(manifest_data), encoding="utf-8")
_mock_manifest_download(monkeypatch, manifest_path)
monkeypatch.setattr(
"specify_cli.commands.bundle._manifest_component_view",
lambda manifest: [
{
"kind": "extensions",
"id": "[reverse]markup-component[/reverse]",
"version": "[strike]2.0.0[/strike]",
}
],
)
monkeypatch.setattr(
"specify_cli.commands.bundle._bundle_overlaps",
lambda project_root, manifest, *, offline: [
"[blink]markup-overlap[/blink]"
],
)
result = runner.invoke(
app,
["bundle", "info", MARKUP_BUNDLE_ID, "--offline"],
)
assert result.exit_code == 0, result.output
output = " ".join(strip_ansi(result.output).split())
for value in (
entry["id"],
entry["name"],
entry["version"],
entry["role"],
entry["description"],
entry["author"],
entry["license"],
entry["requires"]["speckit_version"],
MARKUP_SOURCE_ID,
"[conceal]markup-integration[/conceal]",
"[reverse]markup-component[/reverse]",
"[strike]2.0.0[/strike]",
"[blink]markup-overlap[/blink]",
):
assert value in output
def test_info_escapes_catalog_provides_fallback_markup(project: Path, monkeypatch):
markup_count = "[bold]markup-count[/bold]"
_configure_markup_catalog(
project,
provides={"extensions": markup_count},
)
bundle_dir = project / "markup-bundle"
bundle_dir.mkdir()
manifest_data = valid_manifest_dict(provides={})
manifest_data["bundle"]["id"] = MARKUP_BUNDLE_ID
manifest_path = bundle_dir / "bundle.yml"
manifest_path.write_text(yaml.safe_dump(manifest_data), encoding="utf-8")
_mock_manifest_download(monkeypatch, manifest_path)
result = runner.invoke(
app,
["bundle", "info", MARKUP_BUNDLE_ID, "--offline"],
)
assert result.exit_code == 0, result.output
assert markup_count in strip_ansi(result.output)
def test_info_expands_discovery_only_bundle(project: Path, monkeypatch):
# Discovery-only bundles must still be fully inspectable via `info`;
# only `install` is refused for them.

View File

@@ -113,6 +113,44 @@ def test_absent_or_empty_catalogs_is_noop(tmp_path: Path, body: str):
assert len(sources) > 0
def test_load_source_stack_rejects_unknown_schema_version(tmp_path: Path):
"""A bundle-catalogs.yml with an unsupported MAJOR schema_version must raise
on the resolution path (load_source_stack -> _merge_config), matching the
sibling reader commands_impl/catalog_config._read. Without this a file
written by a newer/incompatible Spec Kit was silently parsed under v1
assumptions on the install/search path, while the other reader rejected it."""
make_project(tmp_path)
config = {
"schema_version": "2.0",
"catalogs": [{"id": "corp", "url": "https://corp/catalog.json",
"priority": 1, "install_policy": "install-allowed"}],
}
(tmp_path / ".specify" / "bundle-catalogs.yml").write_text(
yaml.safe_dump(config), encoding="utf-8"
)
with pytest.raises(BundlerError, match="Unsupported catalog config schema version"):
load_source_stack(tmp_path)
def test_load_source_stack_accepts_matching_or_absent_schema_version(tmp_path: Path):
"""A matching major version (1.x) and an absent schema_version both stay
valid — the guard rejects only a different major, so existing configs that
omit the key are unaffected."""
make_project(tmp_path)
cfg = tmp_path / ".specify" / "bundle-catalogs.yml"
cfg.write_text(yaml.safe_dump({
"schema_version": "1.5", # same major as CONFIG_SCHEMA_VERSION (1.0)
"catalogs": [{"id": "corp", "url": "https://corp/catalog.json",
"priority": 1, "install_policy": "install-allowed"}],
}), encoding="utf-8")
assert "corp" in {s.id for s in load_source_stack(tmp_path)}
cfg.write_text(yaml.safe_dump({ # no schema_version key
"catalogs": [{"id": "corp2", "url": "https://corp2/catalog.json",
"priority": 1, "install_policy": "install-allowed"}],
}), encoding="utf-8")
assert "corp2" in {s.id for s in load_source_stack(tmp_path)}
def test_project_config_overrides_same_id(tmp_path: Path):
make_project(tmp_path)
config = {
@@ -209,6 +247,25 @@ def test_catalog_entry_rejects_non_boolean_verified():
CatalogEntry.from_dict(data)
def test_catalog_entry_preserves_sha256_through_provenance():
digest = "a" * 64
payload = catalog_payload(
{"demo": catalog_entry_dict("demo", sha256=f"sha256:{digest}")}
)
entry = load_catalog_payload(payload)["demo"]
source = CatalogSource(
id="team",
url="https://example.com/catalog.json",
priority=10,
install_policy=InstallPolicy.INSTALL_ALLOWED,
scope=Scope.PROJECT,
)
assert entry.sha256 == f"sha256:{digest}"
assert entry.with_provenance(source).sha256 == f"sha256:{digest}"
def test_load_payload_rejects_id_key_mismatch():
# The enclosing key is authoritative; an entry whose own id disagrees with
# the key must be rejected so a catalog can't list a spoofed/unresolvable id.

View File

@@ -222,6 +222,32 @@ def test_python_custom_markers_matching_bash(tmp_path: Path) -> None:
assert "old" not in content
@requires_posix_bash
def test_python_blank_markers_use_defaults_matching_bash(tmp_path: Path) -> None:
# Regression: with blank markers (config relying on the built-in defaults),
# the Bash port must fall back to DEFAULT_START/END, matching the Python and
# PowerShell ports. Previously the Bash config-parser transport dropped the
# trailing empty marker lines under $(...) command substitution, tripping the
# "malformed config parser output" guard so the default-marker substitution
# became unreachable and the context file was never updated.
markers = {"start": "", "end": ""}
repo_a, repo_b = twin_projects(
tmp_path, context_file="AGENTS.md", context_markers=markers
)
add_plan(repo_a)
add_plan(repo_b)
bash = run_bash(repo_a)
py = run_python(repo_b)
assert_parity(bash, py, repo_a, repo_b)
content = (repo_b / "AGENTS.md").read_bytes()
assert content == (repo_a / "AGENTS.md").read_bytes()
assert b"<!-- SPECKIT START -->" in content
assert b"<!-- SPECKIT END -->" in content
assert b"at specs/001-demo/plan.md" in content
@requires_posix_bash
def test_python_multiple_context_files_dedup_matching_bash(tmp_path: Path) -> None:
files = ["AGENTS.md", "docs/CONTEXT.md", "AGENTS.md"]
@@ -317,6 +343,27 @@ def test_python_mtime_fallback_matching_bash(tmp_path: Path) -> None:
assert b"at specs/001-new/plan.md" in content
@requires_posix_bash
def test_python_mtime_fallback_finds_nested_plan_matching_bash(tmp_path: Path) -> None:
# Regression: the mtime fallback must discover plan.md in nested scoped
# layouts (specs/<scope>/<feature>/plan.md), matching the Bash/PowerShell
# ports and the documented recursive-discovery contract (see #3024). A
# one-level scan (specs/*/plan.md) would miss this and omit the plan link.
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
for repo in (repo_a, repo_b):
plan = repo / "specs" / "scope-a" / "002-nested" / "plan.md"
plan.parent.mkdir(parents=True, exist_ok=True)
plan.write_text("# plan\n", encoding="utf-8")
bash = run_bash(repo_a)
py = run_python(repo_b)
assert_parity(bash, py, repo_a, repo_b)
content = (repo_b / "AGENTS.md").read_bytes()
assert content == (repo_a / "AGENTS.md").read_bytes()
assert b"at specs/scope-a/002-nested/plan.md" in content
@requires_posix_bash
def test_python_prefers_feature_json_over_mtime_matching_bash(tmp_path: Path) -> None:
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")

View File

@@ -7,7 +7,9 @@ proving the real in-process primitive dispatch (T044) works without a network.
from __future__ import annotations
import os
import zipfile
from pathlib import Path
from unittest.mock import patch
import pytest
import yaml
@@ -171,3 +173,62 @@ def test_download_manifest_rejects_non_https_url_even_offline(tmp_path: Path):
)
with pytest.raises(BundlerError, match="HTTPS"):
_download_manifest(resolved, offline=True)
def test_local_zip_uses_bounded_archive_open(tmp_path: Path):
artifact = tmp_path / "too-many-entries.zip"
with zipfile.ZipFile(artifact, "w") as archive:
archive.writestr("bundle.yml", yaml.safe_dump(valid_manifest_dict()))
for index in range(512):
archive.writestr(f"assets/{index}.txt", "")
with pytest.raises(BundlerError, match="too many entries"):
_local_manifest_source(str(artifact))
def test_invalid_local_manifest_is_rejected_before_project_init(
tmp_path: Path,
monkeypatch,
):
bundle_dir = tmp_path / "invalid-bundle"
data = valid_manifest_dict()
data["bundle"]["author"] = ""
write_manifest(bundle_dir, data)
empty_cwd = tmp_path / "empty"
empty_cwd.mkdir()
monkeypatch.chdir(empty_cwd)
runner = CliRunner()
with patch("specify_cli.commands.bundle._run_init") as run_init:
result = runner.invoke(
app,
["bundle", "install", str(bundle_dir), "--offline"],
)
assert result.exit_code == 1
assert "Missing required field: bundle.author" in result.output
run_init.assert_not_called()
def test_incompatible_local_manifest_is_rejected_before_project_init(
tmp_path: Path,
monkeypatch,
):
bundle_dir = tmp_path / "incompatible-bundle"
data = valid_manifest_dict()
data["requires"]["speckit_version"] = ">=999.0.0"
write_manifest(bundle_dir, data)
empty_cwd = tmp_path / "empty"
empty_cwd.mkdir()
monkeypatch.chdir(empty_cwd)
runner = CliRunner()
with patch("specify_cli.commands.bundle._run_init") as run_init:
result = runner.invoke(
app,
["bundle", "install", str(bundle_dir), "--offline"],
)
assert result.exit_code == 1
assert "requires Spec Kit >=999.0.0" in result.output
run_init.assert_not_called()

View File

@@ -204,19 +204,69 @@ class TestBuildCommandInvocation:
def test_skills_core_command(self):
from specify_cli.integrations import get_integration
i = get_integration("codex")
assert i.build_command_invocation("speckit.plan") == "/speckit-plan"
assert i.build_command_invocation("plan") == "/speckit-plan"
assert i.build_command_invocation("speckit.plan") == "$speckit-plan"
assert i.build_command_invocation("plan") == "$speckit-plan"
def test_skills_extension_command(self):
from specify_cli.integrations import get_integration
i = get_integration("codex")
assert i.build_command_invocation("speckit.git.commit") == "/speckit-git-commit"
assert i.build_command_invocation("git.commit") == "/speckit-git-commit"
assert i.build_command_invocation("speckit.git.commit") == "$speckit-git-commit"
assert i.build_command_invocation("git.commit") == "$speckit-git-commit"
def test_skills_extension_command_with_args(self):
from specify_cli.integrations import get_integration
i = get_integration("codex")
assert i.build_command_invocation("speckit.git.commit", "fix typo") == "/speckit-git-commit fix typo"
assert i.build_command_invocation("speckit.git.commit", "fix typo") == "$speckit-git-commit fix typo"
@pytest.mark.parametrize("integration_key", ["codex", "zcode"])
def test_dollar_skill_post_processing_is_idempotent(self, integration_key):
from specify_cli.integrations import get_integration
content = (
"---\nname: test\n---\n\n"
"Literal slash invocation: /speckit-plan\n"
"- For each executable hook, output the following based on its flag:\n"
)
integration = get_integration(integration_key)
once = integration.post_process_skill_content(content)
twice = integration.post_process_skill_content(once)
assert twice == once
assert once.count("replace dots (`.`) with hyphens") == 1
assert "$speckit-git-commit" in once
assert "/speckit-plan" in once
def test_kimi_skill_post_processing_is_idempotent(self):
"""Kimi's post_process_skill_content must be idempotent.
The hook-command note is injected with the /skill: prefix by the base
class (via get_invocation_prefix), so the idempotency check matches on
re-runs without requiring the broad /speckit- -> /skill:speckit- body
replacement to recognise a duplicate.
"""
from specify_cli.integrations import get_integration
content = (
"---\nname: test\n---\n\n"
"Literal slash invocation: /speckit-plan\n"
"- For each executable hook, output the following based on its flag:\n"
)
integration = get_integration("kimi")
once = integration.post_process_skill_content(content)
twice = integration.post_process_skill_content(once)
assert twice == once
assert once.count("replace dots (`.`) with hyphens") == 1
assert "/skill:speckit-git-commit" in once
def test_get_invocation_prefix_skill_colon(self):
"""get_invocation_prefix returns '/skill:' for Kimi in skills mode."""
from specify_cli._invocation_style import get_invocation_prefix
assert get_invocation_prefix("kimi", True) == "/skill:"
assert get_invocation_prefix("kimi", False) == "/"
assert get_invocation_prefix("codex", True) == "$"
assert get_invocation_prefix("claude", True) == "/"
def test_forge_core_command_hyphenated(self):
"""Forge installs hyphenated slash-commands (/speckit-<name>), so the
@@ -268,6 +318,26 @@ class TestResolveCommandRefs:
result = IntegrationBase.resolve_command_refs(text, "-")
assert result == "Run `/speckit-plan` to plan."
def test_dollar_prefix_core_command(self):
text = "Run `__SPECKIT_COMMAND_PLAN__` to plan."
result = IntegrationBase.resolve_command_refs(text, "-", "$")
assert result == "Run `$speckit-plan` to plan."
def test_skill_colon_prefix_core_command(self):
text = "Run `__SPECKIT_COMMAND_PLAN__` to plan."
result = IntegrationBase.resolve_command_refs(text, "-", "/skill:")
assert result == "Run `/skill:speckit-plan` to plan."
def test_process_template_kimi_uses_skill_colon_prefix(self):
"""process_template must use /skill: prefix for Kimi without relying on
post_process_skill_content's broad replacement."""
text = "---\ndescription: test\n---\nRun `__SPECKIT_COMMAND_PLAN__` to plan."
result = IntegrationBase.process_template(
text, "kimi", "sh", invoke_separator="-"
)
assert "/skill:speckit-plan" in result
assert "/speckit-plan" not in result
def test_multiple_placeholders(self):
text = "__SPECKIT_COMMAND_SPECIFY__ then __SPECKIT_COMMAND_PLAN__ then __SPECKIT_COMMAND_TASKS__"
result = IntegrationBase.resolve_command_refs(text, ".")

View File

@@ -3,6 +3,7 @@
import io
import json
import os
import runpy
import pytest
import yaml
@@ -1180,6 +1181,23 @@ class TestSharedInfraCommandRefs:
assert "__SPECKIT_COMMAND_" not in content
assert "/speckit-tasks" in content
def test_dollar_prefix_in_page_templates(self, tmp_path):
"""Dollar-style skills agents get $speckit-<name> in page templates."""
from specify_cli import _install_shared_infra
project = tmp_path / "dollar-test"
project.mkdir()
(project / ".specify").mkdir()
_install_shared_infra(
project, "sh", invoke_separator="-", invoke_prefix="$"
)
plan = project / ".specify" / "templates" / "plan-template.md"
content = plan.read_text(encoding="utf-8")
assert "$speckit-plan" in content
assert "/speckit-plan" not in content
@pytest.mark.parametrize("script_type", ["sh", "ps"])
def test_dot_separator_in_shared_scripts(self, tmp_path, script_type):
"""Markdown agents get /speckit.<name> in shared script hints."""
@@ -1220,6 +1238,48 @@ class TestSharedInfraCommandRefs:
assert "/speckit.plan" not in content
assert "/speckit.tasks" not in content
@pytest.mark.parametrize("script_type", ["sh", "ps", "py"])
def test_dollar_prefix_in_shared_scripts(self, tmp_path, script_type):
"""Dollar-style skills agents get native prefixes in shared script hints."""
from specify_cli import _install_shared_infra
project = tmp_path / f"dollar-script-{script_type}"
project.mkdir()
(project / ".specify").mkdir()
_install_shared_infra(
project, script_type, invoke_separator="-", invoke_prefix="$"
)
if script_type == "py":
state = {
"integration": "codex",
"integration_settings": {
"codex": {"invoke_separator": "-"},
},
}
(project / ".specify" / "integration.json").write_text(
json.dumps(state), encoding="utf-8"
)
common = project / ".specify" / "scripts" / "python" / "common.py"
namespace = runpy.run_path(str(common))
assert namespace["format_speckit_command"]("plan", project) == (
"$speckit-plan"
)
return
content = self._combined_script_content(project, script_type)
assert "$speckit-specify" in content
assert "$speckit-plan" in content
assert "$speckit-tasks" in content
assert "/speckit-specify" not in content
assert "/speckit-plan" not in content
assert "/speckit-tasks" not in content
if script_type == "sh":
assert r"\$speckit-specify" in content
assert r"\$speckit-plan" in content
assert r"\$speckit-tasks" in content
def test_full_init_claude_resolves_page_templates(self, tmp_path):
"""Full CLI init with Claude (skills agent) produces hyphen refs in page templates."""
from typer.testing import CliRunner
@@ -1343,6 +1403,18 @@ class TestIntegrationCatalogDiscoveryCLI:
"_install_allowed": True,
},
]
MARKUP_INTEGRATION = {
"id": "[red]markup-id[/red]",
"name": "[green]Markup Name[/green]",
"version": "[blue]1.0.0[/blue]",
"description": "[yellow]Markup Description[/yellow]",
"author": "[magenta]Markup Author[/magenta]",
"license": "[cyan]Markup License[/cyan]",
"repository": "[bold]Markup Repository[/bold]",
"tags": ["[italic]markup-tag[/italic]"],
"_catalog_name": "[underline]markup-catalog[/underline]",
"_install_allowed": False,
}
def _make_project(self, tmp_path):
project = tmp_path / "proj"
@@ -1806,6 +1878,25 @@ class TestIntegrationCatalogDiscoveryCLI:
# acme-coder is flagged _install_allowed=False, so we should warn
assert "Not directly installable" in result.output
def test_search_escapes_catalog_markup(self, tmp_path, monkeypatch):
project = self._make_project(tmp_path)
self._patch_catalog(monkeypatch, integrations=[self.MARKUP_INTEGRATION])
result = self._invoke(["integration", "search"], project)
assert result.exit_code == 0, result.output
output = _normalize_cli_output(result.output)
for value in (
self.MARKUP_INTEGRATION["id"],
self.MARKUP_INTEGRATION["name"],
self.MARKUP_INTEGRATION["version"],
self.MARKUP_INTEGRATION["description"],
self.MARKUP_INTEGRATION["author"],
self.MARKUP_INTEGRATION["tags"][0],
self.MARKUP_INTEGRATION["_catalog_name"],
):
assert value in output
# -- info --------------------------------------------------------------
def test_info_found(self, tmp_path, monkeypatch):
@@ -1828,6 +1919,19 @@ class TestIntegrationCatalogDiscoveryCLI:
assert result.exit_code == 1
assert "not found" in result.output
def test_info_not_found_escapes_query_markup(self, tmp_path, monkeypatch):
project = self._make_project(tmp_path)
self._patch_catalog(monkeypatch)
integration_id = "[red]does-not-exist[/red]"
result = self._invoke(
["integration", "info", integration_id],
project,
)
assert result.exit_code == 1
assert integration_id in _normalize_cli_output(result.output)
def test_info_builtin_not_in_catalog(self, tmp_path, monkeypatch):
project = self._make_project(tmp_path)
# Empty catalog, but copilot is a registered built-in.
@@ -1836,6 +1940,30 @@ class TestIntegrationCatalogDiscoveryCLI:
assert result.exit_code == 0, result.output
assert "Built-in integration" in result.output
def test_info_escapes_catalog_markup(self, tmp_path, monkeypatch):
project = self._make_project(tmp_path)
self._patch_catalog(monkeypatch, integrations=[self.MARKUP_INTEGRATION])
result = self._invoke(
["integration", "info", self.MARKUP_INTEGRATION["id"]],
project,
)
assert result.exit_code == 0, result.output
output = _normalize_cli_output(result.output)
for value in (
self.MARKUP_INTEGRATION["id"],
self.MARKUP_INTEGRATION["name"],
self.MARKUP_INTEGRATION["version"],
self.MARKUP_INTEGRATION["description"],
self.MARKUP_INTEGRATION["author"],
self.MARKUP_INTEGRATION["license"],
self.MARKUP_INTEGRATION["repository"],
self.MARKUP_INTEGRATION["tags"][0],
self.MARKUP_INTEGRATION["_catalog_name"],
):
assert value in output
# -- validation vs network guidance ------------------------------------
def test_search_local_config_error_shows_local_config_tip(

View File

@@ -0,0 +1,607 @@
"""Tests for AlquimiaAIIntegration."""
import json
import os
from unittest.mock import patch
import yaml
from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
from specify_cli.integrations.base import IntegrationBase, SkillsIntegration
from specify_cli.integrations.alquimia import ARGUMENT_HINTS
from specify_cli.integrations.manifest import IntegrationManifest
class TestAlquimiaAIIntegration:
def test_registered(self):
assert "alquimia" in INTEGRATION_REGISTRY
assert get_integration("alquimia") is not None
def test_is_base_integration(self):
assert isinstance(get_integration("alquimia"), IntegrationBase)
def test_config_uses_skills(self):
integration = get_integration("alquimia")
assert integration.config["folder"] == ".alquimia/"
assert integration.config["commands_subdir"] == "skills"
def test_registrar_config_uses_skill_layout(self):
integration = get_integration("alquimia")
assert integration.registrar_config["dir"] == ".alquimia/skills"
assert integration.registrar_config["format"] == "markdown"
assert integration.registrar_config["args"] == "$ARGUMENTS"
assert integration.registrar_config["extension"] == "/SKILL.md"
def test_requires_cli_is_true(self):
integration = get_integration("alquimia")
assert integration.config["requires_cli"] is True
assert integration.multi_install_safe is True
def test_build_exec_args_uses_headless_prompt_flag(self):
"""Workflow dispatch relies on the inherited
``SkillsIntegration.build_exec_args()`` — pin its argv shape so a
future change to the base class or this integration's config is
caught here rather than surfacing as a silent workflow failure."""
integration = get_integration("alquimia")
args = integration.build_exec_args(
"hello", model="alquimia-default", output_json=True
)
assert args is not None
assert args[0] == "alquimia" or args[0].endswith("/alquimia")
assert "-p" in args
assert "hello" in args
assert "--model" in args
assert "alquimia-default" in args
assert "--output-format" in args
assert "json" in args
def test_setup_creates_skill_files(self, tmp_path):
integration = get_integration("alquimia")
manifest = IntegrationManifest("alquimia", tmp_path)
created = integration.setup(tmp_path, manifest, script_type="sh")
skill_files = [path for path in created if path.name == "SKILL.md"]
assert skill_files
skills_dir = tmp_path / ".alquimia" / "skills"
assert skills_dir.is_dir()
plan_skill = skills_dir / "speckit-plan" / "SKILL.md"
assert plan_skill.exists()
content = plan_skill.read_text(encoding="utf-8")
assert "{SCRIPT}" not in content
assert "{ARGS}" not in content
assert "__AGENT__" not in content
assert "__SPECKIT_COMMAND_" not in content, "unprocessed __SPECKIT_COMMAND_*__"
assert "/speckit." not in content, (
"skills agent must use /speckit-<name> not /speckit.<name>"
)
parts = content.split("---", 2)
parsed = yaml.safe_load(parts[1])
assert parsed["name"] == "speckit-plan"
assert parsed["user-invocable"] is True
assert parsed["disable-model-invocation"] is False
assert parsed["metadata"]["source"] == "templates/commands/plan.md"
def test_render_skill_unicode(self):
"""Test rendering a skill preserves non-ASCII characters."""
integration = get_integration("alquimia")
rendered = integration._render_skill(
"constitution",
{"description": "Prüfe Konformität der Implementierung"},
"Body",
)
assert "Prüfe Konformität" in rendered
def test_setup_does_not_write_context_section(self, tmp_path):
"""The CLI no longer manages the agent context file — that is owned by
the opt-in agent-context extension. Setup must not create or touch it."""
integration = get_integration("alquimia")
manifest = IntegrationManifest("alquimia", tmp_path)
integration.setup(tmp_path, manifest, script_type="sh")
for path in tmp_path.rglob("*"):
if path.is_file():
text = path.read_text(encoding="utf-8", errors="ignore")
assert "<!-- SPECKIT START -->" not in text
def test_teardown_does_not_touch_existing_context_file(self, tmp_path):
"""A user-authored context file is left intact on teardown."""
integration = get_integration("alquimia")
ctx_path = tmp_path / "ALQUIMIA.md"
original = "# ALQUIMIA.md\n\nUser content.\n"
ctx_path.write_text(original, encoding="utf-8")
manifest = IntegrationManifest("alquimia", tmp_path)
integration.setup(tmp_path, manifest, script_type="sh")
integration.teardown(tmp_path, manifest)
assert ctx_path.read_text(encoding="utf-8") == original
def test_integration_flag_creates_skill_files_cli(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "alquimia-promote"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(
app,
[
"init",
"--here",
"--integration",
"alquimia",
"--script",
"sh",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
assert (project / ".alquimia" / "skills" / "speckit-plan" / "SKILL.md").exists()
assert not (project / ".alquimia" / "commands").exists()
init_options = json.loads(
(project / ".specify" / "init-options.json").read_text(encoding="utf-8")
)
assert init_options["ai"] == "alquimia"
assert init_options["ai_skills"] is True
assert init_options["integration"] == "alquimia"
def test_integration_flag_creates_skill_files(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "alquimia-integration"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(
app,
[
"init",
"--here",
"--integration",
"alquimia",
"--script",
"sh",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
assert (
project / ".alquimia" / "skills" / "speckit-specify" / "SKILL.md"
).exists()
assert (
project / ".specify" / "integrations" / "alquimia.manifest.json"
).exists()
def test_interactive_alquimia_selection_uses_integration_path(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "alquimia-interactive"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
with (
patch(
"specify_cli.commands.init._stdin_is_interactive", return_value=True
),
patch(
"specify_cli.commands.init.select_with_arrows",
return_value="alquimia",
),
):
result = runner.invoke(
app,
[
"init",
"--here",
"--script",
"sh",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
assert (project / ".specify" / "integration.json").exists()
assert (
project / ".specify" / "integrations" / "alquimia.manifest.json"
).exists()
skill_file = project / ".alquimia" / "skills" / "speckit-plan" / "SKILL.md"
assert skill_file.exists()
skill_content = skill_file.read_text(encoding="utf-8")
assert "user-invocable: true" in skill_content
assert "disable-model-invocation: false" in skill_content
init_options = json.loads(
(project / ".specify" / "init-options.json").read_text(encoding="utf-8")
)
assert init_options["ai"] == "alquimia"
assert init_options["ai_skills"] is True
assert init_options["integration"] == "alquimia"
def test_alquimia_init_remains_usable_when_converter_fails(self, tmp_path):
"""Alquimia init should succeed even without install_skills."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
target = tmp_path / "fail-proj"
result = runner.invoke(
app,
[
"init",
str(target),
"--integration",
"alquimia",
"--script",
"sh",
"--ignore-agent-tools",
],
)
assert result.exit_code == 0
assert (
target / ".alquimia" / "skills" / "speckit-specify" / "SKILL.md"
).exists()
def test_alquimia_preset_creates_new_skill_without_commands_dir(self, tmp_path):
from specify_cli import save_init_options
from specify_cli.presets import PresetManager
project = tmp_path / "alquimia-preset-skill"
project.mkdir()
save_init_options(
project, {"ai": "alquimia", "ai_skills": True, "script": "sh"}
)
skills_dir = project / ".alquimia" / "skills"
skills_dir.mkdir(parents=True, exist_ok=True)
preset_dir = tmp_path / "alquimia-skill-command"
preset_dir.mkdir()
(preset_dir / "commands").mkdir()
(preset_dir / "commands" / "speckit.research.md").write_text(
"---\n"
"description: Research workflow\n"
"---\n\n"
"preset:alquimia-skill-command\n"
)
manifest_data = {
"schema_version": "1.0",
"preset": {
"id": "alquimia-skill-command",
"name": "Alquimia Skill Command",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "command",
"name": "speckit.research",
"file": "commands/speckit.research.md",
}
]
},
}
with open(preset_dir / "preset.yml", "w") as f:
yaml.dump(manifest_data, f)
manager = PresetManager(project)
manager.install_from_directory(preset_dir, "0.1.5")
skill_file = skills_dir / "speckit-research" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text(encoding="utf-8")
assert "preset:alquimia-skill-command" in content
assert "name: speckit-research" in content
assert "user-invocable: true" in content
assert "disable-model-invocation: false" in content
metadata = manager.registry.get("alquimia-skill-command")
assert "speckit-research" in metadata.get("registered_skills", {}).get(
"alquimia", []
)
class TestAlquimiaArgumentHints:
"""Verify that argument-hint frontmatter is injected for Alquimia skills."""
def test_converge_has_no_argument_hint(self):
"""Converge should not advertise unsupported feature-name arguments."""
assert "converge" not in ARGUMENT_HINTS
def test_all_skills_have_hints(self, tmp_path):
"""Every skill with a configured hint must contain an argument-hint line."""
i = get_integration("alquimia")
m = IntegrationManifest("alquimia", tmp_path)
created = i.setup(tmp_path, m, script_type="sh")
skill_files = [f for f in created if f.name == "SKILL.md"]
assert len(skill_files) > 0
for f in skill_files:
stem = f.parent.name
if stem.startswith("speckit-"):
stem = stem[len("speckit-") :]
content = f.read_text(encoding="utf-8")
if stem in ARGUMENT_HINTS:
assert "argument-hint:" in content, (
f"{f.parent.name}/SKILL.md is missing argument-hint frontmatter"
)
else:
assert "argument-hint:" not in content, (
f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter"
)
def test_hints_match_expected_values(self, tmp_path):
"""Each skill's argument-hint must match the expected text."""
i = get_integration("alquimia")
m = IntegrationManifest("alquimia", tmp_path)
created = i.setup(tmp_path, m, script_type="sh")
skill_files = [f for f in created if f.name == "SKILL.md"]
for f in skill_files:
# Extract stem: speckit-plan -> plan
stem = f.parent.name
if stem.startswith("speckit-"):
stem = stem[len("speckit-") :]
expected_hint = ARGUMENT_HINTS.get(stem)
content = f.read_text(encoding="utf-8")
if expected_hint is None:
assert "argument-hint:" not in content, (
f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter"
)
else:
assert f'argument-hint: "{expected_hint}"' in content, (
f"{f.parent.name}/SKILL.md: expected hint '{expected_hint}' not found"
)
def test_hint_is_inside_frontmatter(self, tmp_path):
"""argument-hint must appear between the --- delimiters, not in the body."""
i = get_integration("alquimia")
m = IntegrationManifest("alquimia", tmp_path)
created = i.setup(tmp_path, m, script_type="sh")
skill_files = [f for f in created if f.name == "SKILL.md"]
for f in skill_files:
content = f.read_text(encoding="utf-8")
parts = content.split("---", 2)
assert len(parts) >= 3, f"No frontmatter in {f.parent.name}/SKILL.md"
frontmatter = parts[1]
body = parts[2]
stem = f.parent.name
if stem.startswith("speckit-"):
stem = stem[len("speckit-") :]
if stem in ARGUMENT_HINTS:
assert "argument-hint:" in frontmatter, (
f"{f.parent.name}/SKILL.md: argument-hint not in frontmatter section"
)
assert "argument-hint:" not in body, (
f"{f.parent.name}/SKILL.md: argument-hint leaked into body"
)
else:
assert "argument-hint:" not in content, (
f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter"
)
def test_hint_appears_after_description(self, tmp_path):
"""argument-hint must immediately follow the description line."""
i = get_integration("alquimia")
m = IntegrationManifest("alquimia", tmp_path)
created = i.setup(tmp_path, m, script_type="sh")
skill_files = [f for f in created if f.name == "SKILL.md"]
for f in skill_files:
content = f.read_text(encoding="utf-8")
lines = content.splitlines()
stem = f.parent.name
if stem.startswith("speckit-"):
stem = stem[len("speckit-") :]
if stem not in ARGUMENT_HINTS:
assert "argument-hint:" not in content, (
f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter"
)
continue
found_description = False
for idx, line in enumerate(lines):
if line.startswith("description:"):
found_description = True
assert idx + 1 < len(lines), (
f"{f.parent.name}/SKILL.md: description is last line"
)
assert lines[idx + 1].startswith("argument-hint:"), (
f"{f.parent.name}/SKILL.md: argument-hint does not follow description"
)
break
assert found_description, (
f"{f.parent.name}/SKILL.md: no description: line found in output"
)
def test_inject_argument_hint_only_in_frontmatter(self):
"""inject_argument_hint must not modify description: lines in the body."""
from specify_cli.integrations.alquimia import AlquimiaAIIntegration
content = (
"---\ndescription: My command\n---\n\ndescription: this is body text\n"
)
result = AlquimiaAIIntegration.inject_argument_hint(content, "Test hint")
lines = result.splitlines()
hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:"))
assert hint_count == 1, (
f"Expected exactly 1 argument-hint line, found {hint_count}"
)
def test_inject_argument_hint_skips_if_already_present(self):
"""inject_argument_hint must not duplicate if argument-hint already exists."""
from specify_cli.integrations.alquimia import AlquimiaAIIntegration
content = (
"---\n"
"description: My command\n"
'argument-hint: "Existing hint"\n'
"---\n"
"\n"
"Body text\n"
)
result = AlquimiaAIIntegration.inject_argument_hint(content, "New hint")
assert result == content, "Content should be unchanged when hint already exists"
lines = result.splitlines()
hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:"))
assert hint_count == 1
class TestAlquimiaDisableModelInvocation:
"""Verify disable-model-invocation is false for Alquimia skills."""
def test_setup_sets_disable_model_invocation_false(self, tmp_path):
"""Generated SKILL.md files must have disable-model-invocation: false."""
i = get_integration("alquimia")
m = IntegrationManifest("alquimia", tmp_path)
created = i.setup(tmp_path, m, script_type="sh")
skill_files = [f for f in created if f.name == "SKILL.md"]
assert len(skill_files) > 0
for f in skill_files:
content = f.read_text(encoding="utf-8")
parts = content.split("---", 2)
parsed = yaml.safe_load(parts[1])
assert parsed["disable-model-invocation"] is False, (
f"{f.parent.name}: expected disable-model-invocation: false"
)
def test_disable_model_invocation_not_true(self, tmp_path):
"""No Alquimia skill should have disable-model-invocation: true."""
i = get_integration("alquimia")
m = IntegrationManifest("alquimia", tmp_path)
created = i.setup(tmp_path, m, script_type="sh")
for f in created:
if f.name != "SKILL.md":
continue
content = f.read_text(encoding="utf-8")
assert "disable-model-invocation: true" not in content, (
f"{f.parent.name}: must not have disable-model-invocation: true"
)
def test_non_alquimia_agents_lack_disable_model_invocation(self, tmp_path):
"""Non-Alquimia skill agents should not get disable-model-invocation."""
from specify_cli.agents import CommandRegistrar
fm = CommandRegistrar.build_skill_frontmatter(
"codex", "speckit-plan", "desc", "templates/commands/plan.md"
)
assert "disable-model-invocation" not in fm
assert "user-invocable" not in fm
def test_skills_default_post_process_preserves_content_without_hooks(
self, tmp_path
):
"""SkillsIntegration agents without an override preserve non-hook content."""
# ``agy`` is a plain SkillsIntegration with no post-process override,
# so it stands in for the base-class default behavior.
agy = get_integration("agy")
if agy is None:
return # agy not registered in this build
content = "---\nname: test\n---\nBody"
assert agy.post_process_skill_content(content) == content
class TestAlquimiaHookCommandNote:
"""Verify dot-to-hyphen normalization note is injected in hook sections."""
def test_hook_note_injected_in_skills_with_hooks(self, tmp_path):
"""Skills that have hook sections should get the normalization note."""
i = get_integration("alquimia")
m = IntegrationManifest("alquimia", tmp_path)
i.setup(tmp_path, m, script_type="sh")
specify_skill = tmp_path / ".alquimia/skills/speckit-specify/SKILL.md"
assert specify_skill.exists()
content = specify_skill.read_text(encoding="utf-8")
# specify.md has hook sections
assert "replace dots" in content, (
"speckit-specify should have dot-to-hyphen hook note"
)
def test_hook_note_not_in_skills_without_hooks(self, tmp_path):
"""Skills without hook sections should not get the note."""
content = "---\nname: test\ndescription: test\n---\n\nNo hooks here.\n"
result = SkillsIntegration._inject_hook_command_note(content)
assert "replace dots" not in result
def test_hook_note_idempotent(self, tmp_path):
"""Injecting the note twice should not duplicate it."""
content = (
"---\nname: test\n---\n\n"
"- For each executable hook, output the following based on its flag:\n"
)
once = SkillsIntegration._inject_hook_command_note(content)
twice = SkillsIntegration._inject_hook_command_note(once)
assert once == twice, "Hook note injection should be idempotent"
def test_hook_note_fills_missing_repeated_instructions(self, tmp_path):
"""Already-noted hook sections should not suppress later sections."""
from specify_cli.integrations.base import _HOOK_COMMAND_NOTE
content = (
"---\nname: test\n---\n\n"
f"{_HOOK_COMMAND_NOTE}"
"- For each executable hook, output the following based on its flag:\n"
"\n"
" - For each executable hook, output the following based on its flag:\n"
)
result = SkillsIntegration._inject_hook_command_note(content)
assert result.count("replace dots (`.`) with hyphens") == 2
def test_hook_note_not_suppressed_by_unrelated_phrase(self, tmp_path):
"""Unrelated text should not trip the hook-note idempotence guard."""
content = (
"---\nname: test\n---\n\n"
"This paragraph says replace dots in a different context.\n"
"- For each executable hook, output the following based on its flag:\n"
)
result = SkillsIntegration._inject_hook_command_note(content)
assert "This paragraph says replace dots in a different context." in result
assert result.count("replace dots (`.`) with hyphens") == 1
def test_hook_note_preserves_indentation(self, tmp_path):
"""The injected note should match the indentation of the target line."""
content = (
"---\nname: test\n---\n\n"
" - For each executable hook, output the following\n"
)
result = SkillsIntegration._inject_hook_command_note(content)
lines = result.splitlines()
note_line = [line for line in lines if "replace dots" in line][0]
assert note_line.startswith(" "), "Note should preserve indentation"
def test_post_process_injects_all_alquimia_flags(self):
"""post_process_skill_content should inject all Alquimia-specific fields."""
i = get_integration("alquimia")
content = (
"---\nname: test\ndescription: test\n---\n\n"
"- For each executable hook, output the following\n"
)
result = i.post_process_skill_content(content)
assert "user-invocable: true" in result
assert "disable-model-invocation: false" in result
assert "replace dots" in result

View File

@@ -191,7 +191,7 @@ class SkillsIntegrationTests:
"---\n"
"name: test\n"
"---\n\n"
"- When constructing slash commands from hook command names, "
"- When constructing command invocations from hook command names, "
"replace dots (`.`) with hyphens (`-`). "
"For example, `speckit.git.commit` → `/speckit-git-commit`.\n"
"- For each executable hook, output the following first block:\n"

View File

@@ -220,6 +220,33 @@ class TestActiveCatalogs:
# ---------------------------------------------------------------------------
class _OversizedResponse:
"""Response stub that supports bounded streaming reads for oversized-catalog tests."""
def __init__(self, data, url=""):
self._data = json.dumps(data).encode()
self._url = url if isinstance(url, str) else url.full_url
self._pos = 0
def read(self, n=-1):
if n < 0:
chunk = self._data[self._pos:]
self._pos = len(self._data)
return chunk
chunk = self._data[self._pos : self._pos + n]
self._pos += len(chunk)
return chunk
def geturl(self):
return self._url
def __enter__(self):
return self
def __exit__(self, *a):
pass
class TestCatalogFetch:
"""Tests that use a local HTTP server stub via monkeypatch."""
@@ -230,9 +257,16 @@ class TestCatalogFetch:
def __init__(self, data, url=""):
self._data = json.dumps(data).encode()
self._url = url if isinstance(url, str) else url.full_url
self._pos = 0
def read(self):
return self._data
def read(self, n=-1):
if n < 0:
chunk = self._data[self._pos:]
self._pos = len(self._data)
return chunk
chunk = self._data[self._pos:self._pos + n]
self._pos += len(chunk)
return chunk
def geturl(self):
return self._url
@@ -395,6 +429,90 @@ class TestCatalogFetch:
with pytest.raises(IntegrationCatalogError, match="Failed to fetch any integration catalog"):
cat.search()
def test_oversized_catalog_response_rejected(self, tmp_path, monkeypatch):
"""Response exceeding MAX_JSON_METADATA_BYTES is caught as IntegrationCatalogError.
The per-entry error is logged as a warning and skipped (not fatal).
When ALL catalogs are oversized, search() raises the aggregate error.
"""
from specify_cli._download_security import MAX_JSON_METADATA_BYTES
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
(tmp_path / ".specify").mkdir()
cat = IntegrationCatalog(tmp_path)
# Build a valid catalog dict whose JSON encoding exceeds the limit.
oversized = {
"schema_version": "1.0",
"integrations": {},
"padding": "x" * (MAX_JSON_METADATA_BYTES + 1),
}
import specify_cli.authentication.http as _auth_http
def _oversized_urlopen(req, timeout=10):
url = req if isinstance(req, str) else req.full_url
return _OversizedResponse(oversized, url)
monkeypatch.setattr(_auth_http.urllib.request, "urlopen", _oversized_urlopen)
# Both default + community catalogs are oversized → all fail → aggregate error.
# The per-entry IntegrationCatalogError (with "exceeds maximum size") is
# logged as a warning; the aggregate raise has a different message.
with pytest.raises(IntegrationCatalogError, match="Failed to fetch any integration catalog"):
cat.search()
def test_oversized_catalog_does_not_block_healthy_one(self, tmp_path, monkeypatch):
"""When one catalog is oversized, the healthy catalog still returns results."""
from specify_cli._download_security import MAX_JSON_METADATA_BYTES
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
specify = tmp_path / ".specify"
specify.mkdir()
healthy_catalog = {
"schema_version": "1.0",
"integrations": {
"good-agent": {
"id": "good-agent",
"name": "Good Agent",
"version": "1.0.0",
"description": "A healthy integration",
"author": "test-org",
},
},
}
oversized_catalog = {
"schema_version": "1.0",
"integrations": {},
"padding": "x" * (MAX_JSON_METADATA_BYTES + 1),
}
cfg = specify / "integration-catalogs.yml"
cfg.write_text(yaml.dump({"catalogs": [
{"url": "https://healthy.example.com/catalog.json", "name": "healthy", "priority": 1, "install_allowed": True},
{"url": "https://oversized.example.com/catalog.json", "name": "oversized", "priority": 2, "install_allowed": True},
]}))
cat = IntegrationCatalog(tmp_path)
import specify_cli.authentication.http as _auth_http
def _multi_catalog_urlopen(req, timeout=10):
url = req if isinstance(req, str) else req.full_url
if "oversized" in url:
return _OversizedResponse(oversized_catalog, url)
return _OversizedResponse(healthy_catalog, url)
monkeypatch.setattr(_auth_http.urllib.request, "urlopen", _multi_catalog_urlopen)
# The oversized catalog is skipped; the healthy catalog's integrations are returned.
results = cat.search()
ids = [r["id"] for r in results]
assert "good-agent" in ids
def test_clear_cache(self, tmp_path):
(tmp_path / ".specify").mkdir()
cat = IntegrationCatalog(tmp_path)
@@ -592,8 +710,15 @@ class TestIntegrationListCatalog:
def __init__(self, data, url=""):
self._data = json.dumps(data).encode()
self._url = url if isinstance(url, str) else url.full_url
def read(self):
return self._data
self._pos = 0
def read(self, n=-1):
if n < 0:
chunk = self._data[self._pos:]
self._pos = len(self._data)
return chunk
chunk = self._data[self._pos:self._pos + n]
self._pos += len(chunk)
return chunk
def geturl(self):
return self._url
def __enter__(self):
@@ -633,6 +758,40 @@ class TestIntegrationListCatalog:
assert "copilot" in result.output
assert "installed" in result.output
def test_catalog_list_escapes_rich_markup(self, tmp_path, monkeypatch):
"""User-editable catalog name/url/description must not be parsed as Rich markup."""
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.integrations.catalog import IntegrationCatalog
runner = CliRunner()
project = self._init_project(tmp_path)
configs = [
{
"name": "Bracket [Catalog]",
"url": "https://example.com/[cat].json",
"description": "desc [with] brackets",
"install_allowed": True,
},
]
monkeypatch.setattr(
IntegrationCatalog,
"get_project_catalog_configs",
lambda self: [dict(c) for c in configs],
)
old = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "catalog", "list"])
finally:
os.chdir(old)
assert result.exit_code == 0, result.output
assert "Bracket [Catalog]" in result.output
assert "https://example.com/[cat].json" in result.output
assert "desc [with] brackets" in result.output
# ---------------------------------------------------------------------------
# CLI: integration upgrade

View File

@@ -303,7 +303,7 @@ class TestClaudeIntegration:
assert "disable-model-invocation: false" in content
metadata = manager.registry.get("claude-skill-command")
assert "speckit-research" in metadata.get("registered_skills", [])
assert "speckit-research" in metadata.get("registered_skills", {}).get("claude", [])
class TestClaudeArgumentHints:

View File

@@ -12,7 +12,6 @@ class TestCodexIntegration(SkillsIntegrationTests):
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".agents/skills"
class TestCodexInitFlow:
"""--integration codex creates expected files."""
@@ -98,6 +97,8 @@ class TestCodexHookCommandNote:
assert "replace dots" in content, (
"speckit-specify should have dot-to-hyphen hook note"
)
assert "constructing command invocations" in content
assert "constructing slash commands" not in content
def test_hook_note_not_in_skills_without_hooks(self):
"""Skills without hook sections should not get the note."""

View File

@@ -188,6 +188,43 @@ class TestCopilotIntegration:
assert "Copy `.specify/templates/spec-template.md`" not in content
assert "Load `.specify/templates/spec-template.md`" not in content
def test_setup_falls_back_to_bundled_command_template_without_preset_override(self, tmp_path):
"""Copilot should keep using the bundled specify command template when no preset override exists."""
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
copilot.setup(tmp_path, m)
specify_file = tmp_path / ".github" / "agents" / "speckit.specify.agent.md"
content = specify_file.read_text(encoding="utf-8")
assert "Create or update the feature specification" in content
assert "preset override content" not in content
def test_setup_uses_preset_command_override_when_present(self, tmp_path):
"""Copilot should prefer a preset-provided command template over the bundled one."""
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
preset_dir = tmp_path / ".specify" / "presets" / "demo"
(preset_dir / "commands").mkdir(parents=True, exist_ok=True)
(preset_dir / "commands" / "speckit.specify.md").write_text(
"preset override content\n",
encoding="utf-8",
)
(tmp_path / ".specify" / "presets" / ".registry").write_text(
'{"schema_version": "1.0", "presets": {"demo": {"version": "1.0.0", "source": "local", "enabled": true, "priority": 10}}}',
encoding="utf-8",
)
copilot.setup(tmp_path, m)
specify_file = tmp_path / ".github" / "agents" / "speckit.specify.agent.md"
content = specify_file.read_text(encoding="utf-8")
assert "preset override content" in content
assert "Create or update the feature specification" not in content
def test_plan_command_has_no_context_placeholder(self, tmp_path):
"""The core plan command must not carry a context-file placeholder —
agent context files are owned by the opt-in agent-context extension."""

View File

@@ -43,6 +43,20 @@ class TestDroidIntegration(SkillsIntegrationTests):
i = get_integration(self.KEY)
assert i.multi_install_safe is True
def test_is_slash_skills_agent(self):
"""Droid is an always-skills agent whose commands install as
/speckit-<name>, so is_slash_skills_agent must report True — otherwise
hook invocations and the init next-steps panel render the dotted
/speckit.<name> form Droid never registers (mirrors grok/trae/zed/devin)."""
from specify_cli._invocation_style import is_slash_skills_agent
# True in BOTH the enabled and disabled cases: Droid is *always* slash,
# not conditional. The disabled case is what distinguishes an
# ALWAYS_SLASH agent from a CONDITIONAL_SLASH one (which would be False
# when ai_skills is disabled).
assert is_slash_skills_agent("droid", True) is True
assert is_slash_skills_agent("droid", False) is True
def test_install_url_points_to_factory(self):
i = get_integration(self.KEY)
url = i.config.get("install_url")

View File

@@ -422,7 +422,12 @@ class TestForgeCommandRegistrar:
# Kilo Code uses standard markdown format without name injection.
# The format_name callback should not be invoked for non-Forge agents.
kilocode_cmd = tmp_path / ".kilocode" / "workflows" / "speckit.my-extension.example.md"
kilocode_cmd = (
tmp_path
/ ".kilo"
/ "commands"
/ "speckit.my-extension.example.md"
)
assert kilocode_cmd.exists()
content = kilocode_cmd.read_text(encoding="utf-8")

View File

@@ -55,6 +55,62 @@ class TestGenericIntegration:
with pytest.raises(ValueError, match="--commands-dir is required"):
i.setup(tmp_path, m, parsed_options={"commands_dir": ""})
@pytest.mark.parametrize("blank", [" ", "\t"])
def test_resolve_commands_dir_rejects_blank_parsed_value(self, blank):
"""A whitespace-only value must raise too: it resolves to a directory
literally named " ", scattering command files just like the empty case."""
from specify_cli.integrations.generic import GenericIntegration
with pytest.raises(ValueError, match="--commands-dir is required"):
GenericIntegration._resolve_commands_dir({"commands_dir": blank}, {})
@pytest.mark.parametrize(
"raw", ["--commands-dir ' '", "--commands-dir=' '", "--commands-dir '\t'"]
)
def test_resolve_commands_dir_rejects_blank_raw_value(self, raw):
"""Same rule on the raw_options branch, so the two cannot drift apart."""
from specify_cli.integrations.generic import GenericIntegration
with pytest.raises(ValueError, match="--commands-dir is required"):
GenericIntegration._resolve_commands_dir({}, {"raw_options": raw})
@pytest.mark.parametrize("padded", [" .myagent/cmds ", "\t.myagent/cmds"])
def test_resolve_commands_dir_returns_padded_value_verbatim(self, padded):
"""A padded but non-blank value is accepted and returned UNCHANGED: the
blankness test uses strip(), but rewriting the value would silently
retarget a directory the user asked for by name."""
from specify_cli.integrations.generic import GenericIntegration
assert GenericIntegration._resolve_commands_dir(
{"commands_dir": padded}, {}
) == padded
# Quoted in raw_options, since shlex.split() would otherwise consume the
# surrounding whitespace before this code ever sees it.
assert GenericIntegration._resolve_commands_dir(
{}, {"raw_options": f"--commands-dir='{padded}'"}
) == padded
@pytest.mark.parametrize("raw", ["--commands-dir=", "--commands-dir ''", '--commands-dir ""'])
def test_resolve_commands_dir_rejects_empty_raw_value(self, raw):
"""An empty --commands-dir in raw_options must raise the same "required"
error as the parsed-options path — not return "" (which resolves to the
project root and writes command files there). Mirrors the parsed branch."""
from specify_cli.integrations.generic import GenericIntegration
with pytest.raises(ValueError, match="--commands-dir is required"):
GenericIntegration._resolve_commands_dir({}, {"raw_options": raw})
def test_resolve_commands_dir_accepts_nonempty_raw_value(self):
"""A non-empty raw --commands-dir still resolves unchanged."""
from specify_cli.integrations.generic import GenericIntegration
assert GenericIntegration._resolve_commands_dir(
{}, {"raw_options": "--commands-dir .myagent/commands"}
) == ".myagent/commands"
assert GenericIntegration._resolve_commands_dir(
{}, {"raw_options": "--commands-dir=.myagent/commands"}
) == ".myagent/commands"
def test_setup_writes_to_correct_directory(self, tmp_path):
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)

View File

@@ -1,10 +1,76 @@
"""Tests for KilocodeIntegration."""
from specify_cli.agents import CommandRegistrar
from specify_cli.integrations import get_integration
from .test_integration_base_markdown import MarkdownIntegrationTests
class TestKilocodeIntegration(MarkdownIntegrationTests):
KEY = "kilocode"
FOLDER = ".kilocode/"
COMMANDS_SUBDIR = "workflows"
REGISTRAR_DIR = ".kilocode/workflows"
FOLDER = ".kilo/"
COMMANDS_SUBDIR = "commands"
REGISTRAR_DIR = ".kilo/commands"
def test_registrar_config_has_legacy_dir(self):
integration = get_integration(self.KEY)
assert integration.registrar_config["legacy_dir"] == ".kilocode/workflows"
def test_legacy_dir_extension_registration(self, tmp_path):
"""Extension commands still register into legacy Kilo projects."""
legacy_dir = tmp_path / ".kilocode" / "workflows"
legacy_dir.mkdir(parents=True)
(legacy_dir / "speckit.specify.md").write_text(
"# existing", encoding="utf-8"
)
src_dir = tmp_path / "_ext_src"
src_dir.mkdir()
(src_dir / "myext.md").write_text(
"---\ndescription: test\n---\n# ext command",
encoding="utf-8",
)
registrar = CommandRegistrar()
commands = [{"name": "speckit.myext", "file": "myext.md"}]
results = registrar.register_commands(
self.KEY,
commands,
"test-ext",
src_dir,
tmp_path,
)
assert results == ["speckit.myext"]
assert (legacy_dir / "speckit.myext.md").exists()
assert not (tmp_path / ".kilo" / "commands").exists()
def test_legacy_dir_extension_unregister(self, tmp_path):
"""Unregister removes commands from legacy Kilo projects."""
legacy_dir = tmp_path / ".kilocode" / "workflows"
legacy_dir.mkdir(parents=True)
cmd_file = legacy_dir / "speckit.myext.md"
cmd_file.write_text("# ext command", encoding="utf-8")
registrar = CommandRegistrar()
registrar.unregister_commands({"kilocode": ["speckit.myext"]}, tmp_path)
assert not cmd_file.exists()
def test_unregister_cleans_legacy_when_both_dirs_exist(self, tmp_path):
"""Unregister removes stale legacy files after Kilo path migration."""
canonical_dir = tmp_path / ".kilo" / "commands"
canonical_dir.mkdir(parents=True)
legacy_dir = tmp_path / ".kilocode" / "workflows"
legacy_dir.mkdir(parents=True)
canonical_cmd = canonical_dir / "speckit.myext.md"
canonical_cmd.write_text("# ext command", encoding="utf-8")
legacy_cmd = legacy_dir / "speckit.myext.md"
legacy_cmd.write_text("# stale ext command", encoding="utf-8")
registrar = CommandRegistrar()
registrar.unregister_commands({"kilocode": ["speckit.myext"]}, tmp_path)
assert not canonical_cmd.exists()
assert not legacy_cmd.exists()

View File

@@ -1,5 +1,7 @@
"""Tests for PiIntegration."""
from specify_cli.integrations import get_integration
from .test_integration_base_markdown import MarkdownIntegrationTests
@@ -8,3 +10,9 @@ class TestPiIntegration(MarkdownIntegrationTests):
FOLDER = ".pi/"
COMMANDS_SUBDIR = "prompts"
REGISTRAR_DIR = ".pi/prompts"
def test_multi_install_safe(self):
# Pi writes only to its isolated, static root .pi/prompts, disjoint from
# every other integration, so it must be co-install safe (mirrors
# qwen/shai/qodercli and the kiro-cli #3471 precedent).
assert get_integration(self.KEY).multi_install_safe is True

Some files were not shown because too many files have changed in this diff Show More