mirror of
https://github.com/github/spec-kit.git
synced 2026-08-15 14:02:04 +02:00
* 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 after2486c08, 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 HEADb9d9053(#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(). After1d8f9e3, 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 HEAD31c9b97(#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 HEADab6c28c), 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 HEADd0d152e: 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>
5498 lines
238 KiB
Python
5498 lines
238 KiB
Python
"""
|
|
Preset Manager for Spec Kit
|
|
|
|
Handles installation, removal, and management of Spec Kit presets.
|
|
Presets are self-contained, versioned collections of templates
|
|
(artifact, command, and script templates) that can be installed to
|
|
customize the Spec-Driven Development workflow.
|
|
"""
|
|
|
|
import copy
|
|
import json
|
|
import hashlib
|
|
import os
|
|
import tempfile
|
|
import zipfile
|
|
import shutil
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Optional, Dict, List, Any, Union, Set
|
|
|
|
if TYPE_CHECKING:
|
|
from ..agents import CommandRegistrar
|
|
from datetime import datetime, timezone
|
|
import re
|
|
|
|
import yaml
|
|
from packaging import version as pkg_version
|
|
from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
|
|
|
from ..extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority
|
|
from .._init_options import (
|
|
MISSING_INIT_OPTIONS_FILE,
|
|
is_ai_skills_enabled,
|
|
load_init_options,
|
|
resolve_active_agent_for_registration,
|
|
)
|
|
from ..integrations.base import IntegrationBase
|
|
from .._utils import dump_frontmatter, version_satisfies
|
|
from ..shared_infra import (
|
|
_ensure_safe_shared_destination,
|
|
_ensure_safe_shared_directory,
|
|
_write_shared_bytes,
|
|
_write_shared_text,
|
|
verify_archive_sha256,
|
|
)
|
|
|
|
|
|
_CONSTITUTION_PROVENANCE_FILE = ".constitution-template.json"
|
|
|
|
|
|
def _content_sha256(content: bytes) -> str:
|
|
return hashlib.sha256(content).hexdigest()
|
|
|
|
|
|
def _constitution_is_generated(
|
|
project_root: Path,
|
|
memory_constitution: Path,
|
|
resolver: "PresetResolver",
|
|
) -> bool:
|
|
"""Return whether the live constitution is an unchanged generated file."""
|
|
_ensure_safe_shared_destination(project_root, memory_constitution)
|
|
content = memory_constitution.read_bytes()
|
|
provenance = memory_constitution.parent / _CONSTITUTION_PROVENANCE_FILE
|
|
_ensure_safe_shared_destination(project_root, provenance)
|
|
|
|
if provenance.exists():
|
|
try:
|
|
metadata = json.loads(provenance.read_text(encoding="utf-8"))
|
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
return False
|
|
return (
|
|
isinstance(metadata, dict)
|
|
and metadata.get("sha256") == _content_sha256(content)
|
|
)
|
|
|
|
# Older projects have no provenance sidecar. Only the immutable bundled or
|
|
# source-checkout core template is safe to treat as generated.
|
|
core = resolver._find_bundled_core(
|
|
"constitution-template", "template", ".md"
|
|
)
|
|
return core is not None and core.read_bytes() == content
|
|
|
|
|
|
def _constitution_provenance_matches_preset(
|
|
project_root: Path,
|
|
memory_constitution: Path,
|
|
pack_id: str,
|
|
pack_version: str,
|
|
) -> bool:
|
|
"""Return whether provenance identifies a preset as the materialized source."""
|
|
provenance = memory_constitution.parent / _CONSTITUTION_PROVENANCE_FILE
|
|
if not provenance.parent.exists():
|
|
return False
|
|
_ensure_safe_shared_destination(project_root, provenance)
|
|
if not provenance.exists():
|
|
return False
|
|
try:
|
|
metadata = json.loads(provenance.read_text(encoding="utf-8"))
|
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
return False
|
|
return (
|
|
isinstance(metadata, dict)
|
|
and metadata.get("source") == f"{pack_id} v{pack_version}"
|
|
)
|
|
|
|
|
|
def _materialize_constitution_template(
|
|
project_root: Path,
|
|
memory_constitution: Path,
|
|
) -> str | None:
|
|
"""Materialize constitution-template content into memory/constitution.md.
|
|
|
|
Returns:
|
|
"copied" when the winning layer is ``replace`` and the source file is
|
|
copied verbatim; "composed" when a composing strategy is materialized
|
|
via ``resolve_content``; ``None`` when no constitution template resolves.
|
|
"""
|
|
resolver = PresetResolver(project_root)
|
|
layers = resolver.collect_all_layers("constitution-template", "template")
|
|
if not layers:
|
|
return None
|
|
|
|
top_layer = layers[0]
|
|
if top_layer["strategy"] == "replace":
|
|
content = top_layer["path"].read_bytes()
|
|
result = "copied"
|
|
else:
|
|
composed_content = resolver.resolve_content("constitution-template", "template")
|
|
if composed_content is None:
|
|
return None
|
|
content = composed_content.encode("utf-8")
|
|
result = "composed"
|
|
|
|
_ensure_safe_shared_directory(project_root, memory_constitution.parent)
|
|
_write_shared_bytes(project_root, memory_constitution, content)
|
|
provenance = memory_constitution.parent / _CONSTITUTION_PROVENANCE_FILE
|
|
_write_shared_text(
|
|
project_root,
|
|
provenance,
|
|
json.dumps(
|
|
{
|
|
"sha256": _content_sha256(content),
|
|
"source": top_layer["source"],
|
|
},
|
|
indent=2,
|
|
)
|
|
+ "\n",
|
|
)
|
|
return result
|
|
|
|
|
|
def _substitute_core_template(
|
|
body: str,
|
|
cmd_name: str,
|
|
project_root: "Path",
|
|
registrar: "CommandRegistrar",
|
|
) -> "tuple[str, dict]":
|
|
"""Substitute {CORE_TEMPLATE} with the body of the installed core command template.
|
|
|
|
Args:
|
|
body: Preset command body (may contain {CORE_TEMPLATE} placeholder).
|
|
cmd_name: Full command name (e.g. "speckit.git.feature" or "speckit.specify").
|
|
project_root: Project root path.
|
|
registrar: CommandRegistrar instance for parse_frontmatter.
|
|
|
|
Returns:
|
|
A tuple of (body, core_frontmatter) where body has {CORE_TEMPLATE} replaced
|
|
by the core template body and core_frontmatter holds the core template's parsed
|
|
frontmatter (so callers can inherit scripts/agent_scripts from it). Both are
|
|
unchanged / empty when the placeholder is absent or the core template file does
|
|
not exist.
|
|
"""
|
|
if "{CORE_TEMPLATE}" not in body:
|
|
return body, {}
|
|
|
|
# Derive the short name (strip "speckit." prefix) used by core command templates.
|
|
short_name = cmd_name
|
|
if short_name.startswith("speckit."):
|
|
short_name = short_name[len("speckit."):]
|
|
|
|
resolver = PresetResolver(project_root)
|
|
# Resolution order for the core template:
|
|
# 1. resolve_core(cmd_name) — covers tier-1 project overrides and tier-3/4
|
|
# name-based lookup (file named <cmd_name>.md). Checked first so that a
|
|
# local override always wins, even for extension commands.
|
|
# 2. resolve_extension_command_via_manifest(cmd_name) — manifest-based tier-3
|
|
# fallback for extension commands whose file is named differently from the
|
|
# command name (e.g. speckit.selftest.extension → commands/selftest.md).
|
|
# 3. resolve_core(short_name) — core template fallback using the unprefixed
|
|
# name (e.g. specify → templates/commands/specify.md).
|
|
# resolve_core() skips installed presets (tier 2) to prevent accidental nesting
|
|
# where another preset's wrap output is mistaken for the real core.
|
|
core_file = (
|
|
resolver.resolve_core(cmd_name, "command")
|
|
or resolver.resolve_extension_command_via_manifest(cmd_name)
|
|
or resolver.resolve_core(short_name, "command")
|
|
)
|
|
if core_file is None:
|
|
return body, {}
|
|
|
|
core_frontmatter, core_body = registrar.parse_frontmatter(core_file.read_text(encoding="utf-8"))
|
|
return body.replace("{CORE_TEMPLATE}", core_body), core_frontmatter
|
|
|
|
|
|
@dataclass
|
|
class PresetCatalogEntry:
|
|
"""Represents a single entry in the preset catalog stack."""
|
|
url: str
|
|
name: str
|
|
priority: int
|
|
install_allowed: bool
|
|
description: str = ""
|
|
|
|
|
|
class PresetError(Exception):
|
|
"""Base exception for preset-related errors."""
|
|
pass
|
|
|
|
|
|
class PresetValidationError(PresetError):
|
|
"""Raised when preset manifest validation fails."""
|
|
pass
|
|
|
|
|
|
class PresetCompatibilityError(PresetError):
|
|
"""Raised when preset is incompatible with current environment."""
|
|
pass
|
|
|
|
|
|
VALID_PRESET_TEMPLATE_TYPES = {"template", "command", "script"}
|
|
VALID_PRESET_STRATEGIES = {"replace", "prepend", "append", "wrap"}
|
|
# Scripts only support replace and wrap (prepend/append don't make semantic sense for executable code)
|
|
VALID_SCRIPT_STRATEGIES = {"replace", "wrap"}
|
|
|
|
|
|
class PresetManifest:
|
|
"""Represents and validates a preset manifest (preset.yml)."""
|
|
|
|
SCHEMA_VERSION = "1.0"
|
|
REQUIRED_FIELDS = ["schema_version", "preset", "requires", "provides"]
|
|
|
|
def __init__(self, manifest_path: Path):
|
|
"""Load and validate preset manifest.
|
|
|
|
Args:
|
|
manifest_path: Path to preset.yml file
|
|
|
|
Raises:
|
|
PresetValidationError: If manifest is invalid
|
|
"""
|
|
self.path = manifest_path
|
|
self.data = self._load_yaml(manifest_path)
|
|
self._validate()
|
|
|
|
def _load_yaml(self, path: Path) -> dict:
|
|
"""Load YAML file safely."""
|
|
try:
|
|
with open(path, 'r', encoding='utf-8') as f:
|
|
data = yaml.safe_load(f)
|
|
except yaml.YAMLError as e:
|
|
raise PresetValidationError(f"Invalid YAML in {path}: {e}")
|
|
except FileNotFoundError:
|
|
raise PresetValidationError(f"Manifest not found: {path}")
|
|
except UnicodeDecodeError as e:
|
|
raise PresetValidationError(
|
|
f"Manifest is not valid UTF-8: {path} ({e.reason} at byte {e.start})"
|
|
)
|
|
except OSError as e:
|
|
raise PresetValidationError(f"Could not read manifest {path}: {e}")
|
|
if data is None:
|
|
return {}
|
|
if not isinstance(data, dict):
|
|
raise PresetValidationError(
|
|
f"Manifest must be a YAML mapping, got {type(data).__name__}: {path}"
|
|
)
|
|
return data
|
|
|
|
def _validate(self):
|
|
"""Validate manifest structure and required fields."""
|
|
# Check required top-level fields
|
|
for field in self.REQUIRED_FIELDS:
|
|
if field not in self.data:
|
|
raise PresetValidationError(f"Missing required field: {field}")
|
|
|
|
# Validate schema version
|
|
if self.data["schema_version"] != self.SCHEMA_VERSION:
|
|
raise PresetValidationError(
|
|
f"Unsupported schema version: {self.data['schema_version']} "
|
|
f"(expected {self.SCHEMA_VERSION})"
|
|
)
|
|
|
|
# Validate preset metadata
|
|
pack = self.data["preset"]
|
|
for field in ["id", "name", "version", "description"]:
|
|
if field not in pack:
|
|
raise PresetValidationError(f"Missing preset.{field}")
|
|
|
|
# Validate pack ID format
|
|
if not re.match(r'^[a-z0-9-]+$', pack["id"]):
|
|
raise PresetValidationError(
|
|
f"Invalid preset ID '{pack['id']}': "
|
|
"must be lowercase alphanumeric with hyphens only"
|
|
)
|
|
|
|
# Validate semantic version
|
|
try:
|
|
pkg_version.Version(pack["version"])
|
|
except pkg_version.InvalidVersion:
|
|
raise PresetValidationError(f"Invalid version: {pack['version']}")
|
|
|
|
# Validate requires section
|
|
requires = self.data["requires"]
|
|
if "speckit_version" not in requires:
|
|
raise PresetValidationError("Missing requires.speckit_version")
|
|
|
|
# Validate provides section
|
|
provides = self.data["provides"]
|
|
if "templates" not in provides or not provides["templates"]:
|
|
raise PresetValidationError(
|
|
"Preset must provide at least one template"
|
|
)
|
|
|
|
# Validate templates
|
|
for tmpl in provides["templates"]:
|
|
if "type" not in tmpl or "name" not in tmpl or "file" not in tmpl:
|
|
raise PresetValidationError(
|
|
"Template missing 'type', 'name', or 'file'"
|
|
)
|
|
|
|
if tmpl["type"] not in VALID_PRESET_TEMPLATE_TYPES:
|
|
raise PresetValidationError(
|
|
f"Invalid template type '{tmpl['type']}': "
|
|
f"must be one of {sorted(VALID_PRESET_TEMPLATE_TYPES)}"
|
|
)
|
|
|
|
# Validate file path safety: must be relative, no parent traversal
|
|
file_path = tmpl["file"]
|
|
normalized = os.path.normpath(file_path)
|
|
if os.path.isabs(normalized) or normalized.startswith(".."):
|
|
raise PresetValidationError(
|
|
f"Invalid template file path '{file_path}': "
|
|
"must be a relative path within the preset directory"
|
|
)
|
|
|
|
# Validate strategy field (optional, defaults to "replace")
|
|
strategy = tmpl.get("strategy", "replace")
|
|
if not isinstance(strategy, str):
|
|
raise PresetValidationError(
|
|
f"Invalid strategy value: must be a string, "
|
|
f"got {type(strategy).__name__}"
|
|
)
|
|
strategy = strategy.lower()
|
|
# Persist normalized value so downstream code sees lowercase
|
|
if "strategy" in tmpl:
|
|
tmpl["strategy"] = strategy
|
|
if strategy not in VALID_PRESET_STRATEGIES:
|
|
raise PresetValidationError(
|
|
f"Invalid strategy '{strategy}': "
|
|
f"must be one of {sorted(VALID_PRESET_STRATEGIES)}"
|
|
)
|
|
if tmpl["type"] == "script" and strategy not in VALID_SCRIPT_STRATEGIES:
|
|
raise PresetValidationError(
|
|
f"Invalid strategy '{strategy}' for script: "
|
|
f"scripts only support {sorted(VALID_SCRIPT_STRATEGIES)}"
|
|
)
|
|
|
|
# Validate template name format
|
|
if tmpl["type"] == "command":
|
|
# Commands use dot notation (e.g. speckit.specify)
|
|
if not re.match(r'^[a-z0-9.-]+$', tmpl["name"]):
|
|
raise PresetValidationError(
|
|
f"Invalid command name '{tmpl['name']}': "
|
|
"must be lowercase alphanumeric with hyphens and dots only"
|
|
)
|
|
else:
|
|
if not re.match(r'^[a-z0-9-]+$', tmpl["name"]):
|
|
raise PresetValidationError(
|
|
f"Invalid template name '{tmpl['name']}': "
|
|
"must be lowercase alphanumeric with hyphens only"
|
|
)
|
|
|
|
@property
|
|
def id(self) -> str:
|
|
"""Get preset ID."""
|
|
return self.data["preset"]["id"]
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
"""Get preset name."""
|
|
return self.data["preset"]["name"]
|
|
|
|
@property
|
|
def version(self) -> str:
|
|
"""Get preset version."""
|
|
return self.data["preset"]["version"]
|
|
|
|
@property
|
|
def description(self) -> str:
|
|
"""Get preset description."""
|
|
return self.data["preset"]["description"]
|
|
|
|
@property
|
|
def author(self) -> str:
|
|
"""Get preset author."""
|
|
return self.data["preset"].get("author", "")
|
|
|
|
@property
|
|
def requires_speckit_version(self) -> str:
|
|
"""Get required spec-kit version range."""
|
|
return self.data["requires"]["speckit_version"]
|
|
|
|
@property
|
|
def templates(self) -> List[Dict[str, Any]]:
|
|
"""Get list of provided templates."""
|
|
return self.data["provides"]["templates"]
|
|
|
|
@property
|
|
def tags(self) -> List[str]:
|
|
"""Get preset tags."""
|
|
return self.data.get("tags", [])
|
|
|
|
def get_hash(self) -> str:
|
|
"""Calculate SHA256 hash of manifest file."""
|
|
with open(self.path, 'rb') as f:
|
|
return f"sha256:{hashlib.sha256(f.read()).hexdigest()}"
|
|
|
|
|
|
class PresetRegistry:
|
|
"""Manages the registry of installed presets."""
|
|
|
|
REGISTRY_FILE = ".registry"
|
|
SCHEMA_VERSION = "1.0"
|
|
|
|
def __init__(self, packs_dir: Path):
|
|
"""Initialize registry.
|
|
|
|
Args:
|
|
packs_dir: Path to .specify/presets/ directory
|
|
"""
|
|
self.packs_dir = packs_dir
|
|
self.registry_path = packs_dir / self.REGISTRY_FILE
|
|
self.data = self._load()
|
|
|
|
def _load(self) -> dict:
|
|
"""Load registry from disk."""
|
|
if not self.registry_path.exists():
|
|
return {
|
|
"schema_version": self.SCHEMA_VERSION,
|
|
"presets": {}
|
|
}
|
|
|
|
try:
|
|
with open(self.registry_path, 'r') as f:
|
|
data = json.load(f)
|
|
# Validate loaded data is a dict (handles corrupted registry files)
|
|
if not isinstance(data, dict):
|
|
return {
|
|
"schema_version": self.SCHEMA_VERSION,
|
|
"presets": {}
|
|
}
|
|
# Normalize presets field (handles corrupted presets value)
|
|
if not isinstance(data.get("presets"), dict):
|
|
data["presets"] = {}
|
|
return data
|
|
except (json.JSONDecodeError, FileNotFoundError):
|
|
return {
|
|
"schema_version": self.SCHEMA_VERSION,
|
|
"presets": {}
|
|
}
|
|
|
|
def _save(self):
|
|
"""Save registry to disk."""
|
|
self.packs_dir.mkdir(parents=True, exist_ok=True)
|
|
with open(self.registry_path, 'w') as f:
|
|
json.dump(self.data, f, indent=2)
|
|
|
|
def add(self, pack_id: str, metadata: dict):
|
|
"""Add preset to registry.
|
|
|
|
Args:
|
|
pack_id: Preset ID
|
|
metadata: Pack metadata (version, source, etc.)
|
|
"""
|
|
self.data["presets"][pack_id] = {
|
|
**copy.deepcopy(metadata),
|
|
"installed_at": datetime.now(timezone.utc).isoformat()
|
|
}
|
|
self._save()
|
|
|
|
def remove(self, pack_id: str):
|
|
"""Remove preset from registry.
|
|
|
|
Args:
|
|
pack_id: Preset ID
|
|
"""
|
|
packs = self.data.get("presets")
|
|
if not isinstance(packs, dict):
|
|
return
|
|
if pack_id in packs:
|
|
del packs[pack_id]
|
|
self._save()
|
|
|
|
def update(self, pack_id: str, updates: dict):
|
|
"""Update preset metadata in registry.
|
|
|
|
Merges the provided updates with the existing entry, preserving any
|
|
fields not specified. The installed_at timestamp is always preserved
|
|
from the original entry.
|
|
|
|
Args:
|
|
pack_id: Preset ID
|
|
updates: Partial metadata to merge into existing metadata
|
|
|
|
Raises:
|
|
KeyError: If preset is not installed
|
|
"""
|
|
packs = self.data.get("presets")
|
|
if not isinstance(packs, dict) or pack_id not in packs:
|
|
raise KeyError(f"Preset '{pack_id}' not found in registry")
|
|
existing = packs[pack_id]
|
|
# Handle corrupted registry entries (e.g., string/list instead of dict)
|
|
if not isinstance(existing, dict):
|
|
existing = {}
|
|
# Merge: existing fields preserved, new fields override (deep copy to prevent caller mutation)
|
|
merged = {**existing, **copy.deepcopy(updates)}
|
|
# Always preserve original installed_at based on key existence, not truthiness,
|
|
# to handle cases where the field exists but may be falsy (legacy/corruption)
|
|
if "installed_at" in existing:
|
|
merged["installed_at"] = existing["installed_at"]
|
|
else:
|
|
# If not present in existing, explicitly remove from merged if caller provided it
|
|
merged.pop("installed_at", None)
|
|
packs[pack_id] = merged
|
|
self._save()
|
|
|
|
def restore(self, pack_id: str, metadata: dict):
|
|
"""Restore preset metadata to registry without modifying timestamps.
|
|
|
|
Use this method for rollback scenarios where you have a complete backup
|
|
of the registry entry (including installed_at) and want to restore it
|
|
exactly as it was.
|
|
|
|
Args:
|
|
pack_id: Preset ID
|
|
metadata: Complete preset metadata including installed_at
|
|
|
|
Raises:
|
|
ValueError: If metadata is None or not a dict
|
|
"""
|
|
if metadata is None or not isinstance(metadata, dict):
|
|
raise ValueError(f"Cannot restore '{pack_id}': metadata must be a dict")
|
|
# Ensure presets dict exists (handle corrupted registry)
|
|
if not isinstance(self.data.get("presets"), dict):
|
|
self.data["presets"] = {}
|
|
self.data["presets"][pack_id] = copy.deepcopy(metadata)
|
|
self._save()
|
|
|
|
def get(self, pack_id: str) -> Optional[dict]:
|
|
"""Get preset metadata from registry.
|
|
|
|
Returns a deep copy to prevent callers from accidentally mutating
|
|
nested internal registry state without going through the write path.
|
|
|
|
Args:
|
|
pack_id: Preset ID
|
|
|
|
Returns:
|
|
Deep copy of preset metadata, or None if not found or corrupted
|
|
"""
|
|
packs = self.data.get("presets")
|
|
if not isinstance(packs, dict):
|
|
return None
|
|
entry = packs.get(pack_id)
|
|
# Return None for missing or corrupted (non-dict) entries
|
|
if entry is None or not isinstance(entry, dict):
|
|
return None
|
|
return copy.deepcopy(entry)
|
|
|
|
def list(self) -> Dict[str, dict]:
|
|
"""Get all installed presets with valid metadata.
|
|
|
|
Returns a deep copy of presets with dict metadata only.
|
|
Corrupted entries (non-dict values) are filtered out.
|
|
|
|
Returns:
|
|
Dictionary of pack_id -> metadata (deep copies), empty dict if corrupted
|
|
"""
|
|
packs = self.data.get("presets", {}) or {}
|
|
if not isinstance(packs, dict):
|
|
return {}
|
|
# Filter to only valid dict entries to match type contract
|
|
return {
|
|
pack_id: copy.deepcopy(meta)
|
|
for pack_id, meta in packs.items()
|
|
if isinstance(meta, dict)
|
|
}
|
|
|
|
def keys(self) -> set:
|
|
"""Get all preset IDs including corrupted entries.
|
|
|
|
Lightweight method that returns IDs without deep-copying metadata.
|
|
Use this when you only need to check which presets are tracked.
|
|
|
|
Returns:
|
|
Set of preset IDs (includes corrupted entries)
|
|
"""
|
|
packs = self.data.get("presets", {}) or {}
|
|
if not isinstance(packs, dict):
|
|
return set()
|
|
return set(packs.keys())
|
|
|
|
def list_by_priority(self, include_disabled: bool = False) -> List[tuple]:
|
|
"""Get all installed presets sorted by priority.
|
|
|
|
Lower priority number = higher precedence (checked first).
|
|
Presets with equal priority are sorted alphabetically by ID
|
|
for deterministic ordering.
|
|
|
|
Args:
|
|
include_disabled: If True, include disabled presets. Default False.
|
|
|
|
Returns:
|
|
List of (pack_id, metadata_copy) tuples sorted by priority.
|
|
Metadata is deep-copied to prevent accidental mutation.
|
|
"""
|
|
packs = self.data.get("presets", {}) or {}
|
|
if not isinstance(packs, dict):
|
|
packs = {}
|
|
sortable_packs = []
|
|
for pack_id, meta in packs.items():
|
|
if not isinstance(meta, dict):
|
|
continue
|
|
# Skip disabled presets unless explicitly requested
|
|
if not include_disabled and not meta.get("enabled", True):
|
|
continue
|
|
metadata_copy = copy.deepcopy(meta)
|
|
metadata_copy["priority"] = normalize_priority(metadata_copy.get("priority", 10))
|
|
sortable_packs.append((pack_id, metadata_copy))
|
|
return sorted(
|
|
sortable_packs,
|
|
key=lambda item: (item[1]["priority"], item[0]),
|
|
)
|
|
|
|
def is_installed(self, pack_id: str) -> bool:
|
|
"""Check if preset is installed.
|
|
|
|
Args:
|
|
pack_id: Preset ID
|
|
|
|
Returns:
|
|
True if pack is installed, False if not or registry corrupted
|
|
"""
|
|
packs = self.data.get("presets")
|
|
if not isinstance(packs, dict):
|
|
return False
|
|
return pack_id in packs
|
|
|
|
|
|
class PresetManager:
|
|
"""Manages preset lifecycle: installation, removal, updates."""
|
|
|
|
def __init__(self, project_root: Path):
|
|
"""Initialize preset manager.
|
|
|
|
Args:
|
|
project_root: Path to project root directory
|
|
"""
|
|
self.project_root = project_root
|
|
self.presets_dir = project_root / ".specify" / "presets"
|
|
self.registry = PresetRegistry(self.presets_dir)
|
|
|
|
def check_compatibility(
|
|
self,
|
|
manifest: PresetManifest,
|
|
speckit_version: str
|
|
) -> bool:
|
|
"""Check if preset is compatible with current spec-kit version.
|
|
|
|
Args:
|
|
manifest: Preset manifest
|
|
speckit_version: Current spec-kit version
|
|
|
|
Returns:
|
|
True if compatible
|
|
|
|
Raises:
|
|
PresetCompatibilityError: If pack is incompatible
|
|
"""
|
|
required = manifest.requires_speckit_version
|
|
try:
|
|
SpecifierSet(required) # Just to validate
|
|
except InvalidSpecifier:
|
|
raise PresetCompatibilityError(f"Invalid version specifier: {required}")
|
|
|
|
if not version_satisfies(speckit_version, required):
|
|
raise PresetCompatibilityError(
|
|
f"Preset requires spec-kit {required}, "
|
|
f"but {speckit_version} is installed.\n"
|
|
f"Upgrade spec-kit with: {REINSTALL_COMMAND}"
|
|
)
|
|
|
|
return True
|
|
|
|
def _extension_installed_for_command(self, command_name: str) -> bool:
|
|
"""Whether *command_name* may be materialized in this project.
|
|
|
|
Extension command overrides follow ``speckit.<ext-id>.<cmd-name>``;
|
|
they must be skipped everywhere preset artifacts are written —
|
|
registration *and* reconciliation — when the extension isn't
|
|
installed, or reconciliation would materialize files that
|
|
registration refused to track. Core commands (single-dot names,
|
|
e.g. ``speckit.specify``) always pass.
|
|
"""
|
|
parts = command_name.split(".")
|
|
if len(parts) >= 3 and parts[0] == "speckit":
|
|
ext_id = parts[1]
|
|
if not (
|
|
self.project_root / ".specify" / "extensions" / ext_id
|
|
).is_dir():
|
|
return False
|
|
return True
|
|
|
|
def _register_commands(
|
|
self,
|
|
manifest: PresetManifest,
|
|
preset_dir: Path
|
|
) -> Dict[str, List[str]]:
|
|
"""Register preset command overrides with all detected AI agents.
|
|
|
|
Scans the preset's templates for type "command", reads each command
|
|
file, and writes it to every detected agent directory using the
|
|
CommandRegistrar from the agents module.
|
|
|
|
When a command uses a composition strategy (prepend, append, wrap),
|
|
the content is composed with the lower-priority command before
|
|
registration.
|
|
|
|
Args:
|
|
manifest: Preset manifest
|
|
preset_dir: Installed preset directory
|
|
|
|
Returns:
|
|
Dictionary mapping agent names to lists of registered command names
|
|
"""
|
|
command_templates = [
|
|
t for t in manifest.templates if t.get("type") == "command"
|
|
]
|
|
if not command_templates:
|
|
return {}
|
|
|
|
# Filter out extension command overrides if the extension isn't installed.
|
|
filtered = [
|
|
cmd
|
|
for cmd in command_templates
|
|
if self._extension_installed_for_command(cmd["name"])
|
|
]
|
|
|
|
if not filtered:
|
|
return {}
|
|
|
|
# Handle composition strategies: resolve composed content for non-replace commands
|
|
resolver = PresetResolver(self.project_root)
|
|
composed_dir = None
|
|
commands_to_register = []
|
|
for cmd in filtered:
|
|
strategy = cmd.get("strategy", "replace")
|
|
if strategy != "replace":
|
|
# Only pre-compose if this preset is the top composing layer.
|
|
# If a higher-priority replace already wins, skip composition
|
|
# here — reconciliation will write the correct content.
|
|
layers = resolver.collect_all_layers(cmd["name"], "command")
|
|
top_layer_is_ours = (
|
|
layers and layers[0]["path"].is_relative_to(preset_dir)
|
|
)
|
|
if top_layer_is_ours:
|
|
composed = resolver.resolve_content(cmd["name"], "command")
|
|
if composed is not None:
|
|
if composed_dir is None:
|
|
composed_dir = preset_dir / ".composed"
|
|
composed_dir.mkdir(parents=True, exist_ok=True)
|
|
composed_file = composed_dir / f"{cmd['name']}.md"
|
|
composed_file.write_text(composed, encoding="utf-8")
|
|
commands_to_register.append({
|
|
**cmd,
|
|
"file": f".composed/{cmd['name']}.md",
|
|
})
|
|
else:
|
|
raise PresetValidationError(
|
|
f"Command '{cmd['name']}' uses '{strategy}' strategy "
|
|
f"but no base command layer exists to compose onto. "
|
|
f"Ensure a lower-priority preset, extension, or core "
|
|
f"command provides this command before using "
|
|
f"composition strategies."
|
|
)
|
|
else:
|
|
# Not the top layer — register raw file; reconciliation
|
|
# will overwrite with the correct composed/winning content.
|
|
# Note: CommandRegistrar may process frontmatter strategy: wrap
|
|
# from the raw file (legacy compat), but reconciliation runs
|
|
# immediately after install and corrects the final output.
|
|
commands_to_register.append(cmd)
|
|
else:
|
|
commands_to_register.append(cmd)
|
|
|
|
try:
|
|
from ..agents import CommandRegistrar
|
|
except ImportError:
|
|
return {}
|
|
|
|
registrar = CommandRegistrar()
|
|
|
|
# Single-active rule (#2948): preset command overrides register for
|
|
# the active integration only. A project without a recorded active
|
|
# integration (init-options.json does not exist at all — a legacy
|
|
# pre-init-options layout or direct library use) falls back to
|
|
# detection-based registration for all agents. A recorded key with
|
|
# no registrar config (e.g. "generic") naturally yields no matches
|
|
# via only_agent instead of falling back.
|
|
#
|
|
# An init-options.json that exists but is corrupted, unreadable, or
|
|
# has a malformed/empty "ai" value must not be treated the same as
|
|
# "no file" — that would silently reintroduce all-agent
|
|
# registration. Fail closed (register nothing) instead.
|
|
resolved_agent = resolve_active_agent_for_registration(self.project_root)
|
|
if resolved_agent is MISSING_INIT_OPTIONS_FILE:
|
|
active_agent = None
|
|
elif resolved_agent is None:
|
|
return {}
|
|
else:
|
|
active_agent = resolved_agent
|
|
# Mirror the extension path's ai_skills guard: when the active
|
|
# agent is a command-backed integration (extension != "/SKILL.md")
|
|
# running in skills mode, its preset command overrides render as
|
|
# skills via _register_skills, not as command files. Command-mode
|
|
# and skills-mode artifacts are mutually exclusive — writing both
|
|
# (e.g. `integration use copilot` with `--skills`) leaves a stale
|
|
# command file alongside the SKILL.md that is actually active.
|
|
init_options = load_init_options(self.project_root)
|
|
agent_config = registrar.AGENT_CONFIGS.get(active_agent)
|
|
if (
|
|
agent_config
|
|
and is_ai_skills_enabled(init_options)
|
|
and agent_config.get("extension") != "/SKILL.md"
|
|
):
|
|
return {}
|
|
|
|
return registrar.register_commands_for_all_agents(
|
|
commands_to_register,
|
|
manifest.id,
|
|
preset_dir,
|
|
self.project_root,
|
|
create_missing_active_skills_dir=True,
|
|
only_agent=active_agent,
|
|
)
|
|
|
|
def register_enabled_presets_for_agent(self, agent_name: str) -> None:
|
|
"""Re-register enabled presets' command overrides and skills for ``agent_name``.
|
|
|
|
Mirrors ``ExtensionManager.register_enabled_extensions_for_agent`` for
|
|
presets (#2948): ``integration use`` / ``switch`` call this for the
|
|
newly active agent so a preset installed while a different
|
|
integration was active gets rescaffolded on activation, instead of
|
|
writing artifacts for inactive integrations at install time.
|
|
``_register_commands`` / ``_register_skills`` already resolve the
|
|
active integration from init-options themselves, so this re-runs them
|
|
for every enabled preset and merges the fresh result for
|
|
``agent_name`` into its stored registry metadata.
|
|
|
|
Presets are processed in *reverse* priority order (lowest-precedence
|
|
first). Each pass overwrites the same target command/skill files, so
|
|
writing the highest-precedence preset last is what makes it win when
|
|
two enabled presets override the same command — matching the
|
|
priority stack documented for ``list_by_priority()``.
|
|
"""
|
|
if not agent_name:
|
|
return
|
|
|
|
# Resolve once: whether agent_name is a command-backed integration
|
|
# (extension != "/SKILL.md") currently running in skills mode, or
|
|
# vice versa. Native skill-only agents (extension == "/SKILL.md",
|
|
# e.g. claude/codex) have no command/skill toggle at all — both
|
|
# registered_commands and registered_skills legitimately co-exist
|
|
# for them by design, so this restriction only applies to
|
|
# command-backed integrations.
|
|
try:
|
|
from ..agents import CommandRegistrar
|
|
|
|
agent_config = CommandRegistrar().AGENT_CONFIGS.get(agent_name)
|
|
except ImportError:
|
|
agent_config = None
|
|
is_command_backed = bool(agent_config) and agent_config.get("extension") != "/SKILL.md"
|
|
ai_skills_now = is_command_backed and is_ai_skills_enabled(
|
|
load_init_options(self.project_root)
|
|
)
|
|
|
|
resolver = PresetResolver(self.project_root)
|
|
affected_cmd_names: set = set()
|
|
presets_by_priority = list(self.registry.list_by_priority())
|
|
winning_pack_by_command: Dict[str, str] = {}
|
|
winning_source_by_command: Dict[str, Path] = {}
|
|
project_override_commands: set[str] = set()
|
|
for candidate_pack_id, _candidate_metadata in presets_by_priority:
|
|
candidate_manifest = resolver._get_manifest(
|
|
self.presets_dir / candidate_pack_id
|
|
)
|
|
if candidate_manifest is None:
|
|
continue
|
|
for template in candidate_manifest.templates:
|
|
command_name = template.get("name")
|
|
if (
|
|
template.get("type") == "command"
|
|
and isinstance(command_name, str)
|
|
):
|
|
if (
|
|
resolver.overrides_dir / f"{command_name}.md"
|
|
).is_file():
|
|
project_override_commands.add(command_name)
|
|
winning_pack_by_command.setdefault(
|
|
command_name, candidate_pack_id
|
|
)
|
|
source_file = template.get("file")
|
|
if isinstance(source_file, str):
|
|
winning_source_by_command.setdefault(
|
|
command_name,
|
|
self.presets_dir
|
|
/ candidate_pack_id
|
|
/ source_file,
|
|
)
|
|
|
|
pending_command_cleanups: List[
|
|
tuple[
|
|
str,
|
|
Dict[str, List[str]],
|
|
List[str],
|
|
Dict[str, str],
|
|
]
|
|
] = []
|
|
successful_skill_replacements: set[tuple[str, str]] = set()
|
|
pending_skill_cleanups: List[
|
|
tuple[
|
|
str,
|
|
Path,
|
|
Dict[str, List[str]],
|
|
List[str],
|
|
Dict[str, str],
|
|
]
|
|
] = []
|
|
successful_command_replacements: set[tuple[str, str]] = set()
|
|
for pack_id, metadata in reversed(presets_by_priority):
|
|
pack_dir = self.presets_dir / pack_id
|
|
manifest = resolver._get_manifest(pack_dir)
|
|
if manifest is None:
|
|
continue
|
|
|
|
# Registration can write one command and then fail on a later
|
|
# template. Record names first so final reconciliation can repair
|
|
# any partial writes even when _register_commands never returns.
|
|
for tmpl in manifest.templates:
|
|
name = tmpl.get("name")
|
|
if tmpl.get("type") == "command" and isinstance(name, str):
|
|
affected_cmd_names.add(name)
|
|
|
|
# Isolate per-preset failures: one preset that fails to register
|
|
# must not abort registration of the remaining enabled presets.
|
|
try:
|
|
registered_commands = self._register_commands(manifest, pack_dir)
|
|
registered_command_names = set(
|
|
registered_commands.get(agent_name) or []
|
|
)
|
|
for tmpl in manifest.templates:
|
|
if tmpl.get("type") != "command":
|
|
continue
|
|
primary_name = tmpl.get("name")
|
|
if (
|
|
isinstance(primary_name, str)
|
|
and primary_name in registered_command_names
|
|
):
|
|
successful_command_replacements.add(
|
|
(pack_id, primary_name)
|
|
)
|
|
existing_commands = metadata.get("registered_commands", {})
|
|
if not isinstance(existing_commands, dict):
|
|
existing_commands = {}
|
|
merged_commands = copy.deepcopy(existing_commands)
|
|
# Toggled command -> skills for this same agent:
|
|
# _register_commands's ai_skills guard just made this a
|
|
# no-op, but the command file this preset wrote while
|
|
# command mode was active is still on disk and still
|
|
# tracked. Do NOT unregister it yet — _register_skills()
|
|
# below is an independently fallible replacement step, and
|
|
# deleting the old artifact before it succeeds would leave
|
|
# neither the old command file nor a new skill file if
|
|
# skills registration raises. The old artifact is only
|
|
# removed after the skills phase below completes without
|
|
# raising, preserving command/skill mutual exclusion while
|
|
# never leaving a transient failure with nothing in place
|
|
# (#2948).
|
|
stale_command_names: Optional[List[str]] = None
|
|
if registered_commands.get(agent_name):
|
|
existing_names = merged_commands.get(agent_name, [])
|
|
merged_commands[agent_name] = existing_names + [
|
|
name
|
|
for name in registered_commands[agent_name]
|
|
if name not in existing_names
|
|
]
|
|
elif ai_skills_now and merged_commands.get(agent_name):
|
|
stale_command_names = merged_commands[agent_name]
|
|
# Persist the commands phase immediately, mirroring
|
|
# install_from_directory(): _register_skills is an
|
|
# independently fallible phase, and if it raises, the files
|
|
# the commands phase already wrote to disk must still be
|
|
# tracked so preset removal can clean them up (#2948).
|
|
if merged_commands != existing_commands:
|
|
self.registry.update(pack_id, {"registered_commands": merged_commands})
|
|
|
|
registered_skills = self._register_skills(manifest, pack_dir)
|
|
replaced_skill_names = set(registered_skills.get(agent_name) or [])
|
|
for tmpl in manifest.templates:
|
|
if tmpl.get("type") != "command":
|
|
continue
|
|
primary_name = tmpl.get("name")
|
|
if not isinstance(primary_name, str):
|
|
continue
|
|
modern_name, legacy_name = self._skill_names_for_command(
|
|
primary_name
|
|
)
|
|
if (
|
|
modern_name in replaced_skill_names
|
|
or legacy_name in replaced_skill_names
|
|
):
|
|
successful_skill_replacements.add(
|
|
(pack_id, primary_name)
|
|
)
|
|
raw_existing_skills = metadata.get("registered_skills")
|
|
if isinstance(raw_existing_skills, list) and raw_existing_skills:
|
|
# Legacy flat-list value: don't assume agent_name wrote
|
|
# every name (the first post-upgrade operation may be a
|
|
# direct switch to a different skill-mode agent) —
|
|
# infer real ownership from on-disk provenance instead
|
|
# (#2948).
|
|
existing_skills = self._infer_legacy_skill_provenance(
|
|
[n for n in raw_existing_skills if isinstance(n, str)],
|
|
pack_id,
|
|
fallback_agent=agent_name,
|
|
)
|
|
else:
|
|
existing_skills = self._normalize_registered_skills(
|
|
raw_existing_skills, fallback_agent=agent_name
|
|
)
|
|
merged_skills = copy.deepcopy(existing_skills)
|
|
if registered_skills.get(agent_name):
|
|
existing_names = merged_skills.get(agent_name, [])
|
|
merged_skills[agent_name] = existing_names + [
|
|
name
|
|
for name in registered_skills[agent_name]
|
|
if name not in existing_names
|
|
]
|
|
elif is_command_backed and not ai_skills_now and merged_skills.get(agent_name):
|
|
# Mirror image: toggled skills -> command for this same
|
|
# agent. _get_skills_dir() no longer resolves a skills
|
|
# directory once ai_skills is off, so _register_skills
|
|
# is a no-op — but the SKILL.md this preset wrote while
|
|
# skills mode was active is still tracked and still on
|
|
# disk. Restore/remove it narrowly for this agent. This
|
|
# direction is already register-new-then-remove-old:
|
|
# _register_commands (the replacement) ran unconditionally
|
|
# above and only reaches here once it has already
|
|
# succeeded — but that call can still have returned
|
|
# empty or partial results (missing source template,
|
|
# safety-validation skip, corrupted manifest), so only
|
|
# retire the subset of stale skills whose corresponding
|
|
# command name was actually returned for this agent;
|
|
# anything unreplaced stays tracked and on disk (#2948).
|
|
stale_skill_names = merged_skills[agent_name]
|
|
skill_to_primary: Dict[str, str] = {}
|
|
for tmpl in manifest.templates:
|
|
if tmpl.get("type") != "command":
|
|
continue
|
|
primary_name = tmpl.get("name")
|
|
if not isinstance(primary_name, str):
|
|
continue
|
|
modern_name, legacy_name = self._skill_names_for_command(
|
|
primary_name
|
|
)
|
|
skill_to_primary[modern_name] = primary_name
|
|
skill_to_primary[legacy_name] = primary_name
|
|
pending_skill_cleanups.append(
|
|
(
|
|
pack_id,
|
|
pack_dir,
|
|
merged_skills,
|
|
stale_skill_names,
|
|
skill_to_primary,
|
|
)
|
|
)
|
|
# A legacy flat-list registered_skills value (predating
|
|
# per-agent provenance) must migrate to the dict format on
|
|
# disk even when the rescaffolded names are unchanged from
|
|
# what the list already held — comparing only the
|
|
# *normalized* forms would otherwise treat that as a no-op
|
|
# and leave the raw un-migrated list in the registry, which
|
|
# later removal/switch handling treats as legacy
|
|
# best-effort (restoring only the currently active agent's
|
|
# directory) instead of per-agent provenance (#2948).
|
|
needs_migration = (
|
|
isinstance(raw_existing_skills, list) and raw_existing_skills
|
|
)
|
|
if merged_skills != existing_skills or needs_migration:
|
|
self.registry.update(pack_id, {"registered_skills": merged_skills})
|
|
|
|
# The skills phase above completed without raising, but a
|
|
# non-raising result can still be empty or partial (missing
|
|
# source template, safety-validation skip, corrupted
|
|
# manifest) — retiring every stale command purely on "did
|
|
# not raise" would delete a command whose replacement skill
|
|
# never actually landed, leaving neither artifact. Only
|
|
# retire the subset of stale commands whose corresponding
|
|
# skill name was actually returned for this agent; anything
|
|
# unreplaced stays tracked and on disk (#2948).
|
|
if stale_command_names:
|
|
# Commands may carry aliases (CommandRegistrar.register_
|
|
# commands() tracks and returns primary + alias names
|
|
# flattened together into one list), but _register_
|
|
# skills() only ever renders/returns the *primary*
|
|
# command name's skill — running an alias's own name
|
|
# through _skill_names_for_command() never matches
|
|
# anything real, so an alias would stay tracked/on-disk
|
|
# forever even after its primary's skill replacement
|
|
# landed. Map each stale name back to its template's
|
|
# primary via the manifest so the whole primary+alias
|
|
# group is retired or kept together, based solely on
|
|
# whether the *primary*'s skill replacement actually
|
|
# landed (#2948).
|
|
alias_to_primary: Dict[str, str] = {}
|
|
for tmpl in manifest.templates:
|
|
if tmpl.get("type") != "command":
|
|
continue
|
|
primary_name = tmpl.get("name")
|
|
if not isinstance(primary_name, str):
|
|
continue
|
|
for alias in tmpl.get("aliases", []):
|
|
if isinstance(alias, str):
|
|
alias_to_primary[alias] = primary_name
|
|
|
|
pending_command_cleanups.append(
|
|
(
|
|
pack_id,
|
|
merged_commands,
|
|
stale_command_names,
|
|
alias_to_primary,
|
|
)
|
|
)
|
|
except Exception as pack_err:
|
|
from .. import _print_cli_warning
|
|
|
|
_print_cli_warning(
|
|
"register preset artifacts for",
|
|
"preset",
|
|
pack_id,
|
|
pack_err,
|
|
continuing="Continuing with the remaining presets.",
|
|
)
|
|
continue
|
|
|
|
# Registration writes each preset's raw layer. Reconcile before
|
|
# retiring opposite-mode artifacts so project overrides and composed
|
|
# winners are materialized first, and so cleanup runs last instead of
|
|
# being undone by skill reconciliation.
|
|
reconciled_commands: set[str] = set()
|
|
reconciled_skills: set[str] = set()
|
|
if affected_cmd_names:
|
|
try:
|
|
reconciled_commands = self._reconcile_composed_commands(
|
|
list(affected_cmd_names), target_agent=agent_name
|
|
)
|
|
reconciled_skills = self._reconcile_skills(
|
|
list(affected_cmd_names), target_agent=agent_name
|
|
)
|
|
except Exception as exc:
|
|
import warnings
|
|
|
|
warnings.warn(
|
|
f"Post-rescaffold reconciliation failed for '{agent_name}': "
|
|
f"{exc}. Agent command files may be stale; re-run "
|
|
f"'specify integration use {agent_name}' or reinstall "
|
|
f"affected presets to refresh.",
|
|
stacklevel=2,
|
|
)
|
|
|
|
successfully_replaced_winners = {
|
|
command_name
|
|
for command_name, winning_pack_id in winning_pack_by_command.items()
|
|
if command_name not in project_override_commands
|
|
and (
|
|
(winning_pack_id, command_name)
|
|
in successful_skill_replacements
|
|
or (
|
|
command_name in reconciled_skills
|
|
and command_name in winning_source_by_command
|
|
and winning_source_by_command[command_name].is_file()
|
|
)
|
|
)
|
|
}
|
|
successfully_replaced_winners.update(
|
|
project_override_commands & reconciled_skills
|
|
)
|
|
|
|
for (
|
|
pack_id,
|
|
merged_commands,
|
|
stale_command_names,
|
|
alias_to_primary,
|
|
) in pending_command_cleanups:
|
|
fully_replaced = [
|
|
command_name
|
|
for command_name in stale_command_names
|
|
if alias_to_primary.get(command_name, command_name)
|
|
in successfully_replaced_winners
|
|
]
|
|
if not fully_replaced:
|
|
continue
|
|
remaining_stale = [
|
|
command_name
|
|
for command_name in stale_command_names
|
|
if command_name not in fully_replaced
|
|
]
|
|
self._unregister_commands({agent_name: fully_replaced})
|
|
if remaining_stale:
|
|
merged_commands[agent_name] = remaining_stale
|
|
else:
|
|
merged_commands.pop(agent_name, None)
|
|
self.registry.update(
|
|
pack_id, {"registered_commands": merged_commands}
|
|
)
|
|
|
|
successfully_replaced_command_winners = {
|
|
command_name
|
|
for command_name, winning_pack_id in winning_pack_by_command.items()
|
|
if command_name not in project_override_commands
|
|
and (
|
|
(winning_pack_id, command_name)
|
|
in successful_command_replacements
|
|
or (
|
|
command_name in reconciled_commands
|
|
and command_name in winning_source_by_command
|
|
and winning_source_by_command[command_name].is_file()
|
|
)
|
|
)
|
|
}
|
|
successfully_replaced_command_winners.update(
|
|
project_override_commands & reconciled_commands
|
|
)
|
|
|
|
# Skill restoration walks the priority stack, so retire stale layers
|
|
# from highest to lowest. The last cleanup then restores the true
|
|
# non-preset fallback (or removes the skill) rather than cycling back
|
|
# to a lower-priority preset.
|
|
for (
|
|
pack_id,
|
|
pack_dir,
|
|
merged_skills,
|
|
stale_skill_names,
|
|
skill_to_primary,
|
|
) in reversed(pending_skill_cleanups):
|
|
fully_replaced = [
|
|
skill_name
|
|
for skill_name in stale_skill_names
|
|
if skill_to_primary.get(skill_name)
|
|
in successfully_replaced_command_winners
|
|
]
|
|
if not fully_replaced:
|
|
continue
|
|
remaining_stale = [
|
|
skill_name
|
|
for skill_name in stale_skill_names
|
|
if skill_name not in fully_replaced
|
|
]
|
|
override_sources = {
|
|
skill_name: f"override:{skill_to_primary[skill_name]}"
|
|
for skill_name in fully_replaced
|
|
if skill_name in skill_to_primary
|
|
}
|
|
self._unregister_skills(
|
|
{agent_name: fully_replaced},
|
|
pack_dir,
|
|
additional_owned_sources=override_sources,
|
|
)
|
|
if remaining_stale:
|
|
merged_skills[agent_name] = remaining_stale
|
|
else:
|
|
merged_skills.pop(agent_name, None)
|
|
self.registry.update(
|
|
pack_id, {"registered_skills": merged_skills}
|
|
)
|
|
|
|
def unregister_agent_artifacts(self, agent_name: str) -> None:
|
|
"""Remove ``agent_name``'s tracked preset command/skill artifacts.
|
|
|
|
Mirrors ``ExtensionManager.unregister_agent_artifacts()`` (#2948):
|
|
used by ``integration switch`` when deactivating the previous
|
|
integration, so a preset's command overrides and skill mirrors
|
|
written for that agent don't linger as orphans in its directory
|
|
once a different (possibly not-yet-installed) integration becomes
|
|
active — including custom preset commands and files the registrar
|
|
would otherwise skip as user-modified.
|
|
|
|
Scoped strictly to ``agent_name``: only that agent's own tracked
|
|
artifacts and registry entries are touched. Other agents' files,
|
|
tracking, and preset packs themselves are left untouched, and no
|
|
priority-stack reconciliation runs — this is agent-scoped cleanup
|
|
only, not preset removal.
|
|
"""
|
|
if not agent_name:
|
|
return
|
|
|
|
try:
|
|
from ..agents import CommandRegistrar
|
|
|
|
registrar = CommandRegistrar()
|
|
agent_config = registrar.AGENT_CONFIGS.get(agent_name)
|
|
except ImportError:
|
|
registrar = None
|
|
agent_config = None
|
|
if agent_config is None or registrar is None:
|
|
return
|
|
|
|
for pack_id, metadata in list(self.registry.list().items()):
|
|
updates: Dict[str, Any] = {}
|
|
|
|
raw_skills = metadata.get("registered_skills", [])
|
|
if isinstance(raw_skills, list) and raw_skills:
|
|
# Legacy flat-list value predating per-agent provenance:
|
|
# infer real ownership from on-disk markers before removing
|
|
# anything, so only agent_name's actual share is unregistered
|
|
# and the rest migrates to per-agent form instead of either
|
|
# guessing every name belongs to agent_name or blindly
|
|
# leaving other agents' shares unrecoverable (#2948).
|
|
registered_skills_all = self._infer_legacy_skill_provenance(
|
|
[n for n in raw_skills if isinstance(n, str)],
|
|
pack_id,
|
|
fallback_agent=agent_name,
|
|
)
|
|
skills_migrated = True
|
|
elif isinstance(raw_skills, dict):
|
|
registered_skills_all = copy.deepcopy(raw_skills)
|
|
skills_migrated = False
|
|
else:
|
|
registered_skills_all = {}
|
|
skills_migrated = False
|
|
|
|
registered_commands = metadata.get("registered_commands", {})
|
|
if not isinstance(registered_commands, dict):
|
|
registered_commands = {}
|
|
|
|
agent_command_names = [
|
|
n for n in registered_commands.get(agent_name, []) if isinstance(n, str)
|
|
]
|
|
|
|
# Native SKILL.md agents (claude/codex/agy/…) materialize their
|
|
# preset override in _register_commands(), tracked under
|
|
# registered_commands, not registered_skills — see
|
|
# _register_skills()'s own docstring ("Native skill agents …
|
|
# materialize brand-new preset skills in _register_commands()").
|
|
# A legacy flat-list registered_skills value predating that
|
|
# split can still attribute the very same on-disk file to this
|
|
# agent via provenance inference; unregistering through both
|
|
# paths would double-process the identical directory (delete
|
|
# via the commands path, then no-op "restore" via the skills
|
|
# path since the directory is already gone). Mirror remove()'s
|
|
# own coordination: whenever this agent's artifact is already
|
|
# handled via registered_commands, never additionally treat it
|
|
# as a registered_skills entry for the same agent.
|
|
native_skills_entry_removed = False
|
|
if agent_command_names and agent_config.get("extension") == "/SKILL.md":
|
|
native_skills_entry_removed = agent_name in registered_skills_all
|
|
registered_skills_all.pop(agent_name, None)
|
|
|
|
if agent_command_names:
|
|
command_names_to_unregister = agent_command_names
|
|
if agent_config.get("extension") == "/SKILL.md":
|
|
agent_output = registrar._resolve_agent_dir(
|
|
agent_name, agent_config, self.project_root
|
|
)
|
|
shared_names: set[str] = set()
|
|
for other_agent, other_names in registered_commands.items():
|
|
if (
|
|
other_agent == agent_name
|
|
or not isinstance(other_names, list)
|
|
):
|
|
continue
|
|
other_config = registrar.AGENT_CONFIGS.get(other_agent)
|
|
if (
|
|
not other_config
|
|
or other_config.get("extension") != "/SKILL.md"
|
|
):
|
|
continue
|
|
other_output = registrar._resolve_agent_dir(
|
|
other_agent, other_config, self.project_root
|
|
)
|
|
if other_output == agent_output:
|
|
shared_names.update(
|
|
name
|
|
for name in other_names
|
|
if isinstance(name, str)
|
|
)
|
|
command_names_to_unregister = [
|
|
name
|
|
for name in agent_command_names
|
|
if name not in shared_names
|
|
]
|
|
if command_names_to_unregister:
|
|
self._unregister_commands(
|
|
{agent_name: command_names_to_unregister}
|
|
)
|
|
new_registered_commands = copy.deepcopy(registered_commands)
|
|
new_registered_commands.pop(agent_name, None)
|
|
updates["registered_commands"] = new_registered_commands
|
|
|
|
agent_skill_names = registered_skills_all.get(agent_name) or []
|
|
if (
|
|
agent_skill_names
|
|
or skills_migrated
|
|
or native_skills_entry_removed
|
|
):
|
|
if agent_skill_names:
|
|
self._delete_agent_preset_skills(
|
|
agent_name, agent_skill_names, pack_id
|
|
)
|
|
remaining = {
|
|
other_agent: names
|
|
for other_agent, names in registered_skills_all.items()
|
|
if other_agent != agent_name
|
|
}
|
|
updates["registered_skills"] = remaining
|
|
|
|
if updates:
|
|
self.registry.update(pack_id, updates)
|
|
|
|
def _unregister_commands(self, registered_commands: Dict[str, List[str]]) -> None:
|
|
"""Remove previously registered command files from agent directories.
|
|
|
|
Args:
|
|
registered_commands: Dict mapping agent names to command name lists
|
|
"""
|
|
try:
|
|
from ..agents import CommandRegistrar
|
|
except ImportError:
|
|
return
|
|
|
|
registrar = CommandRegistrar()
|
|
registrar.unregister_commands(registered_commands, self.project_root)
|
|
|
|
def _merge_pack_registered_commands(
|
|
self, pack_id: str, written: Optional[Dict[str, List[str]]]
|
|
) -> None:
|
|
"""Merge actually-written agent command registrations into a preset's metadata.
|
|
|
|
Reconciliation (``_reconcile_composed_commands``) can write a
|
|
preset's content into an agent directory the preset never wrote to
|
|
before — most notably a historical (currently inactive) agent
|
|
supplied via ``extra_agents`` when a higher-priority preset is
|
|
removed. If that write isn't reflected back into the winning
|
|
preset's own ``registered_commands``, the registry silently lies
|
|
about which directories the preset owns: a later removal of this
|
|
same preset only cleans up the agents it already knew about,
|
|
orphaning the directory reconciliation just wrote to on its behalf
|
|
(#2948).
|
|
|
|
Args:
|
|
pack_id: The preset whose metadata should be updated.
|
|
written: ``{agent_name: [cmd_name, ...]}`` actually written by
|
|
the reconciliation call just made, exactly mirroring
|
|
``CommandRegistrar.register_commands_for_non_skill_agents``'s
|
|
return value. A falsy value is a no-op.
|
|
"""
|
|
if not written:
|
|
return
|
|
metadata = self.registry.get(pack_id)
|
|
if metadata is None:
|
|
return # pack_id no longer installed (e.g. removed mid-loop)
|
|
existing_commands = metadata.get("registered_commands", {})
|
|
if not isinstance(existing_commands, dict):
|
|
existing_commands = {}
|
|
merged_commands = copy.deepcopy(existing_commands)
|
|
changed = False
|
|
for agent_name, cmd_names in written.items():
|
|
if not cmd_names:
|
|
continue
|
|
existing_names = merged_commands.get(agent_name, [])
|
|
new_names = [n for n in cmd_names if n not in existing_names]
|
|
if new_names:
|
|
merged_commands[agent_name] = existing_names + new_names
|
|
changed = True
|
|
if changed:
|
|
self.registry.update(pack_id, {"registered_commands": merged_commands})
|
|
|
|
def _merge_extension_registered_commands(
|
|
self, extension_id: str, written: Optional[Dict[str, List[str]]]
|
|
) -> None:
|
|
"""Merge reconciliation writes into an extension's registry entry."""
|
|
if not written:
|
|
return
|
|
registry = ExtensionRegistry(self.project_root / ".specify" / "extensions")
|
|
metadata = registry.get(extension_id)
|
|
if metadata is None:
|
|
return
|
|
existing_commands = metadata.get("registered_commands", {})
|
|
if not isinstance(existing_commands, dict):
|
|
existing_commands = {}
|
|
merged_commands = copy.deepcopy(existing_commands)
|
|
changed = False
|
|
for agent_name, cmd_names in written.items():
|
|
existing_names = merged_commands.get(agent_name, [])
|
|
new_names = [name for name in cmd_names if name not in existing_names]
|
|
if new_names:
|
|
merged_commands[agent_name] = existing_names + new_names
|
|
changed = True
|
|
if changed:
|
|
registry.update(extension_id, {"registered_commands": merged_commands})
|
|
|
|
def _reconcile_composed_commands(
|
|
self,
|
|
command_names: List[str],
|
|
extra_agents: Optional[Set[str]] = None,
|
|
target_agent: Optional[str] = None,
|
|
) -> Set[str]:
|
|
"""Re-resolve and re-register composed commands from the full stack.
|
|
|
|
After install or remove, recompute the effective content for each
|
|
command name that participates in composition, and write the winning
|
|
content to the agent directories. This ensures command files always
|
|
reflect the current priority stack rather than depending on
|
|
install/remove order.
|
|
|
|
Single-active rule (#2948): non-skill command-file registration
|
|
performed by this pass is restricted to the active integration, the
|
|
same as ``_register_commands``. Without this, reconciliation after
|
|
install/remove would write command files for every detected
|
|
non-skill agent even though registration itself is active-only,
|
|
leaving inactive integrations with artifacts that are never
|
|
recorded in ``registered_commands`` (and therefore never cleaned up
|
|
on removal).
|
|
|
|
Args:
|
|
command_names: List of command names to reconcile
|
|
extra_agents: Additional agent names to also reconcile besides
|
|
the currently active one. Populated by ``remove()`` with the
|
|
historical agents a just-removed preset's
|
|
``registered_commands`` actually targeted, so a surviving
|
|
lower-priority preset's content is restored there too — not
|
|
only for the currently active agent (#2948). Install/use
|
|
callers omit this, preserving pure active-only behavior.
|
|
target_agent: If set, report only command names written for this
|
|
agent. Other callers receive the union of all written names.
|
|
|
|
Returns:
|
|
Command names successfully written by this reconciliation pass.
|
|
"""
|
|
if not command_names:
|
|
return set()
|
|
|
|
# Never materialize extension-scoped commands whose extension isn't
|
|
# installed. Registration (_register_commands / _register_skills)
|
|
# already refuses them, so a reconciliation pass writing them would
|
|
# create files no registry entry tracks. Filtering here — the single
|
|
# chokepoint every install/remove/rescaffold reconciliation funnels
|
|
# through — keeps all callers consistent without each one re-applying
|
|
# the filter when seeding names from manifest templates.
|
|
command_names = [
|
|
name
|
|
for name in command_names
|
|
if self._extension_installed_for_command(name)
|
|
]
|
|
if not command_names:
|
|
return set()
|
|
|
|
try:
|
|
from ..agents import CommandRegistrar
|
|
except ImportError:
|
|
return set()
|
|
|
|
resolver = PresetResolver(self.project_root)
|
|
registrar = CommandRegistrar()
|
|
reconciled_commands: set[str] = set()
|
|
|
|
def record_written(written: Dict[str, List[str]]) -> None:
|
|
if target_agent is not None:
|
|
reconciled_commands.update(written.get(target_agent, []))
|
|
else:
|
|
for names in written.values():
|
|
reconciled_commands.update(names)
|
|
|
|
# Resolve the active-only restriction once. MISSING_INIT_OPTIONS_FILE
|
|
# (legacy pre-init-options project) keeps the pre-#2948 fallback of
|
|
# registering every detected non-skill agent; a corrupted/malformed
|
|
# init-options.json fails closed via a sentinel that matches no real
|
|
# agent name instead of silently falling back to "no restriction".
|
|
resolved_agent = resolve_active_agent_for_registration(self.project_root)
|
|
if resolved_agent is MISSING_INIT_OPTIONS_FILE:
|
|
only_agent: Optional[str] = None
|
|
elif resolved_agent is None:
|
|
only_agent = ""
|
|
else:
|
|
only_agent = resolved_agent
|
|
# Mirror _register_commands's ai_skills guard: a command-backed
|
|
# active agent running in skills mode renders preset/extension
|
|
# overrides as skills, not command files, so this non-skill
|
|
# command reconciliation pass must not target it either.
|
|
agent_config = registrar.AGENT_CONFIGS.get(only_agent)
|
|
if (
|
|
agent_config
|
|
and is_ai_skills_enabled(load_init_options(self.project_root))
|
|
and agent_config.get("extension") != "/SKILL.md"
|
|
):
|
|
only_agent = ""
|
|
|
|
# The active agent's participation is decided exclusively by the
|
|
# only_agent guard above (which encodes the ai_skills mode). A
|
|
# partially failed command→skills toggle can leave the active agent
|
|
# behind in extra_agents via its stale registered_commands entry,
|
|
# and register_commands_for_non_skill_agents admits every
|
|
# extra_agents member even when only_agent excludes the agent —
|
|
# recreating a command file for an agent now running in skills
|
|
# mode. Never re-admit the active agent through the
|
|
# historical-agents side channel (#2948).
|
|
if extra_agents and isinstance(resolved_agent, str):
|
|
extra_agents = set(extra_agents) - {resolved_agent}
|
|
|
|
# Cache registry and manifests outside the loop to avoid
|
|
# repeated filesystem reads for each command name.
|
|
presets_by_priority = list(self.registry.list_by_priority())
|
|
|
|
for cmd_name in command_names:
|
|
layers = resolver.collect_all_layers(cmd_name, "command")
|
|
if not layers:
|
|
continue
|
|
|
|
# If the top layer is replace, it wins entirely — lower layers
|
|
# are irrelevant regardless of their strategies.
|
|
top_is_replace = layers[0]["strategy"] == "replace"
|
|
has_composition = not top_is_replace and any(
|
|
layer["strategy"] != "replace" for layer in layers
|
|
)
|
|
if not has_composition:
|
|
# Pure replace — the top layer wins.
|
|
top_layer = layers[0]
|
|
top_path = top_layer["path"]
|
|
# Try to find which preset owns this layer
|
|
registered = False
|
|
for pack_id, _meta in presets_by_priority:
|
|
pack_dir = self.presets_dir / pack_id
|
|
if top_path.is_relative_to(pack_dir):
|
|
manifest = resolver._get_manifest(pack_dir)
|
|
if manifest:
|
|
for tmpl in manifest.templates:
|
|
if tmpl.get("name") == cmd_name and tmpl.get("type") == "command":
|
|
written = self._register_for_non_skill_agents(
|
|
registrar, [tmpl], manifest.id, pack_dir,
|
|
only_agent=only_agent, extra_agents=extra_agents,
|
|
)
|
|
record_written(written)
|
|
self._merge_pack_registered_commands(manifest.id, written)
|
|
registered = True
|
|
break
|
|
break
|
|
if not registered:
|
|
# Top layer is a non-preset source (extension, core, or
|
|
# project override). Register directly from the layer path.
|
|
source = layers[0]["source"]
|
|
extension_id = None
|
|
written: Dict[str, List[str]] = {}
|
|
if source.startswith("extension:"):
|
|
# Use extension's own registration to preserve context formatting
|
|
extension_id = source.split(":", 1)[1].split(" ", 1)[0]
|
|
ext_dir = (
|
|
self.project_root / ".specify" / "extensions" / extension_id
|
|
)
|
|
ext_manifest_path = ext_dir / "extension.yml"
|
|
if ext_manifest_path.exists():
|
|
try:
|
|
from ..extensions import ExtensionManifest
|
|
ext_manifest = ExtensionManifest(ext_manifest_path)
|
|
# Filter to only the command being reconciled
|
|
matching_cmds = [
|
|
c for c in ext_manifest.commands
|
|
if c.get("name") == cmd_name
|
|
]
|
|
if matching_cmds:
|
|
written = registrar.register_commands_for_non_skill_agents(
|
|
matching_cmds, extension_id, ext_dir,
|
|
self.project_root,
|
|
context_note=f"\n<!-- Extension: {extension_id} -->\n<!-- Config: .specify/extensions/{extension_id}/ -->\n",
|
|
extension_id=extension_id,
|
|
only_agent=only_agent,
|
|
extra_agents=extra_agents,
|
|
)
|
|
record_written(written)
|
|
registered = True
|
|
except Exception:
|
|
# Extension registration failed; fall back to
|
|
# generic path-based registration below.
|
|
pass
|
|
if not registered:
|
|
source_id = extension_id or source
|
|
written = self._register_command_from_path(
|
|
registrar, cmd_name, top_path,
|
|
source_id=source_id,
|
|
only_agent=only_agent, extra_agents=extra_agents,
|
|
)
|
|
record_written(written)
|
|
if extension_id:
|
|
self._merge_extension_registered_commands(
|
|
extension_id, written
|
|
)
|
|
else:
|
|
# Composed command — resolve from full stack
|
|
composed = resolver.resolve_content(cmd_name, "command")
|
|
if composed is None:
|
|
# Composition no longer possible (e.g. base layer removed).
|
|
# Unregister any stale command file from non-skill agents.
|
|
import warnings
|
|
warnings.warn(
|
|
f"Cannot compose command '{cmd_name}': no base layer. "
|
|
f"Stale command files may remain.",
|
|
stacklevel=2,
|
|
)
|
|
registrar._ensure_configs()
|
|
# Include aliases from the top layer's manifest
|
|
cmd_names_to_unregister = [cmd_name]
|
|
for _pid, _meta in presets_by_priority:
|
|
_pd = self.presets_dir / _pid
|
|
_m = resolver._get_manifest(_pd)
|
|
if _m:
|
|
for _t in _m.templates:
|
|
if _t.get("name") == cmd_name and _t.get("type") == "command":
|
|
for alias in _t.get("aliases", []):
|
|
if isinstance(alias, str):
|
|
cmd_names_to_unregister.append(alias)
|
|
break
|
|
# Mirror the active-only restriction used elsewhere in
|
|
# this pass: without it, unregistering a stale composed
|
|
# command would touch every non-skill agent's directory,
|
|
# deleting historical artifacts from integrations that
|
|
# were never active when this preset registered (#2948).
|
|
registrar.unregister_commands(
|
|
{
|
|
agent: cmd_names_to_unregister
|
|
for agent in registrar.AGENT_CONFIGS
|
|
if registrar.AGENT_CONFIGS[agent].get("extension") != "/SKILL.md"
|
|
and (
|
|
only_agent is None
|
|
or agent == only_agent
|
|
or agent in (extra_agents or ())
|
|
)
|
|
},
|
|
self.project_root,
|
|
)
|
|
continue
|
|
|
|
# Write to the highest-priority preset's .composed dir
|
|
registered = False
|
|
for pack_id, _meta in presets_by_priority:
|
|
pack_dir = self.presets_dir / pack_id
|
|
manifest = resolver._get_manifest(pack_dir)
|
|
if not manifest:
|
|
continue
|
|
for tmpl in manifest.templates:
|
|
if tmpl.get("name") == cmd_name and tmpl.get("type") == "command":
|
|
composed_dir = pack_dir / ".composed"
|
|
composed_dir.mkdir(parents=True, exist_ok=True)
|
|
composed_file = composed_dir / f"{cmd_name}.md"
|
|
composed_file.write_text(composed, encoding="utf-8")
|
|
written = self._register_for_non_skill_agents(
|
|
registrar,
|
|
[{**tmpl, "file": f".composed/{cmd_name}.md"}],
|
|
manifest.id, pack_dir,
|
|
only_agent=only_agent, extra_agents=extra_agents,
|
|
)
|
|
record_written(written)
|
|
self._merge_pack_registered_commands(manifest.id, written)
|
|
registered = True
|
|
break
|
|
else:
|
|
continue
|
|
break
|
|
if not registered:
|
|
# No preset owns this composed command — write to a
|
|
# shared .composed dir and register from the top layer.
|
|
shared_composed = self.presets_dir / ".composed"
|
|
shared_composed.mkdir(parents=True, exist_ok=True)
|
|
composed_file = shared_composed / f"{cmd_name}.md"
|
|
composed_file.write_text(composed, encoding="utf-8")
|
|
source = layers[0]["source"]
|
|
if source.startswith("extension:"):
|
|
source_id = source.split(":", 1)[1].split(" ", 1)[0]
|
|
else:
|
|
source_id = source
|
|
written = self._register_command_from_path(
|
|
registrar, cmd_name, composed_file,
|
|
source_id=source_id,
|
|
only_agent=only_agent, extra_agents=extra_agents,
|
|
)
|
|
record_written(written)
|
|
if source.startswith("extension:"):
|
|
self._merge_extension_registered_commands(
|
|
source_id, written
|
|
)
|
|
|
|
return reconciled_commands
|
|
|
|
def _register_command_from_path(
|
|
self,
|
|
registrar: Any,
|
|
cmd_name: str,
|
|
cmd_path: Path,
|
|
source_id: str = "reconciled",
|
|
only_agent: Optional[str] = None,
|
|
extra_agents: Optional[Set[str]] = None,
|
|
) -> Dict[str, List[str]]:
|
|
"""Register a single command from a file path (non-preset source).
|
|
|
|
Used by reconciliation when the winning layer is an extension,
|
|
core template, or project override rather than a preset.
|
|
|
|
Args:
|
|
registrar: CommandRegistrar instance
|
|
cmd_name: Command name
|
|
cmd_path: Path to the command file
|
|
source_id: Source attribution for rendered output
|
|
only_agent: If set, restrict registration to this single agent (#2948).
|
|
extra_agents: Additional agent names to register for besides
|
|
``only_agent`` (post-removal reconciliation only, #2948).
|
|
|
|
Returns:
|
|
``{agent_name: [cmd_name, ...]}`` for every agent this call
|
|
actually registered the command for (empty if the source path
|
|
doesn't exist or nothing was written).
|
|
"""
|
|
if not cmd_path.exists():
|
|
return {}
|
|
cmd_tmpl: Dict[str, Any] = {
|
|
"name": cmd_name,
|
|
"type": "command",
|
|
"file": cmd_path.name,
|
|
}
|
|
# Load aliases from extension manifest when the winning layer is an extension
|
|
if source_id and not source_id.startswith("preset:"):
|
|
try:
|
|
from ..extensions import ExtensionManifest
|
|
for ext_dir in (self.project_root / ".specify" / "extensions").iterdir():
|
|
if not ext_dir.is_dir():
|
|
continue
|
|
if cmd_path.is_relative_to(ext_dir):
|
|
manifest_path = ext_dir / "extension.yml"
|
|
if manifest_path.exists():
|
|
ext_manifest = ExtensionManifest(manifest_path)
|
|
for cmd in ext_manifest.commands:
|
|
if cmd.get("name") == cmd_name:
|
|
aliases = cmd.get("aliases", [])
|
|
if isinstance(aliases, list) and aliases:
|
|
cmd_tmpl["aliases"] = aliases
|
|
break
|
|
break
|
|
except Exception:
|
|
pass # best-effort alias loading
|
|
return self._register_for_non_skill_agents(
|
|
registrar, [cmd_tmpl], source_id, cmd_path.parent,
|
|
only_agent=only_agent, extra_agents=extra_agents,
|
|
)
|
|
|
|
def _register_for_non_skill_agents(
|
|
self,
|
|
registrar: Any,
|
|
commands: List[Dict[str, Any]],
|
|
source_id: str,
|
|
source_dir: Path,
|
|
only_agent: Optional[str] = None,
|
|
extra_agents: Optional[Set[str]] = None,
|
|
) -> Dict[str, List[str]]:
|
|
"""Register commands for non-skill agents during reconciliation.
|
|
|
|
Skill-based agents (``/SKILL.md`` layout) are handled separately:
|
|
- On removal: ``_unregister_skills()`` restores from core/extension,
|
|
then ``_reconcile_skills()`` re-runs ``_register_skills()`` for the
|
|
next winning preset so SKILL.md files get proper frontmatter and
|
|
descriptions.
|
|
- On install: ``_register_skills()`` writes formatted SKILL.md, then
|
|
``_reconcile_skills()`` ensures the actual priority winner is used.
|
|
|
|
Writing raw command content to skill agents would produce invalid
|
|
SKILL.md files (missing skill frontmatter, descriptions, etc.).
|
|
|
|
Args:
|
|
only_agent: If set, restrict registration to this single agent,
|
|
matching the active-only rule applied by ``_register_commands``
|
|
(#2948).
|
|
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 (#2948).
|
|
|
|
Returns:
|
|
``{agent_name: [cmd_name, ...]}`` for every agent this call
|
|
actually registered a command for, mirroring
|
|
``CommandRegistrar.register_commands_for_non_skill_agents``'s
|
|
return value so callers can merge it into a preset's own
|
|
``registered_commands`` tracking (#2948).
|
|
"""
|
|
return registrar.register_commands_for_non_skill_agents(
|
|
commands, source_id, source_dir, self.project_root,
|
|
only_agent=only_agent, extra_agents=extra_agents,
|
|
)
|
|
|
|
class _FilteredManifest:
|
|
"""Wrapper that exposes only selected command templates from a manifest.
|
|
|
|
Used by _reconcile_skills to avoid overwriting skills for commands
|
|
that aren't being reconciled.
|
|
"""
|
|
|
|
def __init__(self, manifest: "PresetManifest", cmd_names: set):
|
|
self._manifest = manifest
|
|
self._cmd_names = cmd_names
|
|
|
|
def __getattr__(self, name: str):
|
|
return getattr(self._manifest, name)
|
|
|
|
@property
|
|
def templates(self) -> List[Dict[str, Any]]:
|
|
return [
|
|
t for t in self._manifest.templates
|
|
if t.get("name") in self._cmd_names
|
|
]
|
|
|
|
def _merge_pack_registered_skills(
|
|
self, pack_id: str, written: Optional[Dict[str, List[str]]]
|
|
) -> None:
|
|
"""Merge actually-written agent skill registrations into a preset's metadata.
|
|
|
|
Mirrors :meth:`_merge_pack_registered_commands` for the skills
|
|
side: ``_reconcile_skills`` can render a preset's SKILL.md content
|
|
into an agent directory the preset never wrote to before — most
|
|
notably a historical (currently inactive) agent restored via
|
|
``extra_skills_dirs`` when a higher-priority preset is removed. If
|
|
that write isn't reflected back into the winning preset's own
|
|
``registered_skills``, a later removal of this same preset only
|
|
cleans up the agents it already knew about, orphaning the skill
|
|
directory reconciliation just wrote to on its behalf (#2948).
|
|
|
|
Args:
|
|
pack_id: The preset whose metadata should be updated.
|
|
written: ``{agent_name: [skill_name, ...]}`` actually written
|
|
by the ``_register_skills`` call just made. A falsy value
|
|
is a no-op.
|
|
"""
|
|
if not written:
|
|
return
|
|
metadata = self.registry.get(pack_id)
|
|
if metadata is None:
|
|
return # pack_id no longer installed (e.g. removed mid-loop)
|
|
raw_existing_skills = metadata.get("registered_skills")
|
|
if isinstance(raw_existing_skills, list) and raw_existing_skills:
|
|
# Legacy flat-list value: infer real per-agent ownership from
|
|
# on-disk provenance rather than guessing (#2948).
|
|
fallback_agent = next(iter(written)) if written else None
|
|
existing_skills = self._infer_legacy_skill_provenance(
|
|
[n for n in raw_existing_skills if isinstance(n, str)],
|
|
pack_id,
|
|
fallback_agent=fallback_agent,
|
|
)
|
|
else:
|
|
existing_skills = self._normalize_registered_skills(raw_existing_skills)
|
|
merged_skills = copy.deepcopy(existing_skills)
|
|
changed = (
|
|
isinstance(raw_existing_skills, list) and bool(raw_existing_skills)
|
|
)
|
|
for agent_name, skill_names in written.items():
|
|
if not skill_names:
|
|
continue
|
|
existing_names = merged_skills.get(agent_name, [])
|
|
new_names = [n for n in skill_names if n not in existing_names]
|
|
if new_names:
|
|
merged_skills[agent_name] = existing_names + new_names
|
|
changed = True
|
|
if changed:
|
|
self.registry.update(pack_id, {"registered_skills": merged_skills})
|
|
|
|
def _reconcile_skills(
|
|
self,
|
|
command_names: List[str],
|
|
extra_skills_dirs: Optional[
|
|
Dict[Path, tuple[Optional[str], List[str]]]
|
|
] = None,
|
|
target_agent: Optional[str] = None,
|
|
) -> Set[str]:
|
|
"""Re-register skills for commands whose winning layer changed.
|
|
|
|
After a preset is removed, finds the next preset in the priority
|
|
stack that provides each command and re-runs skill registration
|
|
for that preset so SKILL.md files reflect the current winner.
|
|
|
|
Args:
|
|
command_names: List of command names to reconcile skills for
|
|
extra_skills_dirs: Additional
|
|
``{skills_dir: (renderer_agent, managed_skill_names)}``
|
|
entries restored by ``_unregister_skills``. Reconciliation
|
|
is limited to the names actually managed in each directory.
|
|
target_agent: If set, report only command names written for this
|
|
agent. Other callers receive the union of all written names.
|
|
|
|
Returns:
|
|
Command names whose skill output was successfully written.
|
|
"""
|
|
if not command_names:
|
|
return set()
|
|
|
|
command_names = [
|
|
name
|
|
for name in command_names
|
|
if self._extension_installed_for_command(name)
|
|
]
|
|
if not command_names:
|
|
return set()
|
|
|
|
resolver = PresetResolver(self.project_root)
|
|
active_skills_dir = self._get_skills_dir()
|
|
|
|
from .. import load_init_options
|
|
|
|
init_opts = load_init_options(self.project_root)
|
|
active_ai = init_opts.get("ai") if isinstance(init_opts, dict) else None
|
|
if not isinstance(active_ai, str) or not active_ai:
|
|
active_ai = None
|
|
|
|
# Cache registry once to avoid repeated filesystem reads
|
|
presets_by_priority = list(self.registry.list_by_priority())
|
|
|
|
# Group command names by winning preset to batch _register_skills calls
|
|
# while only registering skills for the specific commands being
|
|
# reconciled. This resolution (which preset/content wins) is
|
|
# directory-independent, so it's computed once and then applied to
|
|
# every affected directory below.
|
|
preset_cmds: Dict[str, List[str]] = {}
|
|
non_preset_skills: List[tuple] = []
|
|
managed_skill_names: set = set()
|
|
reconciled_skill_commands: set[str] = set()
|
|
|
|
for cmd_name in command_names:
|
|
layers = resolver.collect_all_layers(cmd_name, "command")
|
|
if not layers:
|
|
continue
|
|
|
|
skill_name, legacy_skill_name = self._skill_names_for_command(
|
|
cmd_name
|
|
)
|
|
candidate_skill_names = {skill_name, legacy_skill_name}
|
|
# Track whether any preset previously registered this skill
|
|
# (i.e., it was actively managed), so a not-yet-existing skill
|
|
# dir can be re-created per affected directory below.
|
|
for _pid, meta in presets_by_priority:
|
|
if not isinstance(meta, dict):
|
|
continue
|
|
recorded = meta.get("registered_skills", [])
|
|
if isinstance(recorded, dict):
|
|
recorded_names = {
|
|
name
|
|
for names in recorded.values()
|
|
if isinstance(names, list)
|
|
for name in names
|
|
}
|
|
elif isinstance(recorded, list):
|
|
recorded_names = set(recorded)
|
|
else:
|
|
recorded_names = set()
|
|
recorded_candidates = (
|
|
candidate_skill_names & recorded_names
|
|
)
|
|
if recorded_candidates:
|
|
managed_skill_names.update(recorded_candidates)
|
|
|
|
top_path = layers[0]["path"]
|
|
# Find the preset that owns the winning layer
|
|
found_preset = False
|
|
for pack_id, _meta in presets_by_priority:
|
|
pack_dir = self.presets_dir / pack_id
|
|
if top_path.is_relative_to(pack_dir):
|
|
preset_cmds.setdefault(pack_id, []).append(cmd_name)
|
|
found_preset = True
|
|
break
|
|
if not found_preset:
|
|
# Winner is a non-preset source (core/extension/override).
|
|
# Track the winning layer path for skill restoration.
|
|
non_preset_skills.append((skill_name, cmd_name, layers[0]))
|
|
|
|
core_ext_skills = [s for s in non_preset_skills if s[2]["source"] != "project override"]
|
|
override_skills = [s for s in non_preset_skills if s[2]["source"] == "project override"]
|
|
|
|
def apply_to_dir(
|
|
skills_dir: Path,
|
|
dir_agent: Optional[str],
|
|
*,
|
|
is_active: bool,
|
|
managed_names: Optional[Set[str]] = None,
|
|
) -> None:
|
|
dir_managed_names = (
|
|
managed_skill_names if managed_names is None else managed_names
|
|
)
|
|
# Restore skills for commands whose winner is non-preset.
|
|
# _unregister_skills_in_dir can rmtree the skill dir, so
|
|
# overrides must be handled directly (create dir + write)
|
|
# without that call.
|
|
dir_core_ext_names = [
|
|
candidate
|
|
for _skill_name, cmd_name, _top_layer in core_ext_skills
|
|
for candidate in self._skill_names_for_command(cmd_name)
|
|
if candidate in dir_managed_names
|
|
]
|
|
if dir_core_ext_names:
|
|
self._unregister_skills_in_dir(
|
|
dir_core_ext_names, skills_dir, dir_agent
|
|
)
|
|
|
|
for _skill_name, cmd_name, top_layer in override_skills:
|
|
target_skill_names = [
|
|
name
|
|
for name in self._skill_names_for_command(cmd_name)
|
|
if name in dir_managed_names
|
|
]
|
|
if not target_skill_names:
|
|
continue
|
|
try:
|
|
from ..agents import CommandRegistrar
|
|
from .. import SKILL_DESCRIPTIONS
|
|
from ..shared_infra import _write_shared_text
|
|
registrar = CommandRegistrar()
|
|
content = top_layer["path"].read_text(encoding="utf-8")
|
|
fm, body = registrar.parse_frontmatter(content)
|
|
short_name = cmd_name
|
|
if short_name.startswith("speckit."):
|
|
short_name = short_name[len("speckit."):]
|
|
desc = fm.get("description", "") or SKILL_DESCRIPTIONS.get(
|
|
short_name.replace(".", "-"),
|
|
f"Command: {short_name}",
|
|
)
|
|
selected_ai = dir_agent if isinstance(dir_agent, str) else ""
|
|
if selected_ai:
|
|
body = registrar.resolve_skill_placeholders(
|
|
selected_ai, fm, body, self.project_root
|
|
)
|
|
body = self._resolve_skill_command_refs(
|
|
body, registrar, selected_ai, self.project_root
|
|
)
|
|
from ..integrations import get_integration
|
|
integration = get_integration(selected_ai) if selected_ai else None
|
|
skill_title = self._skill_title_from_command(cmd_name)
|
|
wrote_override = False
|
|
for target_skill_name in target_skill_names:
|
|
skill_subdir = skills_dir / target_skill_name
|
|
# Same symlink guard as _register_skills's
|
|
# registration path (#2948).
|
|
if not self._validate_skill_subdir(
|
|
skill_subdir,
|
|
create=True,
|
|
skills_root=skills_dir,
|
|
):
|
|
continue
|
|
fm_data = registrar.build_skill_frontmatter(
|
|
selected_ai,
|
|
target_skill_name,
|
|
desc,
|
|
f"override:{cmd_name}",
|
|
)
|
|
registrar.apply_argument_hint(
|
|
fm, fm_data, integration
|
|
)
|
|
fm_text = dump_frontmatter(fm_data)
|
|
skill_content = (
|
|
f"---\n{fm_text}\n---\n\n"
|
|
f"# Speckit {skill_title} Skill\n\n{body}\n"
|
|
)
|
|
if integration is not None and hasattr(
|
|
integration, "post_process_skill_content"
|
|
):
|
|
skill_content = (
|
|
integration.post_process_skill_content(
|
|
skill_content
|
|
)
|
|
)
|
|
_write_shared_text(
|
|
skills_dir,
|
|
skill_subdir / "SKILL.md",
|
|
skill_content,
|
|
)
|
|
wrote_override = True
|
|
if (
|
|
wrote_override
|
|
and (
|
|
target_agent is None
|
|
or dir_agent == target_agent
|
|
)
|
|
):
|
|
reconciled_skill_commands.add(cmd_name)
|
|
except Exception:
|
|
pass # best-effort override skill restoration
|
|
|
|
# Register skills only for the specific commands being
|
|
# reconciled, not all commands in each winning preset's
|
|
# manifest.
|
|
for pack_id, cmds in preset_cmds.items():
|
|
dir_cmds = [
|
|
cmd
|
|
for cmd in cmds
|
|
if any(
|
|
name in dir_managed_names
|
|
for name in self._skill_names_for_command(cmd)
|
|
)
|
|
]
|
|
if not dir_cmds:
|
|
continue
|
|
pack_dir = self.presets_dir / pack_id
|
|
manifest_path = pack_dir / "preset.yml"
|
|
if not manifest_path.exists():
|
|
continue
|
|
try:
|
|
manifest = PresetManifest(manifest_path)
|
|
except PresetValidationError:
|
|
continue
|
|
cmds_set = set(dir_cmds)
|
|
filtered_manifest = self._FilteredManifest(manifest, cmds_set)
|
|
# Not dead code: _register_skills only *overwrites* skill
|
|
# subdirectories that already exist (plus brand-new ones for
|
|
# the active ai_skills agent). For a restore into a
|
|
# historical directory, _unregister_skills has just deleted
|
|
# the retiring preset's subdirectory, so pre-create the
|
|
# tracked (dir_managed_names) subdirectories here — under
|
|
# the same symlink guard — or the surviving preset's
|
|
# override would be silently skipped (#2948).
|
|
for cmd_name in dir_cmds:
|
|
for skill_name in self._skill_names_for_command(cmd_name):
|
|
if skill_name not in dir_managed_names:
|
|
continue
|
|
skill_subdir = skills_dir / skill_name
|
|
if not self._validate_skill_subdir(
|
|
skill_subdir,
|
|
create=True,
|
|
skills_root=skills_dir,
|
|
):
|
|
continue
|
|
if is_active:
|
|
# Preserve exact prior behaviour for the currently
|
|
# active directory (including the ability to create
|
|
# brand-new skill subdirectories when ai_skills is on).
|
|
written = self._register_skills(filtered_manifest, pack_dir)
|
|
else:
|
|
written = self._register_skills(
|
|
filtered_manifest, pack_dir,
|
|
target_dir=skills_dir, target_agent=dir_agent or "",
|
|
)
|
|
if target_agent is None:
|
|
written_names = {
|
|
name
|
|
for names in written.values()
|
|
for name in names
|
|
}
|
|
else:
|
|
written_names = set(written.get(target_agent, []))
|
|
for cmd_name in dir_cmds:
|
|
if written_names.intersection(
|
|
self._skill_names_for_command(cmd_name)
|
|
):
|
|
reconciled_skill_commands.add(cmd_name)
|
|
# The winning preset may not have previously written to
|
|
# this directory's agent (most notably a historical agent
|
|
# reconciliation just restored content into via
|
|
# extra_skills_dirs). If that write isn't merged back into
|
|
# the preset's own registered_skills, its registry entry
|
|
# silently lies about which directories it owns and a
|
|
# later removal of this same preset orphans the directory
|
|
# reconciliation just wrote to on its behalf (#2948).
|
|
self._merge_pack_registered_skills(pack_id, written)
|
|
|
|
extra_dirs = extra_skills_dirs or {}
|
|
if active_skills_dir:
|
|
active_provenance = extra_dirs.get(active_skills_dir)
|
|
if extra_skills_dirs is None or active_provenance:
|
|
apply_to_dir(
|
|
active_skills_dir,
|
|
active_ai,
|
|
is_active=True,
|
|
managed_names=(
|
|
set(active_provenance[1])
|
|
if active_provenance
|
|
else None
|
|
),
|
|
)
|
|
|
|
for extra_dir, (extra_agent, extra_names) in extra_dirs.items():
|
|
if extra_dir == active_skills_dir:
|
|
continue # already reconciled above as the active directory
|
|
apply_to_dir(
|
|
extra_dir,
|
|
extra_agent,
|
|
is_active=False,
|
|
managed_names=set(extra_names),
|
|
)
|
|
|
|
return reconciled_skill_commands
|
|
|
|
def _resolve_agent_skills_dir(self, agent_name: str) -> Path:
|
|
"""Resolve the real skill output directory for an integration."""
|
|
from .. import _get_skills_dir as _project_skills_dir
|
|
from ..agents import CommandRegistrar
|
|
|
|
registrar = CommandRegistrar()
|
|
agent_config = registrar.AGENT_CONFIGS.get(agent_name)
|
|
if agent_config and agent_config.get("extension") == "/SKILL.md":
|
|
return registrar._resolve_agent_dir(
|
|
agent_name, agent_config, self.project_root
|
|
)
|
|
return _project_skills_dir(self.project_root, agent_name)
|
|
|
|
def _skills_validation_root(self, skills_dir: Path) -> Optional[Path]:
|
|
"""Return the trusted root containing a project or user skill dir."""
|
|
for root in (self.project_root, Path.home()):
|
|
if skills_dir.is_relative_to(root):
|
|
return root
|
|
return None
|
|
|
|
def _get_skills_dir(self) -> Optional[Path]:
|
|
"""Return the active skills directory for preset skill overrides.
|
|
|
|
Uses :func:`resolve_active_skills_dir` for activation/detection,
|
|
then resolves native skill agents through the registrar's output
|
|
directory so integrations such as Hermes write to their global
|
|
skills path rather than their project-local detection marker.
|
|
|
|
Returns ``None`` (instead of raising) when the directory cannot
|
|
be created due to symlink, containment, or permission issues so
|
|
that callers can fall back gracefully.
|
|
"""
|
|
from .. import (
|
|
_print_cli_warning,
|
|
load_init_options,
|
|
resolve_active_skills_dir,
|
|
)
|
|
from ..shared_infra import _ensure_safe_shared_directory
|
|
try:
|
|
skills_dir = resolve_active_skills_dir(self.project_root)
|
|
except (ValueError, OSError) as exc:
|
|
_print_cli_warning(
|
|
"resolve", "skills directory", None, exc,
|
|
continuing="Continuing without skill registration.",
|
|
)
|
|
return None
|
|
if skills_dir is None:
|
|
return None
|
|
|
|
opts = load_init_options(self.project_root)
|
|
selected_ai = opts.get("ai") if isinstance(opts, dict) else None
|
|
if not isinstance(selected_ai, str) or not selected_ai:
|
|
return skills_dir
|
|
|
|
agent_skills_dir = self._resolve_agent_skills_dir(selected_ai)
|
|
if agent_skills_dir == skills_dir:
|
|
return skills_dir
|
|
|
|
validation_root = self._skills_validation_root(agent_skills_dir)
|
|
if validation_root is None:
|
|
_print_cli_warning(
|
|
"resolve",
|
|
"skills directory",
|
|
str(agent_skills_dir),
|
|
ValueError("skills directory is outside trusted roots"),
|
|
continuing="Continuing without skill registration.",
|
|
)
|
|
return None
|
|
try:
|
|
_ensure_safe_shared_directory(
|
|
validation_root,
|
|
agent_skills_dir,
|
|
context="preset skills directory",
|
|
)
|
|
except (ValueError, OSError) as exc:
|
|
_print_cli_warning(
|
|
"resolve", "skills directory", str(agent_skills_dir), exc,
|
|
continuing="Continuing without skill registration.",
|
|
)
|
|
return None
|
|
return agent_skills_dir
|
|
|
|
@staticmethod
|
|
def _skill_names_for_command(cmd_name: str) -> tuple[str, str]:
|
|
"""Return the modern and legacy skill directory names for a command."""
|
|
raw_short_name = cmd_name
|
|
if raw_short_name.startswith("speckit."):
|
|
raw_short_name = raw_short_name[len("speckit."):]
|
|
|
|
modern_skill_name = f"speckit-{raw_short_name.replace('.', '-')}"
|
|
legacy_skill_name = f"speckit.{raw_short_name}"
|
|
return modern_skill_name, legacy_skill_name
|
|
|
|
@staticmethod
|
|
def _skill_title_from_command(cmd_name: str) -> str:
|
|
"""Return a human-friendly title for a skill command name."""
|
|
title_name = cmd_name
|
|
if title_name.startswith("speckit."):
|
|
title_name = title_name[len("speckit."):]
|
|
return title_name.replace(".", " ").replace("-", " ").title()
|
|
|
|
@staticmethod
|
|
def _resolve_skill_command_refs(
|
|
body: str,
|
|
registrar: "CommandRegistrar",
|
|
selected_ai: str,
|
|
project_root: "Path | None" = None,
|
|
) -> str:
|
|
"""Render ``__SPECKIT_COMMAND_*__`` tokens in a skill body as invocations.
|
|
|
|
Looks up the agent's invoke separator and rewrites each
|
|
``__SPECKIT_COMMAND_<NAME>__`` placeholder into the matching
|
|
slash-command invocation — ``/speckit-<cmd>`` for a ``-`` separator,
|
|
``/speckit.<cmd>`` for ``.`` — the same rendering the command layer
|
|
applies via ``CommandRegistrar.register_commands()``.
|
|
|
|
For dual-layout agents (e.g. Bob) the separator depends on the
|
|
project's persisted skills state, so — when *project_root* is provided
|
|
— the separator is resolved from the integration via
|
|
``invoke_separator_for_mode`` rather than the single static
|
|
``AGENT_CONFIGS`` value.
|
|
"""
|
|
separator = None
|
|
if project_root is not None and isinstance(selected_ai, str):
|
|
try:
|
|
from .. import load_init_options
|
|
from ..integrations import get_integration
|
|
|
|
integration = get_integration(selected_ai)
|
|
if integration is not None:
|
|
separator = integration.invoke_separator_for_mode(
|
|
is_ai_skills_enabled(load_init_options(project_root))
|
|
)
|
|
except Exception:
|
|
separator = None
|
|
if separator is None:
|
|
separator = registrar.AGENT_CONFIGS.get(selected_ai, {}).get(
|
|
"invoke_separator", "."
|
|
)
|
|
return IntegrationBase.resolve_command_refs(body, separator)
|
|
|
|
def _build_extension_skill_restore_index(self) -> Dict[str, Dict[str, Any]]:
|
|
"""Index extension-backed skill restore data by skill directory name."""
|
|
from ..extensions import ExtensionManifest, ValidationError
|
|
|
|
resolver = PresetResolver(self.project_root)
|
|
extensions_dir = self.project_root / ".specify" / "extensions"
|
|
restore_index: Dict[str, Dict[str, Any]] = {}
|
|
|
|
for _priority, ext_id, _metadata in resolver._get_all_extensions_by_priority():
|
|
ext_dir = extensions_dir / ext_id
|
|
manifest_path = ext_dir / "extension.yml"
|
|
if not manifest_path.is_file():
|
|
continue
|
|
|
|
try:
|
|
manifest = ExtensionManifest(manifest_path)
|
|
except (ValidationError, TypeError, AttributeError):
|
|
continue
|
|
|
|
ext_root = ext_dir.resolve()
|
|
for cmd_info in manifest.commands:
|
|
cmd_name = cmd_info.get("name")
|
|
cmd_file_rel = cmd_info.get("file")
|
|
if not isinstance(cmd_name, str) or not isinstance(cmd_file_rel, str):
|
|
continue
|
|
|
|
cmd_path = Path(cmd_file_rel)
|
|
if cmd_path.is_absolute():
|
|
continue
|
|
|
|
try:
|
|
source_file = (ext_root / cmd_path).resolve()
|
|
source_file.relative_to(ext_root)
|
|
except (OSError, ValueError):
|
|
continue
|
|
|
|
if not source_file.is_file():
|
|
continue
|
|
|
|
restore_info = {
|
|
"command_name": cmd_name,
|
|
"source_file": source_file,
|
|
"source": f"extension:{manifest.id}",
|
|
"extension_id": manifest.id,
|
|
"extension_dir": ext_root,
|
|
}
|
|
modern_skill_name, legacy_skill_name = self._skill_names_for_command(cmd_name)
|
|
restore_index.setdefault(modern_skill_name, restore_info)
|
|
if legacy_skill_name != modern_skill_name:
|
|
restore_index.setdefault(legacy_skill_name, restore_info)
|
|
|
|
return restore_index
|
|
|
|
def _register_skills(
|
|
self,
|
|
manifest: "PresetManifest",
|
|
preset_dir: Path,
|
|
*,
|
|
target_dir: Optional[Path] = None,
|
|
target_agent: Optional[str] = None,
|
|
) -> Dict[str, List[str]]:
|
|
"""Generate SKILL.md files for preset command overrides.
|
|
|
|
For every command template in the preset, checks whether a
|
|
corresponding skill already exists in any detected skills
|
|
directory. If so, the skill is overwritten with content derived
|
|
from the preset's command file. This ensures that presets that
|
|
override commands also propagate to the agentskills.io skill
|
|
layer when skills mode was used during project initialisation.
|
|
|
|
Args:
|
|
manifest: Preset manifest.
|
|
preset_dir: Installed preset directory.
|
|
target_dir: Explicit skills directory to render into, instead
|
|
of resolving the currently active one. Used by
|
|
``_reconcile_skills`` to restore a surviving preset's
|
|
override into a historical (currently inactive) agent's
|
|
directory that removal of a higher-priority preset just
|
|
reverted (#2948).
|
|
target_agent: Explicit agent name to render for, paired with
|
|
``target_dir``. When set, skills are only ever restored
|
|
into already-tracked directories/names — brand-new skill
|
|
subdirectories are never created for a non-active,
|
|
explicitly targeted directory (that creation path is only
|
|
meaningful for the currently active agent).
|
|
|
|
Returns:
|
|
``{agent_name: [skill_name, ...]}`` for the single active
|
|
agent skills were written for (empty if none were written),
|
|
matching the shape ``registered_commands`` already uses so the
|
|
two can be tracked/restored consistently (#2948).
|
|
"""
|
|
command_templates = [
|
|
t for t in manifest.templates if t.get("type") == "command"
|
|
]
|
|
if not command_templates:
|
|
return {}
|
|
|
|
# Filter out extension command overrides if the extension isn't installed,
|
|
# matching the same logic used by _register_commands().
|
|
filtered = [
|
|
cmd
|
|
for cmd in command_templates
|
|
if self._extension_installed_for_command(cmd["name"])
|
|
]
|
|
|
|
if not filtered:
|
|
return {}
|
|
|
|
skills_dir = target_dir if target_dir is not None else self._get_skills_dir()
|
|
if not skills_dir:
|
|
return {}
|
|
|
|
from .. import SKILL_DESCRIPTIONS, load_init_options
|
|
from ..agents import CommandRegistrar
|
|
from ..integrations import get_integration
|
|
from ..shared_infra import _write_shared_text
|
|
|
|
init_opts = load_init_options(self.project_root)
|
|
if not isinstance(init_opts, dict):
|
|
init_opts = {}
|
|
selected_ai = target_agent if target_agent is not None else init_opts.get("ai")
|
|
if not isinstance(selected_ai, str) or not selected_ai:
|
|
return {}
|
|
# A target_dir/target_agent call reconciles an explicitly-known,
|
|
# already-tracked directory (see _reconcile_skills) rather than the
|
|
# currently active agent, so ai_skills_enabled must not be derived
|
|
# from the *current* project-wide toggle for that other agent — it
|
|
# only controls whether brand-new skill subdirectories may be
|
|
# created below, which is only meaningful for the active agent.
|
|
ai_skills_enabled = target_agent is None and is_ai_skills_enabled(init_opts)
|
|
registrar = CommandRegistrar()
|
|
integration = get_integration(selected_ai)
|
|
agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {})
|
|
# Native skill agents (e.g. codex/kimi/agy/trae) materialize brand-new
|
|
# preset skills in _register_commands() because their detected agent
|
|
# directory is already the skills directory. This flag is only for
|
|
# command-backed agents that also mirror commands into skills.
|
|
create_missing_skills = ai_skills_enabled and agent_config.get("extension") != "/SKILL.md"
|
|
|
|
written: List[str] = []
|
|
|
|
for cmd_tmpl in filtered:
|
|
cmd_name = cmd_tmpl["name"]
|
|
cmd_file_rel = cmd_tmpl["file"]
|
|
source_file = preset_dir / cmd_file_rel
|
|
if not source_file.exists():
|
|
continue
|
|
|
|
# Use composed content if available (written by _register_commands
|
|
# for commands with non-replace strategies), otherwise the original.
|
|
composed_file = preset_dir / ".composed" / f"{cmd_name}.md"
|
|
if composed_file.exists():
|
|
source_file = composed_file
|
|
|
|
# Derive the short command name (e.g. "specify" from "speckit.specify")
|
|
raw_short_name = cmd_name
|
|
if raw_short_name.startswith("speckit."):
|
|
raw_short_name = raw_short_name[len("speckit."):]
|
|
short_name = raw_short_name.replace(".", "-")
|
|
skill_name, legacy_skill_name = self._skill_names_for_command(cmd_name)
|
|
skill_title = self._skill_title_from_command(cmd_name)
|
|
|
|
# Only overwrite skills that already exist under skills_dir,
|
|
# including Kimi native skills when ai_skills is false.
|
|
# If both modern and legacy directories exist, update both.
|
|
target_skill_names: List[str] = []
|
|
if (skills_dir / skill_name).is_dir():
|
|
target_skill_names.append(skill_name)
|
|
if legacy_skill_name != skill_name and (skills_dir / legacy_skill_name).is_dir():
|
|
target_skill_names.append(legacy_skill_name)
|
|
if not target_skill_names and create_missing_skills:
|
|
missing_skill_dir = skills_dir / skill_name
|
|
if not missing_skill_dir.exists():
|
|
target_skill_names.append(skill_name)
|
|
if not target_skill_names:
|
|
continue
|
|
|
|
# Parse the command file
|
|
content = source_file.read_text(encoding="utf-8")
|
|
frontmatter, body = registrar.parse_frontmatter(content)
|
|
|
|
if frontmatter.get("strategy") == "wrap":
|
|
body, core_frontmatter = _substitute_core_template(body, cmd_name, self.project_root, registrar)
|
|
frontmatter = dict(frontmatter)
|
|
for key in ("scripts", "agent_scripts"):
|
|
if key not in frontmatter and key in core_frontmatter:
|
|
frontmatter[key] = core_frontmatter[key]
|
|
|
|
original_desc = frontmatter.get("description", "")
|
|
enhanced_desc = original_desc or SKILL_DESCRIPTIONS.get(
|
|
short_name,
|
|
f"Spec-kit workflow command: {short_name}",
|
|
)
|
|
frontmatter = dict(frontmatter)
|
|
frontmatter["description"] = enhanced_desc
|
|
body = registrar.resolve_skill_placeholders(
|
|
selected_ai, frontmatter, body, self.project_root
|
|
)
|
|
body = self._resolve_skill_command_refs(body, registrar, selected_ai, self.project_root)
|
|
|
|
for target_skill_name in target_skill_names:
|
|
skill_subdir = skills_dir / target_skill_name
|
|
if skill_subdir.exists() and not skill_subdir.is_dir():
|
|
continue
|
|
# Validate (and create, if missing) the skill's own
|
|
# subdirectory under the same symlink guard as its parent —
|
|
# is_dir() above follows symlinks, so a symlinked subdir
|
|
# with a real parent would otherwise slip through and have
|
|
# SKILL.md written through it to an arbitrary location (#2948).
|
|
if not self._validate_skill_subdir(
|
|
skill_subdir, create=True, skills_root=skills_dir
|
|
):
|
|
continue
|
|
frontmatter_data = registrar.build_skill_frontmatter(
|
|
selected_ai,
|
|
target_skill_name,
|
|
enhanced_desc,
|
|
f"preset:{manifest.id}",
|
|
)
|
|
registrar.apply_argument_hint(frontmatter, frontmatter_data, integration)
|
|
frontmatter_text = dump_frontmatter(frontmatter_data)
|
|
skill_content = (
|
|
f"---\n"
|
|
f"{frontmatter_text}\n"
|
|
f"---\n\n"
|
|
f"# Speckit {skill_title} Skill\n\n"
|
|
f"{body}\n"
|
|
)
|
|
if integration is not None and hasattr(integration, "post_process_skill_content"):
|
|
skill_content = integration.post_process_skill_content(
|
|
skill_content
|
|
)
|
|
|
|
skill_file = skill_subdir / "SKILL.md"
|
|
_write_shared_text(
|
|
skills_dir, skill_file, skill_content
|
|
)
|
|
written.append(target_skill_name)
|
|
self._merge_pack_registered_skills(
|
|
manifest.id, {selected_ai: [target_skill_name]}
|
|
)
|
|
|
|
return {selected_ai: written} if written else {}
|
|
|
|
def _infer_legacy_skill_provenance(
|
|
self, skill_names: List[str], pack_id: str, fallback_agent: str
|
|
) -> Dict[str, List[str]]:
|
|
"""Infer per-agent ownership of a legacy flat-list ``registered_skills`` value.
|
|
|
|
Pre-#2948 registries recorded ``registered_skills`` as a flat list
|
|
with no record of which agent directory each name was actually
|
|
written under. Blindly attributing every name to ``fallback_agent``
|
|
(the agent currently being processed) loses the real writer whenever
|
|
the *first* operation after upgrading is a direct switch to a
|
|
*different* agent — e.g. a legacy Copilot override (written while
|
|
Copilot was active with ``ai_skills`` enabled) followed directly by
|
|
``integration use claude``, with no intervening rescaffold for
|
|
Copilot — permanently orphaning Copilot's override on later
|
|
removal.
|
|
|
|
Every project-local configured integration's skills directory is probed (via
|
|
the same safe, symlink-validated helpers used for
|
|
restore/removal), not only agents whose registrar config is
|
|
statically ``/SKILL.md``-only: a command-backed agent (e.g.
|
|
Copilot, whose command extension is ``.agent.md``) renders its
|
|
preset overrides as ``SKILL.md`` files exactly like a native
|
|
skill-only agent whenever it was the active agent with
|
|
``ai_skills`` enabled, so excluding it would miss real,
|
|
preset-owned provenance and misattribute it to whichever agent
|
|
happens to be processed first. Each directory is probed for a
|
|
``SKILL.md`` whose frontmatter records this exact preset as the
|
|
owner (``metadata.source == "preset:<pack_id>"``, the same marker
|
|
:meth:`_register_skills` writes) — this marker check is what keeps
|
|
the broadened probe from falsely attributing ownership to an
|
|
agent's directory that never actually held this preset's override
|
|
(e.g. a command-mode agent that never rendered skills, or an
|
|
unrelated skill of the same name). A name can legitimately be
|
|
found under more than one agent's directory — the preset may have
|
|
been active while the user switched between several agents before
|
|
provenance tracking existed — so every matching agent is recorded,
|
|
not just the first. Names that can't be matched to any directory
|
|
(e.g. the file was deleted out of band) fall back to
|
|
``fallback_agent``, preserving the previous best-effort behaviour
|
|
for the unrecoverable case.
|
|
"""
|
|
from ..agents import CommandRegistrar
|
|
|
|
registrar = CommandRegistrar()
|
|
candidate_agents = sorted(registrar.AGENT_CONFIGS)
|
|
|
|
# Multiple agent names can resolve to the same physical directory
|
|
# (e.g. agy/amp/codex/zed all use .agents/skills); group by
|
|
# directory so each is probed once and attributed to a single
|
|
# deterministic canonical agent name, matching the tie-break
|
|
# already used by _unregister_skills's directory grouping. Deliberately
|
|
# keep the unresolved path (matching what _safe_skills_dir_for_agent
|
|
# already validated) rather than calling .resolve() here: on macOS
|
|
# /var is itself a symlink to /private/var, so resolving would make
|
|
# this path diverge from self.project_root's own resolution state
|
|
# and make every subsequent containment check in
|
|
# _validate_skill_subdir() spuriously fail.
|
|
dir_to_agents: Dict[Path, List[str]] = {}
|
|
for agent_name in candidate_agents:
|
|
skills_dir = self._safe_skills_dir_for_agent(agent_name)
|
|
if skills_dir is None:
|
|
continue
|
|
# Only project-local skills directories are eligible: the
|
|
# legacy provenance markers don't record which project owns a
|
|
# skill under a home directory, so deletion stays restricted
|
|
# to the project root. Revisit if provenance ever records the
|
|
# owning project.
|
|
if not Path(os.path.abspath(skills_dir)).is_relative_to(
|
|
Path(os.path.abspath(self.project_root))
|
|
):
|
|
continue
|
|
dir_to_agents.setdefault(skills_dir, []).append(agent_name)
|
|
|
|
marker = f"preset:{pack_id}"
|
|
# Filter unsafe names once, up front, rather than only inside the
|
|
# matching loop: any name skipped there would otherwise still
|
|
# land in "unmatched" below and get blindly attributed to
|
|
# fallback_agent anyway, defeating the guard entirely (#2948).
|
|
safe_skill_names = [
|
|
name for name in skill_names if self._is_safe_registry_skill_name(name)
|
|
]
|
|
inferred: Dict[str, List[str]] = {}
|
|
matched_names: set = set()
|
|
for resolved_dir, agents in dir_to_agents.items():
|
|
canonical_agent = fallback_agent if fallback_agent in agents else sorted(agents)[0]
|
|
for name in safe_skill_names:
|
|
skill_subdir = resolved_dir / name
|
|
if not self._validate_skill_subdir(
|
|
skill_subdir, create=False, skills_root=resolved_dir
|
|
):
|
|
continue
|
|
skill_file = skill_subdir / "SKILL.md"
|
|
if not skill_file.is_file():
|
|
continue
|
|
try:
|
|
content = skill_file.read_text(encoding="utf-8")
|
|
except (OSError, UnicodeDecodeError):
|
|
continue
|
|
frontmatter, _ = registrar.parse_frontmatter(content)
|
|
skill_metadata = frontmatter.get("metadata")
|
|
source = (
|
|
skill_metadata.get("source")
|
|
if isinstance(skill_metadata, dict)
|
|
else None
|
|
)
|
|
if source == marker:
|
|
inferred.setdefault(canonical_agent, []).append(name)
|
|
matched_names.add(name)
|
|
|
|
unmatched = [name for name in safe_skill_names if name not in matched_names]
|
|
if unmatched and fallback_agent:
|
|
fallback_names = inferred.setdefault(fallback_agent, [])
|
|
for name in unmatched:
|
|
if name not in fallback_names:
|
|
fallback_names.append(name)
|
|
|
|
return inferred
|
|
|
|
@staticmethod
|
|
def _normalize_registered_skills(
|
|
value: Any, fallback_agent: Optional[str] = None
|
|
) -> Dict[str, List[str]]:
|
|
"""Normalize a ``registered_skills`` registry value to per-agent form.
|
|
|
|
The registry stores ``registered_skills`` as ``Dict[str, List[str]]``
|
|
(agent name -> skill names actually written for that agent),
|
|
mirroring ``registered_commands``. Older registries predate that
|
|
provenance and stored a flat ``List[str]`` with no record of which
|
|
agent directory the names were written under; since that can't be
|
|
recovered, ``fallback_agent`` (when given) attributes the legacy
|
|
list to the agent currently being processed so the format
|
|
self-migrates on the next write. Without a fallback agent, legacy
|
|
lists are dropped rather than guessed at.
|
|
|
|
Callers that can identify the owning preset (i.e. have a
|
|
``pack_id``) should prefer :meth:`_infer_legacy_skill_provenance`
|
|
for a legacy flat-list value instead, which probes on-disk
|
|
provenance rather than assuming ``fallback_agent`` wrote every name.
|
|
"""
|
|
if isinstance(value, dict):
|
|
return {
|
|
agent: list(names)
|
|
for agent, names in value.items()
|
|
if isinstance(agent, str) and isinstance(names, list)
|
|
}
|
|
if isinstance(value, list) and value and fallback_agent:
|
|
return {fallback_agent: [n for n in value if isinstance(n, str)]}
|
|
return {}
|
|
|
|
def _safe_skills_dir_for_agent(self, agent_name: str) -> Optional[Path]:
|
|
"""Resolve ``agent_name``'s skills directory, validated for safety.
|
|
|
|
Unlike :meth:`_get_skills_dir` (which resolves only the *currently
|
|
active* integration via init-options), this resolves an arbitrary
|
|
agent's directory from persisted provenance so a preset's skill
|
|
registrations can be restored/cleaned up under an agent that isn't
|
|
currently active. The candidate directory is validated through the
|
|
project's shared symlink/containment guard before any file in it is
|
|
touched; directories that don't exist or fail validation are
|
|
skipped rather than raising.
|
|
"""
|
|
from ..agents import CommandRegistrar
|
|
from ..shared_infra import _ensure_safe_shared_directory
|
|
|
|
if agent_name not in CommandRegistrar.AGENT_CONFIGS:
|
|
return None
|
|
skills_dir = self._resolve_agent_skills_dir(agent_name)
|
|
validation_root = self._skills_validation_root(skills_dir)
|
|
if validation_root is None:
|
|
return None
|
|
try:
|
|
_ensure_safe_shared_directory(
|
|
validation_root, skills_dir,
|
|
create=False, context="preset skills directory",
|
|
)
|
|
except (ValueError, OSError):
|
|
return None
|
|
return skills_dir
|
|
|
|
@staticmethod
|
|
def _is_safe_registry_skill_name(name: Any) -> bool:
|
|
"""Validate a registry-provided skill name is a single safe path component.
|
|
|
|
``registered_skills`` entries are persisted registry data, not
|
|
derived from the current preset manifest, so a corrupted or
|
|
maliciously edited registry could contain an absolute path, a
|
|
multi-segment path (containing ``/`` or ``\\``), or a traversal
|
|
component (``"."``/``".."``) instead of a plain skill directory
|
|
name. Any of these — if joined directly onto a skills directory —
|
|
can escape the intended skill subtree while still resolving to a
|
|
location inside the project root, which is enough to pass the
|
|
parent-directory containment/symlink check alone (#2948). This
|
|
centralizes the single boundary check every preset cleanup and
|
|
provenance loop that consumes registry-provided skill names must
|
|
apply before ever constructing a path from one.
|
|
"""
|
|
if not isinstance(name, str) or not name:
|
|
return False
|
|
if name in (".", ".."):
|
|
return False
|
|
candidate = Path(name)
|
|
if candidate.is_absolute():
|
|
return False
|
|
if len(candidate.parts) != 1:
|
|
return False
|
|
if candidate.name != name:
|
|
return False
|
|
return True
|
|
|
|
def _validate_skill_subdir(
|
|
self,
|
|
skill_subdir: Path,
|
|
*,
|
|
create: bool,
|
|
skills_root: Optional[Path] = None,
|
|
) -> bool:
|
|
"""Validate a single skill's subdirectory is symlink-free.
|
|
|
|
Unlike :meth:`_safe_skills_dir_for_agent` (which only validates the
|
|
*parent* skills directory), this validates the skill's own
|
|
subdirectory — e.g. ``.claude/skills/speckit-specify`` — so a
|
|
symlink planted at that level (with a safe parent) can't be used to
|
|
write or delete through to a location outside the project. Shared by
|
|
both the registration path (``create=True``, so a missing directory
|
|
is created component-by-component under the same guard) and the
|
|
restore/removal path (``create=False``, so a missing directory is
|
|
left for the caller's own existence check to skip). Returns
|
|
``False`` rather than raising when the path escapes the project
|
|
root or crosses a symlink. ``skills_root`` supplies the trusted
|
|
agent output boundary for native global skill integrations such as
|
|
Hermes; project-local callers default to ``self.project_root``.
|
|
"""
|
|
from ..shared_infra import _ensure_safe_shared_directory, _validate_safe_shared_directory
|
|
|
|
validation_root = skills_root or self.project_root
|
|
if validation_root.is_symlink():
|
|
return False
|
|
try:
|
|
if create:
|
|
_ensure_safe_shared_directory(
|
|
validation_root, skill_subdir,
|
|
create=True, context="preset skill directory",
|
|
)
|
|
else:
|
|
_validate_safe_shared_directory(
|
|
validation_root, skill_subdir
|
|
)
|
|
except (ValueError, OSError):
|
|
return False
|
|
return True
|
|
|
|
def _unregister_skills(
|
|
self,
|
|
registered_skills: Union[Dict[str, List[str]], List[str]],
|
|
preset_dir: Union[Path, str],
|
|
*,
|
|
additional_owned_sources: Optional[Dict[str, str]] = None,
|
|
) -> Dict[Path, tuple[Optional[str], List[str]]]:
|
|
"""Restore original SKILL.md files after a preset is removed.
|
|
|
|
For each skill that was overridden by the preset, attempts to
|
|
regenerate the skill from the core command template. If no core
|
|
template exists, the skill directory is removed.
|
|
|
|
``registered_skills`` records exactly which agent directories this
|
|
preset actually wrote to (see :meth:`_register_skills`), so removal
|
|
restores precisely those directories rather than guessing at every
|
|
skill-mode agent that happens to exist on disk. Each directory is
|
|
re-resolved and safety-validated at removal time (see
|
|
:meth:`_safe_skills_dir_for_agent`) since it may belong to an agent
|
|
that isn't currently active.
|
|
|
|
Args:
|
|
registered_skills: Per-agent skill names written by the preset
|
|
(``{agent_name: [skill_name, ...]}``), or a legacy flat
|
|
``List[str]`` from a registry written before this
|
|
provenance tracking existed.
|
|
preset_dir: The preset's installed directory (may already be deleted).
|
|
additional_owned_sources: Generated non-preset source markers
|
|
that this cleanup may also replace for specific skill names.
|
|
|
|
Returns:
|
|
``{skills_dir: (renderer_agent, managed_skill_names)}`` for
|
|
every directory and skill name actually restored or removed.
|
|
"""
|
|
if not registered_skills:
|
|
return {}
|
|
|
|
pack_id = preset_dir if isinstance(preset_dir, str) else preset_dir.name
|
|
|
|
if isinstance(registered_skills, dict):
|
|
from .. import load_init_options
|
|
|
|
init_opts = load_init_options(self.project_root)
|
|
active_agent = init_opts.get("ai") if isinstance(init_opts, dict) else None
|
|
if not isinstance(active_agent, str) or not active_agent:
|
|
active_agent = None
|
|
|
|
# Multiple integration keys can share the same physical
|
|
# directory (e.g. agy/codex/zed all resolve to
|
|
# ``.agents/skills``). Restoring that directory once per
|
|
# recorded agent would have each pass's agent-specific
|
|
# rendering (frontmatter, post-processing) overwrite the
|
|
# previous one, with whichever agent is iterated *last* silently
|
|
# winning regardless of which agent is actually active. Group
|
|
# provenance by resolved directory so each physical directory is
|
|
# restored exactly once, using the active agent's renderer when
|
|
# it shares that directory (otherwise any recorded owner,
|
|
# chosen deterministically).
|
|
groups: Dict[Path, Dict[str, Any]] = {}
|
|
for agent_name, skill_names in registered_skills.items():
|
|
if not skill_names:
|
|
continue
|
|
skills_dir = self._safe_skills_dir_for_agent(agent_name)
|
|
if skills_dir is None:
|
|
continue
|
|
group = groups.setdefault(skills_dir, {"agents": [], "names": []})
|
|
group["agents"].append(agent_name)
|
|
for name in skill_names:
|
|
if (
|
|
self._is_safe_registry_skill_name(name)
|
|
and name not in group["names"]
|
|
):
|
|
group["names"].append(name)
|
|
|
|
restored: Dict[Path, tuple[Optional[str], List[str]]] = {}
|
|
for skills_dir, group in groups.items():
|
|
agents = group["agents"]
|
|
renderer_agent = (
|
|
active_agent if active_agent in agents else sorted(agents)[0]
|
|
)
|
|
mutated_names = self._unregister_skills_in_dir(
|
|
group["names"],
|
|
skills_dir,
|
|
renderer_agent,
|
|
pack_id=pack_id,
|
|
additional_owned_sources=additional_owned_sources,
|
|
)
|
|
if mutated_names:
|
|
restored[skills_dir] = (
|
|
renderer_agent,
|
|
mutated_names,
|
|
)
|
|
return restored
|
|
|
|
# Legacy flat-list format: no record of which agent directory these
|
|
# names were written under, so best-effort restore is limited to the
|
|
# currently active agent's directory (the pre-provenance behaviour).
|
|
skills_dir = self._get_skills_dir()
|
|
if not skills_dir:
|
|
return {}
|
|
from .. import load_init_options
|
|
|
|
init_opts = load_init_options(self.project_root)
|
|
if not isinstance(init_opts, dict):
|
|
init_opts = {}
|
|
selected_ai = init_opts.get("ai")
|
|
selected_ai = selected_ai if isinstance(selected_ai, str) else None
|
|
safe_names = [
|
|
name
|
|
for name in registered_skills
|
|
if self._is_safe_registry_skill_name(name)
|
|
]
|
|
mutated_names = self._unregister_skills_in_dir(
|
|
safe_names,
|
|
skills_dir,
|
|
selected_ai,
|
|
pack_id=pack_id,
|
|
additional_owned_sources=additional_owned_sources,
|
|
)
|
|
return (
|
|
{skills_dir: (selected_ai, mutated_names)}
|
|
if mutated_names
|
|
else {}
|
|
)
|
|
|
|
def _delete_agent_preset_skills(
|
|
self, agent_name: str, skill_names: List[str], pack_id: str
|
|
) -> None:
|
|
"""Delete still-preset-owned skills when an agent is deactivated."""
|
|
skills_dir = self._safe_skills_dir_for_agent(agent_name)
|
|
if skills_dir is None:
|
|
return
|
|
|
|
from ..agents import CommandRegistrar
|
|
|
|
registrar = CommandRegistrar()
|
|
marker = f"preset:{pack_id}"
|
|
override_sources: Dict[str, str] = {}
|
|
manifest = PresetResolver(self.project_root)._get_manifest(
|
|
self.presets_dir / pack_id
|
|
)
|
|
if manifest is not None:
|
|
for template in manifest.templates:
|
|
command_name = template.get("name")
|
|
if (
|
|
template.get("type") == "command"
|
|
and isinstance(command_name, str)
|
|
):
|
|
for skill_name in self._skill_names_for_command(
|
|
command_name
|
|
):
|
|
override_sources[skill_name] = (
|
|
f"override:{command_name}"
|
|
)
|
|
for skill_name in skill_names:
|
|
if not self._is_safe_registry_skill_name(skill_name):
|
|
continue
|
|
skill_subdir = skills_dir / skill_name
|
|
if not self._validate_skill_subdir(
|
|
skill_subdir, create=False, skills_root=skills_dir
|
|
):
|
|
continue
|
|
skill_file = skill_subdir / "SKILL.md"
|
|
if not skill_file.is_file():
|
|
continue
|
|
try:
|
|
content = skill_file.read_text(encoding="utf-8")
|
|
except (OSError, UnicodeDecodeError):
|
|
continue
|
|
frontmatter, _ = registrar.parse_frontmatter(content)
|
|
metadata = frontmatter.get("metadata")
|
|
source = (
|
|
metadata.get("source")
|
|
if isinstance(metadata, dict)
|
|
else None
|
|
)
|
|
owned_sources = {marker}
|
|
override_source = override_sources.get(skill_name)
|
|
if override_source:
|
|
owned_sources.add(override_source)
|
|
if source in owned_sources:
|
|
shutil.rmtree(skill_subdir)
|
|
|
|
def _unregister_skills_in_dir(
|
|
self,
|
|
skill_names: List[str],
|
|
skills_dir: Path,
|
|
selected_ai: Optional[str],
|
|
*,
|
|
pack_id: Optional[str] = None,
|
|
additional_owned_sources: Optional[Dict[str, str]] = None,
|
|
) -> List[str]:
|
|
"""Restore original SKILL.md files within a single skills directory.
|
|
|
|
Args:
|
|
skill_names: List of skill names written by the preset.
|
|
skills_dir: The skills directory to restore within.
|
|
selected_ai: The agent name that owns ``skills_dir``, used for
|
|
placeholder resolution and argument-hint formatting.
|
|
additional_owned_sources: Generated non-preset source markers
|
|
accepted as owned for specific skill names.
|
|
|
|
Returns:
|
|
Skill names whose files were restored or removed.
|
|
"""
|
|
from .. import SKILL_DESCRIPTIONS
|
|
from ..agents import CommandRegistrar
|
|
from ..integrations import get_integration
|
|
from ..shared_infra import _write_shared_text
|
|
|
|
# Locate core command templates from the project's installed templates
|
|
core_templates_dir = self.project_root / ".specify" / "templates" / "commands"
|
|
registrar = CommandRegistrar()
|
|
integration = get_integration(selected_ai) if isinstance(selected_ai, str) else None
|
|
extension_restore_index = self._build_extension_skill_restore_index()
|
|
mutated_names: List[str] = []
|
|
|
|
for skill_name in skill_names:
|
|
# Guard against a corrupted/malicious registry entry: a
|
|
# registered_skills name is persisted data, not derived from
|
|
# the current manifest, so it must be validated as a single,
|
|
# relative, non-"."/".." path component before ever being
|
|
# joined onto skills_dir. Without this, an absolute name
|
|
# discards skills_dir entirely (Path's "/" operator drops the
|
|
# left side for an absolute right side) or a multi-component
|
|
# name containing ".." can resolve to a different, unrelated
|
|
# directory that still happens to be inside the project root
|
|
# — passing the containment-only symlink guard below and
|
|
# letting removal overwrite/delete it (#2948).
|
|
if not self._is_safe_registry_skill_name(skill_name):
|
|
continue
|
|
|
|
# Derive command name from skill name (speckit-specify -> specify)
|
|
short_name = skill_name
|
|
if short_name.startswith("speckit-"):
|
|
short_name = short_name[len("speckit-"):]
|
|
elif short_name.startswith("speckit."):
|
|
short_name = short_name[len("speckit."):]
|
|
|
|
skill_subdir = skills_dir / skill_name
|
|
skill_file = skill_subdir / "SKILL.md"
|
|
if not skill_subdir.is_dir():
|
|
continue
|
|
# is_dir() follows symlinks, so a symlinked skill subdirectory
|
|
# (with a safe, non-symlinked parent) would otherwise slip past
|
|
# _safe_skills_dir_for_agent's parent-only check and have
|
|
# write_text/rmtree operate through it (#2948).
|
|
if not self._validate_skill_subdir(
|
|
skill_subdir, create=False, skills_root=skills_dir
|
|
):
|
|
continue
|
|
if not skill_file.is_file():
|
|
# Only manage directories that contain the expected skill entrypoint.
|
|
continue
|
|
if pack_id is not None:
|
|
try:
|
|
current_content = skill_file.read_text(encoding="utf-8")
|
|
except (OSError, UnicodeDecodeError):
|
|
continue
|
|
current_frontmatter, _ = registrar.parse_frontmatter(current_content)
|
|
current_metadata = current_frontmatter.get("metadata")
|
|
current_source = (
|
|
current_metadata.get("source")
|
|
if isinstance(current_metadata, dict)
|
|
else None
|
|
)
|
|
owned_sources = {f"preset:{pack_id}"}
|
|
if additional_owned_sources:
|
|
additional_source = additional_owned_sources.get(
|
|
skill_name
|
|
)
|
|
if additional_source:
|
|
owned_sources.add(additional_source)
|
|
if current_source not in owned_sources:
|
|
continue
|
|
|
|
# Try to find the core command template
|
|
core_file = core_templates_dir / f"{short_name}.md" if core_templates_dir.exists() else None
|
|
if core_file and not core_file.exists():
|
|
core_file = None
|
|
|
|
if core_file:
|
|
# Restore from core template
|
|
content = core_file.read_text(encoding="utf-8")
|
|
frontmatter, body = registrar.parse_frontmatter(content)
|
|
if isinstance(selected_ai, str):
|
|
body = registrar.resolve_skill_placeholders(
|
|
selected_ai, frontmatter, body, self.project_root
|
|
)
|
|
body = self._resolve_skill_command_refs(
|
|
body, registrar, selected_ai, self.project_root
|
|
)
|
|
|
|
original_desc = frontmatter.get("description", "")
|
|
enhanced_desc = original_desc or SKILL_DESCRIPTIONS.get(
|
|
short_name,
|
|
f"Spec-kit workflow command: {short_name}",
|
|
)
|
|
|
|
frontmatter_data = registrar.build_skill_frontmatter(
|
|
selected_ai if isinstance(selected_ai, str) else "",
|
|
skill_name,
|
|
enhanced_desc,
|
|
f"templates/commands/{short_name}.md",
|
|
)
|
|
registrar.apply_argument_hint(frontmatter, frontmatter_data, integration)
|
|
frontmatter_text = dump_frontmatter(frontmatter_data)
|
|
skill_title = self._skill_title_from_command(short_name)
|
|
skill_content = (
|
|
f"---\n"
|
|
f"{frontmatter_text}\n"
|
|
f"---\n\n"
|
|
f"# Speckit {skill_title} Skill\n\n"
|
|
f"{body}\n"
|
|
)
|
|
if integration is not None and hasattr(integration, "post_process_skill_content"):
|
|
skill_content = integration.post_process_skill_content(
|
|
skill_content
|
|
)
|
|
_write_shared_text(skills_dir, skill_file, skill_content)
|
|
mutated_names.append(skill_name)
|
|
continue
|
|
|
|
extension_restore = extension_restore_index.get(skill_name)
|
|
if extension_restore:
|
|
content = extension_restore["source_file"].read_text(encoding="utf-8")
|
|
frontmatter, body = registrar.parse_frontmatter(content)
|
|
# Mirror the register-time rewrite (#2101): resolve
|
|
# extension-relative subdir references (agents/,
|
|
# knowledge-base/, etc.) to their installed location before
|
|
# the generic placeholder resolution below, otherwise
|
|
# restoring after a preset override removal would leave
|
|
# bare, unresolvable paths in the skill body.
|
|
body = registrar.rewrite_extension_paths(
|
|
body,
|
|
extension_restore["extension_id"],
|
|
extension_restore["extension_dir"],
|
|
)
|
|
if isinstance(selected_ai, str):
|
|
body = registrar.resolve_skill_placeholders(
|
|
selected_ai, frontmatter, body, self.project_root
|
|
)
|
|
body = self._resolve_skill_command_refs(
|
|
body, registrar, selected_ai, self.project_root
|
|
)
|
|
|
|
command_name = extension_restore["command_name"]
|
|
title_name = self._skill_title_from_command(command_name)
|
|
|
|
frontmatter_data = registrar.build_skill_frontmatter(
|
|
selected_ai if isinstance(selected_ai, str) else "",
|
|
skill_name,
|
|
frontmatter.get("description", f"Extension command: {command_name}"),
|
|
extension_restore["source"],
|
|
)
|
|
registrar.apply_argument_hint(frontmatter, frontmatter_data, integration)
|
|
frontmatter_text = dump_frontmatter(frontmatter_data)
|
|
skill_content = (
|
|
f"---\n"
|
|
f"{frontmatter_text}\n"
|
|
f"---\n\n"
|
|
f"# {title_name} Skill\n\n"
|
|
f"{body}\n"
|
|
)
|
|
if integration is not None and hasattr(integration, "post_process_skill_content"):
|
|
skill_content = integration.post_process_skill_content(
|
|
skill_content
|
|
)
|
|
_write_shared_text(skills_dir, skill_file, skill_content)
|
|
mutated_names.append(skill_name)
|
|
else:
|
|
# No core or extension template — remove the skill entirely
|
|
shutil.rmtree(skill_subdir)
|
|
mutated_names.append(skill_name)
|
|
|
|
return mutated_names
|
|
|
|
def install_from_directory(
|
|
self,
|
|
source_dir: Path,
|
|
speckit_version: str,
|
|
priority: int = 10,
|
|
) -> PresetManifest:
|
|
"""Install preset from a local directory.
|
|
|
|
Args:
|
|
source_dir: Path to preset directory
|
|
speckit_version: Current spec-kit version
|
|
priority: Resolution priority (lower = higher precedence, default 10)
|
|
|
|
Returns:
|
|
Installed preset manifest
|
|
|
|
Raises:
|
|
PresetValidationError: If manifest is invalid or priority is invalid
|
|
PresetCompatibilityError: If pack is incompatible
|
|
"""
|
|
# Validate priority
|
|
if priority < 1:
|
|
raise PresetValidationError("Priority must be a positive integer (1 or higher)")
|
|
|
|
manifest_path = source_dir / "preset.yml"
|
|
manifest = PresetManifest(manifest_path)
|
|
|
|
self.check_compatibility(manifest, speckit_version)
|
|
|
|
if self.registry.is_installed(manifest.id):
|
|
raise PresetError(
|
|
f"Preset '{manifest.id}' is already installed. "
|
|
f"Use 'specify preset remove {manifest.id}' first."
|
|
)
|
|
|
|
dest_dir = self.presets_dir / manifest.id
|
|
if dest_dir.exists():
|
|
shutil.rmtree(dest_dir)
|
|
|
|
shutil.copytree(source_dir, dest_dir)
|
|
|
|
# Pre-register the preset so that composition resolution can see it
|
|
# in the priority stack when resolving composed command content.
|
|
self.registry.add(manifest.id, {
|
|
"version": manifest.version,
|
|
"source": "local",
|
|
"manifest_hash": manifest.get_hash(),
|
|
"enabled": True,
|
|
"priority": priority,
|
|
"registered_commands": {},
|
|
"registered_skills": {},
|
|
})
|
|
|
|
registered_commands: Dict[str, List[str]] = {}
|
|
registered_skills: Dict[str, List[str]] = {}
|
|
try:
|
|
# Register command overrides with AI agents and persist the result
|
|
# immediately so cleanup can recover even if installation stops
|
|
# before later phases complete.
|
|
registered_commands = self._register_commands(manifest, dest_dir)
|
|
self.registry.update(manifest.id, {
|
|
"registered_commands": registered_commands,
|
|
})
|
|
|
|
# Update corresponding skills when skills mode was previously used
|
|
# and persist that result as well.
|
|
registered_skills = self._register_skills(manifest, dest_dir)
|
|
self.registry.update(manifest.id, {
|
|
"registered_skills": registered_skills,
|
|
})
|
|
except Exception:
|
|
# Roll back all side effects. _register_skills persists each
|
|
# successful write immediately, so reload that partial map when
|
|
# a later template fails before the call can return.
|
|
if registered_commands:
|
|
self._unregister_commands(registered_commands)
|
|
persisted_metadata = self.registry.get(manifest.id) or {}
|
|
persisted_skills = persisted_metadata.get(
|
|
"registered_skills", registered_skills
|
|
)
|
|
if persisted_skills:
|
|
self._unregister_skills(persisted_skills, dest_dir)
|
|
try:
|
|
if dest_dir.exists():
|
|
shutil.rmtree(dest_dir)
|
|
except OSError:
|
|
pass # best-effort cleanup; don't mask the original error
|
|
self.registry.remove(manifest.id)
|
|
raise
|
|
|
|
# Reconcile all affected commands from the full priority stack so that
|
|
# install order doesn't determine the winning command file.
|
|
cmd_names = [
|
|
t["name"]
|
|
for t in manifest.templates
|
|
if t.get("type") == "command"
|
|
]
|
|
if cmd_names:
|
|
try:
|
|
self._reconcile_composed_commands(cmd_names)
|
|
self._reconcile_skills(cmd_names)
|
|
except Exception as exc:
|
|
import warnings
|
|
warnings.warn(
|
|
f"Post-install reconciliation failed for {manifest.id}: {exc}. "
|
|
f"Agent command files may not reflect the current priority stack.",
|
|
stacklevel=2,
|
|
)
|
|
|
|
# Seed/re-seed memory/constitution.md from a preset-provided
|
|
# constitution-template. The constitution is the only template that is
|
|
# materialized to a live file rather than resolved on demand, so a
|
|
# preset that ships one (e.g. strategy: replace with a ratified
|
|
# constitution) must be propagated here. Guard against clobbering an
|
|
# already-authored constitution by only replacing a file whose recorded
|
|
# hash (or exact legacy core-template content) proves it was generated.
|
|
self._seed_constitution_from_preset(manifest, dest_dir)
|
|
|
|
return manifest
|
|
|
|
def _seed_constitution_from_preset(
|
|
self, manifest: PresetManifest, preset_dir: Path
|
|
) -> None:
|
|
"""Seed memory/constitution.md from a preset constitution-template.
|
|
|
|
Only runs when the preset declares a ``type: template`` entry named
|
|
``constitution-template`` or provides one at a convention path, and the
|
|
live memory file is either missing or is an unchanged generated file.
|
|
Authored constitutions are never overwritten.
|
|
"""
|
|
provides_constitution = any(
|
|
t.get("type") == "template" and t.get("name") == "constitution-template"
|
|
for t in manifest.templates
|
|
) or any(
|
|
(preset_dir / relative_path).is_file()
|
|
for relative_path in (
|
|
"templates/constitution-template.md",
|
|
"constitution-template.md",
|
|
)
|
|
)
|
|
if not provides_constitution:
|
|
return
|
|
|
|
self.reconcile_constitution(
|
|
f"Failed to seed constitution from preset {manifest.id}",
|
|
create_if_missing=True,
|
|
)
|
|
|
|
def reconcile_constitution(
|
|
self, failure_context: str, *, create_if_missing: bool = False
|
|
) -> None:
|
|
"""Reconcile generated constitution content without failing a persisted change."""
|
|
try:
|
|
self._reconcile_constitution(create_if_missing=create_if_missing)
|
|
except (OSError, UnicodeDecodeError, PresetValidationError, ValueError) as exc:
|
|
import warnings
|
|
|
|
warnings.warn(
|
|
f"{failure_context}: {exc}.",
|
|
stacklevel=2,
|
|
)
|
|
|
|
def _reconcile_constitution(self, *, create_if_missing: bool = False) -> None:
|
|
"""Materialize the winning constitution layer when the live file is generated."""
|
|
memory_constitution = (
|
|
self.project_root / ".specify" / "memory" / "constitution.md"
|
|
)
|
|
if not memory_constitution.exists() and not create_if_missing:
|
|
return
|
|
resolver = PresetResolver(self.project_root)
|
|
if memory_constitution.exists() and not _constitution_is_generated(
|
|
self.project_root, memory_constitution, resolver
|
|
):
|
|
return
|
|
_materialize_constitution_template(self.project_root, memory_constitution)
|
|
|
|
def install_from_zip(
|
|
self,
|
|
zip_path: Path,
|
|
speckit_version: str,
|
|
priority: int = 10,
|
|
) -> PresetManifest:
|
|
"""Install preset from ZIP file.
|
|
|
|
Args:
|
|
zip_path: Path to preset ZIP file
|
|
speckit_version: Current spec-kit version
|
|
priority: Resolution priority (lower = higher precedence, default 10)
|
|
|
|
Returns:
|
|
Installed preset manifest
|
|
|
|
Raises:
|
|
PresetValidationError: If manifest is invalid or priority is invalid
|
|
PresetCompatibilityError: If pack is incompatible
|
|
"""
|
|
# Validate priority early
|
|
if priority < 1:
|
|
raise PresetValidationError("Priority must be a positive integer (1 or higher)")
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
temp_path = Path(tmpdir)
|
|
|
|
with zipfile.ZipFile(zip_path, 'r') as zf:
|
|
temp_path_resolved = temp_path.resolve()
|
|
for member in zf.namelist():
|
|
member_path = (temp_path / member).resolve()
|
|
try:
|
|
member_path.relative_to(temp_path_resolved)
|
|
except ValueError:
|
|
raise PresetValidationError(
|
|
f"Unsafe path in ZIP archive: {member} "
|
|
"(potential path traversal)"
|
|
)
|
|
zf.extractall(temp_path)
|
|
|
|
pack_dir = temp_path
|
|
manifest_path = pack_dir / "preset.yml"
|
|
|
|
if not manifest_path.exists():
|
|
subdirs = [d for d in temp_path.iterdir() if d.is_dir()]
|
|
if len(subdirs) == 1:
|
|
pack_dir = subdirs[0]
|
|
manifest_path = pack_dir / "preset.yml"
|
|
|
|
if not manifest_path.exists():
|
|
raise PresetValidationError(
|
|
"No preset.yml found in ZIP file"
|
|
)
|
|
|
|
return self.install_from_directory(pack_dir, speckit_version, priority)
|
|
|
|
def remove(self, pack_id: str) -> bool:
|
|
"""Remove an installed preset.
|
|
|
|
Args:
|
|
pack_id: Preset ID
|
|
|
|
Returns:
|
|
True if pack was removed
|
|
"""
|
|
if not self.registry.is_installed(pack_id):
|
|
return False
|
|
|
|
metadata = self.registry.get(pack_id)
|
|
# Restore original skills when preset is removed
|
|
registered_skills = metadata.get("registered_skills", []) if metadata else []
|
|
if isinstance(registered_skills, list) and registered_skills:
|
|
# Legacy flat-list registries predate per-agent provenance
|
|
# tracking. Migration to the per-agent dict form previously
|
|
# only happened during a rescaffold (register_enabled_presets_
|
|
# for_agent); if the *first* post-upgrade operation is instead
|
|
# `preset remove` (no intervening use/upgrade), the legacy
|
|
# branch of _unregister_skills restores only the currently
|
|
# active agent's directory, leaving this preset's overrides in
|
|
# every previously active agent's directory orphaned. Infer
|
|
# real per-agent ownership from the on-disk preset marker now,
|
|
# while pack_id is still known, and hand the resulting mapping
|
|
# through the same dict-based cleanup path already used for
|
|
# non-legacy registries (#2948).
|
|
from .. import load_init_options
|
|
|
|
init_opts = load_init_options(self.project_root)
|
|
fallback_agent = init_opts.get("ai") if isinstance(init_opts, dict) else None
|
|
if not isinstance(fallback_agent, str):
|
|
fallback_agent = ""
|
|
registered_skills = self._infer_legacy_skill_provenance(
|
|
[name for name in registered_skills if isinstance(name, str)],
|
|
pack_id,
|
|
fallback_agent=fallback_agent,
|
|
)
|
|
registered_commands = metadata.get("registered_commands", {}) if metadata else {}
|
|
pack_dir = self.presets_dir / pack_id
|
|
|
|
# Record which historical agents this preset's registered_commands
|
|
# actually targeted, *before* any filtering below, so post-removal
|
|
# reconciliation can restore a surviving lower-priority preset's
|
|
# override into every one of those directories too — not only the
|
|
# currently active agent's. Without this, removing a preset that
|
|
# was rendered under a previously-active (now inactive) agent
|
|
# deletes that agent's command file via _unregister_commands below,
|
|
# but active-only reconciliation would only recreate the surviving
|
|
# winner for the current agent, leaving the inactive integration
|
|
# with a missing/stale file (#2948).
|
|
try:
|
|
from ..agents import CommandRegistrar as _CommandRegistrarForScope
|
|
except ImportError:
|
|
_CommandRegistrarForScope = None
|
|
affected_command_agents = {
|
|
agent_name
|
|
for agent_name in registered_commands
|
|
if _CommandRegistrarForScope is None
|
|
or _CommandRegistrarForScope.AGENT_CONFIGS.get(agent_name, {}).get("extension") != "/SKILL.md"
|
|
}
|
|
|
|
# Collect ALL command names before filtering for reconciliation,
|
|
# so commands registered only for skill-based agents are also
|
|
# reconciled. Every command-type template's primary name is added
|
|
# unconditionally (not just aliases) since ai_skills-mode presets
|
|
# never populate registered_commands for command-backed
|
|
# integrations (see _register_commands's ai_skills guard) — without
|
|
# this, removing a skills-mode preset that overrides a command no
|
|
# other preset registered "the normal way" would skip reconciliation
|
|
# entirely and _unregister_skills would restore core/extension
|
|
# content instead of a surviving lower-priority preset's override.
|
|
removed_cmd_names = set()
|
|
removed_constitution = any(
|
|
path.exists()
|
|
for path in (
|
|
pack_dir / "templates" / "constitution-template.md",
|
|
pack_dir / "constitution-template.md",
|
|
)
|
|
)
|
|
if metadata and isinstance(metadata.get("version"), str):
|
|
memory_constitution = (
|
|
self.project_root / ".specify" / "memory" / "constitution.md"
|
|
)
|
|
removed_constitution = removed_constitution or (
|
|
_constitution_provenance_matches_preset(
|
|
self.project_root,
|
|
memory_constitution,
|
|
pack_id,
|
|
metadata["version"],
|
|
)
|
|
)
|
|
for cmd_names in registered_commands.values():
|
|
removed_cmd_names.update(cmd_names)
|
|
manifest_path = pack_dir / "preset.yml"
|
|
if manifest_path.exists():
|
|
try:
|
|
manifest = PresetManifest(manifest_path)
|
|
for tmpl in manifest.templates:
|
|
if (
|
|
tmpl.get("type") == "template"
|
|
and tmpl.get("name") == "constitution-template"
|
|
):
|
|
removed_constitution = True
|
|
if tmpl.get("type") == "command":
|
|
name = tmpl.get("name")
|
|
if isinstance(name, str):
|
|
removed_cmd_names.add(name)
|
|
for alias in tmpl.get("aliases", []):
|
|
if isinstance(alias, str):
|
|
removed_cmd_names.add(alias)
|
|
except PresetValidationError:
|
|
# Invalid manifest — skip alias extraction; primary command
|
|
# names from registered_commands are still unregistered.
|
|
pass
|
|
|
|
affected_skill_dirs: Dict[
|
|
Path, tuple[Optional[str], List[str]]
|
|
] = {}
|
|
if registered_skills:
|
|
restorable_skills = registered_skills
|
|
# A skill tracked for a command-backed agent whose ai_skills is
|
|
# now off is a leftover from a partially failed skills→command
|
|
# toggle. Restoring it via _unregister_skills (and letting
|
|
# _reconcile_skills reapply a surviving lower preset through
|
|
# extra_skills_dirs) would hand the active command-mode agent a
|
|
# skill artifact it must not have — its current representation
|
|
# is the command file handled via registered_commands above.
|
|
# Delete the preset-owned skill instead and keep its directory
|
|
# out of restoration/reconciliation entirely (#2948). Inactive
|
|
# agents' entries still restore as before.
|
|
# The legacy branch above locally imports load_init_options,
|
|
# shadowing the module-level name for this whole function.
|
|
from .._init_options import load_init_options as _load_init_options
|
|
|
|
resolved_active = resolve_active_agent_for_registration(
|
|
self.project_root
|
|
)
|
|
if (
|
|
isinstance(registered_skills, dict)
|
|
and isinstance(resolved_active, str)
|
|
and resolved_active in registered_skills
|
|
and _CommandRegistrarForScope is not None
|
|
and _CommandRegistrarForScope.AGENT_CONFIGS.get(
|
|
resolved_active, {}
|
|
).get("extension") != "/SKILL.md"
|
|
and not is_ai_skills_enabled(
|
|
_load_init_options(self.project_root)
|
|
)
|
|
):
|
|
raw_names = registered_skills.get(resolved_active)
|
|
stale_names = [
|
|
name
|
|
for name in (
|
|
raw_names if isinstance(raw_names, list) else []
|
|
)
|
|
if isinstance(name, str)
|
|
]
|
|
restorable_skills = {
|
|
agent_name: names
|
|
for agent_name, names in registered_skills.items()
|
|
if agent_name != resolved_active
|
|
}
|
|
if stale_names:
|
|
self._delete_agent_preset_skills(
|
|
resolved_active, stale_names, pack_id
|
|
)
|
|
override_sources = {
|
|
skill_name: f"override:{command_name}"
|
|
for command_name in removed_cmd_names
|
|
for skill_name in self._skill_names_for_command(command_name)
|
|
}
|
|
affected_skill_dirs = self._unregister_skills(
|
|
restorable_skills,
|
|
pack_dir,
|
|
additional_owned_sources=override_sources,
|
|
)
|
|
try:
|
|
from ..agents import CommandRegistrar
|
|
except ImportError:
|
|
CommandRegistrar = None
|
|
if CommandRegistrar is not None:
|
|
skill_coverage = (
|
|
registered_skills
|
|
if isinstance(registered_skills, dict)
|
|
else {}
|
|
)
|
|
commands_to_unregister: Dict[str, List[str]] = {}
|
|
for agent_name, cmd_names in registered_commands.items():
|
|
is_native_skill_agent = (
|
|
CommandRegistrar.AGENT_CONFIGS.get(
|
|
agent_name, {}
|
|
).get("extension")
|
|
== "/SKILL.md"
|
|
)
|
|
if not is_native_skill_agent:
|
|
commands_to_unregister[agent_name] = cmd_names
|
|
continue
|
|
|
|
raw_skill_names = skill_coverage.get(agent_name, [])
|
|
covered_skill_names = {
|
|
name
|
|
for name in (
|
|
raw_skill_names
|
|
if isinstance(raw_skill_names, list)
|
|
else []
|
|
)
|
|
if isinstance(name, str)
|
|
}
|
|
uncovered_commands = [
|
|
cmd_name
|
|
for cmd_name in cmd_names
|
|
if not isinstance(cmd_name, str)
|
|
or covered_skill_names.isdisjoint(
|
|
self._skill_names_for_command(cmd_name)
|
|
)
|
|
]
|
|
if uncovered_commands:
|
|
commands_to_unregister[agent_name] = (
|
|
uncovered_commands
|
|
)
|
|
registered_commands = commands_to_unregister
|
|
|
|
# Unregister non-skill command files from AI agents.
|
|
if registered_commands:
|
|
self._unregister_commands(registered_commands)
|
|
|
|
if pack_dir.exists():
|
|
shutil.rmtree(pack_dir)
|
|
|
|
self.registry.remove(pack_id)
|
|
|
|
# Reconcile: if other presets still provide these commands,
|
|
# re-resolve from the remaining stack so the next layer takes effect.
|
|
if removed_cmd_names:
|
|
try:
|
|
self._reconcile_composed_commands(
|
|
list(removed_cmd_names), extra_agents=affected_command_agents
|
|
)
|
|
self._reconcile_skills(
|
|
list(removed_cmd_names), extra_skills_dirs=affected_skill_dirs
|
|
)
|
|
except Exception as exc:
|
|
import warnings
|
|
warnings.warn(
|
|
f"Post-removal reconciliation failed for {pack_id}: {exc}. "
|
|
f"Agent command files may be stale; reinstall affected presets "
|
|
f"or run 'specify preset add' to refresh.",
|
|
stacklevel=2,
|
|
)
|
|
|
|
if removed_constitution:
|
|
try:
|
|
self._reconcile_constitution()
|
|
except (OSError, UnicodeDecodeError, PresetValidationError, ValueError) as exc:
|
|
import warnings
|
|
|
|
warnings.warn(
|
|
f"Post-removal constitution reconciliation failed for {pack_id}: "
|
|
f"{exc}. The live constitution may be stale.",
|
|
stacklevel=2,
|
|
)
|
|
|
|
return True
|
|
|
|
def list_installed(self) -> List[Dict[str, Any]]:
|
|
"""List all installed presets with metadata.
|
|
|
|
Returns:
|
|
List of preset metadata dictionaries
|
|
"""
|
|
result = []
|
|
|
|
for pack_id, metadata in self.registry.list().items():
|
|
# Ensure metadata is a dictionary to avoid AttributeError when using .get()
|
|
if not isinstance(metadata, dict):
|
|
metadata = {}
|
|
pack_dir = self.presets_dir / pack_id
|
|
manifest_path = pack_dir / "preset.yml"
|
|
|
|
try:
|
|
manifest = PresetManifest(manifest_path)
|
|
result.append({
|
|
"id": pack_id,
|
|
"name": manifest.name,
|
|
"version": metadata.get("version", manifest.version),
|
|
"description": manifest.description,
|
|
"enabled": metadata.get("enabled", True),
|
|
"installed_at": metadata.get("installed_at"),
|
|
"template_count": len(manifest.templates),
|
|
"tags": manifest.tags,
|
|
"priority": normalize_priority(metadata.get("priority")),
|
|
})
|
|
except PresetValidationError:
|
|
result.append({
|
|
"id": pack_id,
|
|
"name": pack_id,
|
|
"version": metadata.get("version", "unknown"),
|
|
"description": "⚠️ Corrupted preset",
|
|
"enabled": False,
|
|
"installed_at": metadata.get("installed_at"),
|
|
"template_count": 0,
|
|
"tags": [],
|
|
"priority": normalize_priority(metadata.get("priority")),
|
|
})
|
|
|
|
return result
|
|
|
|
def get_pack(self, pack_id: str) -> Optional[PresetManifest]:
|
|
"""Get manifest for an installed preset.
|
|
|
|
Args:
|
|
pack_id: Preset ID
|
|
|
|
Returns:
|
|
Preset manifest or None if not installed
|
|
"""
|
|
if not self.registry.is_installed(pack_id):
|
|
return None
|
|
|
|
pack_dir = self.presets_dir / pack_id
|
|
manifest_path = pack_dir / "preset.yml"
|
|
|
|
try:
|
|
return PresetManifest(manifest_path)
|
|
except PresetValidationError:
|
|
return None
|
|
|
|
|
|
class PresetCatalog:
|
|
"""Manages preset catalog fetching, caching, and searching.
|
|
|
|
Supports multi-catalog stacks with priority-based resolution,
|
|
mirroring the extension catalog system.
|
|
"""
|
|
|
|
DEFAULT_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.json"
|
|
COMMUNITY_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json"
|
|
CACHE_DURATION = 3600 # 1 hour in seconds
|
|
|
|
def __init__(self, project_root: Path):
|
|
"""Initialize preset catalog manager.
|
|
|
|
Args:
|
|
project_root: Root directory of the spec-kit project
|
|
"""
|
|
self.project_root = project_root
|
|
self.presets_dir = project_root / ".specify" / "presets"
|
|
self.cache_dir = self.presets_dir / ".cache"
|
|
self.cache_file = self.cache_dir / "catalog.json"
|
|
self.cache_metadata_file = self.cache_dir / "catalog-metadata.json"
|
|
|
|
def _validate_catalog_url(self, url: str) -> None:
|
|
"""Validate that a catalog URL uses HTTPS (localhost HTTP allowed).
|
|
|
|
Args:
|
|
url: URL to validate
|
|
|
|
Raises:
|
|
PresetValidationError: If URL is invalid or uses non-HTTPS scheme
|
|
"""
|
|
from urllib.parse import urlparse
|
|
|
|
try:
|
|
parsed = urlparse(url)
|
|
hostname = parsed.hostname
|
|
except ValueError:
|
|
raise PresetValidationError(f"Catalog URL is malformed: {url}") from None
|
|
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
|
|
if parsed.scheme != "https" and not (
|
|
parsed.scheme == "http" and is_localhost
|
|
):
|
|
raise PresetValidationError(
|
|
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
|
|
"HTTP is only allowed for localhost."
|
|
)
|
|
# Check hostname, not netloc: netloc is truthy for host-less URLs like
|
|
# "https://:8080" or "https://user@", so the host guarantee this error
|
|
# promises would not actually hold. hostname is None in those cases (#3209).
|
|
if not hostname:
|
|
raise PresetValidationError(
|
|
"Catalog URL must be a valid URL with a host."
|
|
)
|
|
|
|
def _make_request(self, url: str):
|
|
"""Build a urllib Request, adding auth headers when a provider matches.
|
|
|
|
Delegates to :func:`specify_cli.authentication.http.build_request`.
|
|
"""
|
|
from specify_cli.authentication.http import build_request
|
|
return build_request(url)
|
|
|
|
def _open_url(
|
|
self,
|
|
url: str,
|
|
timeout: int = 10,
|
|
extra_headers: Optional[Dict[str, str]] = None,
|
|
redirect_validator=None,
|
|
):
|
|
"""Open a URL with provider-based auth, trying each configured provider.
|
|
|
|
Delegates to :func:`specify_cli.authentication.http.open_url`.
|
|
*redirect_validator*, when provided, is invoked as ``(old_url, new_url)``
|
|
before EACH redirect hop, so an HTTPS host guarantee can be enforced on
|
|
every intermediate URL, not just the terminal one.
|
|
"""
|
|
from specify_cli.authentication.http import open_url
|
|
return open_url(
|
|
url,
|
|
timeout,
|
|
extra_headers=extra_headers,
|
|
redirect_validator=redirect_validator,
|
|
)
|
|
|
|
def _resolve_github_release_asset_api_url(
|
|
self,
|
|
download_url: str,
|
|
timeout: int = 60,
|
|
) -> Optional[str]:
|
|
"""Resolve a GitHub release asset URL to its REST API asset URL.
|
|
|
|
Passes the ``github`` provider hosts from ``auth.json`` so GitHub
|
|
Enterprise Server release assets resolve via ``/api/v3``.
|
|
"""
|
|
from specify_cli._github_http import resolve_github_release_asset_api_url
|
|
from specify_cli.authentication.http import github_provider_hosts
|
|
|
|
return resolve_github_release_asset_api_url(
|
|
download_url,
|
|
self._open_url,
|
|
timeout=timeout,
|
|
github_hosts=github_provider_hosts(),
|
|
)
|
|
|
|
def _validate_catalog_payload(self, catalog_data: Any, url: str) -> None:
|
|
"""Validate a parsed preset-catalog payload's shape.
|
|
|
|
Applied to both network-fetched and cache-loaded payloads so a
|
|
once-poisoned cache (older spec-kit version, manual edit, upstream
|
|
served a bad payload before the network-side guards were added)
|
|
cannot re-crash ``_get_merged_packs`` on subsequent calls.
|
|
|
|
Checking only key presence would let a payload like
|
|
``{"presets": []}`` or ``{"presets": null}`` slip through here and
|
|
then crash with ``AttributeError: 'list' object has no attribute
|
|
'items'`` deep inside ``_get_merged_packs``. The sibling
|
|
integration catalog reader already guards both the root object and
|
|
the nested mapping (see ``integrations/catalog.py``); the preset
|
|
catalog must stay consistent so a malformed payload surfaces as
|
|
the user-facing ``Invalid preset catalog format`` error instead of
|
|
a raw Python traceback.
|
|
|
|
Args:
|
|
catalog_data: Parsed JSON payload from the catalog source.
|
|
url: Source URL — used in the error message so the user can
|
|
tell which catalog in a multi-catalog stack is malformed.
|
|
|
|
Raises:
|
|
PresetError: If the payload's shape is invalid.
|
|
"""
|
|
if not isinstance(catalog_data, dict):
|
|
raise PresetError(
|
|
f"Invalid preset catalog format from {url}: "
|
|
"expected a JSON object"
|
|
)
|
|
if (
|
|
"schema_version" not in catalog_data
|
|
or "presets" not in catalog_data
|
|
):
|
|
raise PresetError(f"Invalid preset catalog format from {url}")
|
|
if not isinstance(catalog_data.get("presets"), dict):
|
|
raise PresetError(
|
|
f"Invalid preset catalog format from {url}: "
|
|
"'presets' must be a JSON object"
|
|
)
|
|
|
|
def _load_catalog_config(self, config_path: Path) -> Optional[List[PresetCatalogEntry]]:
|
|
"""Load catalog stack configuration from a YAML file.
|
|
|
|
Args:
|
|
config_path: Path to preset-catalogs.yml
|
|
|
|
Returns:
|
|
Ordered list of PresetCatalogEntry objects, or None if file
|
|
doesn't exist or contains no valid catalog entries.
|
|
|
|
Raises:
|
|
PresetValidationError: If any catalog entry has an invalid URL,
|
|
the file cannot be parsed, or a priority value is invalid.
|
|
"""
|
|
if not config_path.exists():
|
|
return None
|
|
try:
|
|
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
|
except (yaml.YAMLError, OSError, UnicodeError) as e:
|
|
raise PresetValidationError(
|
|
f"Failed to read catalog config {config_path}: {e}"
|
|
)
|
|
if not isinstance(data, dict):
|
|
raise PresetValidationError(
|
|
f"Invalid catalog config {config_path}: expected a mapping at root, got {type(data).__name__}"
|
|
)
|
|
catalogs_data = data.get("catalogs", [])
|
|
if not catalogs_data:
|
|
return None
|
|
if not isinstance(catalogs_data, list):
|
|
raise PresetValidationError(
|
|
f"Invalid catalog config: 'catalogs' must be a list, got {type(catalogs_data).__name__}"
|
|
)
|
|
entries: List[PresetCatalogEntry] = []
|
|
for idx, item in enumerate(catalogs_data):
|
|
if not isinstance(item, dict):
|
|
raise PresetValidationError(
|
|
f"Invalid catalog entry at index {idx}: expected a mapping, got {type(item).__name__}"
|
|
)
|
|
url = str(item.get("url", "")).strip()
|
|
if not url:
|
|
continue
|
|
self._validate_catalog_url(url)
|
|
raw_priority = item.get("priority", idx + 1)
|
|
# Reject bools explicitly: ``bool`` is a subclass of ``int`` so
|
|
# ``int(True)`` silently returns 1, which would let a YAML
|
|
# ``priority: true`` slip through as a valid priority of 1. The
|
|
# sibling integration-catalog reader in ``catalogs.py`` already
|
|
# guards this; mirror the check here so the three catalog
|
|
# validators stay consistent.
|
|
if isinstance(raw_priority, bool):
|
|
raise PresetValidationError(
|
|
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
|
|
f"expected integer, got {raw_priority!r}"
|
|
)
|
|
try:
|
|
priority = int(raw_priority)
|
|
except (TypeError, ValueError, OverflowError):
|
|
# OverflowError: int(float("inf")) — a YAML ``priority: .inf``
|
|
# would otherwise escape as an uncaught traceback instead of the
|
|
# clean validation error (mirrors catalogs.py).
|
|
raise PresetValidationError(
|
|
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
|
|
f"expected integer, got {raw_priority!r}"
|
|
)
|
|
raw_install = item.get("install_allowed", False)
|
|
if isinstance(raw_install, str):
|
|
install_allowed = raw_install.strip().lower() in ("true", "yes", "1")
|
|
else:
|
|
install_allowed = bool(raw_install)
|
|
entries.append(PresetCatalogEntry(
|
|
url=url,
|
|
name=str(item.get("name", f"catalog-{idx + 1}")),
|
|
priority=priority,
|
|
install_allowed=install_allowed,
|
|
description=str(item.get("description", "")),
|
|
))
|
|
entries.sort(key=lambda e: e.priority)
|
|
return entries if entries else None
|
|
|
|
def get_active_catalogs(self) -> List[PresetCatalogEntry]:
|
|
"""Get the ordered list of active preset catalogs.
|
|
|
|
Resolution order:
|
|
1. SPECKIT_PRESET_CATALOG_URL env var — single catalog replacing all defaults
|
|
2. Project-level .specify/preset-catalogs.yml
|
|
3. User-level ~/.specify/preset-catalogs.yml
|
|
4. Built-in default stack (default + community)
|
|
|
|
Returns:
|
|
List of PresetCatalogEntry objects sorted by priority (ascending)
|
|
|
|
Raises:
|
|
PresetValidationError: If a catalog URL is invalid
|
|
"""
|
|
import sys
|
|
|
|
# 1. SPECKIT_PRESET_CATALOG_URL env var replaces all defaults
|
|
if env_value := os.environ.get("SPECKIT_PRESET_CATALOG_URL"):
|
|
catalog_url = env_value.strip()
|
|
self._validate_catalog_url(catalog_url)
|
|
if catalog_url != self.DEFAULT_CATALOG_URL:
|
|
if not getattr(self, "_non_default_catalog_warning_shown", False):
|
|
print(
|
|
"Warning: Using non-default preset catalog. "
|
|
"Only use catalogs from sources you trust.",
|
|
file=sys.stderr,
|
|
)
|
|
self._non_default_catalog_warning_shown = True
|
|
return [PresetCatalogEntry(url=catalog_url, name="custom", priority=1, install_allowed=True, description="Custom catalog via SPECKIT_PRESET_CATALOG_URL")]
|
|
|
|
# 2. Project-level config overrides all defaults
|
|
project_config_path = self.project_root / ".specify" / "preset-catalogs.yml"
|
|
catalogs = self._load_catalog_config(project_config_path)
|
|
if catalogs is not None:
|
|
return catalogs
|
|
|
|
# 3. User-level config
|
|
user_config_path = Path.home() / ".specify" / "preset-catalogs.yml"
|
|
catalogs = self._load_catalog_config(user_config_path)
|
|
if catalogs is not None:
|
|
return catalogs
|
|
|
|
# 4. Built-in default stack
|
|
return [
|
|
PresetCatalogEntry(url=self.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True, description="Built-in catalog of installable presets"),
|
|
PresetCatalogEntry(url=self.COMMUNITY_CATALOG_URL, name="community", priority=2, install_allowed=False, description="Community-contributed presets (discovery only)"),
|
|
]
|
|
|
|
def get_catalog_url(self) -> str:
|
|
"""Get the primary catalog URL.
|
|
|
|
Returns the URL of the highest-priority catalog. Kept for backward
|
|
compatibility. Use get_active_catalogs() for full multi-catalog support.
|
|
|
|
Returns:
|
|
URL of the primary catalog
|
|
"""
|
|
active = self.get_active_catalogs()
|
|
return active[0].url if active else self.DEFAULT_CATALOG_URL
|
|
|
|
def _get_cache_paths(self, url: str):
|
|
"""Get cache file paths for a given catalog URL.
|
|
|
|
For the DEFAULT_CATALOG_URL, uses legacy cache files for backward
|
|
compatibility. For all other URLs, uses URL-hash-based cache files.
|
|
|
|
Returns:
|
|
Tuple of (cache_file_path, cache_metadata_path)
|
|
"""
|
|
if url == self.DEFAULT_CATALOG_URL:
|
|
return self.cache_file, self.cache_metadata_file
|
|
url_hash = hashlib.sha256(url.encode()).hexdigest()[:16]
|
|
return (
|
|
self.cache_dir / f"catalog-{url_hash}.json",
|
|
self.cache_dir / f"catalog-{url_hash}-metadata.json",
|
|
)
|
|
|
|
def _is_url_cache_valid(self, url: str) -> bool:
|
|
"""Check if cached catalog for a specific URL is still valid."""
|
|
cache_file, metadata_file = self._get_cache_paths(url)
|
|
if not cache_file.exists() or not metadata_file.exists():
|
|
return False
|
|
try:
|
|
metadata = json.loads(metadata_file.read_text(encoding="utf-8"))
|
|
cached_at = datetime.fromisoformat(metadata.get("cached_at", ""))
|
|
if cached_at.tzinfo is None:
|
|
cached_at = cached_at.replace(tzinfo=timezone.utc)
|
|
age_seconds = (
|
|
datetime.now(timezone.utc) - cached_at
|
|
).total_seconds()
|
|
return age_seconds < self.CACHE_DURATION
|
|
except (
|
|
json.JSONDecodeError,
|
|
OSError,
|
|
UnicodeError,
|
|
ValueError,
|
|
KeyError,
|
|
TypeError,
|
|
AttributeError,
|
|
):
|
|
# Cache validity is best-effort: invalid/missing fields, an
|
|
# unreadable metadata file (permissions / disk), a wrongly
|
|
# encoded one (written by a tool using the system locale
|
|
# codec), or a metadata payload that parses to a non-mapping
|
|
# like ``[]`` or ``"oops"`` (so ``metadata.get(...)`` raises
|
|
# ``AttributeError``) all degrade to "cache invalid" so the
|
|
# caller falls through to a network refetch instead of
|
|
# crashing.
|
|
return False
|
|
|
|
def _fetch_single_catalog(self, entry: PresetCatalogEntry, force_refresh: bool = False) -> Dict[str, Any]:
|
|
"""Fetch a single catalog with per-URL caching.
|
|
|
|
Args:
|
|
entry: PresetCatalogEntry describing the catalog to fetch
|
|
force_refresh: If True, bypass cache
|
|
|
|
Returns:
|
|
Catalog data dictionary
|
|
|
|
Raises:
|
|
PresetError: If catalog cannot be fetched
|
|
"""
|
|
cache_file, metadata_file = self._get_cache_paths(entry.url)
|
|
|
|
# Use cache if valid. A previously-cached payload must clear the
|
|
# same shape checks as a freshly-fetched one — otherwise a once-
|
|
# poisoned cache would re-crash on every invocation despite the
|
|
# cache being "valid" by age. If validation fails on the cached
|
|
# read, fall through to the network fetch path so the cache gets
|
|
# refreshed.
|
|
if not force_refresh and self._is_url_cache_valid(entry.url):
|
|
try:
|
|
cached_data = json.loads(cache_file.read_text(encoding="utf-8"))
|
|
self._validate_catalog_payload(cached_data, entry.url)
|
|
return cached_data
|
|
except (json.JSONDecodeError, OSError, UnicodeError, PresetError):
|
|
# Cache is best-effort: a JSON-decode failure, an OS-level
|
|
# read failure (permissions / disk / handle limit), or a
|
|
# text-encoding failure on a cache file written by an
|
|
# older client all fall through to the network fetch path.
|
|
# Only the network failure is surfaced to the caller.
|
|
pass
|
|
|
|
try:
|
|
# Validate EVERY redirect hop (not just the terminal URL): an
|
|
# https -> http -> attacker-controlled-https chain would pass a
|
|
# final-URL-only check while the insecure intermediate hop lets a
|
|
# network attacker rewrite the next redirect. redirect_validator runs
|
|
# before each hop; the final geturl() check is retained as a
|
|
# belt-and-braces guard. Mirrors bundler/services/adapters.py.
|
|
def _validate_redirect(_old_url: str, new_url: str) -> None:
|
|
self._validate_catalog_url(new_url)
|
|
|
|
with self._open_url(
|
|
entry.url, timeout=10, redirect_validator=_validate_redirect
|
|
) as response:
|
|
final_url = response.geturl()
|
|
if final_url != entry.url:
|
|
self._validate_catalog_url(final_url)
|
|
catalog_data = json.loads(response.read())
|
|
|
|
self._validate_catalog_payload(catalog_data, entry.url)
|
|
|
|
# Both files are written explicitly as UTF-8 to match the
|
|
# ``read_text(encoding="utf-8")`` on the read side and the
|
|
# ``integrations/catalog.py`` precedent. Without this,
|
|
# platforms whose default encoding isn't UTF-8 would write
|
|
# locale-encoded bytes the read path can't decode, forcing an
|
|
# unnecessary refetch on every invocation. The write itself
|
|
# is best-effort like the read side: an unwritable cache dir
|
|
# (read-only checkout, permissions) must not be re-raised as
|
|
# a ``PresetError`` for a payload that was already fetched
|
|
# and validated.
|
|
try:
|
|
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
cache_file.write_text(
|
|
json.dumps(catalog_data, indent=2), encoding="utf-8"
|
|
)
|
|
metadata = {
|
|
"cached_at": datetime.now(timezone.utc).isoformat(),
|
|
"catalog_url": entry.url,
|
|
}
|
|
metadata_file.write_text(
|
|
json.dumps(metadata, indent=2), encoding="utf-8"
|
|
)
|
|
except OSError:
|
|
pass # Cache is best-effort; proceed with fetched data
|
|
|
|
return catalog_data
|
|
|
|
except (ImportError, Exception) as e:
|
|
if isinstance(e, PresetError):
|
|
raise
|
|
raise PresetError(
|
|
f"Failed to fetch preset catalog from {entry.url}: {e}"
|
|
)
|
|
|
|
def _get_merged_packs(self, force_refresh: bool = False) -> Dict[str, Dict[str, Any]]:
|
|
"""Fetch and merge presets from all active catalogs.
|
|
|
|
Higher-priority catalogs (lower priority number) win on ID conflicts.
|
|
|
|
Returns:
|
|
Merged dictionary of pack_id -> pack_data
|
|
"""
|
|
active_catalogs = self.get_active_catalogs()
|
|
merged: Dict[str, Dict[str, Any]] = {}
|
|
|
|
for entry in reversed(active_catalogs):
|
|
try:
|
|
data = self._fetch_single_catalog(entry, force_refresh)
|
|
for pack_id, pack_data in data.get("presets", {}).items():
|
|
# Per-entry guard: ``_fetch_single_catalog`` already
|
|
# validates that ``data["presets"]`` is a mapping, but it
|
|
# does not (and should not) validate every entry shape
|
|
# there — one malformed entry shouldn't poison an
|
|
# otherwise valid catalog. Skip non-mapping entries here
|
|
# so a payload like ``{"presets": {"foo": [], "bar":
|
|
# {...}}}`` still merges the valid entries without
|
|
# crashing on ``**pack_data``. Mirrors
|
|
# ``integrations/catalog.py:245``.
|
|
if not isinstance(pack_data, dict):
|
|
continue
|
|
pack_data_with_catalog = {**pack_data, "_catalog_name": entry.name, "_install_allowed": entry.install_allowed}
|
|
merged[pack_id] = pack_data_with_catalog
|
|
except PresetError:
|
|
continue
|
|
|
|
return merged
|
|
|
|
def is_cache_valid(self) -> bool:
|
|
"""Check if cached catalog is still valid.
|
|
|
|
Returns ``False`` for any read/decoding failure on the metadata
|
|
file (missing fields, malformed JSON, permissions / disk errors,
|
|
wrong text encoding) so callers fall through to a network refetch
|
|
instead of crashing. Treating cache validity as best-effort
|
|
matches the contract used by ``_is_url_cache_valid`` above.
|
|
|
|
Returns:
|
|
True if cache exists and is within cache duration
|
|
"""
|
|
if not self.cache_file.exists() or not self.cache_metadata_file.exists():
|
|
return False
|
|
|
|
try:
|
|
metadata = json.loads(
|
|
self.cache_metadata_file.read_text(encoding="utf-8")
|
|
)
|
|
cached_at = datetime.fromisoformat(metadata.get("cached_at", ""))
|
|
if cached_at.tzinfo is None:
|
|
cached_at = cached_at.replace(tzinfo=timezone.utc)
|
|
age_seconds = (
|
|
datetime.now(timezone.utc) - cached_at
|
|
).total_seconds()
|
|
return age_seconds < self.CACHE_DURATION
|
|
except (
|
|
json.JSONDecodeError,
|
|
OSError,
|
|
UnicodeError,
|
|
ValueError,
|
|
KeyError,
|
|
TypeError,
|
|
AttributeError,
|
|
):
|
|
# ``AttributeError`` covers the case where the metadata file
|
|
# parses to a non-mapping (``[]``, ``"oops"``, ``42``) so
|
|
# ``metadata.get(...)`` would otherwise crash. All decode /
|
|
# shape failures degrade to "cache invalid" so the caller
|
|
# falls through to a network refetch.
|
|
return False
|
|
|
|
def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]:
|
|
"""Fetch preset catalog from URL or cache.
|
|
|
|
Args:
|
|
force_refresh: If True, bypass cache and fetch from network
|
|
|
|
Returns:
|
|
Catalog data dictionary
|
|
|
|
Raises:
|
|
PresetError: If catalog cannot be fetched
|
|
"""
|
|
catalog_url = self.get_catalog_url()
|
|
|
|
# Match the ``_fetch_single_catalog`` cache contract: a poisoned
|
|
# or unreadable cache silently falls through to a network refetch
|
|
# rather than crashing the caller. ``_validate_catalog_payload``
|
|
# is reused here so a cache written by an older client
|
|
# (pre-validation) is rejected and refreshed instead of returning
|
|
# the stale malformed payload.
|
|
if not force_refresh and self.is_cache_valid():
|
|
try:
|
|
metadata = json.loads(
|
|
self.cache_metadata_file.read_text(encoding="utf-8")
|
|
)
|
|
if metadata.get("catalog_url") == catalog_url:
|
|
cached_data = json.loads(
|
|
self.cache_file.read_text(encoding="utf-8")
|
|
)
|
|
self._validate_catalog_payload(cached_data, catalog_url)
|
|
return cached_data
|
|
except (json.JSONDecodeError, OSError, UnicodeError, PresetError):
|
|
# Cache is corrupt, unreadable, or fails the shape check;
|
|
# fall through to network fetch.
|
|
pass
|
|
|
|
try:
|
|
# Same redirect hardening as _fetch_single_catalog: validate every
|
|
# redirect hop AND the final URL so this legacy single-catalog path
|
|
# is not vulnerable to an HTTPS->HTTP redirected payload either.
|
|
def _validate_redirect(_old_url: str, new_url: str) -> None:
|
|
self._validate_catalog_url(new_url)
|
|
|
|
with self._open_url(
|
|
catalog_url, timeout=10, redirect_validator=_validate_redirect
|
|
) as response:
|
|
final_url = response.geturl()
|
|
if final_url != catalog_url:
|
|
self._validate_catalog_url(final_url)
|
|
catalog_data = json.loads(response.read())
|
|
|
|
# Validate catalog structure. Reuses the same helper as
|
|
# ``_fetch_single_catalog`` so all three branches (root type,
|
|
# missing keys, nested-mapping type) stay consistent.
|
|
self._validate_catalog_payload(catalog_data, catalog_url)
|
|
|
|
# Save to cache. Explicit UTF-8 on both writes mirrors the
|
|
# ``read_text(encoding="utf-8")`` on the read side and the
|
|
# ``integrations/catalog.py`` precedent — otherwise platforms
|
|
# whose default encoding isn't UTF-8 would write
|
|
# locale-encoded bytes the read path can't decode, forcing an
|
|
# unnecessary refetch on every invocation. Like the read
|
|
# side, the write is best-effort: an unwritable cache dir
|
|
# must not be re-raised as a ``PresetError`` for a payload
|
|
# that was already fetched and validated.
|
|
try:
|
|
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
self.cache_file.write_text(
|
|
json.dumps(catalog_data, indent=2), encoding="utf-8"
|
|
)
|
|
|
|
metadata = {
|
|
"cached_at": datetime.now(timezone.utc).isoformat(),
|
|
"catalog_url": catalog_url,
|
|
}
|
|
self.cache_metadata_file.write_text(
|
|
json.dumps(metadata, indent=2), encoding="utf-8"
|
|
)
|
|
except OSError:
|
|
pass # Cache is best-effort; proceed with fetched data
|
|
|
|
return catalog_data
|
|
|
|
except (ImportError, Exception) as e:
|
|
if isinstance(e, PresetError):
|
|
raise
|
|
raise PresetError(
|
|
f"Failed to fetch preset catalog from {catalog_url}: {e}"
|
|
)
|
|
|
|
def search(
|
|
self,
|
|
query: Optional[str] = None,
|
|
tag: Optional[str] = None,
|
|
author: Optional[str] = None,
|
|
) -> List[Dict[str, Any]]:
|
|
"""Search catalog for presets.
|
|
|
|
Searches across all active catalogs (merged by priority) so that
|
|
community and custom catalogs are included in results.
|
|
|
|
Args:
|
|
query: Search query (searches name, description, tags)
|
|
tag: Filter by specific tag
|
|
author: Filter by author name
|
|
|
|
Returns:
|
|
List of matching preset metadata
|
|
"""
|
|
try:
|
|
packs = self._get_merged_packs()
|
|
except PresetError:
|
|
return []
|
|
|
|
results = []
|
|
|
|
for pack_id, pack_data in packs.items():
|
|
if author and pack_data.get("author", "").lower() != author.lower():
|
|
continue
|
|
|
|
if tag and tag.lower() not in [
|
|
t.lower() for t in pack_data.get("tags", [])
|
|
]:
|
|
continue
|
|
|
|
if query:
|
|
query_lower = query.lower()
|
|
searchable_text = " ".join(
|
|
[
|
|
pack_data.get("name", ""),
|
|
pack_data.get("description", ""),
|
|
pack_id,
|
|
]
|
|
+ pack_data.get("tags", [])
|
|
).lower()
|
|
|
|
if query_lower not in searchable_text:
|
|
continue
|
|
|
|
results.append({**pack_data, "id": pack_id})
|
|
|
|
return results
|
|
|
|
def get_pack_info(
|
|
self, pack_id: str
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""Get detailed information about a specific preset.
|
|
|
|
Searches across all active catalogs (merged by priority).
|
|
|
|
Args:
|
|
pack_id: ID of the preset
|
|
|
|
Returns:
|
|
Pack metadata or None if not found
|
|
"""
|
|
try:
|
|
packs = self._get_merged_packs()
|
|
except PresetError:
|
|
return None
|
|
|
|
if pack_id in packs:
|
|
return {**packs[pack_id], "id": pack_id}
|
|
return None
|
|
|
|
def download_pack(
|
|
self, pack_id: str, target_dir: Optional[Path] = None
|
|
) -> Path:
|
|
"""Download preset ZIP from catalog.
|
|
|
|
Args:
|
|
pack_id: ID of the preset to download
|
|
target_dir: Directory to save ZIP file (defaults to cache directory)
|
|
|
|
Returns:
|
|
Path to downloaded ZIP file
|
|
|
|
Raises:
|
|
PresetError: If pack not found or download fails
|
|
"""
|
|
import urllib.error
|
|
|
|
pack_info = self.get_pack_info(pack_id)
|
|
if not pack_info:
|
|
raise PresetError(
|
|
f"Preset '{pack_id}' not found in catalog"
|
|
)
|
|
|
|
# Bundled presets without a download URL must be installed locally
|
|
if pack_info.get("bundled") and not pack_info.get("download_url"):
|
|
from ..extensions import REINSTALL_COMMAND
|
|
raise PresetError(
|
|
f"Preset '{pack_id}' is bundled with spec-kit and has no download URL. "
|
|
f"It should be installed from the local package. "
|
|
f"Use 'specify preset add {pack_id}' to install from the bundled package, "
|
|
f"or reinstall spec-kit if the bundled files are missing: {REINSTALL_COMMAND}"
|
|
)
|
|
|
|
if not pack_info.get("_install_allowed", True):
|
|
catalog_name = pack_info.get("_catalog_name", "unknown")
|
|
raise PresetError(
|
|
f"Preset '{pack_id}' is from the '{catalog_name}' catalog which does not allow installation. "
|
|
f"Use --from with the preset's repository URL instead."
|
|
)
|
|
|
|
download_url = pack_info.get("download_url")
|
|
if not download_url:
|
|
raise PresetError(
|
|
f"Preset '{pack_id}' has no download URL"
|
|
)
|
|
|
|
from urllib.parse import urlparse
|
|
|
|
# A malformed authority (e.g. an unterminated IPv6 bracket
|
|
# "https://[::1") makes urlparse / hostname access raise ValueError.
|
|
# The download_url comes from catalog payload data, so surface a clean
|
|
# PresetError rather than leaking a raw ValueError past the command
|
|
# handler (which only catches PresetError). Mirrors catalogs (#3435)
|
|
# and workflows/catalog.py (#3484).
|
|
try:
|
|
parsed = urlparse(download_url)
|
|
hostname = parsed.hostname
|
|
except ValueError:
|
|
raise PresetError(
|
|
f"Preset download URL is malformed: {download_url}"
|
|
) from None
|
|
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
|
|
if parsed.scheme != "https" and not (
|
|
parsed.scheme == "http" and is_localhost
|
|
):
|
|
raise PresetError(
|
|
f"Preset download URL must use HTTPS: {download_url}"
|
|
)
|
|
|
|
if target_dir is None:
|
|
target_dir = self.cache_dir / "downloads"
|
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
version = pack_info.get("version", "unknown")
|
|
zip_filename = f"{pack_id}-{version}.zip"
|
|
zip_path = target_dir / zip_filename
|
|
|
|
extra_headers = None
|
|
resolved_download_url = self._resolve_github_release_asset_api_url(download_url)
|
|
if resolved_download_url:
|
|
download_url = resolved_download_url
|
|
extra_headers = {"Accept": "application/octet-stream"}
|
|
|
|
try:
|
|
with self._open_url(download_url, timeout=60, extra_headers=extra_headers) as response:
|
|
zip_data = response.read()
|
|
|
|
verify_archive_sha256(
|
|
zip_data, pack_info.get("sha256"), pack_id, PresetError
|
|
)
|
|
|
|
zip_path.write_bytes(zip_data)
|
|
return zip_path
|
|
|
|
except urllib.error.URLError as e:
|
|
raise PresetError(
|
|
f"Failed to download preset from {download_url}: {e}"
|
|
)
|
|
except IOError as e:
|
|
raise PresetError(f"Failed to save preset ZIP: {e}")
|
|
|
|
def clear_cache(self):
|
|
"""Clear all catalog cache files, including per-URL hashed caches."""
|
|
if self.cache_dir.exists():
|
|
for f in self.cache_dir.iterdir():
|
|
if f.is_file() and f.name.startswith("catalog"):
|
|
f.unlink(missing_ok=True)
|
|
|
|
|
|
class PresetResolver:
|
|
"""Resolves template names to file paths using a priority stack.
|
|
|
|
Resolution order:
|
|
1. .specify/templates/overrides/ - Project-local overrides
|
|
2. .specify/presets/<preset-id>/ - Installed presets
|
|
3. .specify/extensions/<ext-id>/templates/ - Extension-provided templates
|
|
4. .specify/templates/ - Core templates (shipped with Spec Kit)
|
|
"""
|
|
|
|
def __init__(self, project_root: Path):
|
|
"""Initialize preset resolver.
|
|
|
|
Args:
|
|
project_root: Path to project root directory
|
|
"""
|
|
self.project_root = project_root
|
|
self.templates_dir = project_root / ".specify" / "templates"
|
|
self.presets_dir = project_root / ".specify" / "presets"
|
|
self.overrides_dir = self.templates_dir / "overrides"
|
|
self.extensions_dir = project_root / ".specify" / "extensions"
|
|
self._manifest_cache: Dict[str, Optional["PresetManifest"]] = {}
|
|
|
|
def _get_manifest(self, pack_dir: Path) -> Optional["PresetManifest"]:
|
|
"""Get a cached preset manifest, parsing it on first access."""
|
|
key = str(pack_dir)
|
|
if key not in self._manifest_cache:
|
|
manifest_path = pack_dir / "preset.yml"
|
|
if manifest_path.exists():
|
|
try:
|
|
self._manifest_cache[key] = PresetManifest(manifest_path)
|
|
except PresetValidationError:
|
|
self._manifest_cache[key] = None
|
|
else:
|
|
self._manifest_cache[key] = None
|
|
return self._manifest_cache[key]
|
|
|
|
def _manifest_declared_template(
|
|
self, pack_dir: Path, template_name: str, template_type: str
|
|
) -> tuple[dict | None, Path | None]:
|
|
"""Resolve a preset's manifest-declared template entry and usable file.
|
|
|
|
Returns ``(entry, candidate)``:
|
|
- ``entry`` is the matching ``provides.templates`` mapping, or ``None`` if
|
|
the manifest is absent or does not list this ``(name, type)``.
|
|
- ``candidate`` is the declared ``file:`` resolved under ``pack_dir`` IFF
|
|
it is a regular file (``is_file()``); ``None`` otherwise — a missing,
|
|
empty, or non-file (e.g. directory) declaration yields ``(entry, None)``.
|
|
|
|
The manifest is authoritative: when it declares a template (``entry`` is
|
|
not ``None``) but the file is unusable (``candidate`` is ``None``),
|
|
callers must NOT fall back to the convention lookup — that would mask a
|
|
typo or pick up an undeclared file. Shared by ``resolve()`` and
|
|
``collect_all_layers()`` so their manifest-first resolution cannot
|
|
silently diverge again (the divergence this fix addressed).
|
|
"""
|
|
manifest = self._get_manifest(pack_dir)
|
|
if not manifest:
|
|
return None, None
|
|
for tmpl in manifest.templates:
|
|
if tmpl.get("name") == template_name and tmpl.get("type") == template_type:
|
|
file_path = tmpl.get("file")
|
|
if file_path:
|
|
manifest_candidate = pack_dir / file_path
|
|
return tmpl, (
|
|
manifest_candidate if manifest_candidate.is_file() else None
|
|
)
|
|
return tmpl, None
|
|
return None, None
|
|
|
|
def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]:
|
|
"""Build unified list of registered and unregistered extensions sorted by priority.
|
|
|
|
Registered extensions use their stored priority; unregistered directories
|
|
get implicit priority=10. Results are sorted by (priority, ext_id) for
|
|
deterministic ordering.
|
|
|
|
Returns:
|
|
List of (priority, ext_id, metadata_or_none) tuples sorted by priority.
|
|
"""
|
|
if not self.extensions_dir.exists():
|
|
return []
|
|
|
|
registry = ExtensionRegistry(self.extensions_dir)
|
|
# Use keys() to track ALL extensions (including corrupted entries) without deep copy
|
|
# This prevents corrupted entries from being picked up as "unregistered" dirs
|
|
registered_extension_ids = registry.keys()
|
|
|
|
# Get all registered extensions including disabled; we filter disabled manually below
|
|
all_registered = registry.list_by_priority(include_disabled=True)
|
|
|
|
all_extensions: list[tuple[int, str, dict | None]] = []
|
|
|
|
# Only include enabled extensions in the result
|
|
for ext_id, metadata in all_registered:
|
|
# Skip disabled extensions
|
|
if not metadata.get("enabled", True):
|
|
continue
|
|
priority = normalize_priority(metadata.get("priority") if metadata else None)
|
|
all_extensions.append((priority, ext_id, metadata))
|
|
|
|
# Add unregistered directories with implicit priority=10
|
|
for ext_dir in self.extensions_dir.iterdir():
|
|
if not ext_dir.is_dir() or ext_dir.name.startswith("."):
|
|
continue
|
|
if ext_dir.name not in registered_extension_ids:
|
|
all_extensions.append((10, ext_dir.name, None))
|
|
|
|
# Sort by (priority, ext_id) for deterministic ordering
|
|
all_extensions.sort(key=lambda x: (x[0], x[1]))
|
|
return all_extensions
|
|
|
|
@staticmethod
|
|
def _core_stem(template_name: str) -> Optional[str]:
|
|
"""Extract the stem for core command lookup.
|
|
|
|
Commands use dot notation (e.g. ``speckit.specify``), but core
|
|
command files are named by stem (e.g. ``specify.md``). Returns
|
|
the stem if *template_name* follows the ``speckit.<stem>`` pattern,
|
|
or ``None`` otherwise.
|
|
"""
|
|
if template_name.startswith("speckit."):
|
|
return template_name[len("speckit."):]
|
|
return None
|
|
|
|
def resolve(
|
|
self,
|
|
template_name: str,
|
|
template_type: str = "template",
|
|
skip_presets: bool = False,
|
|
) -> Optional[Path]:
|
|
"""Resolve a template name to its file path.
|
|
|
|
Walks the priority stack and returns the first match.
|
|
|
|
Args:
|
|
template_name: Template name (e.g., "spec-template")
|
|
template_type: Template type ("template", "command", or "script")
|
|
skip_presets: When True, skip tier 2 (installed presets). Use
|
|
resolve_core() as the preferred caller-facing API for this.
|
|
|
|
Returns:
|
|
Path to the resolved template file, or None if not found
|
|
"""
|
|
# Determine subdirectory based on template type
|
|
if template_type == "template":
|
|
subdirs = ["templates", ""]
|
|
elif template_type == "command":
|
|
subdirs = ["commands"]
|
|
elif template_type == "script":
|
|
subdirs = ["scripts"]
|
|
else:
|
|
subdirs = [""]
|
|
|
|
# Determine file extension based on template type
|
|
ext = ".md"
|
|
if template_type == "script":
|
|
ext = ".sh" # scripts use .sh; callers can also check .ps1
|
|
|
|
# Priority 1: Project-local overrides
|
|
if template_type == "script":
|
|
override = self.overrides_dir / "scripts" / f"{template_name}{ext}"
|
|
else:
|
|
override = self.overrides_dir / f"{template_name}{ext}"
|
|
if override.exists():
|
|
return override
|
|
|
|
# Priority 2: Installed presets (sorted by priority — lower number wins)
|
|
if not skip_presets and self.presets_dir.exists():
|
|
registry = PresetRegistry(self.presets_dir)
|
|
for pack_id, _metadata in registry.list_by_priority():
|
|
pack_dir = self.presets_dir / pack_id
|
|
# The preset manifest is authoritative: if it declares this
|
|
# template with an explicit ``file:``, resolve to that path —
|
|
# and do NOT fall back to convention when it's missing, to
|
|
# avoid masking typos or picking up an undeclared file. Only
|
|
# when the manifest is absent or doesn't list this template do
|
|
# we use the convention-based subdir lookup. Mirrors
|
|
# collect_all_layers()/resolve_content() so resolve() and
|
|
# resolve_with_source() agree with them instead of returning
|
|
# the core template (or a stray convention file).
|
|
entry, manifest_candidate = self._manifest_declared_template(
|
|
pack_dir, template_name, template_type
|
|
)
|
|
if manifest_candidate is not None:
|
|
return manifest_candidate
|
|
if entry is not None:
|
|
# Manifest declares this template but the file is missing,
|
|
# non-file (e.g. a directory), or an empty/falsey ``file``
|
|
# value. The manifest is authoritative, so skip this pack's
|
|
# convention fallback rather than mask a typo — mirrors
|
|
# collect_all_layers().
|
|
continue
|
|
for subdir in subdirs:
|
|
if subdir:
|
|
candidate = pack_dir / subdir / f"{template_name}{ext}"
|
|
else:
|
|
candidate = pack_dir / f"{template_name}{ext}"
|
|
if candidate.exists():
|
|
return candidate
|
|
|
|
# Priority 3: Extension-provided templates (sorted by priority — lower number wins)
|
|
for _priority, ext_id, _metadata in self._get_all_extensions_by_priority():
|
|
ext_dir = self.extensions_dir / ext_id
|
|
if not ext_dir.is_dir():
|
|
continue
|
|
for subdir in subdirs:
|
|
if subdir:
|
|
candidate = ext_dir / subdir / f"{template_name}{ext}"
|
|
else:
|
|
candidate = ext_dir / f"{template_name}{ext}"
|
|
if candidate.exists():
|
|
return candidate
|
|
|
|
# Priority 4: Core templates
|
|
if template_type == "template":
|
|
core = self.templates_dir / f"{template_name}.md"
|
|
if core.exists():
|
|
return core
|
|
elif template_type == "command":
|
|
core = self.templates_dir / "commands" / f"{template_name}.md"
|
|
if core.exists():
|
|
return core
|
|
# Fallback: speckit.<stem> → <stem>.md
|
|
stem = self._core_stem(template_name)
|
|
if stem:
|
|
core = self.templates_dir / "commands" / f"{stem}.md"
|
|
if core.exists():
|
|
return core
|
|
elif template_type == "script":
|
|
core = self.templates_dir / "scripts" / f"{template_name}{ext}"
|
|
if core.exists():
|
|
return core
|
|
|
|
# Priority 5: Bundled core_pack (wheel install) or repo-root templates
|
|
# (source-checkout / editable install). This is the canonical home for
|
|
# speckit's built-in command/template files and must always be checked
|
|
# so that strategy:wrap presets can locate {CORE_TEMPLATE}.
|
|
from specify_cli import _locate_core_pack, _repo_root # local import to avoid cycles
|
|
_core_pack = _locate_core_pack()
|
|
if _core_pack is not None:
|
|
# Wheel install path
|
|
if template_type == "template":
|
|
candidate = _core_pack / "templates" / f"{template_name}.md"
|
|
elif template_type == "command":
|
|
candidate = _core_pack / "commands" / f"{template_name}.md"
|
|
if not candidate.exists():
|
|
stem = self._core_stem(template_name)
|
|
if stem:
|
|
candidate = _core_pack / "commands" / f"{stem}.md"
|
|
elif template_type == "script":
|
|
candidate = _core_pack / "scripts" / f"{template_name}{ext}"
|
|
else:
|
|
candidate = _core_pack / f"{template_name}.md"
|
|
if candidate.exists():
|
|
return candidate
|
|
else:
|
|
# Source-checkout / editable install: templates live at repo root
|
|
repo_root = _repo_root()
|
|
if template_type == "template":
|
|
candidate = repo_root / "templates" / f"{template_name}.md"
|
|
elif template_type == "command":
|
|
candidate = repo_root / "templates" / "commands" / f"{template_name}.md"
|
|
if not candidate.exists():
|
|
stem = self._core_stem(template_name)
|
|
if stem:
|
|
candidate = repo_root / "templates" / "commands" / f"{stem}.md"
|
|
elif template_type == "script":
|
|
candidate = repo_root / "scripts" / f"{template_name}{ext}"
|
|
else:
|
|
candidate = repo_root / f"{template_name}.md"
|
|
if candidate.exists():
|
|
return candidate
|
|
|
|
return None
|
|
|
|
def resolve_core(
|
|
self,
|
|
template_name: str,
|
|
template_type: str = "template",
|
|
) -> Optional[Path]:
|
|
"""Resolve while skipping installed presets (tier 2).
|
|
|
|
Searches tiers 1, 3, 4, and 5 (bundled core_pack / repo-root fallback).
|
|
Use when resolving {CORE_TEMPLATE} to guarantee the result is actual
|
|
base content, never another preset's wrap output.
|
|
"""
|
|
return self.resolve(template_name, template_type, skip_presets=True)
|
|
|
|
def resolve_extension_command_via_manifest(self, cmd_name: str) -> Optional[Path]:
|
|
"""Resolve an extension command by consulting installed extension manifests.
|
|
|
|
Walks installed extension directories in priority order, loads each
|
|
extension.yml via ExtensionManifest, and looks up the command by its
|
|
declared name to find the actual file path. This is necessary because
|
|
the manifest's ``provides.commands[].file`` field is authoritative and
|
|
may differ from the command name
|
|
(e.g. ``speckit.selftest.extension`` → ``commands/selftest.md``).
|
|
|
|
Returns None if no manifest maps the given command name, so the caller
|
|
can fall back to the name-based lookup.
|
|
"""
|
|
if not self.extensions_dir.exists():
|
|
return None
|
|
|
|
from ..extensions import ExtensionManifest, ValidationError
|
|
|
|
for _priority, ext_id, _metadata in self._get_all_extensions_by_priority():
|
|
ext_dir = self.extensions_dir / ext_id
|
|
manifest_path = ext_dir / "extension.yml"
|
|
if not manifest_path.is_file():
|
|
continue
|
|
try:
|
|
manifest = ExtensionManifest(manifest_path)
|
|
except (ValidationError, OSError, TypeError, AttributeError):
|
|
continue
|
|
for cmd_info in manifest.commands:
|
|
if cmd_info.get("name") != cmd_name:
|
|
continue
|
|
file_rel = cmd_info.get("file")
|
|
if not file_rel:
|
|
continue
|
|
# Mirror the containment check in ExtensionManager to guard against
|
|
# path traversal via a malformed manifest (e.g. file: ../../AGENTS.md).
|
|
cmd_path = Path(file_rel)
|
|
if cmd_path.is_absolute():
|
|
continue
|
|
try:
|
|
ext_root = ext_dir.resolve()
|
|
candidate = (ext_root / cmd_path).resolve()
|
|
candidate.relative_to(ext_root) # raises ValueError if outside
|
|
except (OSError, ValueError):
|
|
continue
|
|
if candidate.is_file():
|
|
return candidate
|
|
return None
|
|
|
|
def resolve_with_source(
|
|
self,
|
|
template_name: str,
|
|
template_type: str = "template",
|
|
) -> Optional[Dict[str, str]]:
|
|
"""Resolve a template name and return source attribution.
|
|
|
|
Args:
|
|
template_name: Template name (e.g., "spec-template")
|
|
template_type: Template type ("template", "command", or "script")
|
|
|
|
Returns:
|
|
Dictionary with 'path' and 'source' keys, or None if not found
|
|
"""
|
|
# Delegate to resolve() for the actual lookup, then determine source
|
|
resolved = self.resolve(template_name, template_type)
|
|
if resolved is None:
|
|
return None
|
|
|
|
resolved_str = str(resolved)
|
|
|
|
# Determine source attribution
|
|
if str(self.overrides_dir) in resolved_str:
|
|
return {"path": resolved_str, "source": "project override"}
|
|
|
|
if str(self.presets_dir) in resolved_str and self.presets_dir.exists():
|
|
registry = PresetRegistry(self.presets_dir)
|
|
for pack_id, _metadata in registry.list_by_priority():
|
|
pack_dir = self.presets_dir / pack_id
|
|
try:
|
|
resolved.relative_to(pack_dir)
|
|
meta = registry.get(pack_id)
|
|
version = meta.get("version", "?") if meta else "?"
|
|
return {
|
|
"path": resolved_str,
|
|
"source": f"{pack_id} v{version}",
|
|
}
|
|
except ValueError:
|
|
continue
|
|
|
|
for _priority, ext_id, ext_meta in self._get_all_extensions_by_priority():
|
|
ext_dir = self.extensions_dir / ext_id
|
|
if not ext_dir.is_dir():
|
|
continue
|
|
try:
|
|
resolved.relative_to(ext_dir)
|
|
if ext_meta:
|
|
version = ext_meta.get("version", "?")
|
|
return {
|
|
"path": resolved_str,
|
|
"source": f"extension:{ext_id} v{version}",
|
|
}
|
|
else:
|
|
return {
|
|
"path": resolved_str,
|
|
"source": f"extension:{ext_id} (unregistered)",
|
|
}
|
|
except ValueError:
|
|
continue
|
|
|
|
return {"path": resolved_str, "source": "core"}
|
|
|
|
def collect_all_layers(
|
|
self,
|
|
template_name: str,
|
|
template_type: str = "template",
|
|
) -> List[Dict[str, Any]]:
|
|
"""Collect all layers in the priority stack for a template.
|
|
|
|
Returns layers from highest priority (checked first) to lowest priority.
|
|
Each layer is a dict with 'path', 'source', and 'strategy' keys.
|
|
|
|
Args:
|
|
template_name: Template name (e.g., "spec-template")
|
|
template_type: Template type ("template", "command", or "script")
|
|
|
|
Returns:
|
|
List of layer dicts ordered highest-to-lowest priority.
|
|
"""
|
|
if template_type == "template":
|
|
subdirs = ["templates", ""]
|
|
elif template_type == "command":
|
|
subdirs = ["commands"]
|
|
elif template_type == "script":
|
|
subdirs = ["scripts"]
|
|
else:
|
|
subdirs = [""]
|
|
|
|
ext = ".md"
|
|
if template_type == "script":
|
|
ext = ".sh"
|
|
|
|
layers: List[Dict[str, Any]] = []
|
|
|
|
def _find_in_subdirs(base_dir: Path) -> Optional[Path]:
|
|
for subdir in subdirs:
|
|
if subdir:
|
|
candidate = base_dir / subdir / f"{template_name}{ext}"
|
|
else:
|
|
candidate = base_dir / f"{template_name}{ext}"
|
|
if candidate.exists():
|
|
return candidate
|
|
return None
|
|
|
|
# Priority 1: Project-local overrides (always "replace" strategy)
|
|
if template_type == "script":
|
|
override = self.overrides_dir / "scripts" / f"{template_name}{ext}"
|
|
else:
|
|
override = self.overrides_dir / f"{template_name}{ext}"
|
|
if override.exists():
|
|
layers.append({
|
|
"path": override,
|
|
"source": "project override",
|
|
"strategy": "replace",
|
|
})
|
|
|
|
# Priority 2: Installed presets (sorted by priority — lower number = higher precedence)
|
|
if self.presets_dir.exists():
|
|
registry = PresetRegistry(self.presets_dir)
|
|
for pack_id, metadata in registry.list_by_priority():
|
|
pack_dir = self.presets_dir / pack_id
|
|
# Read strategy and manifest file path from preset manifest
|
|
strategy = "replace"
|
|
manifest_has_strategy = False
|
|
entry, manifest_candidate = self._manifest_declared_template(
|
|
pack_dir, template_name, template_type
|
|
)
|
|
if entry is not None:
|
|
strategy = entry.get("strategy", "replace")
|
|
manifest_has_strategy = "strategy" in entry
|
|
# Use the manifest's declared file when it's a usable regular file;
|
|
# only fall back to convention-based lookup when the manifest
|
|
# doesn't list this template at all, so preset.yml stays
|
|
# authoritative (a declared-but-unusable file skips convention —
|
|
# parity with resolve()).
|
|
candidate = None
|
|
if manifest_candidate is not None:
|
|
candidate = manifest_candidate
|
|
elif entry is None:
|
|
candidate = _find_in_subdirs(pack_dir)
|
|
if candidate:
|
|
# Legacy fallback: if manifest doesn't explicitly declare a
|
|
# strategy, check the command file's frontmatter for any valid
|
|
# strategy. Skip when the manifest entry includes strategy key
|
|
# (even if it's "replace") to avoid overriding explicit declarations.
|
|
if not manifest_has_strategy and strategy == "replace" and template_type == "command":
|
|
try:
|
|
cmd_content = candidate.read_text(encoding="utf-8")
|
|
lines = cmd_content.splitlines(keepends=True)
|
|
if lines and lines[0].rstrip("\r\n") == "---":
|
|
fence_end = -1
|
|
for fi, fline in enumerate(lines[1:], start=1):
|
|
if fline.rstrip("\r\n") == "---":
|
|
fence_end = fi
|
|
break
|
|
if fence_end > 0:
|
|
fm_text = "".join(lines[1:fence_end])
|
|
fm_data = yaml.safe_load(fm_text)
|
|
if isinstance(fm_data, dict):
|
|
fm_strategy = fm_data.get("strategy")
|
|
if isinstance(fm_strategy, str) and fm_strategy.lower() in VALID_PRESET_STRATEGIES:
|
|
strategy = fm_strategy.lower()
|
|
except (yaml.YAMLError, OSError):
|
|
# Best-effort legacy frontmatter parsing: keep default
|
|
# strategy ("replace") when content is unreadable/invalid.
|
|
pass
|
|
version = metadata.get("version", "?") if metadata else "?"
|
|
layers.append({
|
|
"path": candidate,
|
|
"source": f"{pack_id} v{version}",
|
|
"strategy": strategy,
|
|
})
|
|
|
|
# Priority 3: Extension-provided templates (always "replace")
|
|
for _priority, ext_id, ext_meta in self._get_all_extensions_by_priority():
|
|
ext_dir = self.extensions_dir / ext_id
|
|
if not ext_dir.is_dir():
|
|
continue
|
|
# Try convention-based lookup first
|
|
candidate = _find_in_subdirs(ext_dir)
|
|
# If not found and this is a command, check extension manifest
|
|
if candidate is None and template_type == "command":
|
|
ext_manifest_path = ext_dir / "extension.yml"
|
|
if ext_manifest_path.exists():
|
|
try:
|
|
from ..extensions import ExtensionManifest, ValidationError as ExtValidationError
|
|
ext_manifest = ExtensionManifest(ext_manifest_path)
|
|
for cmd in ext_manifest.commands:
|
|
if cmd.get("name") == template_name:
|
|
cmd_file = cmd.get("file")
|
|
if cmd_file:
|
|
c = ext_dir / cmd_file
|
|
if c.exists():
|
|
candidate = c
|
|
break
|
|
except (ExtValidationError, yaml.YAMLError):
|
|
# Invalid extension manifest — fall back to
|
|
# convention-based lookup (already attempted above).
|
|
pass
|
|
if candidate:
|
|
if ext_meta:
|
|
version = ext_meta.get("version", "?")
|
|
source = f"extension:{ext_id} v{version}"
|
|
else:
|
|
source = f"extension:{ext_id} (unregistered)"
|
|
layers.append({
|
|
"path": candidate,
|
|
"source": source,
|
|
"strategy": "replace",
|
|
"extension_id": ext_id,
|
|
"extension_dir": ext_dir,
|
|
})
|
|
|
|
# Priority 4: Core templates (always "replace")
|
|
core = None
|
|
if template_type == "template":
|
|
c = self.templates_dir / f"{template_name}.md"
|
|
if c.exists():
|
|
core = c
|
|
elif template_type == "command":
|
|
c = self.templates_dir / "commands" / f"{template_name}.md"
|
|
if c.exists():
|
|
core = c
|
|
else:
|
|
# Fallback: speckit.<stem> → <stem>.md
|
|
stem = self._core_stem(template_name)
|
|
if stem:
|
|
c = self.templates_dir / "commands" / f"{stem}.md"
|
|
if c.exists():
|
|
core = c
|
|
elif template_type == "script":
|
|
c = self.templates_dir / "scripts" / f"{template_name}{ext}"
|
|
if c.exists():
|
|
core = c
|
|
if core:
|
|
layers.append({
|
|
"path": core,
|
|
"source": "core",
|
|
"strategy": "replace",
|
|
})
|
|
else:
|
|
# Priority 5: Bundled core_pack (wheel install) or repo-root
|
|
# templates (source-checkout), matching resolve()'s tier-5 fallback.
|
|
bundled = self._find_bundled_core(template_name, template_type, ext)
|
|
if bundled:
|
|
layers.append({
|
|
"path": bundled,
|
|
"source": "core (bundled)",
|
|
"strategy": "replace",
|
|
})
|
|
|
|
return layers
|
|
|
|
def _find_bundled_core(
|
|
self,
|
|
template_name: str,
|
|
template_type: str,
|
|
ext: str,
|
|
) -> Optional[Path]:
|
|
"""Find a core template from the bundled pack or source checkout.
|
|
|
|
Mirrors the tier-5 fallback logic in ``resolve()`` so that
|
|
``collect_all_layers()`` can locate base layers even when
|
|
``.specify/templates/`` doesn't contain the core file.
|
|
"""
|
|
try:
|
|
from specify_cli import _locate_core_pack, _repo_root
|
|
except ImportError:
|
|
return None
|
|
|
|
stem = self._core_stem(template_name)
|
|
names = [template_name]
|
|
if stem and stem != template_name:
|
|
names.append(stem)
|
|
|
|
core_pack = _locate_core_pack()
|
|
if core_pack is not None:
|
|
for name in names:
|
|
if template_type == "template":
|
|
c = core_pack / "templates" / f"{name}.md"
|
|
elif template_type == "command":
|
|
c = core_pack / "commands" / f"{name}.md"
|
|
elif template_type == "script":
|
|
c = core_pack / "scripts" / f"{name}{ext}"
|
|
else:
|
|
c = core_pack / f"{name}.md"
|
|
if c.exists():
|
|
return c
|
|
else:
|
|
repo_root = _repo_root()
|
|
for name in names:
|
|
if template_type == "template":
|
|
c = repo_root / "templates" / f"{name}.md"
|
|
elif template_type == "command":
|
|
c = repo_root / "templates" / "commands" / f"{name}.md"
|
|
elif template_type == "script":
|
|
c = repo_root / "scripts" / f"{name}{ext}"
|
|
else:
|
|
c = repo_root / f"{name}.md"
|
|
if c.exists():
|
|
return c
|
|
return None
|
|
|
|
def resolve_content(
|
|
self,
|
|
template_name: str,
|
|
template_type: str = "template",
|
|
) -> Optional[str]:
|
|
"""Resolve a template name and return composed content.
|
|
|
|
Walks the priority stack and composes content using strategies:
|
|
- replace (default): highest-priority content wins entirely
|
|
- prepend: content is placed before lower-priority content
|
|
- append: content is placed after lower-priority content
|
|
- wrap: content contains {CORE_TEMPLATE} placeholder replaced
|
|
with lower-priority content (or $CORE_SCRIPT for scripts)
|
|
|
|
Composition is recursive — multiple composing presets chain.
|
|
|
|
Args:
|
|
template_name: Template name (e.g., "spec-template")
|
|
template_type: Template type ("template", "command", or "script")
|
|
|
|
Returns:
|
|
Composed content string, or None if not found
|
|
"""
|
|
layers = self.collect_all_layers(template_name, template_type)
|
|
if not layers:
|
|
return None
|
|
|
|
def _read_layer_content(layer: Dict[str, Any]) -> str:
|
|
"""Read a layer's raw text, rewriting extension-relative subdir
|
|
references (agents/, knowledge-base/, etc.) to their installed
|
|
location when the layer is extension-provided (#2101).
|
|
|
|
Extension layers are always inserted with strategy "replace"
|
|
(see collect_all_layers), so a layer only ever needs this
|
|
rewrite when it wins outright above or serves as the
|
|
composition base below — never as a mid-stack composing
|
|
(append/prepend/wrap) layer.
|
|
"""
|
|
text = layer["path"].read_text(encoding="utf-8")
|
|
extension_id = layer.get("extension_id")
|
|
extension_dir = layer.get("extension_dir")
|
|
if extension_id and extension_dir:
|
|
from ..agents import CommandRegistrar
|
|
|
|
text = CommandRegistrar.rewrite_extension_paths(
|
|
text, extension_id, extension_dir
|
|
)
|
|
return text
|
|
|
|
# If the top (highest-priority) layer is replace, it wins entirely —
|
|
# lower layers are irrelevant regardless of their strategies.
|
|
if layers[0]["strategy"] == "replace":
|
|
return _read_layer_content(layers[0])
|
|
|
|
# Composition: build content bottom-up from the effective base.
|
|
# The base is the nearest replace layer scanning from highest priority
|
|
# downward. Only layers above the base contribute to composition.
|
|
#
|
|
# layers is ordered highest-priority first. We process in reverse.
|
|
reversed_layers = list(reversed(layers))
|
|
|
|
# Find the effective base: scan from highest priority (layers[0]) downward
|
|
# to find the nearest replace layer. Only compose layers above that base.
|
|
# layers is highest-priority first; reversed_layers is lowest first.
|
|
base_layer_idx = None # index in layers[] (highest-priority first)
|
|
for idx, layer in enumerate(layers):
|
|
if layer["strategy"] == "replace":
|
|
base_layer_idx = idx
|
|
break
|
|
|
|
if base_layer_idx is None:
|
|
return None # no replace base found
|
|
|
|
# Convert to reversed_layers index
|
|
base_reversed_idx = len(layers) - 1 - base_layer_idx
|
|
content = _read_layer_content(layers[base_layer_idx])
|
|
# Compose only the layers above the base (higher priority = lower index in layers,
|
|
# higher index in reversed_layers). Process bottom-up from base+1.
|
|
start_idx = base_reversed_idx + 1
|
|
|
|
# For command composition, strip frontmatter from each layer to avoid
|
|
# leaking YAML metadata into the composed body. The highest-priority
|
|
# layer's frontmatter will be reattached at the end.
|
|
is_command = template_type == "command"
|
|
top_frontmatter_text = None
|
|
base_frontmatter_text = None
|
|
|
|
def _split_frontmatter(text: str) -> tuple:
|
|
"""Return (frontmatter_block_with_fences, body) or (None, text).
|
|
|
|
Uses line-based fence detection (fence must be ``---`` on its
|
|
own line) to avoid false matches on ``---`` inside YAML values.
|
|
"""
|
|
lines = text.splitlines(keepends=True)
|
|
if not lines or lines[0].rstrip("\r\n") != "---":
|
|
return None, text
|
|
|
|
fence_end = -1
|
|
for i, line in enumerate(lines[1:], start=1):
|
|
if line.rstrip("\r\n") == "---":
|
|
fence_end = i
|
|
break
|
|
|
|
if fence_end == -1:
|
|
return None, text
|
|
|
|
fm_block = "".join(lines[:fence_end + 1]).rstrip("\r\n")
|
|
body = "".join(lines[fence_end + 1:])
|
|
return fm_block, body
|
|
|
|
if is_command:
|
|
fm, body = _split_frontmatter(content)
|
|
if fm:
|
|
top_frontmatter_text = fm
|
|
base_frontmatter_text = fm
|
|
content = body
|
|
|
|
# Apply composition layers from bottom to top
|
|
for layer in reversed_layers[start_idx:]:
|
|
layer_content = layer["path"].read_text(encoding="utf-8")
|
|
strategy = layer["strategy"]
|
|
|
|
if is_command:
|
|
fm, layer_body = _split_frontmatter(layer_content)
|
|
layer_content = layer_body
|
|
# Track the highest-priority frontmatter seen;
|
|
# replace layers reset both top and base frontmatter since
|
|
# they replace the entire command including metadata.
|
|
if strategy == "replace":
|
|
top_frontmatter_text = fm
|
|
base_frontmatter_text = fm
|
|
elif fm:
|
|
top_frontmatter_text = fm
|
|
|
|
if strategy == "replace":
|
|
content = layer_content
|
|
elif strategy == "prepend":
|
|
content = layer_content + "\n\n" + content
|
|
elif strategy == "append":
|
|
content = content + "\n\n" + layer_content
|
|
elif strategy == "wrap":
|
|
if template_type == "script":
|
|
placeholder = "$CORE_SCRIPT"
|
|
else:
|
|
placeholder = "{CORE_TEMPLATE}"
|
|
if placeholder not in layer_content:
|
|
raise PresetValidationError(
|
|
f"Wrap strategy in '{layer['source']}' is missing "
|
|
f"the {placeholder} placeholder. The wrapper must "
|
|
f"contain {placeholder} to indicate where the "
|
|
f"lower-priority content should be inserted."
|
|
)
|
|
content = layer_content.replace(placeholder, content)
|
|
|
|
# Reattach the highest-priority frontmatter for commands,
|
|
# inheriting scripts/agent_scripts from the base if missing
|
|
# and stripping the strategy key (internal-only, not for agent output).
|
|
if is_command and top_frontmatter_text:
|
|
def _parse_fm_yaml(fm_block: str) -> dict:
|
|
"""Parse YAML from a frontmatter block (with --- fences)."""
|
|
lines = fm_block.splitlines()
|
|
# Parse only interior lines (between --- fences)
|
|
if len(lines) >= 2:
|
|
yaml_lines = lines[1:-1]
|
|
else:
|
|
yaml_lines = []
|
|
try:
|
|
return yaml.safe_load("\n".join(yaml_lines)) or {}
|
|
except yaml.YAMLError:
|
|
return {}
|
|
|
|
top_fm = _parse_fm_yaml(top_frontmatter_text)
|
|
|
|
# Inherit scripts/agent_scripts from base frontmatter if missing
|
|
if base_frontmatter_text and base_frontmatter_text != top_frontmatter_text:
|
|
base_fm = _parse_fm_yaml(base_frontmatter_text)
|
|
for key in ("scripts", "agent_scripts"):
|
|
if key not in top_fm and key in base_fm:
|
|
top_fm[key] = base_fm[key]
|
|
|
|
# Strip strategy key — it's an internal composition directive,
|
|
# not meant for rendered agent command files
|
|
top_fm.pop("strategy", None)
|
|
|
|
if top_fm:
|
|
top_frontmatter_text = (
|
|
"---\n"
|
|
+ dump_frontmatter(top_fm)
|
|
+ "\n---"
|
|
)
|
|
else:
|
|
# Empty frontmatter — omit rather than emitting {}
|
|
top_frontmatter_text = None
|
|
|
|
if top_frontmatter_text:
|
|
content = top_frontmatter_text + "\n\n" + content
|
|
|
|
return content
|