Compare commits

..
27 Commits
Author SHA1 Message Date
github-actions[bot] b46ce37f6c chore: bump version to 0.14.3 2026-07-28 14:15:03 +00:00
github-actions[bot]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>Copilot
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
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
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
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 THUETandGitHub 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-bilalandGitHub 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
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
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
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
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
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-bilalandGitHub 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
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
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
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
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
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
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]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>Copilot
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]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>Copilot
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]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>Copilot
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]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>Copilot
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
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
OscarandGitHub 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
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 RiemGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
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
59 changed files with 17441 additions and 835 deletions
+1 -1
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
+1
View File
@@ -62,6 +62,7 @@ body:
label: AI Agent
description: Which AI agent are you using?
options:
- Alquimia AI
- Amp
- Antigravity
- Auggie CLI
@@ -56,6 +56,7 @@ body:
description: Does this feature relate to a specific AI agent?
options:
- All agents
- Alquimia AI
- Amp
- Antigravity
- Auggie CLI
+88
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
@@ -511,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.*
+31
View File
@@ -2,6 +2,37 @@
<!-- insert new changelog below this comment -->
## [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
+1 -1
View File
@@ -159,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 verify and review quality gates plus transactional merge, cleanup, and delivery summary | `process` | Read+Write | [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) |
+2 -1
View File
@@ -19,8 +19,9 @@ 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 | Governs traceable intake CRUD, bounded public HTTPS sources, and explicitly approved single or series authoring without granting execution authority. | 10 templates, 5 commands, 4 scripts | — | [spec-kit-preset-intake-authoring-governance](https://github.com/hindermath/spec-kit-preset-intake-authoring-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, and campaign intake files before Spec Kit execution and binds accepted outcomes to normalized content hashes. | 8 templates, 3 commands, 2 scripts | — | [spec-kit-preset-intake-review-governance](https://github.com/hindermath/spec-kit-preset-intake-review-governance) |
| Intake Sequencing Governance | Manages traceable intake-series order, typed dependencies, lifecycle, and safe next-candidate selection without executing downstream workflows. | 10 templates, 6 commands, 5 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) |
+15 -1
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` | |
@@ -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,6 +272,7 @@ The currently declared multi-install safe integrations are:
| Key | Command directory |
| --- | ----------------- |
| `alquimia` | `.alquimia/skills` |
| `auggie` | `.augment/commands` |
| `claude` | `.claude/skills` |
| `cline` | `.clinerules/workflows` |
@@ -303,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.
+1 -1
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.
@@ -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",
@@ -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
@@ -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,
)
+2
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:
```
@@ -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 1–2) 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
+19 -16
View File
@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-07-24T00:00:00Z",
"updated_at": "2026-07-27T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"aide": {
@@ -1619,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",
@@ -1664,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",
@@ -2075,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",
@@ -2102,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",
@@ -4818,11 +4818,11 @@
"verify-review-ship": {
"name": "Verify Review Ship",
"id": "verify-review-ship",
"description": "Adds verify and review quality gates plus transactional merge, cleanup, and delivery summary.",
"description": "Post-convergence operational verification, technical review, learning governance, and transactional delivery.",
"author": "Carlos Eduardo Gevaerd Araujo",
"version": "0.3.0",
"download_url": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/archive/refs/tags/v0.3.0.zip",
"sha256": "a7326c899855f46ff28e9f03ede2f89c4db0fd2b8a64c85017b3ab639e004fd3",
"version": "0.4.1",
"download_url": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/archive/refs/tags/v0.4.1.zip",
"sha256": "cfa89b405fcf4857745653e923dfab92f101fbdda15e1e8757ad9f2ea55ae5e2",
"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",
@@ -4831,24 +4831,27 @@
"category": "process",
"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",
"merge",
"workflow"
"cleanup",
"learning",
"governance",
"agent-skills"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-10T00:00:00Z",
"updated_at": "2026-07-24T00:00:00Z"
"updated_at": "2026-07-27T00:00:00Z"
},
"verify-tasks": {
"name": "Verify Tasks Extension",
+10 -1
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",
+39 -10
View File
@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-07-24T00:00:00Z",
"updated_at": "2026-07-28T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
"presets": {
@@ -368,31 +368,31 @@
"intake-authoring-governance": {
"name": "Intake Authoring Governance",
"id": "intake-authoring-governance",
"version": "0.2.0",
"description": "Governs traceable intake CRUD, bounded public HTTPS sources, and explicitly approved single or series authoring without granting execution authority.",
"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.2.0.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.2.0/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": 10,
"templates": 12,
"commands": 5,
"scripts": 4
"scripts": 7
},
"tags": [
"intake",
"authoring",
"governance",
"provenance",
"requirements"
"requirements",
"migration"
],
"created_at": "2026-07-22T00:00:00Z",
"updated_at": "2026-07-24T00:00:00Z"
"updated_at": "2026-07-28T00:00:00Z"
},
"intake-review-governance": {
"name": "Intake Review Governance",
@@ -423,6 +423,35 @@
"created_at": "2026-07-21T00:00:00Z",
"updated_at": "2026-07-24T00:00:00Z"
},
"intake-sequencing-governance": {
"name": "Intake Sequencing Governance",
"id": "intake-sequencing-governance",
"version": "0.1.0",
"description": "Manages traceable intake-series order, typed dependencies, lifecycle, and safe next-candidate selection without executing downstream workflows.",
"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.1.0.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.1.0/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.3"
},
"provides": {
"templates": 10,
"commands": 6,
"scripts": 5
},
"tags": [
"intake",
"sequencing",
"governance",
"dag",
"lifecycle"
],
"created_at": "2026-07-27T00:00:00Z",
"updated_at": "2026-07-27T00:00:00Z"
},
"isaqb-architecture-governance": {
"name": "iSAQB Architecture Governance",
"id": "isaqb-architecture-governance",
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.14.2"
version = "0.14.3"
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"
+701 -23
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)
+48 -1
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
+19 -6
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
+50 -4
View File
@@ -10,7 +10,7 @@ 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
@@ -270,7 +270,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,7 +597,7 @@ class CommandRegistrar:
source_id: str,
source_dir: Path,
project_root: Path,
context_note: str = None,
context_note: Optional[str] = None,
_resolved_dir: Path = None,
link_outputs: bool = False,
extension_id: Optional[str] = None,
@@ -675,6 +675,23 @@ class CommandRegistrar:
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
@@ -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")
@@ -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):
+29
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
+18 -2
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
+85 -6
View File
@@ -14,6 +14,7 @@ from pathlib import Path
import typer
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,
@@ -337,6 +338,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 +355,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 +726,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 +827,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 +845,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 +860,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 +877,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 +915,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 +987,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")
File diff suppressed because it is too large Load Diff
+738 -66
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(
@@ -790,10 +816,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 +919,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
@@ -971,10 +1032,24 @@ def _print_extension_info(ext_info: dict, manager):
# 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 +1097,7 @@ def extension_update(
from . import (
ExtensionManager,
ExtensionCatalog,
ExtensionManifest,
ExtensionError,
ValidationError,
CommandRegistrar,
@@ -1136,9 +1212,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 +1230,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 +1372,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 +1480,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 +1594,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 +1842,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 +1885,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 +1919,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 +1964,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 +2090,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]")
+2
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())
+72 -12
View File
@@ -395,19 +395,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 +438,71 @@ 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,
+101 -89
View File
@@ -29,6 +29,7 @@ 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,
@@ -37,6 +38,7 @@ from ._helpers import (
_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,
)
@@ -133,13 +135,13 @@ def _installed_presets_affecting_agent(
) -> 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
@@ -178,18 +180,36 @@ def _installed_presets_affecting_agent(
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 include_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 = include_skills and bool(registered_skills)
if has_commands or has_skills:
affected.append(preset_id)
return affected
@@ -297,6 +317,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)
@@ -354,6 +382,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)
@@ -475,6 +516,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)
@@ -495,6 +554,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}'")
@@ -572,12 +636,11 @@ def integration_upgrade(
)
# Guard: Kilo's legacy command root moves from .kilocode/workflows to
# .kilo/commands. Preset command artifacts are registered only during
# preset install/remove, with no agent-scoped re-registration hook to
# recreate them at the new command root while preserving priority and
# composition semantics. Refuse before setup writes .kilo/commands rather
# than leaving legacy preset files orphaned or registry-tracked overrides
# missing from the canonical directory.
# .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")
@@ -620,18 +683,12 @@ def integration_upgrade(
)
raise typer.Exit(1)
# 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.
# 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
):
@@ -657,9 +714,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"
@@ -795,66 +852,21 @@ def integration_upgrade(
),
)
# 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(
# 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 layout changed, but old-layout extension "
"artifacts may need manual cleanup."
),
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.",
)
_register_extensions_for_agent(
project_root,
key,
continuing="The integration was upgraded, but installed extensions may need re-registration.",
)
name = (integration.config or {}).get("name", key)
console.print(f"\n[green]✓[/green] Integration '{name}' upgraded successfully")
@@ -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].")
@@ -489,13 +495,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()
@@ -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
@@ -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,
File diff suppressed because it is too large Load Diff
+24 -18
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(
@@ -61,7 +62,7 @@ def preset_list():
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_str = _escape_markup(", ".join(str(t) for t in pack["tags"]))
console.print(f" [dim]Tags: {tags_str}[/dim]")
console.print(f" [dim]Templates: {pack['template_count']}[/dim]")
console.print()
@@ -126,15 +127,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 +163,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)
@@ -288,7 +294,7 @@ def preset_search(
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"])
tags_str = ", ".join(str(t) for t in pack["tags"])
console.print(f" [dim]Tags: {tags_str}[/dim]")
console.print()
@@ -379,7 +385,7 @@ def preset_info(
if local_pack.author:
console.print(f" Author: {local_pack.author}")
if local_pack.tags:
console.print(f" Tags: {', '.join(local_pack.tags)}")
console.print(f" Tags: {', '.join(str(t) for t in local_pack.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', '')}")
@@ -415,7 +421,7 @@ def preset_info(
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" Tags: {', '.join(str(t) for t in pack_info['tags'])}")
if pack_info.get("repository"):
console.print(f" Repository: {pack_info['repository']}")
if pack_info.get("license"):
@@ -580,10 +586,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()
+124 -28
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:
@@ -2354,14 +2360,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 +2387,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 +2415,23 @@ 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']}")
console.print(f" Description: {_escape_markup(str(info['description']))}")
if info.get("tags"):
console.print(f" Tags: {', '.join(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 +2452,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 +2664,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 +2707,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 +2798,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 +2850,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 +2857,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 +2893,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 +3163,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()
+26 -6
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
@@ -505,7 +508,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 +542,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 +993,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
@@ -1178,7 +1190,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 +1224,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:
+11
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")
+6
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 multiple‑choice questions:
- **Analyze all options** and determine the **most suitable option** based on:
- Best practices for the project type
+57
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.
@@ -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")
@@ -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()
@@ -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
@@ -633,6 +633,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
@@ -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:
@@ -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."""
+832 -40
View File
@@ -1407,6 +1407,195 @@ class TestIntegrationInstall:
project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md"
).exists()
def test_extension_add_registers_active_integration_only(self, tmp_path):
"""``extension add`` registers commands for the active integration only.
Maintainer-requested behavior for #2948: with multiple integrations
installed, ``extension add`` must treat the project as single-active —
only the current integration gets the new extension's commands.
Non-active integrations receive them when selected via
``integration use`` / ``switch`` (rescaffold).
"""
project = _init_project(tmp_path, "claude")
result = _run_in_project(project, [
"integration", "install", "codex",
"--script", "sh",
])
assert result.exit_code == 0, result.output
result = _run_in_project(project, ["extension", "add", "git"])
assert result.exit_code == 0, f"extension add failed: {result.output}"
registry_path = project / ".specify" / "extensions" / ".registry"
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
"extensions"
]["git"]["registered_commands"]
assert "claude" in registered, "active integration gets the extension"
assert "codex" not in registered, (
"non-active integration must not be registered on add (#2948)"
)
assert (
project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md"
).exists()
assert not (
project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md"
).exists()
# Selecting the other integration rescaffolds it with the extension.
result = _run_in_project(project, ["integration", "use", "codex"])
assert result.exit_code == 0, result.output
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
"extensions"
]["git"]["registered_commands"]
assert "codex" in registered, "use registers extensions for the new active agent"
assert (
project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md"
).exists()
def test_extension_add_generic_active_does_not_backfill_other_agents(self, tmp_path):
"""A recorded but unsupported active key (``generic``) must not
fall back to registering every detected agent.
``generic`` is deliberately excluded from ``AGENT_CONFIGS`` because
its output directory is only known via ``--commands-dir``, not a
static config. Before the fix, treating that active key like "no
active integration recorded" made the fallback register the
extension for every other detected agent — exactly the multi-target
behavior #2948 is meant to stop.
"""
project = _init_project(
tmp_path, "generic",
integration_options="--commands-dir .myagent/commands",
)
result = _run_in_project(project, [
"integration", "install", "codex",
"--script", "sh",
"--force",
])
assert result.exit_code == 0, result.output
result = _run_in_project(project, ["extension", "add", "git"])
assert result.exit_code == 0, f"extension add failed: {result.output}"
registry_path = project / ".specify" / "extensions" / ".registry"
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
"extensions"
]["git"]["registered_commands"]
assert "codex" not in registered, (
"a recorded but unsupported active key must not target other "
"detected agents (#2948)"
)
def test_extension_add_malformed_ai_value_fails_closed(self, tmp_path):
"""A recorded but malformed ``ai`` value (e.g. a list) must not be
treated as "no active integration recorded" and must not crash.
Before the fix, ``init_options.get("ai")`` being falsy (``[]``,
``""``, ``0``) triggered the same all-agents fallback as a missing
key, and a *truthy* non-string value (e.g. a non-empty list) would
reach ``AGENT_CONFIGS.get(active_agent)`` and raise ``TypeError``
because a list is unhashable. Corrupted init-options must instead
fail closed: register nothing rather than crash or back-fill every
detected agent.
"""
project = _init_project(tmp_path, "claude")
result = _run_in_project(project, [
"integration", "install", "codex",
"--script", "sh",
])
assert result.exit_code == 0, result.output
init_options_path = project / ".specify" / "init-options.json"
init_options = json.loads(init_options_path.read_text(encoding="utf-8"))
init_options["ai"] = []
init_options_path.write_text(json.dumps(init_options), encoding="utf-8")
result = _run_in_project(project, ["extension", "add", "git"])
assert result.exit_code == 0, f"extension add failed: {result.output}"
registry_path = project / ".specify" / "extensions" / ".registry"
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
"extensions"
]["git"]["registered_commands"]
assert registered == {}, (
"a malformed recorded 'ai' value must fail closed, not "
"back-fill every detected agent (#2948)"
)
def test_extension_add_corrupted_init_options_file_fails_closed(self, tmp_path):
"""A present-but-unparseable init-options.json must fail closed too,
not be treated the same as "no file at all".
``load_init_options`` returns ``{}`` for a corrupted/unreadable
file just like it does for a missing file, so a naive "no active
agent recorded" check based on ``load_init_options`` alone can't
tell a legacy pre-init-options project (legitimate all-agent
fallback) apart from a corrupted-but-present file for a #2948
project (must fail closed). Corrupting the file after a normal
init must not reintroduce the all-agent fallback.
"""
project = _init_project(tmp_path, "claude")
result = _run_in_project(project, [
"integration", "install", "codex",
"--script", "sh",
])
assert result.exit_code == 0, result.output
init_options_path = project / ".specify" / "init-options.json"
init_options_path.write_text("{not valid json", encoding="utf-8")
result = _run_in_project(project, ["extension", "add", "git"])
assert result.exit_code == 0, f"extension add failed: {result.output}"
registry_path = project / ".specify" / "extensions" / ".registry"
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
"extensions"
]["git"]["registered_commands"]
assert registered == {}, (
"a corrupted init-options.json must fail closed, not be "
"treated like a legacy project missing the file entirely (#2948)"
)
def test_extension_add_dangling_init_options_symlink_fails_closed(self, tmp_path):
"""A dangling init-options.json symlink must fail closed too, not be
treated the same as "no file at all".
``Path.exists()`` follows symlinks and returns False for a broken
symlink whose target doesn't exist, so a naive presence check based
on ``Path.exists()`` alone mistakes a dangling symlink for "no file"
and falls back to registering every detected agent.
"""
project = _init_project(tmp_path, "claude")
result = _run_in_project(project, [
"integration", "install", "codex",
"--script", "sh",
])
assert result.exit_code == 0, result.output
init_options_path = project / ".specify" / "init-options.json"
init_options_path.unlink()
init_options_path.symlink_to(project / ".specify" / "does-not-exist.json")
assert not init_options_path.exists() # sanity: dangling
assert init_options_path.is_symlink()
result = _run_in_project(project, ["extension", "add", "git"])
assert result.exit_code == 0, f"extension add failed: {result.output}"
registry_path = project / ".specify" / "extensions" / ".registry"
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
"extensions"
]["git"]["registered_commands"]
assert registered == {}, (
"a dangling init-options.json symlink must fail closed, not be "
"treated like a legacy project missing the file entirely (#2948)"
)
# ── uninstall ────────────────────────────────────────────────────────
@@ -1650,6 +1839,156 @@ class TestIntegrationUse:
assert result.exit_code != 0
assert "not installed" in result.output
def test_use_registers_presets_for_the_newly_active_agent(self, tmp_path):
"""``integration use`` is the single rescaffold point for presets too.
Mirrors the extension single-active rule (#2948): a preset command
override installed while ``claude`` was active must not target the
inactive ``codex`` integration, and switching via ``integration use``
must rescaffold it there.
"""
project = _init_project(tmp_path, "claude")
result = _run_in_project(project, [
"integration", "install", "codex",
"--script", "sh",
])
assert result.exit_code == 0, result.output
preset_src = tmp_path / "cmd-preset"
(preset_src / "commands").mkdir(parents=True)
(preset_src / "commands" / "speckit.specify.md").write_text(
"---\ndescription: Overridden specify\n---\nOverridden content\n",
encoding="utf-8",
)
manifest_data = {
"schema_version": "1.0",
"preset": {
"id": "cmd-preset",
"name": "Command Preset",
"version": "1.0.0",
"description": "Test preset with a command override",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "command",
"name": "speckit.specify",
"file": "commands/speckit.specify.md",
}
]
},
}
import yaml
(preset_src / "preset.yml").write_text(yaml.dump(manifest_data), encoding="utf-8")
result = _run_in_project(project, ["preset", "add", "--dev", str(preset_src)])
assert result.exit_code == 0, f"preset add failed: {result.output}"
registry_path = project / ".specify" / "presets" / ".registry"
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
"presets"
]["cmd-preset"]["registered_commands"]
assert "claude" in registered, "active integration gets the preset command override"
assert "codex" not in registered, (
"non-active integration must not be registered on preset add (#2948)"
)
result = _run_in_project(project, ["integration", "use", "codex"])
assert result.exit_code == 0, result.output
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
"presets"
]["cmd-preset"]["registered_commands"]
assert "codex" in registered, "use registers presets for the new active agent"
assert "claude" in registered, "the previous agent's registration is preserved"
def test_use_reregisters_presets_highest_precedence_last(self, tmp_path):
"""When two enabled presets override the same command, the
higher-precedence preset (lower priority number) must win the
materialized file after ``integration use`` rescaffolds them.
``register_enabled_presets_for_agent`` iterates presets and each
pass overwrites the same target file, so the write order matters.
Before the fix, presets were processed lowest-number-first (highest
precedence first), so the lower-precedence preset was written last
and won -- reversing the documented priority stack (#2948).
"""
project = _init_project(tmp_path, "claude")
result = _run_in_project(project, [
"integration", "install", "codex",
"--script", "sh",
])
assert result.exit_code == 0, result.output
import yaml
def _make_preset(pack_id: str, content: str) -> Path:
src = tmp_path / pack_id
(src / "commands").mkdir(parents=True)
(src / "commands" / "speckit.specify.md").write_text(
f"---\ndescription: {pack_id}\n---\n{content}\n",
encoding="utf-8",
)
manifest_data = {
"schema_version": "1.0",
"preset": {
"id": pack_id,
"name": pack_id,
"version": "1.0.0",
"description": f"Test preset {pack_id}",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "command",
"name": "speckit.specify",
"file": "commands/speckit.specify.md",
}
]
},
}
(src / "preset.yml").write_text(yaml.dump(manifest_data), encoding="utf-8")
return src
# Lower-precedence preset (higher priority number), installed first.
low_precedence_src = _make_preset("low-precedence-preset", "LOW PRECEDENCE CONTENT")
result = _run_in_project(project, [
"preset", "add", "--dev", str(low_precedence_src), "--priority", "20",
])
assert result.exit_code == 0, f"preset add (low) failed: {result.output}"
# Higher-precedence preset (lower priority number), installed second.
high_precedence_src = _make_preset("high-precedence-preset", "HIGH PRECEDENCE CONTENT")
result = _run_in_project(project, [
"preset", "add", "--dev", str(high_precedence_src), "--priority", "1",
])
assert result.exit_code == 0, f"preset add (high) failed: {result.output}"
# Sanity: the priority stack already picks the high-precedence
# preset's content for the active (claude) integration.
claude_skill = project / ".claude" / "skills" / "speckit-specify" / "SKILL.md"
assert "HIGH PRECEDENCE CONTENT" in claude_skill.read_text(encoding="utf-8")
assert "LOW PRECEDENCE CONTENT" not in claude_skill.read_text(encoding="utf-8")
result = _run_in_project(project, ["integration", "use", "codex"])
assert result.exit_code == 0, result.output
# After rescaffolding for the newly active codex integration, the
# high-precedence preset must still win -- not whichever preset
# register_enabled_presets_for_agent happened to write last.
codex_skill = project / ".agents" / "skills" / "speckit-specify" / "SKILL.md"
content = codex_skill.read_text(encoding="utf-8")
assert "HIGH PRECEDENCE CONTENT" in content, (
"highest-precedence preset must win after `use` rescaffolds "
"presets for the newly active integration (#2948)"
)
assert "LOW PRECEDENCE CONTENT" not in content
def test_use_refreshes_shared_templates_between_command_styles(self, tmp_path):
project = _init_project(tmp_path, "claude")
template = project / ".specify" / "templates" / "plan-template.md"
@@ -2015,6 +2354,83 @@ class TestIntegrationSwitch:
assert "opencode" in git_meta["registered_commands"]
assert "copilot" not in git_meta["registered_commands"]
def test_switch_to_not_yet_installed_unregisters_old_preset_artifacts(self, tmp_path):
"""Switching to a not-yet-installed integration must also clean up
the old agent's preset command overrides, mirroring the existing
extension cleanup on the same code path (#2948).
Without this, a preset's command override -- including a custom
preset command -- rendered for the previous agent lingers as an
orphan once a different, not-yet-installed integration becomes the
new active agent.
"""
project = _init_project(tmp_path, "auggie")
preset_src = tmp_path / "switch-cleanup-preset"
(preset_src / "commands").mkdir(parents=True)
(preset_src / "commands" / "speckit.specify.md").write_text(
"---\ndescription: Custom preset command\n---\nOverridden content\n",
encoding="utf-8",
)
manifest_data = {
"schema_version": "1.0",
"preset": {
"id": "switch-cleanup-preset",
"name": "Switch Cleanup Preset",
"version": "1.0.0",
"description": "Test preset with a custom command override",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "command",
"name": "speckit.specify",
"file": "commands/speckit.specify.md",
}
]
},
}
import yaml
(preset_src / "preset.yml").write_text(yaml.dump(manifest_data), encoding="utf-8")
result = _run_in_project(project, ["preset", "add", "--dev", str(preset_src)])
assert result.exit_code == 0, f"preset add failed: {result.output}"
auggie_cmd = project / ".augment" / "commands" / "speckit.specify.md"
assert auggie_cmd.exists(), "sanity: preset command registered for auggie"
registry_path = project / ".specify" / "presets" / ".registry"
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
"presets"
]["switch-cleanup-preset"]["registered_commands"]
assert "auggie" in registered, "sanity: auggie tracked before switch"
# opencode is not yet installed in this project.
result = _run_in_project(project, [
"integration", "switch", "opencode",
"--script", "sh",
])
assert result.exit_code == 0, result.output
assert not auggie_cmd.exists(), (
"old agent's preset command override must be removed on switch "
"to a not-yet-installed integration, mirroring the existing "
"extension cleanup on this same code path (#2948)"
)
opencode_cmd = project / ".opencode" / "commands" / "speckit.specify.md"
assert opencode_cmd.exists(), "preset command should be registered for the new agent"
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
"presets"
]["switch-cleanup-preset"]["registered_commands"]
assert "auggie" not in registered, (
"old agent's tracking must be dropped after switch cleanup"
)
assert "opencode" in registered
def test_switch_does_not_register_disabled_extensions(self, tmp_path):
"""Disabled extensions should stay disabled and should not migrate commands."""
project = _init_project(tmp_path, "opencode")
@@ -2311,6 +2727,54 @@ class TestIntegrationSwitch:
template = project / ".specify" / "templates" / "plan-template.md"
assert "/speckit-plan" in template.read_text(encoding="utf-8")
def test_failed_switch_rescaffolds_fallback_extensions(self, tmp_path):
"""Regression (review 3624184343).
When Phase 2 of a switch fails, rollback selects another installed
integration as the new default. Under active-only registration that
fallback may never have received extension artifacts (it was
installed while another integration was active), and Phase 1 already
unregistered the outgoing agent's artifacts — so the restored default
must be rescaffolded, not just written to metadata.
"""
project = _init_project(tmp_path, "claude")
result = _run_in_project(project, ["extension", "add", "git"])
assert result.exit_code == 0, f"extension add failed: {result.output}"
result = _run_in_project(project, [
"integration", "install", "codex",
"--script", "sh",
])
assert result.exit_code == 0, result.output
registry_path = project / ".specify" / "extensions" / ".registry"
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
"extensions"
]["git"]["registered_commands"]
assert "codex" not in registered, (
"precondition: secondary install has no extension artifacts"
)
result = _run_in_project(project, [
"integration", "switch", "generic",
"--script", "sh",
])
assert result.exit_code != 0
data = json.loads(
(project / ".specify" / "integration.json").read_text(encoding="utf-8")
)
assert data["integration"] == "codex", "precondition: fallback restored"
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
"extensions"
]["git"]["registered_commands"]
assert "codex" in registered, (
"rollback must rescaffold extensions for the restored default"
)
assert (
project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md"
).exists()
class TestIntegrationUpgrade:
def test_upgrade_invalid_manifest_reports_cli_error(self, tmp_path):
@@ -2652,10 +3116,10 @@ class TestIntegrationUpgrade:
"disabled extensions must not be re-registered in the canonical dir"
)
def test_upgrade_secondary_kilocode_legacy_dir_preserves_active_agent_skills(
def test_upgrade_secondary_kilocode_legacy_dir_cleans_commands_without_backfill(
self, tmp_path
):
"""Kilo command-root cleanup must not touch active agent extension skills."""
"""Kilo cleanup stays agent-scoped without inactive extension backfill."""
project = _init_project(tmp_path, "copilot", integration_options="--skills")
result = _run_in_project(project, ["extension", "add", "git"])
assert result.exit_code == 0, f"extension add failed: {result.output}"
@@ -2698,12 +3162,17 @@ class TestIntegrationUpgrade:
assert result.exit_code == 0, result.output
assert canonical.is_dir(), ".kilo/commands/ should exist after upgrade"
assert sorted(canonical.glob("speckit.git.*.md")), (
"secondary Kilo should regain enabled extension commands"
assert not sorted(canonical.glob("speckit.git.*.md")), (
"inactive Kilo must wait for use/switch before extension rescaffolding"
)
assert not legacy_git_command.exists(), (
"secondary Kilo legacy extension commands should still be cleaned up"
)
registry = json.loads(registry_path.read_text(encoding="utf-8"))
registered_commands = registry["extensions"]["git"].get(
"registered_commands", {}
)
assert "kilocode" not in registered_commands
assert skill.exists(), (
"secondary Kilo legacy cleanup must not delete the active agent's "
"extension skill"
@@ -2842,24 +3311,241 @@ class TestIntegrationUpgrade:
cmds_agents, skill_names = _git_registry()
assert "bob" in cmds_agents and not skill_names
def test_upgrade_bob_layout_change_rejected_with_presets_installed(self, tmp_path):
"""Regression (review #3415, 4726193915).
def test_upgrade_layout_change_preserves_extension_artifacts_when_reregistration_fails(
self, tmp_path
):
"""Regression (review 3624075109).
A command↔skills layout change cannot reconcile preset artifacts (no
agent-scoped preset re-registration exists). Rather than silently
orphaning preset files / leaving the registry inconsistent, a
layout-changing ``upgrade`` must reject the migration with an
actionable error *before any mutation* when preset overrides are
installed for the agent. A same-layout upgrade must still succeed.
A layout-changing upgrade must not eagerly unregister the agent's
extension artifacts before re-registration: the retirement of each
opposite-mode artifact belongs to
``register_enabled_extensions_for_agent``'s deferred toggle cleanup,
which retires an old artifact only after its replacement in the new
layout is confirmed. If re-registration cannot rebuild an extension
(here: its installed manifest is corrupted), the old artifact and its
registry tracking must survive instead of leaving the extension with
no artifacts at all.
"""
project = _init_project(
tmp_path, "bob", integration_options="--legacy-commands"
)
result = _run_in_project(project, ["extension", "add", "git"])
assert result.exit_code == 0, f"extension add failed: {result.output}"
commands = project / ".bob" / "commands"
assert sorted(commands.glob("speckit.git.*.md")), (
"precondition: git extension renders as legacy command files"
)
# Corrupt the installed extension manifest so re-registration cannot
# rebuild the artifacts in the new layout.
(
project / ".specify" / "extensions" / "git" / "extension.yml"
).write_text("invalid: [", encoding="utf-8")
result = _run_in_project(project, [
"integration", "upgrade", "bob",
"--integration-options", "--skills",
"--script", "sh", "--force",
])
assert result.exit_code == 0, (
f"upgrade is best-effort about extensions: {result.output}"
)
assert sorted(commands.glob("speckit.git.*.md")), (
"old-layout extension artifacts must survive when their "
"replacement could not be registered"
)
registry_path = project / ".specify" / "extensions" / ".registry"
data = json.loads(registry_path.read_text(encoding="utf-8"))
assert "bob" in data["extensions"]["git"].get("registered_commands", {}), (
"extension registry must keep tracking the surviving artifacts"
)
def test_upgrade_active_layout_change_rejected_before_missing_preset_source_can_lose_override(
self, tmp_path
):
"""Regression (review 3623357447).
Layout-changing upgrades must fail closed even for the active
integration. Preset rescaffolding is best-effort, so a missing source
file could otherwise let stale integration cleanup delete the tracked
old-layout override without creating its replacement.
"""
project = _init_project(
tmp_path, "bob", integration_options="--legacy-commands"
)
commands = project / ".bob" / "commands"
skills = project / ".bob" / "skills"
preset_src = tmp_path / "cmd-preset"
(preset_src / "commands").mkdir(parents=True)
(preset_src / "commands" / "speckit.plan.md").write_text(
"---\ndescription: Overridden plan\n---\nOverridden plan content\n",
encoding="utf-8",
)
manifest_data = {
"schema_version": "1.0",
"preset": {
"id": "cmd-preset",
"name": "Command Preset",
"version": "1.0.0",
"description": "Test preset with a command override",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "command",
"name": "speckit.plan",
"file": "commands/speckit.plan.md",
}
]
},
}
import yaml
(preset_src / "preset.yml").write_text(
yaml.dump(manifest_data), encoding="utf-8"
)
result = _run_in_project(project, ["preset", "add", "--dev", str(preset_src)])
assert result.exit_code == 0, f"preset add failed: {result.output}"
cmd_file = commands / "speckit.plan.md"
assert "Overridden plan content" in cmd_file.read_text(encoding="utf-8")
installed_source = (
project
/ ".specify"
/ "presets"
/ "cmd-preset"
/ "commands"
/ "speckit.plan.md"
)
assert installed_source.exists(), "precondition: preset source was installed"
installed_source.unlink()
result = _run_in_project(project, [
"integration", "upgrade", "bob",
"--integration-options", "--skills",
"--script", "sh", "--force",
])
assert result.exit_code != 0, (
"layout change with tracked preset artifacts must be rejected"
)
assert "cmd-preset" in result.output
assert not skills.exists(), "no skills layout must be scaffolded on rejection"
assert "Overridden plan content" in cmd_file.read_text(encoding="utf-8"), (
"tracked old-layout override must remain untouched"
)
def test_upgrade_active_layout_change_rejected_with_disabled_preset(
self, tmp_path
):
"""Regression (review 3623779277).
The post-upgrade rescaffold iterates *enabled* presets only, and a
disabled preset's artifacts are deliberately frozen until removal
(``preset disable``). An active-agent layout change must therefore be
rejected while a disabled preset still owns artifacts for the agent —
proceeding would delete its old-layout files in stale-manifest
cleanup, skip recreating them, and leave its registry entries stale.
Re-enabling does not make a non-transactional layout migration safe.
"""
project = _init_project(
tmp_path, "bob", integration_options="--legacy-commands"
)
commands = project / ".bob" / "commands"
skills = project / ".bob" / "skills"
preset_src = tmp_path / "cmd-preset"
(preset_src / "commands").mkdir(parents=True)
(preset_src / "commands" / "speckit.plan.md").write_text(
"---\ndescription: Overridden plan\n---\nOverridden plan content\n",
encoding="utf-8",
)
manifest_data = {
"schema_version": "1.0",
"preset": {
"id": "cmd-preset",
"name": "Command Preset",
"version": "1.0.0",
"description": "Test preset with a command override",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "command",
"name": "speckit.plan",
"file": "commands/speckit.plan.md",
}
]
},
}
import yaml
(preset_src / "preset.yml").write_text(
yaml.dump(manifest_data), encoding="utf-8"
)
result = _run_in_project(project, ["preset", "add", "--dev", str(preset_src)])
assert result.exit_code == 0, f"preset add failed: {result.output}"
result = _run_in_project(project, ["preset", "disable", "cmd-preset"])
assert result.exit_code == 0, f"preset disable failed: {result.output}"
cmd_file = commands / "speckit.plan.md"
assert "Overridden plan content" in cmd_file.read_text(encoding="utf-8")
result = _run_in_project(project, [
"integration", "upgrade", "bob",
"--integration-options", "--skills",
"--script", "sh", "--force",
])
assert result.exit_code != 0, (
"layout change with a disabled preset must be rejected"
)
assert "cmd-preset" in result.output
assert not skills.exists(), "no skills layout must be scaffolded on rejection"
assert "Overridden plan content" in cmd_file.read_text(encoding="utf-8"), (
"the disabled preset's command file must be left untouched"
)
# Enabled presets are also rejected: rescaffolding can still fail.
result = _run_in_project(project, ["preset", "enable", "cmd-preset"])
assert result.exit_code == 0, f"preset enable failed: {result.output}"
result = _run_in_project(project, [
"integration", "upgrade", "bob",
"--integration-options", "--skills",
"--script", "sh", "--force",
])
assert result.exit_code != 0
assert "cmd-preset" in result.output
assert not skills.exists()
assert "Overridden plan content" in cmd_file.read_text(encoding="utf-8")
def test_upgrade_secondary_layout_change_rejected_with_presets_installed(
self, tmp_path
):
"""Regression (review #3415, 4726193915; updated for review 3623357447).
Preset rescaffolding is active-agent-only, so a layout-changing
``upgrade`` of a *non-active* integration still cannot reconcile that
agent's preset artifacts. It must reject the migration with an
actionable error *before any mutation* when preset overrides are
installed for that agent. A same-layout upgrade must still succeed.
"""
project = _init_project(tmp_path, "copilot")
result = _run_in_project(project, [
"integration", "install", "bob",
"--integration-options", "--legacy-commands",
"--script", "sh", "--force",
])
assert result.exit_code == 0, result.output
commands = project / ".bob" / "commands"
skills = project / ".bob" / "skills"
assert sorted(commands.glob("speckit.*.md"))
# Simulate an installed preset that registered command overrides for bob.
# Simulate a historical preset registration for the non-active bob.
presets_dir = project / ".specify" / "presets"
presets_dir.mkdir(parents=True, exist_ok=True)
(presets_dir / ".registry").write_text(
@@ -2869,20 +3555,22 @@ class TestIntegrationUpgrade:
"version": "1.0.0",
"enabled": True,
"registered_commands": {"bob": ["speckit.plan"]},
"registered_skills": [],
"registered_skills": {},
}
}
}),
encoding="utf-8",
)
# Layout-changing upgrade is rejected, and nothing is mutated.
# Layout-changing upgrade of the secondary agent is rejected untouched.
result = _run_in_project(project, [
"integration", "upgrade", "bob",
"--integration-options", "--skills",
"--script", "sh", "--force",
])
assert result.exit_code != 0, "layout change with presets must be rejected"
assert result.exit_code != 0, (
"secondary layout change with presets must be rejected"
)
assert "preset" in result.output.lower()
assert "my-preset" in result.output
assert not skills.exists(), "no skills layout must be scaffolded on rejection"
@@ -3059,13 +3747,13 @@ class TestIntegrationUpgrade:
"shared .sh scripts must be executable after upgrade"
)
def test_upgrade_backfills_extension_commands_for_agent(self, tmp_path):
"""Upgrade re-registers enabled extensions for the upgraded agent.
def test_upgrade_does_not_backfill_non_active_integration(self, tmp_path):
"""Upgrading a non-active integration must not register extensions for it.
Regression for #2886: agents installed before extension back-fill
existed (or whose extension artifacts went missing) should regain the
enabled extensions' commands on ``upgrade``, reaching parity with
``switch``.
Maintainer-requested behavior for #2948 (reverses the #2886 upgrade
back-fill): non-active integrations only receive extension artifacts
when selected via ``integration use`` / ``switch``. Upgrade of a
non-active integration refreshes its own files and nothing else.
"""
project = _init_project(tmp_path, "claude")
@@ -3078,21 +3766,10 @@ class TestIntegrationUpgrade:
])
assert result.exit_code == 0, result.output
# Simulate a project created before the install/upgrade back-fill: drop
# codex's extension registration and its rendered artifacts.
registry_path = project / ".specify" / "extensions" / ".registry"
registry = json.loads(registry_path.read_text(encoding="utf-8"))
registry["extensions"]["git"]["registered_commands"].pop("codex", None)
registry_path.write_text(json.dumps(registry), encoding="utf-8")
agents_skills = project / ".agents" / "skills"
for skill_dir in agents_skills.glob("speckit-git-*"):
shutil.rmtree(skill_dir)
# Precondition: codex is now missing the git extension.
assert "codex" not in json.loads(registry_path.read_text(encoding="utf-8"))[
"extensions"
]["git"]["registered_commands"]
assert not (agents_skills / "speckit-git-feature" / "SKILL.md").exists()
result = _run_in_project(project, [
"integration", "upgrade", "codex",
@@ -3100,12 +3777,94 @@ class TestIntegrationUpgrade:
])
assert result.exit_code == 0, result.output
# Upgrade back-filled the git extension for codex.
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
"extensions"
]["git"]["registered_commands"]
assert "codex" in registered, "upgrade should re-register extension commands (#2886)"
assert (agents_skills / "speckit-git-feature" / "SKILL.md").exists()
assert "codex" not in registered, (
"upgrade must not back-fill non-active integrations (#2948)"
)
assert not (
project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md"
).exists()
def test_upgrade_active_integration_reregisters_extensions(self, tmp_path):
"""Upgrading the active integration restores its extension commands.
The active integration keeps the re-registration pass on upgrade so
missing or stale extension command files are recreated (#2948 scopes
the pass to the active integration; #2886 introduced it).
"""
project = _init_project(tmp_path, "claude")
result = _run_in_project(project, ["extension", "add", "git"])
assert result.exit_code == 0, f"extension add failed: {result.output}"
cmd_file = project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md"
assert cmd_file.exists(), "precondition: extension command registered"
cmd_file.unlink()
result = _run_in_project(project, [
"integration", "upgrade", "claude",
"--script", "sh",
])
assert result.exit_code == 0, result.output
assert cmd_file.exists(), (
"upgrade of the active integration re-registers extension commands"
)
def test_upgrade_active_integration_reregisters_presets(self, tmp_path):
"""Upgrading the active integration restores missing preset artifacts."""
import yaml
project = _init_project(tmp_path, "claude")
preset_src = tmp_path / "upgrade-preset"
(preset_src / "commands").mkdir(parents=True)
(preset_src / "commands" / "speckit.upgrade-check.md").write_text(
"---\ndescription: Upgrade check\n---\nPreset upgrade body\n",
encoding="utf-8",
)
manifest = {
"schema_version": "1.0",
"preset": {
"id": "upgrade-preset",
"name": "Upgrade Preset",
"version": "1.0.0",
"description": "Upgrade preset test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "command",
"name": "speckit.upgrade-check",
"file": "commands/speckit.upgrade-check.md",
}
]
},
}
(preset_src / "preset.yml").write_text(
yaml.dump(manifest), encoding="utf-8"
)
result = _run_in_project(
project, ["preset", "add", "--dev", str(preset_src)]
)
assert result.exit_code == 0, result.output
skill_dir = (
project / ".claude" / "skills" / "speckit-upgrade-check"
)
skill_file = skill_dir / "SKILL.md"
assert "Preset upgrade body" in skill_file.read_text(encoding="utf-8")
shutil.rmtree(skill_dir)
result = _run_in_project(project, [
"integration", "upgrade", "claude",
"--script", "sh",
])
assert result.exit_code == 0, result.output
assert "Preset upgrade body" in skill_file.read_text(encoding="utf-8")
def test_upgrade_non_active_agent_preserves_active_agent_skills(self, tmp_path):
"""Upgrading a non-active agent must not touch the active agent's skills.
@@ -3195,9 +3954,28 @@ class TestIntegrationUpgrade:
with pytest.raises(_PresetRegistryUnreadableError):
_installed_presets_affecting_agent(project, "bob")
# Malformed registered_skills (not a list) → raise.
# Malformed registered_skills (neither list nor dict) → raise.
registry.write_text(
json.dumps({"presets": {"p1": {"registered_skills": {}}}}),
json.dumps({"presets": {"p1": {"registered_skills": "oops"}}}),
encoding="utf-8",
)
with pytest.raises(_PresetRegistryUnreadableError):
_installed_presets_affecting_agent(project, "bob")
# Dict-shaped fields with non-list values (ownership undecidable)
# must also fail closed, not read as "no artifacts".
registry.write_text(
json.dumps(
{"presets": {"p1": {"registered_skills": {"bob": None}}}}
),
encoding="utf-8",
)
with pytest.raises(_PresetRegistryUnreadableError):
_installed_presets_affecting_agent(project, "bob")
registry.write_text(
json.dumps(
{"presets": {"p1": {"registered_commands": {"bob": ""}}}}
),
encoding="utf-8",
)
with pytest.raises(_PresetRegistryUnreadableError):
@@ -3207,13 +3985,24 @@ class TestIntegrationUpgrade:
registry.write_text(json.dumps({"presets": {}}), encoding="utf-8")
assert _installed_presets_affecting_agent(project, "bob") == []
# Valid registry with a preset registered for bob → reported.
# Valid registry with a preset registered for bob → report its ID.
# registered_skills comes in two shapes: a legacy flat list (not
# agent-scoped → fail closed, any entry affects) and the per-agent
# dict written by preset registration ({agent: [skill names]} → only
# this agent's entries affect it).
registry.write_text(
json.dumps({
"presets": {
"p1": {"registered_commands": {"bob": ["speckit.plan"]}},
"p2": {"registered_commands": {"codex": ["speckit.plan"]}},
"p3": {"registered_skills": ["speckit-x"]},
"p4": {"registered_skills": {"bob": ["speckit-y"]}},
"p5": {"registered_skills": {"codex": ["speckit-z"]}},
"p6": {"registered_skills": {"bob": []}},
"p7": {
"enabled": False,
"registered_commands": {"bob": ["speckit.tasks"]},
},
}
}),
encoding="utf-8",
@@ -3221,9 +4010,12 @@ class TestIntegrationUpgrade:
assert sorted(_installed_presets_affecting_agent(project, "bob")) == [
"p1",
"p3",
"p4",
"p7",
]
assert _installed_command_presets_affecting_agent(project, "bob") == [
"p1"
"p1",
"p7",
]
+1
View File
@@ -11,6 +11,7 @@ from specify_cli.extensions import CommandRegistrar
REPO_ROOT = Path(__file__).resolve().parent.parent
ISSUE_TEMPLATE_AGENT_KEYS = [
"alquimia",
"amp",
"agy",
"auggie",
+850 -6
View File
@@ -1,15 +1,24 @@
"""Tests for bounded HTTP download helpers."""
"""Tests for bounded download and ZIP extraction helpers."""
from __future__ import annotations
import io
import stat
import struct
import weakref
import zipfile
import zlib
import pytest
from specify_cli._download_security import (
MAX_ZIP_CENTRAL_DIRECTORY_BYTES,
build_safe_download_path,
is_https_or_localhost_http,
is_loopback_url,
read_response_limited,
read_zip_member_limited,
safe_extract_zip,
)
@@ -112,8 +121,6 @@ class _Response:
def __init__(self, data: bytes, *, chunk: int | None = None):
self.data = data
self.pos = 0
# When set, never return more than *chunk* bytes per call even if more is
# requested - simulates short reads (e.g. chunked transfer encoding).
self.chunk = chunk
def read(self, size: int = -1) -> bytes:
@@ -160,6 +167,55 @@ class _OneByteResponse:
)
return chunk
def __enter__(self):
return self
def __exit__(self, _exc_type, _exc, _tb):
return False
class _CustomZipError(ValueError):
pass
class _ExplodingResponse:
def read(self, _size: int = -1) -> bytes:
raise zlib.error("corrupt compressed data")
def __enter__(self):
return self
def __exit__(self, _exc_type, _exc, _tb):
return False
class _FakeZipArchive:
def __init__(
self,
response,
*,
filename: str = "extension.yml",
file_size: int = 0,
):
self.response = response
self.info = zipfile.ZipInfo(filename)
self.info.file_size = file_size
def __enter__(self):
return self
def __exit__(self, _exc_type, _exc, _tb):
return False
def getinfo(self, _name):
return self.info
def infolist(self):
return [self.info]
def open(self, _member, _mode="r"):
return self.response
def test_read_response_limited_rejects_oversized_download():
with pytest.raises(ValueError, match="exceeds maximum size"):
@@ -171,9 +227,6 @@ def test_read_response_limited_returns_full_body_within_limit():
def test_read_response_limited_enforces_bound_under_short_reads():
# A server that streams more than max_bytes total while every read() returns
# fewer bytes than requested (chunked encoding) must still be rejected - a
# single read(max_bytes + 1) could be fooled, the accumulating loop cannot.
response = _Response(b"x" * 100, chunk=8)
with pytest.raises(ValueError, match="exceeds maximum size"):
read_response_limited(response, max_bytes=16)
@@ -225,3 +278,794 @@ def test_read_response_limited_rejects_first_byte_at_zero_limit():
max_bytes=0,
error_type=_CustomLimitError,
)
def test_read_response_limited_escapes_control_characters_in_label():
with pytest.raises(ValueError) as exc_info:
read_response_limited(
_Response(b"x"),
max_bytes=0,
label="bad\x1b[2J download",
)
assert "\x1b" not in str(exc_info.value)
assert "\\x1b" in str(exc_info.value)
@pytest.mark.parametrize(
"identifier",
[
"../outside",
"..\\outside",
"a" * 256,
"delete\x7f",
"csi\x9b[2J",
"\ud800",
],
)
def test_build_safe_download_path_rejects_nonportable_identifiers(
tmp_path, identifier
):
with pytest.raises(ValueError, match="Unsafe archive download filename"):
build_safe_download_path(
tmp_path,
identifier,
"1.0.0",
)
@pytest.mark.parametrize(
"member_name",
[
"../evil.txt",
"nested/../../evil.txt",
"nested\\..\\evil.txt",
"C:\\Windows\\evil.txt",
"C:drive-relative.txt",
],
)
def test_safe_extract_zip_rejects_traversal(tmp_path, member_name):
zip_path = tmp_path / "bad.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr(member_name, "nope")
with pytest.raises(ValueError, match="Unsafe path"):
safe_extract_zip(zip_path, tmp_path / "out")
@pytest.mark.parametrize("member_name", [".", "./file.txt", "nested/./file.txt", "nested//file.txt"])
def test_safe_extract_zip_rejects_dot_path_segments(tmp_path, member_name):
zip_path = tmp_path / "bad.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr(member_name, "nope")
with pytest.raises(_CustomZipError, match="Unsafe path"):
safe_extract_zip(zip_path, tmp_path / "out", error_type=_CustomZipError)
def test_safe_extract_zip_rejects_symlinks(tmp_path):
zip_path = tmp_path / "bad.zip"
info = zipfile.ZipInfo("link")
info.external_attr = (stat.S_IFLNK | 0o777) << 16
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr(info, "target")
with pytest.raises(ValueError, match="Unsafe symlink"):
safe_extract_zip(zip_path, tmp_path / "out")
def test_safe_extract_zip_rejects_symlink_without_partial_extraction(tmp_path):
zip_path = tmp_path / "mixed.zip"
link = zipfile.ZipInfo("evil-link")
link.external_attr = (stat.S_IFLNK | 0o777) << 16
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("safe/first.txt", "hello")
zf.writestr(link, "target")
zf.writestr("safe/second.txt", "world")
out_dir = tmp_path / "out"
with pytest.raises(ValueError, match="Unsafe symlink"):
safe_extract_zip(zip_path, out_dir)
assert not out_dir.exists() or not any(out_dir.rglob("*"))
def test_safe_extract_zip_rejects_oversized_member(tmp_path):
zip_path = tmp_path / "bad.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("big.txt", "abcde")
with pytest.raises(ValueError, match="exceeds maximum size"):
safe_extract_zip(zip_path, tmp_path / "out", max_member_bytes=4)
def test_safe_extract_zip_rejects_too_many_entries(tmp_path):
zip_path = tmp_path / "bad.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("one.txt", "1")
zf.writestr("two.txt", "2")
with pytest.raises(ValueError, match="too many entries"):
safe_extract_zip(zip_path, tmp_path / "out", max_entries=1)
def _legacy_zip_eocd(
*,
entries: int,
central_directory_size: int,
central_directory_offset: int = 0,
comment_size: int = 0,
) -> bytes:
return struct.pack(
"<4s4H2LH",
b"PK\x05\x06",
0,
0,
entries,
entries,
central_directory_size,
central_directory_offset,
comment_size,
)
def test_safe_extract_zip_preflights_declared_entry_count(tmp_path, monkeypatch):
zip_path = tmp_path / "too-many.zip"
zip_path.write_bytes(
_legacy_zip_eocd(entries=513, central_directory_size=0)
)
monkeypatch.setattr(
zipfile,
"ZipFile",
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
)
with pytest.raises(ValueError, match="too many entries"):
safe_extract_zip(zip_path, tmp_path / "out")
def test_safe_extract_zip_preflights_actual_entry_count_when_eocd_lies(
tmp_path, monkeypatch
):
central_header = b"PK\x01\x02" + b"\x00" * 42
central_directory = central_header * 513
zip_path = tmp_path / "lying-count.zip"
zip_path.write_bytes(
central_directory
+ _legacy_zip_eocd(
entries=1,
central_directory_size=len(central_directory),
)
)
monkeypatch.setattr(
zipfile,
"ZipFile",
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
)
with pytest.raises(ValueError, match="too many entries"):
safe_extract_zip(zip_path, tmp_path / "out")
def test_safe_extract_zip_rejects_truncated_last_eocd_comment(
tmp_path, monkeypatch
):
trailing_eocd = _legacy_zip_eocd(
entries=0,
central_directory_size=0,
comment_size=1,
)
zip_path = tmp_path / "ambiguous-eocd.zip"
zip_path.write_bytes(
_legacy_zip_eocd(
entries=0,
central_directory_size=0,
comment_size=len(trailing_eocd),
)
+ trailing_eocd
)
monkeypatch.setattr(
zipfile,
"ZipFile",
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
)
with pytest.raises(ValueError, match="Invalid ZIP archive"):
safe_extract_zip(zip_path, tmp_path / "out")
def test_safe_extract_zip_rejects_zip64_before_zipfile_construction(
tmp_path, monkeypatch
):
zip64_eocd = struct.pack(
"<4sQ2H2L4Q",
b"PK\x06\x06",
44,
45,
45,
0,
0,
0,
0,
0,
0,
)
zip64_locator = struct.pack(
"<4sLQL",
b"PK\x06\x07",
0,
0,
1,
)
zip_path = tmp_path / "zip64.zip"
zip_path.write_bytes(
zip64_eocd
+ zip64_locator
+ _legacy_zip_eocd(
entries=0xFFFF,
central_directory_size=0xFFFFFFFF,
central_directory_offset=0xFFFFFFFF,
)
)
with zipfile.ZipFile(zip_path) as zf:
assert zf.namelist() == []
monkeypatch.setattr(
zipfile,
"ZipFile",
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
)
with pytest.raises(ValueError, match="ZIP64"):
safe_extract_zip(zip_path, tmp_path / "out")
@pytest.mark.parametrize(
"indicator",
[
"central-sizes",
"central-offset",
"central-disk",
"local-sizes",
],
)
def test_safe_extract_zip_rejects_entry_zip64_before_zipfile_construction(
tmp_path, monkeypatch, indicator
):
contents = b"contents"
if indicator == "central-offset":
zip64_payload = struct.pack("<Q", 0)
elif indicator == "central-disk":
zip64_payload = struct.pack("<L", 0)
else:
zip64_payload = struct.pack("<QQ", len(contents), len(contents))
info = zipfile.ZipInfo("file.txt")
info.extra = struct.pack("<HH", 0xCAFE, len(zip64_payload)) + zip64_payload
zip_path = tmp_path / f"{indicator}.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr(info, contents)
archive = bytearray(zip_path.read_bytes())
local_header = archive.index(b"PK\x03\x04")
central_header = archive.index(b"PK\x01\x02")
if indicator.startswith("central"):
filename_size = struct.unpack_from("<H", archive, central_header + 28)[0]
extra_offset = central_header + 46 + filename_size
struct.pack_into("<H", archive, extra_offset, 0x0001)
if indicator == "central-sizes":
struct.pack_into("<LL", archive, central_header + 20, 0xFFFFFFFF, 0xFFFFFFFF)
elif indicator == "central-offset":
struct.pack_into("<L", archive, central_header + 42, 0xFFFFFFFF)
else:
struct.pack_into("<H", archive, central_header + 34, 0xFFFF)
else:
filename_size = struct.unpack_from("<H", archive, local_header + 26)[0]
extra_offset = local_header + 30 + filename_size
struct.pack_into("<H", archive, extra_offset, 0x0001)
struct.pack_into("<LL", archive, local_header + 18, 0xFFFFFFFF, 0xFFFFFFFF)
zip_path.write_bytes(archive)
# The stdlib accepts each hybrid ZIP64 entry. The bounded opener must reject
# it during preflight, before handing the archive to ZipFile.
with zipfile.ZipFile(zip_path) as zf:
assert zf.read("file.txt") == contents
monkeypatch.setattr(
zipfile,
"ZipFile",
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
)
with pytest.raises(ValueError, match="ZIP64"):
safe_extract_zip(zip_path, tmp_path / "out")
@pytest.mark.parametrize("header_kind", ["central", "local"])
def test_safe_extract_zip_rejects_zip64_extra_without_sentinel_before_zipfile(
tmp_path, monkeypatch, header_kind
):
info = zipfile.ZipInfo("file.txt")
info.extra = struct.pack("<HH", 0xCAFE, 0)
zip_path = tmp_path / f"{header_kind}-extra.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr(info, b"contents")
archive = bytearray(zip_path.read_bytes())
if header_kind == "central":
header_offset = archive.index(b"PK\x01\x02")
filename_size = struct.unpack_from("<H", archive, header_offset + 28)[0]
extra_offset = header_offset + 46 + filename_size
else:
header_offset = archive.index(b"PK\x03\x04")
filename_size = struct.unpack_from("<H", archive, header_offset + 26)[0]
extra_offset = header_offset + 30 + filename_size
struct.pack_into("<H", archive, extra_offset, 0x0001)
zip_path.write_bytes(archive)
with zipfile.ZipFile(zip_path) as zf:
assert zf.read("file.txt") == b"contents"
monkeypatch.setattr(
zipfile,
"ZipFile",
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
)
with pytest.raises(ValueError, match="ZIP64"):
safe_extract_zip(zip_path, tmp_path / "out")
def test_safe_extract_zip_rejects_force_zip64_local_header_before_zipfile(
tmp_path, monkeypatch
):
zip_path = tmp_path / "forced-local-zip64.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
with zf.open("file.txt", "w", force_zip64=True) as target:
target.write(b"contents")
# For a small streamed member, ZipFile leaves the central directory and
# EOCD legacy-sized while placing ZIP64 sentinels and extra data locally.
with zipfile.ZipFile(zip_path) as zf:
assert zf.read("file.txt") == b"contents"
monkeypatch.setattr(
zipfile,
"ZipFile",
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
)
with pytest.raises(ValueError, match="ZIP64"):
safe_extract_zip(zip_path, tmp_path / "out")
@pytest.mark.parametrize("extract_version", [45, 46])
@pytest.mark.parametrize("visible_version", ["central", "local"])
def test_safe_extract_zip_rejects_masked_zip64_data_descriptor_version_or_newer(
tmp_path, monkeypatch, visible_version, extract_version
):
class UnseekableBuffer(io.BytesIO):
def seek(self, *_args, **_kwargs):
raise io.UnsupportedOperation
stream = UnseekableBuffer()
with zipfile.ZipFile(stream, "w") as zf:
with zf.open("file.txt", "w", force_zip64=True) as target:
target.write(b"contents")
archive = bytearray(stream.getvalue())
local_header = archive.index(b"PK\x03\x04")
central_header = archive.index(b"PK\x01\x02")
assert struct.unpack_from("<H", archive, local_header + 6)[0] & 0x0008
assert struct.unpack_from("<H", archive, local_header + 4)[0] == 45
assert struct.unpack_from("<H", archive, central_header + 6)[0] == 45
# Hide the local size sentinels and ZIP64 extra ID while retaining the
# 64-bit data descriptor emitted by ZipFile. Leave a ZIP64-or-newer
# extractor version visible in exactly one header to exercise both
# preflight checks.
struct.pack_into("<LL", archive, local_header + 18, 0, 0)
filename_size = struct.unpack_from("<H", archive, local_header + 26)[0]
local_extra = local_header + 30 + filename_size
struct.pack_into("<H", archive, local_extra, 0xCAFE)
struct.pack_into("<H", archive, local_header + 4, 20)
struct.pack_into("<H", archive, central_header + 6, 20)
if visible_version == "central":
struct.pack_into("<H", archive, central_header + 6, extract_version)
else:
struct.pack_into("<H", archive, local_header + 4, extract_version)
zip_path = tmp_path / f"masked-{visible_version}-v{extract_version}.zip"
zip_path.write_bytes(archive)
with zipfile.ZipFile(zip_path) as zf:
assert zf.read("file.txt") == b"contents"
monkeypatch.setattr(
zipfile,
"ZipFile",
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
)
with pytest.raises(ValueError, match="extractor version 4.5 or newer"):
safe_extract_zip(zip_path, tmp_path / "out")
@pytest.mark.parametrize("compression", [zipfile.ZIP_BZIP2, zipfile.ZIP_LZMA])
def test_safe_extract_zip_rejects_unbounded_compression_before_zipfile(
tmp_path, monkeypatch, compression
):
zip_path = tmp_path / f"unsupported-{compression}.zip"
with zipfile.ZipFile(zip_path, "w", compression=compression) as zf:
zf.writestr("bomb.txt", b"A" * (1024 * 1024))
# Lie about the output size. For BZIP2/LZMA, ZipExtFile materializes the
# whole decompressor result before slicing it to the requested length.
archive = bytearray(zip_path.read_bytes())
central_header = archive.index(b"PK\x01\x02")
struct.pack_into("<L", archive, central_header + 24, 1)
zip_path.write_bytes(archive)
monkeypatch.setattr(
zipfile,
"ZipFile",
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
)
with pytest.raises(ValueError, match="supports only STORED and DEFLATED"):
safe_extract_zip(zip_path, tmp_path / "out")
@pytest.mark.parametrize(
"compression",
[zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED],
)
def test_safe_extract_zip_accepts_bounded_compression_methods(
tmp_path, compression
):
zip_path = tmp_path / f"supported-{compression}.zip"
with zipfile.ZipFile(zip_path, "w", compression=compression) as zf:
zf.writestr("file.txt", b"contents")
out_dir = tmp_path / "out"
safe_extract_zip(zip_path, out_dir)
assert (out_dir / "file.txt").read_bytes() == b"contents"
def test_safe_extract_zip_accepts_archive_with_prepended_data(tmp_path):
zip_path = tmp_path / "prefixed.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("file.txt", "contents")
zip_path.write_bytes(b"launcher-prefix" + zip_path.read_bytes())
out_dir = tmp_path / "out"
safe_extract_zip(zip_path, out_dir)
assert (out_dir / "file.txt").read_text(encoding="utf-8") == "contents"
def test_safe_extract_zip_rejects_central_entry_from_another_disk(tmp_path):
zip_path = tmp_path / "multi-disk-entry.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("file.txt", "contents")
archive = bytearray(zip_path.read_bytes())
central_header = archive.index(b"PK\x01\x02")
struct.pack_into("<H", archive, central_header + 34, 1)
zip_path.write_bytes(archive)
with pytest.raises(ValueError, match="Multi-disk"):
safe_extract_zip(zip_path, tmp_path / "out")
def test_safe_extract_zip_caps_central_directory_before_zipfile(
tmp_path, monkeypatch
):
zip_path = tmp_path / "large-directory.zip"
zip_path.write_bytes(
_legacy_zip_eocd(
entries=1,
central_directory_size=MAX_ZIP_CENTRAL_DIRECTORY_BYTES + 1,
)
)
monkeypatch.setattr(
zipfile,
"ZipFile",
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
)
with pytest.raises(ValueError, match="central directory exceeds"):
safe_extract_zip(zip_path, tmp_path / "out")
def test_safe_extract_zip_rejects_total_uncompressed_size(tmp_path):
zip_path = tmp_path / "bad.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("one.txt", "123")
zf.writestr("two.txt", "456")
with pytest.raises(ValueError, match="maximum uncompressed size"):
safe_extract_zip(zip_path, tmp_path / "out", max_total_bytes=5)
def test_safe_extract_zip_wraps_bad_zip_file(tmp_path):
zip_path = tmp_path / "bad.zip"
zip_path.write_bytes(b"not a zip archive")
with pytest.raises(_CustomZipError, match="Invalid ZIP archive"):
safe_extract_zip(zip_path, tmp_path / "out", error_type=_CustomZipError)
def test_safe_extract_zip_wraps_unsupported_zip_version(tmp_path):
zip_path = tmp_path / "unsupported.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("file.txt", "contents")
archive = bytearray(zip_path.read_bytes())
central_header = archive.index(b"PK\x01\x02")
struct.pack_into("<H", archive, central_header + 6, 99)
zip_path.write_bytes(archive)
with pytest.raises(
_CustomZipError,
match="extractor version 4.5 or newer",
):
safe_extract_zip(zip_path, tmp_path / "out", error_type=_CustomZipError)
def test_read_zip_member_limited_returns_member_within_limit(tmp_path):
zip_path = tmp_path / "ok.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("extension.yml", "extension:\n id: demo\n")
with zipfile.ZipFile(zip_path, "r") as zf:
data = read_zip_member_limited(zf, "extension.yml")
assert data == b"extension:\n id: demo\n"
def test_read_zip_member_limited_does_not_retain_short_read_fragments():
response = _OneByteResponse(64)
archive = _FakeZipArchive(response, file_size=64)
assert (
read_zip_member_limited(archive, "extension.yml", max_bytes=64)
== b"x" * 64
)
assert response.peak_live <= 2
@pytest.mark.parametrize("value", [None, "1", 1.5, True])
def test_read_zip_member_limited_rejects_non_integer_limits(value):
archive = _FakeZipArchive(_OneByteResponse(0))
with pytest.raises(TypeError, match="integer"):
read_zip_member_limited(archive, "extension.yml", max_bytes=value)
def test_read_zip_member_limited_rejects_negative_limit_without_opening():
archive = _FakeZipArchive(_OneByteResponse(0))
with pytest.raises(ValueError, match="non-negative"):
read_zip_member_limited(archive, "extension.yml", max_bytes=-1)
def test_read_zip_member_limited_rejects_oversized_member(tmp_path):
zip_path = tmp_path / "bomb.zip"
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("extension.yml", "a" * 5000)
with zipfile.ZipFile(zip_path, "r") as zf:
with pytest.raises(ValueError, match="exceeds maximum size"):
read_zip_member_limited(zf, "extension.yml", max_bytes=16)
def test_read_zip_member_limited_rejects_when_declared_size_is_too_small():
archive = _FakeZipArchive(_OneByteResponse(5), file_size=1)
with pytest.raises(ValueError, match="exceeds maximum size"):
read_zip_member_limited(
archive,
"extension.yml",
max_bytes=4,
)
def test_read_zip_member_limited_escapes_control_characters_in_errors():
member_name = "bad\x1b[2J/extension.yml"
archive = _FakeZipArchive(
_OneByteResponse(0),
filename=member_name,
file_size=5,
)
with pytest.raises(ValueError) as exc_info:
read_zip_member_limited(
archive,
member_name,
max_bytes=4,
)
assert "\x1b" not in str(exc_info.value)
assert "\\x1b" in str(exc_info.value)
def test_read_zip_member_limited_wraps_missing_member(tmp_path):
zip_path = tmp_path / "ok.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("other.txt", "x")
with zipfile.ZipFile(zip_path, "r") as zf:
with pytest.raises(_CustomZipError, match="ZIP member not found"):
read_zip_member_limited(zf, "extension.yml", error_type=_CustomZipError)
def test_read_zip_member_limited_wraps_decompression_errors():
archive = _FakeZipArchive(_ExplodingResponse(), file_size=1)
with pytest.raises(_CustomZipError, match="Failed to read ZIP member"):
read_zip_member_limited(
archive,
"extension.yml",
error_type=_CustomZipError,
)
@pytest.mark.parametrize(
"members",
[
[("nested\\file.txt", "first"), ("nested/file.txt", "second")],
[("node", "file"), ("node/child.txt", "child")],
[("node/child.txt", "child"), ("node", "file")],
[("Readme.txt", "first"), ("README.TXT", "second")],
[("caf\u00e9.txt", "first"), ("cafe\u0301.txt", "second")],
],
)
def test_safe_extract_zip_rejects_conflicting_paths_before_writing(
tmp_path, members
):
zip_path = tmp_path / "conflict.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
for name, contents in members:
zf.writestr(name, contents)
out_dir = tmp_path / "out"
with pytest.raises(ValueError, match="Conflicting path"):
safe_extract_zip(zip_path, out_dir)
assert not out_dir.exists() or not any(out_dir.rglob("*"))
@pytest.mark.parametrize(
"member_name",
[
"file::$DATA",
"file.",
"file ",
" leading.txt",
"NUL.txt",
"COM\u00b9.log",
"COM1 .txt",
"CONOUT$.log",
"nested/name?.txt",
"nested/control\u0001.txt",
"nested/delete\u007f.txt",
"nested/csi\u009b[2J.txt",
],
)
def test_safe_extract_zip_rejects_nonportable_member_names(tmp_path, member_name):
zip_path = tmp_path / "nonportable.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr(member_name, "contents")
out_dir = tmp_path / "out"
with pytest.raises(ValueError, match="Unsafe path"):
safe_extract_zip(zip_path, out_dir)
assert not out_dir.exists() or not any(out_dir.rglob("*"))
@pytest.mark.parametrize(
"member_name",
[
"a" * 256,
"a/" * 2048 + "file.txt",
],
)
def test_safe_extract_zip_rejects_excessively_long_paths(tmp_path, member_name):
zip_path = tmp_path / "nonportable.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr(member_name, "contents")
out_dir = tmp_path / "out"
with pytest.raises(ValueError, match="Unsafe path"):
safe_extract_zip(zip_path, out_dir)
assert not out_dir.exists() or not any(out_dir.rglob("*"))
@pytest.mark.parametrize(
("control_character", "escaped_character"),
[
("\x1b", "\\x1b"),
("\x7f", "\\x7f"),
("\x9b", "\\x9b"),
],
)
def test_safe_extract_zip_escapes_unicode_control_characters_in_errors(
tmp_path,
control_character,
escaped_character,
):
zip_path = tmp_path / "terminal-control.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr(f"bad{control_character}[2J.txt", "contents")
with pytest.raises(ValueError) as exc_info:
safe_extract_zip(zip_path, tmp_path / "out")
assert control_character not in str(exc_info.value)
assert escaped_character in str(exc_info.value)
def test_safe_extract_zip_accepts_single_decomposed_unicode_name(tmp_path):
zip_path = tmp_path / "unicode.zip"
out_dir = tmp_path / "out"
decomposed_name = "cafe\u0301.txt"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr(decomposed_name, "contents")
safe_extract_zip(zip_path, out_dir)
assert (out_dir / decomposed_name).read_text(encoding="utf-8") == "contents"
def test_safe_extract_zip_wraps_decompression_errors(tmp_path, monkeypatch):
zip_path = tmp_path / "corrupt.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("extension.yml", "x")
archive = _FakeZipArchive(_ExplodingResponse(), file_size=1)
monkeypatch.setattr(zipfile, "ZipFile", lambda *_args, **_kwargs: archive)
with pytest.raises(_CustomZipError, match="Failed to extract ZIP member"):
safe_extract_zip(
zip_path,
tmp_path / "out",
error_type=_CustomZipError,
)
def test_safe_extract_zip_enforces_actual_member_size(tmp_path, monkeypatch):
zip_path = tmp_path / "lying-size.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("extension.yml", "x")
archive = _FakeZipArchive(_OneByteResponse(5), file_size=1)
monkeypatch.setattr(zipfile, "ZipFile", lambda *_args, **_kwargs: archive)
with pytest.raises(ValueError, match="exceeds maximum size"):
safe_extract_zip(
zip_path,
tmp_path / "out",
max_member_bytes=4,
)
def test_safe_extract_zip_extracts_safe_archive(tmp_path):
zip_path = tmp_path / "ok.zip"
out_dir = tmp_path / "out"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("nested/file.txt", "hello")
safe_extract_zip(zip_path, out_dir)
assert (out_dir / "nested" / "file.txt").read_text(encoding="utf-8") == "hello"
def test_safe_extract_zip_treats_normalized_trailing_backslash_as_directory(tmp_path):
zip_path = tmp_path / "ok.zip"
out_dir = tmp_path / "out"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("nested\\", "")
zf.writestr("nested/file.txt", "hello")
safe_extract_zip(zip_path, out_dir)
assert (out_dir / "nested").is_dir()
assert (out_dir / "nested" / "file.txt").read_text(encoding="utf-8") == "hello"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1045 -26
View File
File diff suppressed because it is too large Load Diff
+4313 -32
View File
File diff suppressed because it is too large Load Diff
+46 -3
View File
@@ -13,7 +13,13 @@ TRAVERSAL_PAYLOADS = [
"../pwned",
"../../etc/passwd",
"subdir/../../escape",
"link/../victim",
"/absolute/evil",
"NUL",
"name:stream",
"x?y",
"trailing.",
"group\\run",
]
@@ -89,7 +95,8 @@ class TestAliasTraversal:
@pytest.mark.parametrize("bad_alias", TRAVERSAL_PAYLOADS)
def test_gemini_rejects_traversal_in_alias(self, tmp_path, bad_alias):
project, ext_dir = _project_and_source(tmp_path)
(project / ".gemini" / "commands").mkdir(parents=True)
commands_dir = project / ".gemini" / "commands"
commands_dir.mkdir(parents=True)
registrar = CommandRegistrar()
with pytest.raises(ValueError, match="escapes|outside|Invalid"):
@@ -102,12 +109,15 @@ class TestAliasTraversal:
)
_assert_no_stray_files(tmp_path, Path(bad_alias).name.replace("/", ""))
assert list(commands_dir.rglob("*")) == []
@pytest.mark.parametrize("bad_alias", TRAVERSAL_PAYLOADS)
def test_copilot_rejects_traversal_in_alias(self, tmp_path, bad_alias):
project, ext_dir = _project_and_source(tmp_path)
(project / ".github" / "agents").mkdir(parents=True)
(project / ".github" / "prompts").mkdir(parents=True)
agents_dir = project / ".github" / "agents"
prompts_dir = project / ".github" / "prompts"
agents_dir.mkdir(parents=True)
prompts_dir.mkdir(parents=True)
registrar = CommandRegistrar()
with pytest.raises(ValueError, match="escapes|outside|Invalid"):
@@ -120,6 +130,8 @@ class TestAliasTraversal:
)
_assert_no_stray_files(tmp_path, Path(bad_alias).name.replace("/", ""))
assert list(agents_dir.rglob("*")) == []
assert list(prompts_dir.rglob("*")) == []
class TestCopilotPromptTraversal:
@@ -290,6 +302,13 @@ class TestRelativeExtensionPathPolicy:
"\\\\server\\share\\x.md",
"../escape.md",
"commands/../../escape.md",
"NUL",
"commands/CON.md",
"commands\\run.md",
"name:stream",
"x?y",
"trailing.",
"directory/",
],
)
def test_unsafe_values_report_violation(self, value):
@@ -380,3 +399,27 @@ class TestReadSkipWarning:
/ "speckit-myext-hi"
/ "SKILL.md"
).exists()
def test_copilot_nested_alias_creates_companion_prompt(self, tmp_path):
project, ext_dir = _project_and_source(tmp_path)
agents_dir = project / ".github" / "agents"
agents_dir.mkdir(parents=True)
registrar = CommandRegistrar()
registered = registrar.register_commands(
"copilot",
[_cmd("speckit.myext.hello", ["group/run"])],
"myext",
ext_dir,
project,
)
assert registered == ["speckit.myext.hello", "group/run"]
assert (agents_dir / "group" / "run.agent.md").is_file()
assert (
project
/ ".github"
/ "prompts"
/ "group"
/ "run.prompt.md"
).is_file()
+562
View File
@@ -3629,6 +3629,31 @@ class TestWorkflowDefinition:
resolved = WorkflowEngine()._resolve_inputs(definition, {}) # must not raise
assert resolved == {}
@pytest.mark.parametrize(
"block", ["workflow:\nsteps: []\n", "workflow: hi\nsteps: []\n", "workflow: [a]\nsteps: []\n"]
)
def test_non_mapping_workflow_block_parses_then_validates(self, block):
# A present-but-non-mapping `workflow:` block must not crash construction
# with AttributeError; it should parse to an empty header so
# validate_workflow reports the missing id/name (it reads the parsed
# attributes, not the raw block).
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
definition = WorkflowDefinition.from_string(block) # must not raise
assert definition.id == ""
errors = validate_workflow(definition)
assert any("workflow.id" in e for e in errors)
# The RAW malformed value is preserved on .data (the guard only
# normalizes the local var, not self.data) — .data is what gets written
# back out when a definition is serialized. Assert it was NOT replaced
# with {} by comparing against the original parse and confirming it is
# still a non-mapping.
import yaml
raw_workflow = yaml.safe_load(block).get("workflow")
assert definition.data["workflow"] == raw_workflow
assert not isinstance(definition.data["workflow"], dict)
def test_from_string_invalid(self):
from specify_cli.workflows.engine import WorkflowDefinition
@@ -6373,6 +6398,7 @@ class TestWorkflowCatalog:
[
"https://[::1", # unterminated IPv6 bracket
"https://[not-an-ip]/x", # bracketed non-IP host
"https://example.com:notaport/catalog.json",
],
)
def test_validate_url_malformed_raises_validation_error(self, project_dir, url):
@@ -6482,6 +6508,62 @@ class TestWorkflowCatalog:
catalog._fetch_single_catalog(entry, force_refresh=True)
assert captured["rv"] is not None
def test_fetch_rejects_oversized_catalog_response(
self, project_dir, monkeypatch
):
from specify_cli.authentication import http as auth_http
from specify_cli.workflows import catalog as catalog_module
from specify_cli.workflows.catalog import (
WorkflowCatalog,
WorkflowCatalogEntry,
WorkflowCatalogError,
)
monkeypatch.setattr(catalog_module, "MAX_JSON_CATALOG_BYTES", 32)
requested_sizes: list[int] = []
class _FakeResponse:
def __init__(self):
self.body = b"x" * 64
self.offset = 0
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def geturl(self):
return "https://example.com/catalog.json"
def read(self, size=-1):
requested_sizes.append(size)
assert size >= 0
chunk_size = min(size, 7)
chunk = self.body[self.offset : self.offset + chunk_size]
self.offset += len(chunk)
return chunk
monkeypatch.setattr(
auth_http,
"open_url",
lambda url, timeout=30, redirect_validator=None: _FakeResponse(),
)
catalog = WorkflowCatalog(project_dir)
entry = WorkflowCatalogEntry(
url="https://example.com/catalog.json",
name="test",
priority=1,
install_allowed=True,
)
with pytest.raises(WorkflowCatalogError, match="exceeds maximum size"):
catalog._fetch_single_catalog(entry, force_refresh=True)
assert requested_sizes
assert not catalog.cache_dir.exists()
def test_add_catalog(self, project_dir):
from specify_cli.workflows.catalog import WorkflowCatalog
@@ -6977,6 +7059,7 @@ class TestStepCatalog:
[
"https://[::1", # unterminated IPv6 bracket
"https://[not-an-ip]/x", # bracketed non-IP host
"https://example.com:notaport/steps.json",
],
)
def test_validate_url_malformed_raises_validation_error(self, project_dir, url):
@@ -7078,6 +7161,62 @@ class TestStepCatalog:
catalog._fetch_single_catalog(entry, force_refresh=True)
assert captured["rv"] is not None
def test_fetch_rejects_oversized_catalog_response(
self, project_dir, monkeypatch
):
from specify_cli.authentication import http as auth_http
from specify_cli.workflows import catalog as catalog_module
from specify_cli.workflows.catalog import (
StepCatalog,
StepCatalogEntry,
StepCatalogError,
)
monkeypatch.setattr(catalog_module, "MAX_JSON_CATALOG_BYTES", 32)
requested_sizes: list[int] = []
class _FakeResponse:
def __init__(self):
self.body = b"x" * 64
self.offset = 0
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def geturl(self):
return "https://example.com/steps.json"
def read(self, size=-1):
requested_sizes.append(size)
assert size >= 0
chunk_size = min(size, 7)
chunk = self.body[self.offset : self.offset + chunk_size]
self.offset += len(chunk)
return chunk
monkeypatch.setattr(
auth_http,
"open_url",
lambda url, timeout=30, redirect_validator=None: _FakeResponse(),
)
catalog = StepCatalog(project_dir)
entry = StepCatalogEntry(
url="https://example.com/steps.json",
name="test",
priority=1,
install_allowed=True,
)
with pytest.raises(StepCatalogError, match="exceeds maximum size"):
catalog._fetch_single_catalog(entry, force_refresh=True)
assert requested_sizes
assert not catalog.cache_dir.exists()
def test_add_catalog(self, project_dir):
from specify_cli.workflows.catalog import StepCatalog
@@ -7929,6 +8068,112 @@ class TestWorkflowInfoStepGraph:
# by Rich as an unknown style tag.
assert "[gate]" in result.output
def test_definition_metadata_fields_escaped(self, temp_dir, monkeypatch):
"""Every metadata field printed from the workflow definition (name,
description, author, integration, input name/type) is untrusted
workflow.yml content. An unescaped `[...]` in any of them would be
parsed as a Rich style tag and silently swallowed, so bracketed text
must survive literally in the output."""
import types
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.workflows.engine import WorkflowEngine
(temp_dir / ".specify" / "workflows").mkdir(parents=True)
fake = types.SimpleNamespace(
name="My [WF]",
id="my-wf",
version="1.0.0 [beta]",
author="Jane [Doe]",
description="Does [stuff] nicely",
default_integration="claude [code]",
inputs={"in [put]": {"type": "str [ing]", "required": True}},
steps=[],
)
monkeypatch.setattr(WorkflowEngine, "load_workflow", lambda self, wid: fake)
monkeypatch.chdir(temp_dir)
result = CliRunner().invoke(app, ["workflow", "info", "my-wf"])
assert result.exit_code == 0, result.output
# Each bracketed token must render literally rather than be consumed as
# an unknown Rich style tag.
assert "My [WF]" in result.output
assert "1.0.0 [beta]" in result.output
assert "Jane [Doe]" in result.output
assert "Does [stuff] nicely" in result.output
assert "claude [code]" in result.output
assert "in [put]" in result.output
assert "str [ing]" in result.output
def test_catalog_metadata_fields_escaped(self, temp_dir, monkeypatch):
"""When the workflow is only found in the catalog (not on disk), its
catalog-derived fields (name, description, tags) are untrusted too and
must be escaped so bracketed content renders literally."""
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.workflows.engine import WorkflowEngine
from specify_cli.workflows import catalog as catalog_mod
(temp_dir / ".specify" / "workflows").mkdir(parents=True)
def _not_on_disk(self, wid):
raise FileNotFoundError(wid)
monkeypatch.setattr(WorkflowEngine, "load_workflow", _not_on_disk)
monkeypatch.setattr(
catalog_mod.WorkflowCatalog,
"get_workflow_info",
lambda self, wid: {
"name": "Cat [WF]",
"version": "2.0.0 [rc]",
"description": "From [catalog]",
"tags": ["a [b]", "c [d]"],
},
)
monkeypatch.chdir(temp_dir)
result = CliRunner().invoke(app, ["workflow", "info", "cat-wf"])
assert result.exit_code == 0, result.output
assert "Cat [WF]" in result.output
assert "2.0.0 [rc]" in result.output
assert "From [catalog]" in result.output
assert "a [b]" in result.output
assert "c [d]" in result.output
def test_not_found_id_escaped(self, temp_dir, monkeypatch):
"""When the workflow is neither on disk nor in the catalog, the
not-found error echoes the requested ID. That ID is user input, so a
bracketed value must render literally instead of being parsed (and
swallowed) as a Rich style tag."""
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.workflows.engine import WorkflowEngine
from specify_cli.workflows import catalog as catalog_mod
(temp_dir / ".specify" / "workflows").mkdir(parents=True)
def _not_on_disk(self, wid):
raise FileNotFoundError(wid)
monkeypatch.setattr(WorkflowEngine, "load_workflow", _not_on_disk)
monkeypatch.setattr(
catalog_mod.WorkflowCatalog,
"get_workflow_info",
lambda self, wid: None,
)
monkeypatch.chdir(temp_dir)
result = CliRunner().invoke(app, ["workflow", "info", "ghost [wf]"])
assert result.exit_code == 1, result.output
assert "not found" in result.output
# The bracketed ID must survive literally, not be eaten as markup.
assert "ghost [wf]" in result.output
class TestWorkflowAddSymlinkGuard:
def test_add_malformed_ipv6_url_exits_cleanly(self, temp_dir, monkeypatch):
@@ -8284,6 +8529,269 @@ class TestWorkflowStepAddCLI:
project_dir / ".specify" / "workflows" / "steps" / "my-step"
).exists()
@pytest.mark.parametrize(
("catalog_fields", "expected"),
[
({"url": 123}, "malformed step.yml URL"),
(
{
"step_yml_url": [],
"url": "https://example.com/step.yml",
},
"malformed step.yml URL",
),
(
{
"url": "https://example.com/step.yml",
"init_url": 123,
},
"malformed __init__.py URL",
),
],
)
def test_add_rejects_non_string_required_urls_before_network(
self, project_dir, monkeypatch, catalog_fields, expected
):
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.authentication import http as auth_http
from specify_cli.workflows.catalog import StepCatalog
monkeypatch.chdir(project_dir)
monkeypatch.setattr(
StepCatalog,
"get_step_info",
lambda self, step_id: {
"id": step_id,
"name": "Test Step",
"_install_allowed": True,
**catalog_fields,
},
)
monkeypatch.setattr(
auth_http,
"open_url",
lambda *args, **kwargs: (_ for _ in ()).throw(
AssertionError("download should not start")
),
)
result = CliRunner().invoke(
app, ["workflow", "step", "add", "my-step"]
)
assert result.exit_code != 0
assert result.exception is None or isinstance(result.exception, SystemExit)
assert expected in result.output
assert not (
project_dir / ".specify" / "workflows" / "steps" / "my-step"
).exists()
@pytest.mark.parametrize(
("alias", "protected_name"),
[
("./step.yml", "step.yml"),
("step.yml/", "step.yml"),
("STEP.YML", "step.yml"),
(".\\step.yml", "step.yml"),
("./__init__.py", "__init__.py"),
("__init__.py/", "__init__.py"),
("__INIT__.PY", "__init__.py"),
(".\\__init__.py", "__init__.py"),
],
)
def test_add_does_not_overwrite_required_files_through_path_aliases(
self, project_dir, monkeypatch, alias, protected_name
):
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.authentication import http as auth_http
from specify_cli.workflows.catalog import StepCatalog
monkeypatch.chdir(project_dir)
alias_url = "https://example.com/overwrite"
monkeypatch.setattr(
StepCatalog,
"get_step_info",
lambda self, step_id: {
"id": step_id,
"name": "Test Step",
"url": "https://example.com/step.yml",
"init_url": "https://example.com/__init__.py",
"_install_allowed": True,
"extra_files": {alias: alias_url},
},
)
bodies = {
"https://example.com/step.yml": b"step:\n type_key: my-step\n",
"https://example.com/__init__.py": b"# trusted init\n",
}
requested_urls: list[str] = []
class _FakeResponse:
def __init__(self, url):
self.url = url
self.body = bodies[url]
self.offset = 0
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def geturl(self):
return self.url
def read(self, size=-1):
if size < 0:
size = len(self.body) - self.offset
chunk = self.body[self.offset : self.offset + size]
self.offset += len(chunk)
return chunk
def fake_open_url(url, timeout=30, redirect_validator=None):
requested_urls.append(url)
return _FakeResponse(url)
monkeypatch.setattr(auth_http, "open_url", fake_open_url)
result = CliRunner().invoke(
app, ["workflow", "step", "add", "my-step"]
)
assert result.exit_code == 0, result.output
assert alias_url not in requested_urls
installed_dir = (
project_dir / ".specify" / "workflows" / "steps" / "my-step"
)
assert (installed_dir / protected_name).read_bytes() == bodies[
f"https://example.com/{protected_name}"
]
def test_add_rejects_too_many_package_files_before_network(
self, project_dir, monkeypatch
):
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.authentication import http as auth_http
from specify_cli.workflows import _commands as workflow_commands
from specify_cli.workflows.catalog import StepCatalog
monkeypatch.chdir(project_dir)
monkeypatch.setattr(workflow_commands, "_MAX_STEP_PACKAGE_FILES", 3)
monkeypatch.setattr(
StepCatalog,
"get_step_info",
lambda self, step_id: {
"id": step_id,
"name": "Test Step",
"url": "https://example.com/step.yml",
"init_url": "https://example.com/__init__.py",
"_install_allowed": True,
"extra_files": {
"one.py": "https://example.com/one.py",
"two.py": "https://example.com/two.py",
},
},
)
monkeypatch.setattr(
auth_http,
"open_url",
lambda *args, **kwargs: (_ for _ in ()).throw(
AssertionError("download should not start")
),
)
result = CliRunner().invoke(
app, ["workflow", "step", "add", "my-step"]
)
assert result.exit_code != 0
assert result.exception is None or isinstance(result.exception, SystemExit)
assert "exceeding the 3-file limit" in result.output
steps_dir = project_dir / ".specify" / "workflows" / "steps"
assert not (steps_dir / "my-step").exists()
assert list(steps_dir.glob("speckit_step_tmp_*")) == []
def test_add_rejects_package_over_cumulative_size_and_cleans_staging(
self, project_dir, monkeypatch
):
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.authentication import http as auth_http
from specify_cli.workflows import _commands as workflow_commands
from specify_cli.workflows.catalog import StepCatalog
monkeypatch.chdir(project_dir)
monkeypatch.setattr(workflow_commands, "_MAX_STEP_PACKAGE_BYTES", 40)
monkeypatch.setattr(
StepCatalog,
"get_step_info",
lambda self, step_id: {
"id": step_id,
"name": "Test Step",
"url": "https://example.com/step.yml",
"init_url": "https://example.com/__init__.py",
"_install_allowed": True,
"extra_files": {
"helper.py": "https://example.com/helper.py",
},
},
)
bodies = {
"https://example.com/step.yml": b"step:\n type_key: my-step\n",
"https://example.com/__init__.py": b"# init\n",
"https://example.com/helper.py": b"0123456789",
}
class _FakeResponse:
def __init__(self, url):
self.url = url
self.body = bodies[url]
self.offset = 0
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def getheader(self, name):
return None
def geturl(self):
return self.url
def read(self, size=-1):
if size < 0:
size = len(self.body) - self.offset
chunk = self.body[self.offset : self.offset + size]
self.offset += len(chunk)
return chunk
monkeypatch.setattr(
auth_http,
"open_url",
lambda url, timeout=30, redirect_validator=None: _FakeResponse(url),
)
result = CliRunner().invoke(
app, ["workflow", "step", "add", "my-step"]
)
assert result.exit_code != 0
assert result.exception is None or isinstance(result.exception, SystemExit)
assert "40-byte total size limit" in result.output
steps_dir = project_dir / ".specify" / "workflows" / "steps"
assert not (steps_dir / "my-step").exists()
assert list(steps_dir.glob("speckit_step_tmp_*")) == []
def test_add_rejects_non_string_extra_files_key(self, project_dir, monkeypatch):
from typer.testing import CliRunner
from specify_cli import app
@@ -10052,6 +10560,60 @@ steps:
assert "desc [with] brackets" in result.output
assert "tag[1]" in result.output
def test_catalog_list_escapes_rich_markup(self, project_dir, 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.workflows.catalog import WorkflowCatalog
monkeypatch.chdir(project_dir)
configs = [
{
"name": "Bracket [Catalog]",
"url": "https://example.com/[cat].json",
"description": "desc [with] brackets",
"install_allowed": True,
},
]
monkeypatch.setattr(
WorkflowCatalog,
"get_catalog_configs",
lambda self: [dict(c) for c in configs],
)
runner = CliRunner()
result = runner.invoke(app, ["workflow", "catalog", "list"])
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
def test_step_catalog_list_escapes_rich_markup(self, project_dir, monkeypatch):
"""User-editable step-catalog name/url/description must not be parsed as Rich markup."""
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.workflows.catalog import StepCatalog
monkeypatch.chdir(project_dir)
configs = [
{
"name": "Bracket [Step]",
"url": "https://example.com/[step].json",
"description": "step [with] brackets",
"install_allowed": True,
},
]
monkeypatch.setattr(
StepCatalog,
"get_catalog_configs",
lambda self: [dict(c) for c in configs],
)
runner = CliRunner()
result = runner.invoke(app, ["workflow", "step", "catalog", "list"])
assert result.exit_code == 0, result.output
assert "Bracket [Step]" in result.output
assert "https://example.com/[step].json" in result.output
assert "step [with] brackets" in result.output
# -- update ----------------------------------------------------------
def test_update_no_workflows_installed(self, project_dir, monkeypatch):
+120
View File
@@ -1,19 +1,59 @@
"""Unit tests for malformed download-URL handling in bundle manifest resolution."""
from __future__ import annotations
import hashlib
import io
from types import SimpleNamespace
import pytest
import yaml
from specify_cli.bundler import BundlerError
from specify_cli.bundler.models.catalog import CatalogEntry
from specify_cli.commands import bundle as bundle_commands
from specify_cli.commands.bundle import _download_manifest, _require_https
from tests.bundler_helpers import catalog_entry_dict, valid_manifest_dict
_MALFORMED_URLS = [
"https://[::1", # unclosed IPv6 bracket
"https://[not-an-ip]/bundle.yml",
"https://example.com:notaport/bundle.yml",
"https://example.com:70000/bundle.yml",
]
class _Response(io.BytesIO):
def __init__(self, body: bytes, url: str) -> None:
super().__init__(body)
self._url = url
def geturl(self) -> str:
return self._url
def _resolved_entry(**overrides) -> SimpleNamespace:
entry = CatalogEntry.from_dict(
catalog_entry_dict(
"demo-bundle",
download_url="https://example.com/demo-bundle.yml",
**overrides,
)
)
return SimpleNamespace(entry=entry)
def _patch_download(monkeypatch, body: bytes) -> None:
def fake_open_url(
url,
timeout=10,
extra_headers=None,
redirect_validator=None,
):
return _Response(body, url)
monkeypatch.setattr("specify_cli.authentication.http.open_url", fake_open_url)
@pytest.mark.parametrize("url", _MALFORMED_URLS)
def test_download_manifest_rejects_malformed_url_cleanly(url):
"""A malformed download_url must raise BundlerError, not a raw ValueError.
@@ -40,3 +80,83 @@ def test_require_https_rejects_malformed_url_cleanly(url):
"""
with pytest.raises(BundlerError):
_require_https("bundle 'x'", url)
def test_download_manifest_bounds_remote_artifact(monkeypatch):
body = yaml.safe_dump(valid_manifest_dict()).encode()
_patch_download(monkeypatch, body)
monkeypatch.setattr(bundle_commands, "MAX_DOWNLOAD_BYTES", len(body) - 1)
with pytest.raises(BundlerError, match="exceeds maximum size"):
_download_manifest(_resolved_entry(), offline=False)
def test_download_manifest_accepts_matching_sha256(monkeypatch):
body = yaml.safe_dump(valid_manifest_dict()).encode()
digest = hashlib.sha256(body).hexdigest()
_patch_download(monkeypatch, body)
manifest = _download_manifest(
_resolved_entry(sha256=f"sha256:{digest}"),
offline=False,
)
assert manifest.bundle.id == "demo-bundle"
def test_download_manifest_accepts_legacy_entry_without_sha256(monkeypatch):
body = yaml.safe_dump(valid_manifest_dict()).encode()
_patch_download(monkeypatch, body)
resolved = SimpleNamespace(
entry=SimpleNamespace(
id="demo-bundle",
version="1.2.0",
download_url="https://example.com/demo-bundle.yml",
)
)
manifest = _download_manifest(resolved, offline=False)
assert manifest.bundle.version == "1.2.0"
@pytest.mark.parametrize("declared", ["0" * 64, "not-a-sha256"])
def test_download_manifest_rejects_bad_sha256(monkeypatch, declared):
body = yaml.safe_dump(valid_manifest_dict()).encode()
_patch_download(monkeypatch, body)
with pytest.raises(BundlerError, match="sha256|Integrity check"):
_download_manifest(
_resolved_entry(sha256=declared),
offline=False,
)
@pytest.mark.parametrize(
("field", "value", "message"),
[
("id", "other-bundle", "id mismatch"),
("version", "9.9.9", "version mismatch"),
],
)
def test_download_manifest_rejects_catalog_identity_mismatch(
monkeypatch,
field,
value,
message,
):
data = valid_manifest_dict()
data["bundle"][field] = value
_patch_download(monkeypatch, yaml.safe_dump(data).encode())
with pytest.raises(BundlerError, match=message):
_download_manifest(_resolved_entry(), offline=False)
def test_download_manifest_rejects_invalid_structure(monkeypatch):
data = valid_manifest_dict()
data["bundle"]["author"] = ""
_patch_download(monkeypatch, yaml.safe_dump(data).encode())
with pytest.raises(BundlerError, match="invalid bundle manifest"):
_download_manifest(_resolved_entry(), offline=False)
+35 -2
View File
@@ -20,6 +20,7 @@ def _source(url: str) -> CatalogSource:
class _FakeResponse:
def __init__(self, body: bytes, final_url: str) -> None:
self._body = body
self._offset = 0
self._final_url = final_url
def __enter__(self) -> "_FakeResponse":
@@ -31,8 +32,12 @@ class _FakeResponse:
def geturl(self) -> str:
return self._final_url
def read(self) -> bytes:
return self._body
def read(self, size: int = -1) -> bytes:
if size < 0:
size = len(self._body) - self._offset
start = self._offset
self._offset = min(len(self._body), self._offset + size)
return self._body[start:self._offset]
def test_http_fetch_uses_shared_client_and_rejects_redirect_downgrade(monkeypatch):
@@ -71,6 +76,34 @@ def test_http_fetch_rejects_non_https_final_url(monkeypatch):
fetcher(_source("https://example.com/c.json"))
def test_http_fetch_bounds_catalog_response(monkeypatch):
body = b'{"schema_version":"1.0","bundles":{}}'
def fake_open_url(url, timeout=10, extra_headers=None, redirect_validator=None):
return _FakeResponse(body, url)
monkeypatch.setattr("specify_cli.authentication.http.open_url", fake_open_url)
monkeypatch.setattr(adapters, "MAX_JSON_CATALOG_BYTES", len(body) - 1)
fetcher = adapters.make_catalog_fetcher(allow_network=True)
with pytest.raises(BundlerError, match="exceeds maximum size"):
fetcher(_source("https://example.com/c.json"))
@pytest.mark.parametrize(
"url",
[
"https://[::1",
"https://example.com:notaport/catalog.json",
"https://example.com:70000/catalog.json",
],
)
def test_fetch_rejects_malformed_source_url_cleanly(url):
fetcher = adapters.make_catalog_fetcher(allow_network=True)
with pytest.raises(BundlerError, match="URL is malformed"):
fetcher(_source(url))
def test_builtin_community_catalog_fetches_repository_catalog_online(monkeypatch):
captured: dict = {}
+1
View File
@@ -20,6 +20,7 @@ requires:
# ones not listed below, as long as that integration provides the four
# core commands referenced in ``steps``.
any:
- "alquimia"
- "claude"
- "copilot"
- "gemini"