mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08: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>
11708 lines
498 KiB
Python
11708 lines
498 KiB
Python
"""
|
|
Unit tests for the preset system.
|
|
|
|
Tests cover:
|
|
- Preset manifest validation
|
|
- Preset registry operations
|
|
- Preset manager installation/removal
|
|
- Template catalog search
|
|
- Template resolver priority stack
|
|
- Extension-provided templates
|
|
"""
|
|
|
|
import pytest
|
|
import io
|
|
import json
|
|
import tempfile
|
|
import shutil
|
|
import warnings
|
|
import zipfile
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
from datetime import datetime, timezone
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock
|
|
|
|
import yaml
|
|
|
|
from tests.conftest import strip_ansi
|
|
from specify_cli.presets import (
|
|
PresetManifest,
|
|
PresetRegistry,
|
|
PresetManager,
|
|
PresetCatalog,
|
|
PresetCatalogEntry,
|
|
PresetResolver,
|
|
PresetError,
|
|
PresetValidationError,
|
|
PresetCompatibilityError,
|
|
VALID_PRESET_TEMPLATE_TYPES,
|
|
)
|
|
from specify_cli.extensions import ExtensionRegistry
|
|
|
|
|
|
# ===== Fixtures =====
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_dir():
|
|
"""Create a temporary directory for tests."""
|
|
tmpdir = tempfile.mkdtemp()
|
|
yield Path(tmpdir)
|
|
shutil.rmtree(tmpdir)
|
|
|
|
|
|
@pytest.fixture
|
|
def valid_pack_data():
|
|
"""Valid preset manifest data."""
|
|
return {
|
|
"schema_version": "1.0",
|
|
"preset": {
|
|
"id": "test-pack",
|
|
"name": "Test Preset",
|
|
"version": "1.0.0",
|
|
"description": "A test preset",
|
|
"author": "Test Author",
|
|
"repository": "https://github.com/test/test-pack",
|
|
"license": "MIT",
|
|
},
|
|
"requires": {
|
|
"speckit_version": ">=0.1.0",
|
|
},
|
|
"provides": {
|
|
"templates": [
|
|
{
|
|
"type": "template",
|
|
"name": "spec-template",
|
|
"file": "templates/spec-template.md",
|
|
"description": "Custom spec template",
|
|
"replaces": "spec-template",
|
|
}
|
|
]
|
|
},
|
|
"tags": ["testing", "example"],
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def pack_dir(temp_dir, valid_pack_data):
|
|
"""Create a complete preset directory structure."""
|
|
p_dir = temp_dir / "test-pack"
|
|
p_dir.mkdir()
|
|
|
|
# Write manifest
|
|
manifest_path = p_dir / "preset.yml"
|
|
with open(manifest_path, 'w') as f:
|
|
yaml.dump(valid_pack_data, f)
|
|
|
|
# Create templates directory
|
|
templates_dir = p_dir / "templates"
|
|
templates_dir.mkdir()
|
|
|
|
# Write template file
|
|
tmpl_file = templates_dir / "spec-template.md"
|
|
tmpl_file.write_text("# Custom Spec Template\n\nThis is a custom template.\n")
|
|
|
|
return p_dir
|
|
|
|
|
|
@pytest.fixture
|
|
def project_dir(temp_dir):
|
|
"""Create a mock spec-kit project directory."""
|
|
proj_dir = temp_dir / "project"
|
|
proj_dir.mkdir()
|
|
|
|
# Create .specify directory
|
|
specify_dir = proj_dir / ".specify"
|
|
specify_dir.mkdir()
|
|
|
|
# Create templates directory with core templates
|
|
templates_dir = specify_dir / "templates"
|
|
templates_dir.mkdir()
|
|
|
|
# Create core spec-template
|
|
core_spec = templates_dir / "spec-template.md"
|
|
core_spec.write_text("# Core Spec Template\n")
|
|
|
|
# Create core plan-template
|
|
core_plan = templates_dir / "plan-template.md"
|
|
core_plan.write_text("# Core Plan Template\n")
|
|
|
|
# Create commands subdirectory
|
|
commands_dir = templates_dir / "commands"
|
|
commands_dir.mkdir()
|
|
|
|
return proj_dir
|
|
|
|
|
|
# ===== PresetManifest Tests =====
|
|
|
|
|
|
class TestPresetManifest:
|
|
"""Test PresetManifest validation and parsing."""
|
|
|
|
def test_valid_manifest(self, pack_dir):
|
|
"""Test loading a valid manifest."""
|
|
manifest = PresetManifest(pack_dir / "preset.yml")
|
|
assert manifest.id == "test-pack"
|
|
assert manifest.name == "Test Preset"
|
|
assert manifest.version == "1.0.0"
|
|
assert manifest.description == "A test preset"
|
|
assert manifest.author == "Test Author"
|
|
assert manifest.requires_speckit_version == ">=0.1.0"
|
|
assert len(manifest.templates) == 1
|
|
assert manifest.tags == ["testing", "example"]
|
|
|
|
def test_missing_manifest(self, temp_dir):
|
|
"""Test that missing manifest raises error."""
|
|
with pytest.raises(PresetValidationError, match="Manifest not found"):
|
|
PresetManifest(temp_dir / "nonexistent.yml")
|
|
|
|
def test_invalid_yaml(self, temp_dir):
|
|
"""Test that invalid YAML raises error."""
|
|
bad_file = temp_dir / "bad.yml"
|
|
bad_file.write_text(": invalid: yaml: {{{")
|
|
with pytest.raises(PresetValidationError, match="Invalid YAML"):
|
|
PresetManifest(bad_file)
|
|
|
|
def test_utf8_non_ascii_description_loads(self, temp_dir, valid_pack_data):
|
|
"""Regression for #2325: non-ASCII (UTF-8) description loads on any platform.
|
|
|
|
On Windows, Python's default text-mode encoding is the locale codepage
|
|
(e.g. cp1252/GBK), which raises UnicodeDecodeError on UTF-8 bytes
|
|
outside the ASCII range. The loader must open with encoding='utf-8'.
|
|
"""
|
|
valid_pack_data["preset"]["description"] = "中文测试 — émojis 🚀"
|
|
manifest_path = temp_dir / "preset.yml"
|
|
manifest_path.write_bytes(
|
|
yaml.safe_dump(valid_pack_data, allow_unicode=True).encode("utf-8")
|
|
)
|
|
|
|
manifest = PresetManifest(manifest_path)
|
|
assert manifest.description == "中文测试 — émojis 🚀"
|
|
|
|
def test_invalid_utf8_bytes_raises_validation_error(self, temp_dir):
|
|
"""Negative case: file containing invalid UTF-8 bytes raises PresetValidationError, not raw UnicodeDecodeError."""
|
|
manifest_path = temp_dir / "preset.yml"
|
|
manifest_path.write_bytes(b"\xff\xfe not valid utf-8 \xff\n")
|
|
|
|
with pytest.raises(PresetValidationError, match="not valid UTF-8"):
|
|
PresetManifest(manifest_path)
|
|
|
|
def test_non_mapping_yaml_raises_validation_error(self, temp_dir):
|
|
"""Manifest whose YAML root is a scalar or list raises PresetValidationError, not TypeError."""
|
|
manifest_path = temp_dir / "preset.yml"
|
|
for bad_content in ("42\n", "[1, 2]\n"):
|
|
manifest_path.write_text(bad_content, encoding="utf-8")
|
|
with pytest.raises(PresetValidationError, match="YAML mapping"):
|
|
PresetManifest(manifest_path)
|
|
|
|
def test_missing_schema_version(self, temp_dir, valid_pack_data):
|
|
"""Test missing schema_version field."""
|
|
del valid_pack_data["schema_version"]
|
|
manifest_path = temp_dir / "preset.yml"
|
|
with open(manifest_path, 'w') as f:
|
|
yaml.dump(valid_pack_data, f)
|
|
with pytest.raises(PresetValidationError, match="Missing required field: schema_version"):
|
|
PresetManifest(manifest_path)
|
|
|
|
def test_wrong_schema_version(self, temp_dir, valid_pack_data):
|
|
"""Test unsupported schema version."""
|
|
valid_pack_data["schema_version"] = "2.0"
|
|
manifest_path = temp_dir / "preset.yml"
|
|
with open(manifest_path, 'w') as f:
|
|
yaml.dump(valid_pack_data, f)
|
|
with pytest.raises(PresetValidationError, match="Unsupported schema version"):
|
|
PresetManifest(manifest_path)
|
|
|
|
def test_missing_pack_id(self, temp_dir, valid_pack_data):
|
|
"""Test missing preset.id field."""
|
|
del valid_pack_data["preset"]["id"]
|
|
manifest_path = temp_dir / "preset.yml"
|
|
with open(manifest_path, 'w') as f:
|
|
yaml.dump(valid_pack_data, f)
|
|
with pytest.raises(PresetValidationError, match="Missing preset.id"):
|
|
PresetManifest(manifest_path)
|
|
|
|
def test_invalid_pack_id_format(self, temp_dir, valid_pack_data):
|
|
"""Test invalid pack ID format."""
|
|
valid_pack_data["preset"]["id"] = "Invalid_ID"
|
|
manifest_path = temp_dir / "preset.yml"
|
|
with open(manifest_path, 'w') as f:
|
|
yaml.dump(valid_pack_data, f)
|
|
with pytest.raises(PresetValidationError, match="Invalid preset ID"):
|
|
PresetManifest(manifest_path)
|
|
|
|
def test_invalid_version(self, temp_dir, valid_pack_data):
|
|
"""Test invalid semantic version."""
|
|
valid_pack_data["preset"]["version"] = "not-a-version"
|
|
manifest_path = temp_dir / "preset.yml"
|
|
with open(manifest_path, 'w') as f:
|
|
yaml.dump(valid_pack_data, f)
|
|
with pytest.raises(PresetValidationError, match="Invalid version"):
|
|
PresetManifest(manifest_path)
|
|
|
|
def test_missing_speckit_version(self, temp_dir, valid_pack_data):
|
|
"""Test missing requires.speckit_version."""
|
|
del valid_pack_data["requires"]["speckit_version"]
|
|
manifest_path = temp_dir / "preset.yml"
|
|
with open(manifest_path, 'w') as f:
|
|
yaml.dump(valid_pack_data, f)
|
|
with pytest.raises(PresetValidationError, match="Missing requires.speckit_version"):
|
|
PresetManifest(manifest_path)
|
|
|
|
def test_no_templates_provided(self, temp_dir, valid_pack_data):
|
|
"""Test pack with no templates."""
|
|
valid_pack_data["provides"]["templates"] = []
|
|
manifest_path = temp_dir / "preset.yml"
|
|
with open(manifest_path, 'w') as f:
|
|
yaml.dump(valid_pack_data, f)
|
|
with pytest.raises(PresetValidationError, match="must provide at least one template"):
|
|
PresetManifest(manifest_path)
|
|
|
|
def test_invalid_template_type(self, temp_dir, valid_pack_data):
|
|
"""Test template with invalid type."""
|
|
valid_pack_data["provides"]["templates"][0]["type"] = "invalid"
|
|
manifest_path = temp_dir / "preset.yml"
|
|
with open(manifest_path, 'w') as f:
|
|
yaml.dump(valid_pack_data, f)
|
|
with pytest.raises(PresetValidationError, match="Invalid template type"):
|
|
PresetManifest(manifest_path)
|
|
|
|
def test_valid_template_types(self):
|
|
"""Test that all expected template types are valid."""
|
|
assert "template" in VALID_PRESET_TEMPLATE_TYPES
|
|
assert "command" in VALID_PRESET_TEMPLATE_TYPES
|
|
assert "script" in VALID_PRESET_TEMPLATE_TYPES
|
|
|
|
def test_template_missing_required_fields(self, temp_dir, valid_pack_data):
|
|
"""Test template missing required fields."""
|
|
valid_pack_data["provides"]["templates"] = [{"type": "template"}]
|
|
manifest_path = temp_dir / "preset.yml"
|
|
with open(manifest_path, 'w') as f:
|
|
yaml.dump(valid_pack_data, f)
|
|
with pytest.raises(PresetValidationError, match="missing 'type', 'name', or 'file'"):
|
|
PresetManifest(manifest_path)
|
|
|
|
def test_invalid_template_name_format(self, temp_dir, valid_pack_data):
|
|
"""Test template with invalid name format."""
|
|
valid_pack_data["provides"]["templates"][0]["name"] = "Invalid Name"
|
|
manifest_path = temp_dir / "preset.yml"
|
|
with open(manifest_path, 'w') as f:
|
|
yaml.dump(valid_pack_data, f)
|
|
with pytest.raises(PresetValidationError, match="Invalid template name"):
|
|
PresetManifest(manifest_path)
|
|
|
|
def test_get_hash(self, pack_dir):
|
|
"""Test manifest hash calculation."""
|
|
manifest = PresetManifest(pack_dir / "preset.yml")
|
|
hash_val = manifest.get_hash()
|
|
assert hash_val.startswith("sha256:")
|
|
assert len(hash_val) > 10
|
|
|
|
def test_multiple_templates(self, temp_dir, valid_pack_data):
|
|
"""Test pack with multiple templates of different types."""
|
|
valid_pack_data["provides"]["templates"] = [
|
|
{"type": "template", "name": "spec-template", "file": "templates/spec-template.md"},
|
|
{"type": "template", "name": "plan-template", "file": "templates/plan-template.md"},
|
|
{"type": "command", "name": "specify", "file": "commands/specify.md"},
|
|
{"type": "script", "name": "create-new-feature", "file": "scripts/create-new-feature.sh"},
|
|
]
|
|
manifest_path = temp_dir / "preset.yml"
|
|
with open(manifest_path, 'w') as f:
|
|
yaml.dump(valid_pack_data, f)
|
|
manifest = PresetManifest(manifest_path)
|
|
assert len(manifest.templates) == 4
|
|
|
|
|
|
# ===== PresetRegistry Tests =====
|
|
|
|
|
|
class TestPresetRegistry:
|
|
"""Test PresetRegistry operations."""
|
|
|
|
def test_empty_registry(self, temp_dir):
|
|
"""Test empty registry initialization."""
|
|
packs_dir = temp_dir / "packs"
|
|
packs_dir.mkdir()
|
|
registry = PresetRegistry(packs_dir)
|
|
assert registry.list() == {}
|
|
assert not registry.is_installed("test-pack")
|
|
|
|
def test_add_and_get(self, temp_dir):
|
|
"""Test adding and retrieving a pack."""
|
|
packs_dir = temp_dir / "packs"
|
|
packs_dir.mkdir()
|
|
registry = PresetRegistry(packs_dir)
|
|
|
|
registry.add("test-pack", {"version": "1.0.0", "source": "local"})
|
|
assert registry.is_installed("test-pack")
|
|
|
|
metadata = registry.get("test-pack")
|
|
assert metadata is not None
|
|
assert metadata["version"] == "1.0.0"
|
|
assert "installed_at" in metadata
|
|
|
|
def test_remove(self, temp_dir):
|
|
"""Test removing a pack."""
|
|
packs_dir = temp_dir / "packs"
|
|
packs_dir.mkdir()
|
|
registry = PresetRegistry(packs_dir)
|
|
|
|
registry.add("test-pack", {"version": "1.0.0"})
|
|
assert registry.is_installed("test-pack")
|
|
|
|
registry.remove("test-pack")
|
|
assert not registry.is_installed("test-pack")
|
|
|
|
def test_remove_nonexistent(self, temp_dir):
|
|
"""Test removing a pack that doesn't exist."""
|
|
packs_dir = temp_dir / "packs"
|
|
packs_dir.mkdir()
|
|
registry = PresetRegistry(packs_dir)
|
|
registry.remove("nonexistent") # Should not raise
|
|
|
|
def test_list(self, temp_dir):
|
|
"""Test listing all packs."""
|
|
packs_dir = temp_dir / "packs"
|
|
packs_dir.mkdir()
|
|
registry = PresetRegistry(packs_dir)
|
|
|
|
registry.add("pack-a", {"version": "1.0.0"})
|
|
registry.add("pack-b", {"version": "2.0.0"})
|
|
|
|
all_packs = registry.list()
|
|
assert len(all_packs) == 2
|
|
assert "pack-a" in all_packs
|
|
assert "pack-b" in all_packs
|
|
|
|
def test_persistence(self, temp_dir):
|
|
"""Test that registry data persists across instances."""
|
|
packs_dir = temp_dir / "packs"
|
|
packs_dir.mkdir()
|
|
|
|
# Add with first instance
|
|
registry1 = PresetRegistry(packs_dir)
|
|
registry1.add("test-pack", {"version": "1.0.0"})
|
|
|
|
# Load with second instance
|
|
registry2 = PresetRegistry(packs_dir)
|
|
assert registry2.is_installed("test-pack")
|
|
|
|
def test_corrupted_registry(self, temp_dir):
|
|
"""Test recovery from corrupted registry file."""
|
|
packs_dir = temp_dir / "packs"
|
|
packs_dir.mkdir()
|
|
|
|
registry_file = packs_dir / ".registry"
|
|
registry_file.write_text("not valid json{{{")
|
|
|
|
registry = PresetRegistry(packs_dir)
|
|
assert registry.list() == {}
|
|
|
|
def test_get_nonexistent(self, temp_dir):
|
|
"""Test getting a nonexistent pack."""
|
|
packs_dir = temp_dir / "packs"
|
|
packs_dir.mkdir()
|
|
registry = PresetRegistry(packs_dir)
|
|
assert registry.get("nonexistent") is None
|
|
|
|
def test_restore(self, temp_dir):
|
|
"""Test restore() preserves timestamps exactly."""
|
|
packs_dir = temp_dir / "packs"
|
|
packs_dir.mkdir()
|
|
registry = PresetRegistry(packs_dir)
|
|
|
|
# Create original entry with a specific timestamp
|
|
original_metadata = {
|
|
"version": "1.0.0",
|
|
"source": "local",
|
|
"installed_at": "2025-01-15T10:30:00+00:00",
|
|
"enabled": True,
|
|
}
|
|
registry.restore("test-pack", original_metadata)
|
|
|
|
# Verify exact restoration
|
|
restored = registry.get("test-pack")
|
|
assert restored["installed_at"] == "2025-01-15T10:30:00+00:00"
|
|
assert restored["version"] == "1.0.0"
|
|
assert restored["enabled"] is True
|
|
|
|
def test_restore_rejects_none_metadata(self, temp_dir):
|
|
"""Test restore() raises ValueError for None metadata."""
|
|
packs_dir = temp_dir / "packs"
|
|
packs_dir.mkdir()
|
|
registry = PresetRegistry(packs_dir)
|
|
|
|
with pytest.raises(ValueError, match="metadata must be a dict"):
|
|
registry.restore("test-pack", None)
|
|
|
|
def test_restore_rejects_non_dict_metadata(self, temp_dir):
|
|
"""Test restore() raises ValueError for non-dict metadata."""
|
|
packs_dir = temp_dir / "packs"
|
|
packs_dir.mkdir()
|
|
registry = PresetRegistry(packs_dir)
|
|
|
|
with pytest.raises(ValueError, match="metadata must be a dict"):
|
|
registry.restore("test-pack", "not-a-dict")
|
|
|
|
with pytest.raises(ValueError, match="metadata must be a dict"):
|
|
registry.restore("test-pack", ["list", "not", "dict"])
|
|
|
|
def test_restore_uses_deep_copy(self, temp_dir):
|
|
"""Test restore() deep copies metadata to prevent mutation."""
|
|
packs_dir = temp_dir / "packs"
|
|
packs_dir.mkdir()
|
|
registry = PresetRegistry(packs_dir)
|
|
|
|
original_metadata = {
|
|
"version": "1.0.0",
|
|
"nested": {"key": "original"},
|
|
}
|
|
registry.restore("test-pack", original_metadata)
|
|
|
|
# Mutate the original metadata after restore
|
|
original_metadata["version"] = "MUTATED"
|
|
original_metadata["nested"]["key"] = "MUTATED"
|
|
|
|
# Registry should have the original values
|
|
stored = registry.get("test-pack")
|
|
assert stored["version"] == "1.0.0"
|
|
assert stored["nested"]["key"] == "original"
|
|
|
|
def test_get_returns_deep_copy(self, temp_dir):
|
|
"""Test that get() returns a deep copy to prevent mutation."""
|
|
packs_dir = temp_dir / "packs"
|
|
packs_dir.mkdir()
|
|
registry = PresetRegistry(packs_dir)
|
|
|
|
registry.add("test-pack", {"version": "1.0.0", "nested": {"key": "original"}})
|
|
|
|
# Get and mutate the returned copy
|
|
metadata = registry.get("test-pack")
|
|
metadata["version"] = "MUTATED"
|
|
metadata["nested"]["key"] = "MUTATED"
|
|
|
|
# Original should be unchanged
|
|
fresh = registry.get("test-pack")
|
|
assert fresh["version"] == "1.0.0"
|
|
assert fresh["nested"]["key"] == "original"
|
|
|
|
def test_get_returns_none_for_corrupted_entry(self, temp_dir):
|
|
"""Test that get() returns None for corrupted (non-dict) entries."""
|
|
packs_dir = temp_dir / "packs"
|
|
packs_dir.mkdir()
|
|
registry = PresetRegistry(packs_dir)
|
|
|
|
# Directly corrupt the registry with non-dict entries
|
|
registry.data["presets"]["corrupted-string"] = "not a dict"
|
|
registry.data["presets"]["corrupted-list"] = ["not", "a", "dict"]
|
|
registry.data["presets"]["corrupted-int"] = 42
|
|
registry._save()
|
|
|
|
# All corrupted entries should return None
|
|
assert registry.get("corrupted-string") is None
|
|
assert registry.get("corrupted-list") is None
|
|
assert registry.get("corrupted-int") is None
|
|
# Non-existent should also return None
|
|
assert registry.get("nonexistent") is None
|
|
|
|
def test_list_returns_deep_copy(self, temp_dir):
|
|
"""Test that list() returns deep copies to prevent mutation."""
|
|
packs_dir = temp_dir / "packs"
|
|
packs_dir.mkdir()
|
|
registry = PresetRegistry(packs_dir)
|
|
|
|
registry.add("test-pack", {"version": "1.0.0", "nested": {"key": "original"}})
|
|
|
|
# Get list and mutate
|
|
all_packs = registry.list()
|
|
all_packs["test-pack"]["version"] = "MUTATED"
|
|
all_packs["test-pack"]["nested"]["key"] = "MUTATED"
|
|
|
|
# Original should be unchanged
|
|
fresh = registry.get("test-pack")
|
|
assert fresh["version"] == "1.0.0"
|
|
assert fresh["nested"]["key"] == "original"
|
|
|
|
def test_list_returns_empty_dict_for_corrupted_registry(self, temp_dir):
|
|
"""Test that list() returns empty dict when presets is not a dict."""
|
|
packs_dir = temp_dir / "packs"
|
|
packs_dir.mkdir()
|
|
registry = PresetRegistry(packs_dir)
|
|
|
|
# Corrupt the registry - presets is a list instead of dict
|
|
registry.data["presets"] = ["not", "a", "dict"]
|
|
registry._save()
|
|
|
|
# list() should return empty dict, not crash
|
|
result = registry.list()
|
|
assert result == {}
|
|
|
|
def test_list_by_priority_excludes_disabled(self, temp_dir):
|
|
"""Test that list_by_priority excludes disabled presets by default."""
|
|
packs_dir = temp_dir / "packs"
|
|
packs_dir.mkdir()
|
|
registry = PresetRegistry(packs_dir)
|
|
|
|
registry.add("pack-enabled", {"version": "1.0.0", "enabled": True, "priority": 5})
|
|
registry.add("pack-disabled", {"version": "1.0.0", "enabled": False, "priority": 1})
|
|
registry.add("pack-default", {"version": "1.0.0", "priority": 10}) # no enabled field = True
|
|
|
|
# Default: exclude disabled
|
|
by_priority = registry.list_by_priority()
|
|
pack_ids = [p[0] for p in by_priority]
|
|
assert "pack-enabled" in pack_ids
|
|
assert "pack-default" in pack_ids
|
|
assert "pack-disabled" not in pack_ids
|
|
|
|
def test_list_by_priority_includes_disabled_when_requested(self, temp_dir):
|
|
"""Test that list_by_priority includes disabled presets when requested."""
|
|
packs_dir = temp_dir / "packs"
|
|
packs_dir.mkdir()
|
|
registry = PresetRegistry(packs_dir)
|
|
|
|
registry.add("pack-enabled", {"version": "1.0.0", "enabled": True, "priority": 5})
|
|
registry.add("pack-disabled", {"version": "1.0.0", "enabled": False, "priority": 1})
|
|
|
|
# Include disabled
|
|
by_priority = registry.list_by_priority(include_disabled=True)
|
|
pack_ids = [p[0] for p in by_priority]
|
|
assert "pack-enabled" in pack_ids
|
|
assert "pack-disabled" in pack_ids
|
|
# Disabled pack has lower priority number, so it comes first when included
|
|
assert pack_ids[0] == "pack-disabled"
|
|
|
|
|
|
# ===== PresetManager Tests =====
|
|
|
|
|
|
class TestPresetManager:
|
|
"""Test PresetManager installation and removal."""
|
|
|
|
def test_install_from_directory(self, project_dir, pack_dir):
|
|
"""Test installing a preset from a directory."""
|
|
manager = PresetManager(project_dir)
|
|
manifest = manager.install_from_directory(pack_dir, "0.1.5")
|
|
|
|
assert manifest.id == "test-pack"
|
|
assert manager.registry.is_installed("test-pack")
|
|
|
|
# Verify files are copied
|
|
installed_dir = project_dir / ".specify" / "presets" / "test-pack"
|
|
assert installed_dir.exists()
|
|
assert (installed_dir / "preset.yml").exists()
|
|
assert (installed_dir / "templates" / "spec-template.md").exists()
|
|
|
|
def test_install_already_installed(self, project_dir, pack_dir):
|
|
"""Test installing an already-installed pack raises error."""
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(pack_dir, "0.1.5")
|
|
|
|
with pytest.raises(PresetError, match="already installed"):
|
|
manager.install_from_directory(pack_dir, "0.1.5")
|
|
|
|
def test_install_incompatible(self, project_dir, temp_dir, valid_pack_data):
|
|
"""Test installing an incompatible pack raises error."""
|
|
valid_pack_data["requires"]["speckit_version"] = ">=99.0.0"
|
|
incompat_dir = temp_dir / "incompat-pack"
|
|
incompat_dir.mkdir()
|
|
manifest_path = incompat_dir / "preset.yml"
|
|
with open(manifest_path, 'w') as f:
|
|
yaml.dump(valid_pack_data, f)
|
|
(incompat_dir / "templates").mkdir()
|
|
(incompat_dir / "templates" / "spec-template.md").write_text("test")
|
|
|
|
manager = PresetManager(project_dir)
|
|
with pytest.raises(PresetCompatibilityError):
|
|
manager.install_from_directory(incompat_dir, "0.1.5")
|
|
|
|
def test_install_from_zip(self, project_dir, pack_dir, temp_dir):
|
|
"""Test installing from a ZIP file."""
|
|
zip_path = temp_dir / "test-pack.zip"
|
|
with zipfile.ZipFile(zip_path, 'w') as zf:
|
|
for file_path in pack_dir.rglob('*'):
|
|
if file_path.is_file():
|
|
arcname = file_path.relative_to(pack_dir)
|
|
zf.write(file_path, arcname)
|
|
|
|
manager = PresetManager(project_dir)
|
|
manifest = manager.install_from_zip(zip_path, "0.1.5")
|
|
assert manifest.id == "test-pack"
|
|
assert manager.registry.is_installed("test-pack")
|
|
|
|
def test_install_from_zip_nested(self, project_dir, pack_dir, temp_dir):
|
|
"""Test installing from ZIP with nested directory."""
|
|
zip_path = temp_dir / "test-pack.zip"
|
|
with zipfile.ZipFile(zip_path, 'w') as zf:
|
|
for file_path in pack_dir.rglob('*'):
|
|
if file_path.is_file():
|
|
arcname = Path("test-pack-v1.0.0") / file_path.relative_to(pack_dir)
|
|
zf.write(file_path, arcname)
|
|
|
|
manager = PresetManager(project_dir)
|
|
manifest = manager.install_from_zip(zip_path, "0.1.5")
|
|
assert manifest.id == "test-pack"
|
|
|
|
def test_install_from_zip_no_manifest(self, project_dir, temp_dir):
|
|
"""Test installing from ZIP without manifest raises error."""
|
|
zip_path = temp_dir / "bad.zip"
|
|
with zipfile.ZipFile(zip_path, 'w') as zf:
|
|
zf.writestr("readme.txt", "no manifest here")
|
|
|
|
manager = PresetManager(project_dir)
|
|
with pytest.raises(PresetValidationError, match="No preset.yml found"):
|
|
manager.install_from_zip(zip_path, "0.1.5")
|
|
|
|
def test_remove(self, project_dir, pack_dir):
|
|
"""Test removing a preset."""
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(pack_dir, "0.1.5")
|
|
assert manager.registry.is_installed("test-pack")
|
|
|
|
result = manager.remove("test-pack")
|
|
assert result is True
|
|
assert not manager.registry.is_installed("test-pack")
|
|
|
|
installed_dir = project_dir / ".specify" / "presets" / "test-pack"
|
|
assert not installed_dir.exists()
|
|
|
|
def test_remove_nonexistent(self, project_dir):
|
|
"""Test removing a pack that doesn't exist."""
|
|
manager = PresetManager(project_dir)
|
|
result = manager.remove("nonexistent")
|
|
assert result is False
|
|
|
|
def test_list_installed(self, project_dir, pack_dir):
|
|
"""Test listing installed packs."""
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(pack_dir, "0.1.5")
|
|
|
|
installed = manager.list_installed()
|
|
assert len(installed) == 1
|
|
assert installed[0]["id"] == "test-pack"
|
|
assert installed[0]["name"] == "Test Preset"
|
|
assert installed[0]["version"] == "1.0.0"
|
|
assert installed[0]["template_count"] == 1
|
|
|
|
def test_list_installed_empty(self, project_dir):
|
|
"""Test listing when no packs installed."""
|
|
manager = PresetManager(project_dir)
|
|
assert manager.list_installed() == []
|
|
|
|
def test_get_pack(self, project_dir, pack_dir):
|
|
"""Test getting a specific installed pack."""
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(pack_dir, "0.1.5")
|
|
|
|
pack = manager.get_pack("test-pack")
|
|
assert pack is not None
|
|
assert pack.id == "test-pack"
|
|
|
|
def test_get_pack_not_installed(self, project_dir):
|
|
"""Test getting a non-installed pack returns None."""
|
|
manager = PresetManager(project_dir)
|
|
assert manager.get_pack("nonexistent") is None
|
|
|
|
def test_check_compatibility_valid(self, pack_dir, temp_dir):
|
|
"""Test compatibility check with valid version."""
|
|
manager = PresetManager(temp_dir)
|
|
manifest = PresetManifest(pack_dir / "preset.yml")
|
|
assert manager.check_compatibility(manifest, "0.1.5") is True
|
|
|
|
def test_check_compatibility_prerelease(self, pack_dir, temp_dir):
|
|
"""Test compatibility check allows prereleases and fails on boundary."""
|
|
manager = PresetManager(temp_dir)
|
|
manifest = PresetManifest(pack_dir / "preset.yml")
|
|
# manifest requires >=0.1.0
|
|
assert manager.check_compatibility(manifest, "0.8.8.dev0") is True
|
|
with pytest.raises(PresetCompatibilityError, match="Preset requires spec-kit"):
|
|
manager.check_compatibility(manifest, "0.1.0.dev0")
|
|
|
|
def test_check_compatibility_invalid(self, pack_dir, temp_dir):
|
|
"""Test compatibility check with invalid specifier."""
|
|
manager = PresetManager(temp_dir)
|
|
manifest = PresetManifest(pack_dir / "preset.yml")
|
|
manifest.data["requires"]["speckit_version"] = "not-a-specifier"
|
|
with pytest.raises(PresetCompatibilityError, match="Invalid version specifier"):
|
|
manager.check_compatibility(manifest, "0.1.5")
|
|
|
|
def test_install_with_priority(self, project_dir, pack_dir):
|
|
"""Test installing a pack with custom priority."""
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(pack_dir, "0.1.5", priority=5)
|
|
|
|
metadata = manager.registry.get("test-pack")
|
|
assert metadata is not None
|
|
assert metadata["priority"] == 5
|
|
|
|
def test_install_default_priority(self, project_dir, pack_dir):
|
|
"""Test that default priority is 10."""
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(pack_dir, "0.1.5")
|
|
|
|
metadata = manager.registry.get("test-pack")
|
|
assert metadata is not None
|
|
assert metadata["priority"] == 10
|
|
|
|
def test_list_installed_includes_priority(self, project_dir, pack_dir):
|
|
"""Test that list_installed includes priority."""
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(pack_dir, "0.1.5", priority=3)
|
|
|
|
installed = manager.list_installed()
|
|
assert len(installed) == 1
|
|
assert installed[0]["priority"] == 3
|
|
|
|
|
|
class TestRegistryPriority:
|
|
"""Test registry priority sorting."""
|
|
|
|
def test_list_by_priority(self, temp_dir):
|
|
"""Test that list_by_priority sorts by priority number."""
|
|
packs_dir = temp_dir / "packs"
|
|
packs_dir.mkdir()
|
|
registry = PresetRegistry(packs_dir)
|
|
|
|
registry.add("pack-high", {"version": "1.0.0", "priority": 1})
|
|
registry.add("pack-low", {"version": "1.0.0", "priority": 20})
|
|
registry.add("pack-mid", {"version": "1.0.0", "priority": 10})
|
|
|
|
sorted_packs = registry.list_by_priority()
|
|
assert len(sorted_packs) == 3
|
|
assert sorted_packs[0][0] == "pack-high"
|
|
assert sorted_packs[1][0] == "pack-mid"
|
|
assert sorted_packs[2][0] == "pack-low"
|
|
|
|
def test_list_by_priority_default(self, temp_dir):
|
|
"""Test that packs without priority default to 10."""
|
|
packs_dir = temp_dir / "packs"
|
|
packs_dir.mkdir()
|
|
registry = PresetRegistry(packs_dir)
|
|
|
|
registry.add("pack-a", {"version": "1.0.0"}) # no priority, defaults to 10
|
|
registry.add("pack-b", {"version": "1.0.0", "priority": 5})
|
|
|
|
sorted_packs = registry.list_by_priority()
|
|
assert sorted_packs[0][0] == "pack-b"
|
|
assert sorted_packs[1][0] == "pack-a"
|
|
|
|
def test_list_by_priority_invalid_priority_defaults(self, temp_dir):
|
|
"""Malformed priority values fall back to the default priority."""
|
|
packs_dir = temp_dir / "packs"
|
|
packs_dir.mkdir()
|
|
registry = PresetRegistry(packs_dir)
|
|
|
|
registry.add("pack-high", {"version": "1.0.0", "priority": 1})
|
|
registry.data["presets"]["pack-invalid"] = {
|
|
"version": "1.0.0",
|
|
"priority": "high",
|
|
}
|
|
registry._save()
|
|
|
|
sorted_packs = registry.list_by_priority()
|
|
|
|
assert [item[0] for item in sorted_packs] == ["pack-high", "pack-invalid"]
|
|
assert sorted_packs[1][1]["priority"] == 10
|
|
|
|
|
|
# ===== PresetResolver Tests =====
|
|
|
|
|
|
class TestPresetResolver:
|
|
"""Test PresetResolver priority stack."""
|
|
|
|
def test_resolve_core_template(self, project_dir):
|
|
"""Test resolving a core template."""
|
|
resolver = PresetResolver(project_dir)
|
|
result = resolver.resolve("spec-template")
|
|
assert result is not None
|
|
assert result.name == "spec-template.md"
|
|
assert "Core Spec Template" in result.read_text()
|
|
|
|
def test_resolve_nonexistent(self, project_dir):
|
|
"""Test resolving a nonexistent template returns None."""
|
|
resolver = PresetResolver(project_dir)
|
|
result = resolver.resolve("nonexistent-template")
|
|
assert result is None
|
|
|
|
def test_resolve_higher_priority_pack_wins(self, project_dir, temp_dir, valid_pack_data):
|
|
"""Test that a pack with lower priority number wins over higher number."""
|
|
manager = PresetManager(project_dir)
|
|
|
|
# Create pack A (priority 10 — lower precedence)
|
|
pack_a_dir = temp_dir / "pack-a"
|
|
pack_a_dir.mkdir()
|
|
data_a = {**valid_pack_data}
|
|
data_a["preset"] = {**valid_pack_data["preset"], "id": "pack-a", "name": "Pack A"}
|
|
with open(pack_a_dir / "preset.yml", 'w') as f:
|
|
yaml.dump(data_a, f)
|
|
(pack_a_dir / "templates").mkdir()
|
|
(pack_a_dir / "templates" / "spec-template.md").write_text("# From Pack A\n")
|
|
|
|
# Create pack B (priority 1 — higher precedence)
|
|
pack_b_dir = temp_dir / "pack-b"
|
|
pack_b_dir.mkdir()
|
|
data_b = {**valid_pack_data}
|
|
data_b["preset"] = {**valid_pack_data["preset"], "id": "pack-b", "name": "Pack B"}
|
|
with open(pack_b_dir / "preset.yml", 'w') as f:
|
|
yaml.dump(data_b, f)
|
|
(pack_b_dir / "templates").mkdir()
|
|
(pack_b_dir / "templates" / "spec-template.md").write_text("# From Pack B\n")
|
|
|
|
# Install A first (priority 10), B second (priority 1)
|
|
manager.install_from_directory(pack_a_dir, "0.1.5", priority=10)
|
|
manager.install_from_directory(pack_b_dir, "0.1.5", priority=1)
|
|
|
|
# Pack B should win because lower priority number
|
|
resolver = PresetResolver(project_dir)
|
|
result = resolver.resolve("spec-template")
|
|
assert result is not None
|
|
assert "From Pack B" in result.read_text()
|
|
|
|
def test_resolve_override_takes_priority(self, project_dir):
|
|
"""Test that project overrides take priority over core."""
|
|
# Create override
|
|
overrides_dir = project_dir / ".specify" / "templates" / "overrides"
|
|
overrides_dir.mkdir(parents=True)
|
|
override = overrides_dir / "spec-template.md"
|
|
override.write_text("# Override Spec Template\n")
|
|
|
|
resolver = PresetResolver(project_dir)
|
|
result = resolver.resolve("spec-template")
|
|
assert result is not None
|
|
assert "Override Spec Template" in result.read_text()
|
|
|
|
def test_resolve_pack_takes_priority_over_core(self, project_dir, pack_dir):
|
|
"""Test that installed packs take priority over core templates."""
|
|
# Install the pack
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(pack_dir, "0.1.5")
|
|
|
|
resolver = PresetResolver(project_dir)
|
|
result = resolver.resolve("spec-template")
|
|
assert result is not None
|
|
assert "Custom Spec Template" in result.read_text()
|
|
|
|
def _install_pack_with_manifest_file(self, project_dir, *, extra_file=False):
|
|
"""Create a pack whose manifest declares a NON-convention file: path.
|
|
|
|
Returns the pack dir under the project. The declared file lives at
|
|
custom/spec.md (not the convention templates/spec-template.md).
|
|
"""
|
|
presets_dir = project_dir / ".specify" / "presets"
|
|
pack_dir = presets_dir / "mypack"
|
|
(pack_dir / "custom").mkdir(parents=True)
|
|
(pack_dir / "custom" / "spec.md").write_text(
|
|
"# Manifest-declared Spec\n", encoding="utf-8"
|
|
)
|
|
if extra_file:
|
|
# An undeclared convention-path file the manifest points away from.
|
|
(pack_dir / "templates").mkdir()
|
|
(pack_dir / "templates" / "spec-template.md").write_text(
|
|
"# Stray Convention Spec\n", encoding="utf-8"
|
|
)
|
|
manifest = {
|
|
"schema_version": "1.0",
|
|
"preset": {
|
|
"id": "mypack",
|
|
"name": "My Pack",
|
|
"version": "1.0.0",
|
|
"description": "declares a non-convention file path",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"templates": [
|
|
{
|
|
"type": "template",
|
|
"name": "spec-template",
|
|
"file": "custom/spec.md",
|
|
"strategy": "replace",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
with open(pack_dir / "preset.yml", "w") as f:
|
|
yaml.dump(manifest, f)
|
|
PresetRegistry(presets_dir).add(
|
|
"mypack", {"version": "1.0.0", "priority": 10}
|
|
)
|
|
return pack_dir
|
|
|
|
def test_resolve_uses_manifest_declared_file_path(self, project_dir):
|
|
"""resolve() must honor a manifest-declared non-convention file: path.
|
|
|
|
Previously the tier-2 loop was convention-only, so it returned the
|
|
core template and resolve_with_source() misattributed source='core',
|
|
diverging from collect_all_layers()/resolve_content().
|
|
"""
|
|
pack_dir = self._install_pack_with_manifest_file(project_dir)
|
|
resolver = PresetResolver(project_dir)
|
|
|
|
result = resolver.resolve("spec-template")
|
|
assert result == pack_dir / "custom" / "spec.md"
|
|
assert "Manifest-declared Spec" in result.read_text()
|
|
|
|
sourced = resolver.resolve_with_source("spec-template")
|
|
assert sourced is not None
|
|
assert "mypack" in sourced["source"]
|
|
# resolve() must agree with collect_all_layers()'s top layer.
|
|
layers = resolver.collect_all_layers("spec-template")
|
|
assert Path(layers[0]["path"]) == pack_dir / "custom" / "spec.md"
|
|
|
|
def test_resolve_manifest_file_wins_over_undeclared_convention_file(
|
|
self, project_dir
|
|
):
|
|
"""A stray convention-path file must not shadow the manifest's file:."""
|
|
pack_dir = self._install_pack_with_manifest_file(
|
|
project_dir, extra_file=True
|
|
)
|
|
resolver = PresetResolver(project_dir)
|
|
result = resolver.resolve("spec-template")
|
|
assert result == pack_dir / "custom" / "spec.md"
|
|
assert "Manifest-declared Spec" in result.read_text()
|
|
|
|
def test_resolve_skips_convention_when_manifest_file_missing(self, project_dir):
|
|
"""When the manifest declares a file: that does not exist, resolve()
|
|
must NOT fall back to a convention file in the same pack (that would
|
|
mask a typo) — it skips the pack and resolves core instead."""
|
|
presets_dir = project_dir / ".specify" / "presets"
|
|
pack_dir = presets_dir / "mypack"
|
|
# Manifest declares custom/spec.md (MISSING); a convention file exists
|
|
# in the pack and must NOT be used.
|
|
(pack_dir / "templates").mkdir(parents=True)
|
|
(pack_dir / "templates" / "spec-template.md").write_text(
|
|
"# Stray Convention Spec\n", encoding="utf-8"
|
|
)
|
|
manifest = {
|
|
"schema_version": "1.0",
|
|
"preset": {
|
|
"id": "mypack",
|
|
"name": "My Pack",
|
|
"version": "1.0.0",
|
|
"description": "declares a missing file path",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"templates": [
|
|
{
|
|
"type": "template",
|
|
"name": "spec-template",
|
|
"file": "custom/spec.md",
|
|
"strategy": "replace",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
with open(pack_dir / "preset.yml", "w") as f:
|
|
yaml.dump(manifest, f)
|
|
PresetRegistry(presets_dir).add(
|
|
"mypack", {"version": "1.0.0", "priority": 10}
|
|
)
|
|
|
|
resolver = PresetResolver(project_dir)
|
|
result = resolver.resolve("spec-template")
|
|
assert result is not None
|
|
content = result.read_text()
|
|
assert "Stray Convention Spec" not in content # pack convention skipped
|
|
assert "Core Spec Template" in content # fell through to core
|
|
|
|
def test_resolve_skips_convention_when_manifest_file_is_directory(
|
|
self, project_dir
|
|
):
|
|
"""When the manifest's file: path resolves to a DIRECTORY (not a regular
|
|
file), resolve()/collect_all_layers() must treat it as missing — exists()
|
|
would accept it and downstream read_text() on a directory would crash.
|
|
The pack is skipped (no convention fallback), so core wins."""
|
|
presets_dir = project_dir / ".specify" / "presets"
|
|
pack_dir = presets_dir / "mypack"
|
|
# Declared file: custom/spec.md is created as a DIRECTORY.
|
|
(pack_dir / "custom" / "spec.md").mkdir(parents=True)
|
|
# A convention file also exists and must NOT be used.
|
|
(pack_dir / "templates").mkdir(parents=True)
|
|
(pack_dir / "templates" / "spec-template.md").write_text(
|
|
"# Stray Convention Spec\n", encoding="utf-8"
|
|
)
|
|
manifest = {
|
|
"schema_version": "1.0",
|
|
"preset": {
|
|
"id": "mypack",
|
|
"name": "My Pack",
|
|
"version": "1.0.0",
|
|
"description": "declares a file: that is actually a directory",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"templates": [
|
|
{
|
|
"type": "template",
|
|
"name": "spec-template",
|
|
"file": "custom/spec.md",
|
|
"strategy": "replace",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
with open(pack_dir / "preset.yml", "w") as f:
|
|
yaml.dump(manifest, f)
|
|
PresetRegistry(presets_dir).add(
|
|
"mypack", {"version": "1.0.0", "priority": 10}
|
|
)
|
|
|
|
resolver = PresetResolver(project_dir)
|
|
result = resolver.resolve("spec-template")
|
|
assert result is not None
|
|
assert result.is_file() # never a directory
|
|
content = result.read_text()
|
|
assert "Stray Convention Spec" not in content # pack convention skipped
|
|
assert "Core Spec Template" in content # fell through to core
|
|
# collect_all_layers() must agree: the directory is not a layer.
|
|
layers = resolver.collect_all_layers("spec-template")
|
|
assert all(Path(layer["path"]).is_file() for layer in layers)
|
|
assert all(
|
|
Path(layer["path"]) != pack_dir / "custom" / "spec.md"
|
|
for layer in layers
|
|
)
|
|
|
|
def test_resolve_override_takes_priority_over_pack(self, project_dir, pack_dir):
|
|
"""Test that overrides take priority over installed packs."""
|
|
# Install the pack
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(pack_dir, "0.1.5")
|
|
|
|
# Create override
|
|
overrides_dir = project_dir / ".specify" / "templates" / "overrides"
|
|
overrides_dir.mkdir(parents=True)
|
|
override = overrides_dir / "spec-template.md"
|
|
override.write_text("# Override Spec Template\n")
|
|
|
|
resolver = PresetResolver(project_dir)
|
|
result = resolver.resolve("spec-template")
|
|
assert result is not None
|
|
assert "Override Spec Template" in result.read_text()
|
|
|
|
def test_resolve_extension_provided_templates(self, project_dir):
|
|
"""Test resolving templates provided by extensions."""
|
|
# Create extension with templates
|
|
ext_dir = project_dir / ".specify" / "extensions" / "my-ext"
|
|
ext_templates_dir = ext_dir / "templates"
|
|
ext_templates_dir.mkdir(parents=True)
|
|
ext_template = ext_templates_dir / "custom-template.md"
|
|
ext_template.write_text("# Extension Custom Template\n")
|
|
|
|
# Register extension in registry
|
|
extensions_dir = project_dir / ".specify" / "extensions"
|
|
ext_registry = ExtensionRegistry(extensions_dir)
|
|
ext_registry.add("my-ext", {"version": "1.0.0", "priority": 10})
|
|
|
|
resolver = PresetResolver(project_dir)
|
|
result = resolver.resolve("custom-template")
|
|
assert result is not None
|
|
assert "Extension Custom Template" in result.read_text()
|
|
|
|
def test_resolve_disabled_extension_templates_skipped(self, project_dir):
|
|
"""Test that disabled extension templates are not resolved."""
|
|
# Create extension with templates
|
|
ext_dir = project_dir / ".specify" / "extensions" / "disabled-ext"
|
|
ext_templates_dir = ext_dir / "templates"
|
|
ext_templates_dir.mkdir(parents=True)
|
|
ext_template = ext_templates_dir / "disabled-template.md"
|
|
ext_template.write_text("# Disabled Extension Template\n")
|
|
|
|
# Register extension as disabled
|
|
extensions_dir = project_dir / ".specify" / "extensions"
|
|
ext_registry = ExtensionRegistry(extensions_dir)
|
|
ext_registry.add("disabled-ext", {"version": "1.0.0", "priority": 1, "enabled": False})
|
|
|
|
# Template should NOT be resolved because extension is disabled
|
|
resolver = PresetResolver(project_dir)
|
|
result = resolver.resolve("disabled-template")
|
|
assert result is None, "Disabled extension template should not be resolved"
|
|
|
|
def test_resolve_disabled_extension_not_picked_up_as_unregistered(self, project_dir):
|
|
"""Test that disabled extensions are not picked up via unregistered dir scan."""
|
|
# Create extension directory with templates
|
|
ext_dir = project_dir / ".specify" / "extensions" / "test-disabled-ext"
|
|
ext_templates_dir = ext_dir / "templates"
|
|
ext_templates_dir.mkdir(parents=True)
|
|
ext_template = ext_templates_dir / "unique-disabled-template.md"
|
|
ext_template.write_text("# Should Not Resolve\n")
|
|
|
|
# Register the extension but disable it
|
|
extensions_dir = project_dir / ".specify" / "extensions"
|
|
ext_registry = ExtensionRegistry(extensions_dir)
|
|
ext_registry.add("test-disabled-ext", {"version": "1.0.0", "enabled": False})
|
|
|
|
# Verify the template is NOT resolved (even though the directory exists)
|
|
resolver = PresetResolver(project_dir)
|
|
result = resolver.resolve("unique-disabled-template")
|
|
assert result is None, "Disabled extension should not be picked up as unregistered"
|
|
|
|
def test_resolve_pack_over_extension(self, project_dir, pack_dir, temp_dir, valid_pack_data):
|
|
"""Test that pack templates take priority over extension templates."""
|
|
# Create extension with templates
|
|
ext_dir = project_dir / ".specify" / "extensions" / "my-ext"
|
|
ext_templates_dir = ext_dir / "templates"
|
|
ext_templates_dir.mkdir(parents=True)
|
|
ext_template = ext_templates_dir / "spec-template.md"
|
|
ext_template.write_text("# Extension Spec Template\n")
|
|
|
|
# Install a pack with the same template
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(pack_dir, "0.1.5")
|
|
|
|
resolver = PresetResolver(project_dir)
|
|
result = resolver.resolve("spec-template")
|
|
assert result is not None
|
|
# Pack should win over extension
|
|
assert "Custom Spec Template" in result.read_text()
|
|
|
|
def test_resolve_with_source_core(self, project_dir):
|
|
"""Test resolve_with_source for core template."""
|
|
resolver = PresetResolver(project_dir)
|
|
result = resolver.resolve_with_source("spec-template")
|
|
assert result is not None
|
|
assert result["source"] == "core"
|
|
assert "spec-template.md" in result["path"]
|
|
|
|
def test_resolve_with_source_override(self, project_dir):
|
|
"""Test resolve_with_source for override template."""
|
|
overrides_dir = project_dir / ".specify" / "templates" / "overrides"
|
|
overrides_dir.mkdir(parents=True)
|
|
override = overrides_dir / "spec-template.md"
|
|
override.write_text("# Override\n")
|
|
|
|
resolver = PresetResolver(project_dir)
|
|
result = resolver.resolve_with_source("spec-template")
|
|
assert result is not None
|
|
assert result["source"] == "project override"
|
|
|
|
def test_resolve_with_source_pack(self, project_dir, pack_dir):
|
|
"""Test resolve_with_source for pack template."""
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(pack_dir, "0.1.5")
|
|
|
|
resolver = PresetResolver(project_dir)
|
|
result = resolver.resolve_with_source("spec-template")
|
|
assert result is not None
|
|
assert "test-pack" in result["source"]
|
|
assert "v1.0.0" in result["source"]
|
|
|
|
def test_resolve_with_source_extension(self, project_dir):
|
|
"""Test resolve_with_source for extension-provided template."""
|
|
ext_dir = project_dir / ".specify" / "extensions" / "my-ext"
|
|
ext_templates_dir = ext_dir / "templates"
|
|
ext_templates_dir.mkdir(parents=True)
|
|
ext_template = ext_templates_dir / "unique-template.md"
|
|
ext_template.write_text("# Unique\n")
|
|
|
|
# Register extension in registry
|
|
extensions_dir = project_dir / ".specify" / "extensions"
|
|
ext_registry = ExtensionRegistry(extensions_dir)
|
|
ext_registry.add("my-ext", {"version": "1.0.0", "priority": 10})
|
|
|
|
resolver = PresetResolver(project_dir)
|
|
result = resolver.resolve_with_source("unique-template")
|
|
assert result is not None
|
|
assert result["source"] == "extension:my-ext v1.0.0"
|
|
|
|
def test_resolve_with_source_not_found(self, project_dir):
|
|
"""Test resolve_with_source for nonexistent template."""
|
|
resolver = PresetResolver(project_dir)
|
|
result = resolver.resolve_with_source("nonexistent")
|
|
assert result is None
|
|
|
|
def test_resolve_skips_hidden_extension_dirs(self, project_dir):
|
|
"""Test that hidden directories in extensions are skipped."""
|
|
ext_dir = project_dir / ".specify" / "extensions" / ".backup"
|
|
ext_templates_dir = ext_dir / "templates"
|
|
ext_templates_dir.mkdir(parents=True)
|
|
ext_template = ext_templates_dir / "hidden-template.md"
|
|
ext_template.write_text("# Hidden\n")
|
|
|
|
resolver = PresetResolver(project_dir)
|
|
result = resolver.resolve("hidden-template")
|
|
assert result is None
|
|
|
|
def test_collect_all_layers_finds_bundled_core_without_specify_commands(
|
|
self, project_dir
|
|
):
|
|
"""Tier-5 fallback locates the bundled core command when
|
|
.specify/templates/commands/ has no matching file.
|
|
|
|
Regression test for #3086: a stale ``.parent`` chain made the
|
|
source-checkout fallback resolve to ``src/templates/...`` (which does
|
|
not exist), so ``wrap`` presets found no base layer. The fallback must
|
|
resolve against the real repo-root ``templates/commands`` tree.
|
|
"""
|
|
# project_dir's commands dir is empty, so tier-4 cannot satisfy this.
|
|
resolver = PresetResolver(project_dir)
|
|
layers = resolver.collect_all_layers("speckit.implement", "command")
|
|
assert layers, "expected a bundled core base layer to be found"
|
|
assert layers[-1]["source"] == "core (bundled)"
|
|
assert layers[-1]["path"].parts[-2:] == ("commands", "implement.md")
|
|
|
|
def test_resolve_command_falls_back_to_bundled_core(self, project_dir):
|
|
"""resolve() tier-5 returns the bundled core command when
|
|
.specify/templates/commands/ lacks it (regression for #3086)."""
|
|
resolver = PresetResolver(project_dir)
|
|
result = resolver.resolve("speckit.implement", "command")
|
|
assert result is not None
|
|
assert result.parts[-2:] == ("commands", "implement.md")
|
|
|
|
|
|
class TestResolveCore:
|
|
"""Test PresetResolver.resolve_core() skips the installed-presets tier."""
|
|
|
|
def test_resolve_core_does_not_return_preset_files(self, project_dir):
|
|
"""resolve_core must not return files from .specify/presets/."""
|
|
preset_cmd_dir = project_dir / ".specify" / "presets" / "my-preset" / "commands"
|
|
preset_cmd_dir.mkdir(parents=True)
|
|
(preset_cmd_dir / "specify.md").write_text("---\ndescription: preset wrap\n---\n\nwrap body\n")
|
|
|
|
resolver = PresetResolver(project_dir)
|
|
result = resolver.resolve_core("specify", "command")
|
|
# The preset file must never be returned — but the bundled core may be.
|
|
if result is not None:
|
|
assert "presets" not in result.parts
|
|
|
|
def test_resolve_core_returns_core_template(self, project_dir):
|
|
"""resolve_core falls through to core templates (tier 4)."""
|
|
core_cmd_dir = project_dir / ".specify" / "templates" / "commands"
|
|
core_cmd_dir.mkdir(parents=True, exist_ok=True)
|
|
(core_cmd_dir / "specify.md").write_text("---\ndescription: core\n---\n\ncore body\n")
|
|
|
|
# Also place a preset file — resolve_core must still return the core
|
|
preset_cmd_dir = project_dir / ".specify" / "presets" / "my-preset" / "commands"
|
|
preset_cmd_dir.mkdir(parents=True)
|
|
(preset_cmd_dir / "specify.md").write_text("---\ndescription: preset wrap\n---\n\nwrap body\n")
|
|
|
|
resolver = PresetResolver(project_dir)
|
|
result = resolver.resolve_core("specify", "command")
|
|
assert result is not None
|
|
assert "presets" not in result.parts
|
|
assert result.parts[-3:] == ("templates", "commands", "specify.md")
|
|
|
|
def test_resolve_core_returns_override(self, project_dir):
|
|
"""resolve_core returns tier-1 override if present."""
|
|
override_dir = project_dir / ".specify" / "templates" / "overrides"
|
|
override_dir.mkdir(parents=True)
|
|
(override_dir / "specify.md").write_text("---\ndescription: override\n---\n\noverride body\n")
|
|
|
|
resolver = PresetResolver(project_dir)
|
|
result = resolver.resolve_core("specify", "command")
|
|
assert result is not None
|
|
assert result.parts[-2:] == ("overrides", "specify.md")
|
|
|
|
def test_resolve_core_returns_extension_template(self, project_dir):
|
|
"""resolve_core returns extension templates (tier 3)."""
|
|
ext_cmd_dir = project_dir / ".specify" / "extensions" / "myext" / "commands"
|
|
ext_cmd_dir.mkdir(parents=True)
|
|
(ext_cmd_dir / "myext-cmd.md").write_text("---\ndescription: ext\n---\n\next body\n")
|
|
|
|
resolver = PresetResolver(project_dir)
|
|
result = resolver.resolve_core("myext-cmd", "command")
|
|
assert result is not None
|
|
assert result.parts[-4:-1] == ("extensions", "myext", "commands")
|
|
|
|
def test_resolve_core_returns_none_when_nothing_found(self, project_dir):
|
|
"""resolve_core returns None when no file found in tiers 1/3/4."""
|
|
resolver = PresetResolver(project_dir)
|
|
result = resolver.resolve_core("nonexistent", "command")
|
|
assert result is None
|
|
|
|
def test_resolve_extension_command_via_manifest_skips_oserror_manifests(self, project_dir):
|
|
"""resolve_extension_command_via_manifest skips extensions whose manifest raises OSError."""
|
|
import unittest.mock as mock
|
|
|
|
ext_dir = project_dir / ".specify" / "extensions" / "bad-ext"
|
|
cmd_dir = ext_dir / "commands"
|
|
cmd_dir.mkdir(parents=True)
|
|
(cmd_dir / "mycmd.md").write_text("---\ndescription: d\n---\n\nbody\n")
|
|
(ext_dir / "extension.yml").write_text(
|
|
"schema_version: '1.0'\n"
|
|
"extension:\n id: bad-ext\n name: Bad\n version: 1.0.0\n"
|
|
" description: d\n author: a\n repository: https://example.com\n"
|
|
" license: MIT\n"
|
|
"requires:\n speckit_version: '>=0.2.0'\n"
|
|
"provides:\n commands:\n"
|
|
" - name: speckit.bad-ext.mycmd\n"
|
|
" file: commands/mycmd.md\n"
|
|
" description: My command\n"
|
|
)
|
|
|
|
resolver = PresetResolver(project_dir)
|
|
# Simulate a permission error when opening the manifest file.
|
|
with mock.patch("builtins.open", side_effect=PermissionError("denied")):
|
|
result = resolver.resolve_extension_command_via_manifest("speckit.bad-ext.mycmd")
|
|
|
|
assert result is None, "OSError during manifest load must be silently skipped"
|
|
|
|
|
|
class TestExtensionPriorityResolution:
|
|
"""Test extension priority resolution with registered and unregistered extensions."""
|
|
|
|
def test_unregistered_beats_registered_with_lower_precedence(self, project_dir):
|
|
"""Unregistered extension (implicit priority 10) beats registered with priority 20."""
|
|
extensions_dir = project_dir / ".specify" / "extensions"
|
|
extensions_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Create registered extension with priority 20 (lower precedence than 10)
|
|
registered_dir = extensions_dir / "registered-ext"
|
|
(registered_dir / "templates").mkdir(parents=True)
|
|
(registered_dir / "templates" / "test-template.md").write_text("# From Registered\n")
|
|
|
|
ext_registry = ExtensionRegistry(extensions_dir)
|
|
ext_registry.add("registered-ext", {"version": "1.0.0", "priority": 20})
|
|
|
|
# Create unregistered extension directory (implicit priority 10)
|
|
unregistered_dir = extensions_dir / "unregistered-ext"
|
|
(unregistered_dir / "templates").mkdir(parents=True)
|
|
(unregistered_dir / "templates" / "test-template.md").write_text("# From Unregistered\n")
|
|
|
|
# Unregistered (priority 10) should beat registered (priority 20)
|
|
resolver = PresetResolver(project_dir)
|
|
result = resolver.resolve("test-template")
|
|
assert result is not None
|
|
assert "From Unregistered" in result.read_text()
|
|
|
|
def test_registered_with_higher_precedence_beats_unregistered(self, project_dir):
|
|
"""Registered extension with priority 5 beats unregistered (implicit priority 10)."""
|
|
extensions_dir = project_dir / ".specify" / "extensions"
|
|
extensions_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Create registered extension with priority 5 (higher precedence than 10)
|
|
registered_dir = extensions_dir / "registered-ext"
|
|
(registered_dir / "templates").mkdir(parents=True)
|
|
(registered_dir / "templates" / "test-template.md").write_text("# From Registered\n")
|
|
|
|
ext_registry = ExtensionRegistry(extensions_dir)
|
|
ext_registry.add("registered-ext", {"version": "1.0.0", "priority": 5})
|
|
|
|
# Create unregistered extension directory (implicit priority 10)
|
|
unregistered_dir = extensions_dir / "unregistered-ext"
|
|
(unregistered_dir / "templates").mkdir(parents=True)
|
|
(unregistered_dir / "templates" / "test-template.md").write_text("# From Unregistered\n")
|
|
|
|
# Registered (priority 5) should beat unregistered (priority 10)
|
|
resolver = PresetResolver(project_dir)
|
|
result = resolver.resolve("test-template")
|
|
assert result is not None
|
|
assert "From Registered" in result.read_text()
|
|
|
|
def test_unregistered_attribution_with_priority_ordering(self, project_dir):
|
|
"""Test resolve_with_source correctly attributes unregistered extension."""
|
|
extensions_dir = project_dir / ".specify" / "extensions"
|
|
extensions_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Create registered extension with priority 20
|
|
registered_dir = extensions_dir / "registered-ext"
|
|
(registered_dir / "templates").mkdir(parents=True)
|
|
(registered_dir / "templates" / "test-template.md").write_text("# From Registered\n")
|
|
|
|
ext_registry = ExtensionRegistry(extensions_dir)
|
|
ext_registry.add("registered-ext", {"version": "1.0.0", "priority": 20})
|
|
|
|
# Create unregistered extension (implicit priority 10)
|
|
unregistered_dir = extensions_dir / "unregistered-ext"
|
|
(unregistered_dir / "templates").mkdir(parents=True)
|
|
(unregistered_dir / "templates" / "test-template.md").write_text("# From Unregistered\n")
|
|
|
|
# Attribution should show unregistered extension
|
|
resolver = PresetResolver(project_dir)
|
|
result = resolver.resolve_with_source("test-template")
|
|
assert result is not None
|
|
assert "unregistered-ext" in result["source"]
|
|
assert "(unregistered)" in result["source"]
|
|
|
|
def test_same_priority_sorted_alphabetically(self, project_dir):
|
|
"""Extensions with same priority are sorted alphabetically by ID."""
|
|
extensions_dir = project_dir / ".specify" / "extensions"
|
|
extensions_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Create two unregistered extensions (both implicit priority 10)
|
|
# "aaa-ext" should come before "zzz-ext" alphabetically
|
|
zzz_dir = extensions_dir / "zzz-ext"
|
|
(zzz_dir / "templates").mkdir(parents=True)
|
|
(zzz_dir / "templates" / "test-template.md").write_text("# From ZZZ\n")
|
|
|
|
aaa_dir = extensions_dir / "aaa-ext"
|
|
(aaa_dir / "templates").mkdir(parents=True)
|
|
(aaa_dir / "templates" / "test-template.md").write_text("# From AAA\n")
|
|
|
|
# AAA should win due to alphabetical ordering at same priority
|
|
resolver = PresetResolver(project_dir)
|
|
result = resolver.resolve("test-template")
|
|
assert result is not None
|
|
assert "From AAA" in result.read_text()
|
|
|
|
|
|
# ===== PresetCatalog Tests =====
|
|
|
|
|
|
class TestPresetCatalog:
|
|
"""Test template catalog functionality."""
|
|
|
|
def _inject_github_config(self, monkeypatch, token_env="GH_TOKEN"):
|
|
from tests.auth_helpers import inject_github_config
|
|
inject_github_config(monkeypatch, token_env)
|
|
|
|
def test_default_catalog_url(self, project_dir):
|
|
"""Test default catalog URL."""
|
|
catalog = PresetCatalog(project_dir)
|
|
assert catalog.DEFAULT_CATALOG_URL.startswith("https://")
|
|
assert catalog.DEFAULT_CATALOG_URL.endswith("/presets/catalog.json")
|
|
|
|
def test_community_catalog_url(self, project_dir):
|
|
"""Test community catalog URL."""
|
|
catalog = PresetCatalog(project_dir)
|
|
assert "presets/catalog.community.json" in catalog.COMMUNITY_CATALOG_URL
|
|
|
|
def test_cache_validation_no_cache(self, project_dir):
|
|
"""Test cache validation when no cache exists."""
|
|
catalog = PresetCatalog(project_dir)
|
|
assert catalog.is_cache_valid() is False
|
|
|
|
def test_cache_validation_valid(self, project_dir):
|
|
"""Test cache validation with valid cache."""
|
|
catalog = PresetCatalog(project_dir)
|
|
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
catalog.cache_file.write_text(json.dumps({
|
|
"schema_version": "1.0",
|
|
"presets": {},
|
|
}))
|
|
catalog.cache_metadata_file.write_text(json.dumps({
|
|
"cached_at": datetime.now(timezone.utc).isoformat(),
|
|
}))
|
|
|
|
assert catalog.is_cache_valid() is True
|
|
|
|
def test_cache_validation_expired(self, project_dir):
|
|
"""Test cache validation with expired cache."""
|
|
catalog = PresetCatalog(project_dir)
|
|
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
catalog.cache_file.write_text(json.dumps({
|
|
"schema_version": "1.0",
|
|
"presets": {},
|
|
}))
|
|
catalog.cache_metadata_file.write_text(json.dumps({
|
|
"cached_at": "2020-01-01T00:00:00+00:00",
|
|
}))
|
|
|
|
assert catalog.is_cache_valid() is False
|
|
|
|
def test_cache_validation_corrupted(self, project_dir):
|
|
"""Test cache validation with corrupted metadata."""
|
|
catalog = PresetCatalog(project_dir)
|
|
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
catalog.cache_file.write_text("not json")
|
|
catalog.cache_metadata_file.write_text("not json")
|
|
|
|
assert catalog.is_cache_valid() is False
|
|
|
|
def test_clear_cache(self, project_dir):
|
|
"""Test clearing the cache."""
|
|
catalog = PresetCatalog(project_dir)
|
|
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
catalog.cache_file.write_text("{}")
|
|
catalog.cache_metadata_file.write_text("{}")
|
|
|
|
catalog.clear_cache()
|
|
|
|
assert not catalog.cache_file.exists()
|
|
assert not catalog.cache_metadata_file.exists()
|
|
|
|
def test_search_with_cached_data(self, project_dir, monkeypatch):
|
|
"""Test search with cached catalog data."""
|
|
from unittest.mock import patch
|
|
|
|
monkeypatch.delenv("SPECKIT_PRESET_CATALOG_URL", raising=False)
|
|
catalog = PresetCatalog(project_dir)
|
|
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
catalog_data = {
|
|
"schema_version": "1.0",
|
|
"presets": {
|
|
"safe-agile": {
|
|
"name": "SAFe Agile Templates",
|
|
"description": "SAFe-aligned templates",
|
|
"author": "agile-community",
|
|
"version": "1.0.0",
|
|
"tags": ["safe", "agile"],
|
|
},
|
|
"healthcare": {
|
|
"name": "Healthcare Compliance",
|
|
"description": "HIPAA-compliant templates",
|
|
"author": "healthcare-org",
|
|
"version": "1.0.0",
|
|
"tags": ["healthcare", "hipaa"],
|
|
},
|
|
}
|
|
}
|
|
|
|
catalog.cache_file.write_text(json.dumps(catalog_data))
|
|
catalog.cache_metadata_file.write_text(json.dumps({
|
|
"cached_at": datetime.now(timezone.utc).isoformat(),
|
|
}))
|
|
|
|
# Isolate from community catalog so results are deterministic
|
|
default_only = [PresetCatalogEntry(url=catalog.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True)]
|
|
with patch.object(catalog, "get_active_catalogs", return_value=default_only):
|
|
# Search by query
|
|
results = catalog.search(query="agile")
|
|
assert len(results) == 1
|
|
assert results[0]["id"] == "safe-agile"
|
|
|
|
# Search by tag
|
|
results = catalog.search(tag="hipaa")
|
|
assert len(results) == 1
|
|
assert results[0]["id"] == "healthcare"
|
|
|
|
# Search by author
|
|
results = catalog.search(author="agile-community")
|
|
assert len(results) == 1
|
|
|
|
# Search all
|
|
results = catalog.search()
|
|
assert len(results) == 2
|
|
|
|
def test_get_pack_info(self, project_dir):
|
|
"""Test getting info for a specific pack."""
|
|
catalog = PresetCatalog(project_dir)
|
|
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
catalog_data = {
|
|
"schema_version": "1.0",
|
|
"presets": {
|
|
"test-pack": {
|
|
"name": "Test Pack",
|
|
"version": "1.0.0",
|
|
},
|
|
}
|
|
}
|
|
|
|
catalog.cache_file.write_text(json.dumps(catalog_data))
|
|
catalog.cache_metadata_file.write_text(json.dumps({
|
|
"cached_at": datetime.now(timezone.utc).isoformat(),
|
|
}))
|
|
|
|
info = catalog.get_pack_info("test-pack")
|
|
assert info is not None
|
|
assert info["name"] == "Test Pack"
|
|
assert info["id"] == "test-pack"
|
|
|
|
assert catalog.get_pack_info("nonexistent") is None
|
|
|
|
def test_validate_catalog_url_https(self, project_dir):
|
|
"""Test that HTTPS URLs are accepted."""
|
|
catalog = PresetCatalog(project_dir)
|
|
catalog._validate_catalog_url("https://example.com/catalog.json")
|
|
|
|
def test_validate_catalog_url_http_rejected(self, project_dir):
|
|
"""Test that HTTP URLs are rejected."""
|
|
catalog = PresetCatalog(project_dir)
|
|
with pytest.raises(PresetValidationError, match="must use HTTPS"):
|
|
catalog._validate_catalog_url("http://example.com/catalog.json")
|
|
|
|
def test_validate_catalog_url_localhost_http_allowed(self, project_dir):
|
|
"""Test that HTTP is allowed for localhost."""
|
|
catalog = PresetCatalog(project_dir)
|
|
catalog._validate_catalog_url("http://localhost:8080/catalog.json")
|
|
catalog._validate_catalog_url("http://127.0.0.1:8080/catalog.json")
|
|
|
|
@pytest.mark.parametrize(
|
|
"url",
|
|
[
|
|
"https://:8080", # port only, no host
|
|
"https://:8080/catalog.json", # port only, with path
|
|
"https://:0", # port only, no host
|
|
"https://user@", # userinfo only, no host
|
|
"https://user:pass@", # userinfo only, no host
|
|
],
|
|
)
|
|
def test_validate_catalog_url_hostless_rejected(self, project_dir, url):
|
|
"""Reject host-less URLs whose netloc is truthy but hostname is None (#3209).
|
|
|
|
``urlparse('https://:8080').netloc`` is ``':8080'`` (truthy) but its
|
|
``hostname`` is ``None``, so a netloc-based check would accept a URL
|
|
with no actual host, contradicting the "valid URL with a host" error.
|
|
"""
|
|
catalog = PresetCatalog(project_dir)
|
|
with pytest.raises(PresetValidationError, match="valid URL with a host"):
|
|
catalog._validate_catalog_url(url)
|
|
|
|
def test_validate_catalog_url_malformed_rejected(self, project_dir):
|
|
"""A malformed URL raises PresetValidationError, not a raw ValueError.
|
|
|
|
``urlparse('https://[::1').hostname`` raises ``ValueError: Invalid IPv6
|
|
URL`` (unterminated bracket). Without wrapping, that leaks past callers'
|
|
``except PresetValidationError`` guards and crashes the CLI. Mirrors the
|
|
shared ``CatalogStackBase`` (#3435) and ``IntegrationCatalog`` behaviour.
|
|
"""
|
|
catalog = PresetCatalog(project_dir)
|
|
with pytest.raises(PresetValidationError, match="malformed"):
|
|
catalog._validate_catalog_url("https://[::1")
|
|
|
|
def test_env_var_catalog_url(self, project_dir, monkeypatch):
|
|
"""Test catalog URL from environment variable."""
|
|
monkeypatch.setenv("SPECKIT_PRESET_CATALOG_URL", "https://custom.example.com/catalog.json")
|
|
catalog = PresetCatalog(project_dir)
|
|
assert catalog.get_catalog_url() == "https://custom.example.com/catalog.json"
|
|
|
|
# --- _make_request / GitHub auth ---
|
|
|
|
def test_make_request_no_token_no_auth_header(self, project_dir, monkeypatch):
|
|
"""Without a token, requests carry no Authorization header."""
|
|
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
|
monkeypatch.delenv("GH_TOKEN", raising=False)
|
|
catalog = PresetCatalog(project_dir)
|
|
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
|
|
assert "Authorization" not in req.headers
|
|
|
|
def test_make_request_whitespace_only_github_token_ignored(self, project_dir, monkeypatch):
|
|
"""A whitespace-only GITHUB_TOKEN is treated as unset."""
|
|
monkeypatch.setenv("GITHUB_TOKEN", " ")
|
|
monkeypatch.delenv("GH_TOKEN", raising=False)
|
|
catalog = PresetCatalog(project_dir)
|
|
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
|
|
assert "Authorization" not in req.headers
|
|
|
|
def test_make_request_whitespace_github_token_falls_back_to_gh_token(self, project_dir, monkeypatch):
|
|
"""When GITHUB_TOKEN is whitespace-only, GH_TOKEN is used as fallback."""
|
|
monkeypatch.setenv("GITHUB_TOKEN", " ")
|
|
monkeypatch.setenv("GH_TOKEN", "ghp_fallback")
|
|
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
|
catalog = PresetCatalog(project_dir)
|
|
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
|
|
assert req.get_header("Authorization") == "Bearer ghp_fallback"
|
|
|
|
def test_make_request_github_token_added_for_github_url(self, project_dir, monkeypatch):
|
|
"""GITHUB_TOKEN is attached for raw.githubusercontent.com URLs."""
|
|
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
|
monkeypatch.delenv("GH_TOKEN", raising=False)
|
|
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
|
catalog = PresetCatalog(project_dir)
|
|
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
|
|
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
|
|
|
|
def test_make_request_gh_token_fallback(self, project_dir, monkeypatch):
|
|
"""GH_TOKEN is used when GITHUB_TOKEN is absent."""
|
|
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
|
monkeypatch.setenv("GH_TOKEN", "ghp_ghtoken")
|
|
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
|
catalog = PresetCatalog(project_dir)
|
|
req = catalog._make_request("https://github.com/org/repo/releases/download/v1/pack.zip")
|
|
assert req.get_header("Authorization") == "Bearer ghp_ghtoken"
|
|
|
|
def test_make_request_gh_token_takes_precedence(self, project_dir, monkeypatch):
|
|
"""When auth.json uses GH_TOKEN, that token is used regardless of GITHUB_TOKEN."""
|
|
monkeypatch.setenv("GITHUB_TOKEN", "ghp_secondary")
|
|
monkeypatch.setenv("GH_TOKEN", "ghp_primary")
|
|
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
|
catalog = PresetCatalog(project_dir)
|
|
req = catalog._make_request("https://api.github.com/repos/org/repo")
|
|
assert req.get_header("Authorization") == "Bearer ghp_primary"
|
|
|
|
def test_make_request_token_added_for_codeload_github_com(self, project_dir, monkeypatch):
|
|
"""GITHUB_TOKEN is attached for codeload.github.com URLs."""
|
|
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
|
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
|
catalog = PresetCatalog(project_dir)
|
|
req = catalog._make_request("https://codeload.github.com/org/repo/zip/refs/tags/v1.0.0")
|
|
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
|
|
|
|
def test_make_request_no_auth_for_non_matching_host(self, project_dir, monkeypatch):
|
|
"""Auth is NOT attached to hosts not listed in auth.json."""
|
|
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
|
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
|
catalog = PresetCatalog(project_dir)
|
|
req = catalog._make_request("https://internal.example.com/catalog.json")
|
|
assert "Authorization" not in req.headers
|
|
|
|
def test_make_request_no_auth_when_no_config(self, project_dir, monkeypatch):
|
|
"""No auth header when no auth.json config exists."""
|
|
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
|
monkeypatch.delenv("GH_TOKEN", raising=False)
|
|
catalog = PresetCatalog(project_dir)
|
|
req = catalog._make_request("https://github.com/org/repo/releases/download/v1/pack.zip")
|
|
assert "Authorization" not in req.headers
|
|
|
|
def test_fetch_single_catalog_sends_auth_header(self, project_dir, monkeypatch):
|
|
"""_fetch_single_catalog passes Authorization header when configured."""
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
|
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
|
catalog = PresetCatalog(project_dir)
|
|
|
|
catalog_data = {"schema_version": "1.0", "presets": {}}
|
|
mock_response = MagicMock()
|
|
mock_response.read.return_value = json.dumps(catalog_data).encode()
|
|
mock_response.__enter__ = lambda s: s
|
|
mock_response.__exit__ = MagicMock(return_value=False)
|
|
mock_response.geturl.return_value = "https://raw.githubusercontent.com/org/repo/main/presets/catalog.json"
|
|
|
|
captured = {}
|
|
mock_opener = MagicMock()
|
|
|
|
def fake_open(req, timeout=None):
|
|
captured["req"] = req
|
|
return mock_response
|
|
|
|
mock_opener.open.side_effect = fake_open
|
|
|
|
entry = PresetCatalogEntry(
|
|
url="https://raw.githubusercontent.com/org/repo/main/presets/catalog.json",
|
|
name="private",
|
|
priority=1,
|
|
install_allowed=True,
|
|
)
|
|
|
|
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
|
catalog._fetch_single_catalog(entry, force_refresh=True)
|
|
|
|
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
|
|
|
|
def test_fetch_single_catalog_revalidates_redirected_url(self, project_dir):
|
|
"""An HTTPS catalog URL that redirects to http:// must be rejected AFTER
|
|
the redirect. _open_url follows redirects (auth stripped on downgrade),
|
|
so without re-validating response.geturl() the http payload would still
|
|
be fetched and trusted — and it supplies each preset's download_url +
|
|
sha256, defeating verify_archive_sha256. Parity with the
|
|
integrations/workflows catalog fetchers."""
|
|
catalog = PresetCatalog(project_dir)
|
|
|
|
class _Resp:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *a):
|
|
return False
|
|
|
|
def read(self):
|
|
return json.dumps({"schema_version": "1.0", "presets": {}}).encode()
|
|
|
|
def geturl(self):
|
|
return "http://evil.test/catalog.json" # downgraded via redirect
|
|
|
|
catalog._open_url = lambda url, timeout=None, redirect_validator=None: _Resp()
|
|
|
|
entry = PresetCatalogEntry(
|
|
url="https://good.example/catalog.json",
|
|
name="c",
|
|
priority=1,
|
|
install_allowed=True,
|
|
)
|
|
with pytest.raises(PresetValidationError, match="HTTPS"):
|
|
catalog._fetch_single_catalog(entry, force_refresh=True)
|
|
|
|
def test_fetch_single_catalog_validates_every_redirect_hop(self, project_dir):
|
|
"""A redirect_validator is passed to _open_url and rejects a non-HTTPS
|
|
INTERMEDIATE hop — closing the https -> http -> attacker-https chain that
|
|
a terminal-URL-only check would miss."""
|
|
catalog = PresetCatalog(project_dir)
|
|
captured = {}
|
|
|
|
def fake_open(url, timeout=None, redirect_validator=None):
|
|
captured["rv"] = redirect_validator
|
|
# Simulate the hop urllib validates before following the redirect.
|
|
redirect_validator("https://good.example/catalog.json", "http://evil.test/hop")
|
|
raise AssertionError("redirect_validator should have raised")
|
|
|
|
catalog._open_url = fake_open
|
|
entry = PresetCatalogEntry(
|
|
url="https://good.example/catalog.json",
|
|
name="c",
|
|
priority=1,
|
|
install_allowed=True,
|
|
)
|
|
with pytest.raises(PresetValidationError, match="HTTPS"):
|
|
catalog._fetch_single_catalog(entry, force_refresh=True)
|
|
assert captured["rv"] is not None
|
|
|
|
def test_fetch_catalog_legacy_revalidates_redirected_url(self, project_dir):
|
|
"""The legacy single-catalog fetch_catalog() path also rejects an
|
|
HTTPS -> http redirected payload (final geturl() check), matching
|
|
_fetch_single_catalog — it previously parsed the body with no check."""
|
|
catalog = PresetCatalog(project_dir)
|
|
|
|
class _Resp:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *a):
|
|
return False
|
|
|
|
def read(self):
|
|
return json.dumps({"schema_version": "1.0", "presets": {}}).encode()
|
|
|
|
def geturl(self):
|
|
return "http://evil.test/catalog.json"
|
|
|
|
catalog._open_url = lambda url, timeout=None, redirect_validator=None: _Resp()
|
|
with pytest.raises(PresetError, match="HTTPS"):
|
|
catalog.fetch_catalog(force_refresh=True)
|
|
|
|
def test_fetch_catalog_legacy_validates_every_redirect_hop(self, project_dir):
|
|
"""The legacy fetch_catalog() path also validates every INTERMEDIATE hop
|
|
(not just the terminal URL): it must supply a redirect_validator that
|
|
rejects an insecure hop, so an https -> http -> https chain is caught."""
|
|
catalog = PresetCatalog(project_dir)
|
|
captured = {}
|
|
|
|
def fake_open(url, timeout=None, redirect_validator=None):
|
|
captured["rv"] = redirect_validator
|
|
redirect_validator(url, "http://evil.test/hop")
|
|
raise AssertionError("redirect_validator should have raised")
|
|
|
|
catalog._open_url = fake_open
|
|
with pytest.raises(PresetError, match="HTTPS"):
|
|
catalog.fetch_catalog(force_refresh=True)
|
|
assert captured["rv"] is not None
|
|
|
|
@pytest.mark.parametrize(
|
|
"payload",
|
|
[
|
|
# Root is not a JSON object.
|
|
[],
|
|
"oops",
|
|
42,
|
|
None,
|
|
# Root is fine but ``presets`` is the wrong type.
|
|
{"schema_version": "1.0", "presets": []},
|
|
{"schema_version": "1.0", "presets": "oops"},
|
|
{"schema_version": "1.0", "presets": None},
|
|
{"schema_version": "1.0", "presets": 42},
|
|
],
|
|
)
|
|
def test_fetch_single_catalog_rejects_malformed_payload(self, project_dir, payload):
|
|
"""Malformed catalog payloads raise PresetError, not AttributeError.
|
|
|
|
Without this guard, a payload like ``{"presets": []}`` would pass the
|
|
key-presence check and then crash with ``AttributeError: 'list' object
|
|
has no attribute 'items'`` deep inside ``_get_merged_packs``. The
|
|
sibling integration catalog reader already validates both the root
|
|
object and the nested mapping (see ``integrations/catalog.py``); the
|
|
preset catalog must stay consistent.
|
|
"""
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
catalog = PresetCatalog(project_dir)
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.read.return_value = json.dumps(payload).encode()
|
|
mock_response.__enter__ = lambda s: s
|
|
mock_response.__exit__ = MagicMock(return_value=False)
|
|
# A real urllib response reports the final URL (== request URL with no
|
|
# redirect); the fetcher re-validates it after redirects.
|
|
mock_response.geturl.return_value = "https://example.com/catalog.json"
|
|
|
|
entry = PresetCatalogEntry(
|
|
url="https://example.com/catalog.json",
|
|
name="default",
|
|
priority=1,
|
|
install_allowed=True,
|
|
)
|
|
|
|
with patch.object(catalog, "_open_url", return_value=mock_response):
|
|
with pytest.raises(PresetError, match="Invalid preset catalog format"):
|
|
catalog._fetch_single_catalog(entry, force_refresh=True)
|
|
|
|
@pytest.mark.parametrize(
|
|
"cached_payload",
|
|
[
|
|
[],
|
|
"oops",
|
|
42,
|
|
None,
|
|
{"schema_version": "1.0", "presets": []},
|
|
{"schema_version": "1.0", "presets": "oops"},
|
|
{"schema_version": "1.0", "presets": None},
|
|
],
|
|
)
|
|
def test_fetch_single_catalog_rejects_malformed_cached_payload(
|
|
self, project_dir, cached_payload
|
|
):
|
|
"""A poisoned cache silently falls back to the network instead of
|
|
crashing — cached payloads pass through the same shape validation
|
|
as freshly-fetched ones.
|
|
|
|
Without this, a cache poisoned by an older spec-kit version (or a
|
|
manual edit, or an upstream that briefly served a bad payload
|
|
before the network guards landed) would re-crash every invocation
|
|
of ``_get_merged_packs`` despite the cache being "valid" by age.
|
|
The recovery contract is: if the cached payload fails validation,
|
|
drop it and refetch — never propagate ``AttributeError`` to the
|
|
caller.
|
|
"""
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
catalog = PresetCatalog(project_dir)
|
|
|
|
# Poison the default-URL cache. ``DEFAULT_CATALOG_URL`` and
|
|
# non-default URLs both flow through the same cache-load branch.
|
|
cache_file, metadata_file = catalog._get_cache_paths(
|
|
catalog.DEFAULT_CATALOG_URL
|
|
)
|
|
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
|
cache_file.write_text(json.dumps(cached_payload))
|
|
metadata_file.write_text(
|
|
json.dumps(
|
|
{
|
|
"cached_at": datetime.now(timezone.utc).isoformat(),
|
|
"catalog_url": catalog.DEFAULT_CATALOG_URL,
|
|
}
|
|
)
|
|
)
|
|
|
|
# Network refetch returns a valid payload so the recovery path
|
|
# can complete.
|
|
valid = {
|
|
"schema_version": "1.0",
|
|
"presets": {"foo": {"name": "Foo", "version": "1.0.0"}},
|
|
}
|
|
mock_response = MagicMock()
|
|
mock_response.read.return_value = json.dumps(valid).encode()
|
|
mock_response.__enter__ = lambda s: s
|
|
mock_response.__exit__ = MagicMock(return_value=False)
|
|
mock_response.geturl.return_value = catalog.DEFAULT_CATALOG_URL
|
|
|
|
entry = PresetCatalogEntry(
|
|
url=catalog.DEFAULT_CATALOG_URL,
|
|
name="default",
|
|
priority=1,
|
|
install_allowed=True,
|
|
)
|
|
|
|
with patch.object(catalog, "_open_url", return_value=mock_response):
|
|
result = catalog._fetch_single_catalog(entry, force_refresh=False)
|
|
|
|
# The poisoned cache was discarded and the network payload returned.
|
|
assert result == valid
|
|
|
|
@pytest.mark.parametrize(
|
|
"payload",
|
|
[
|
|
# Root is not a JSON object.
|
|
[],
|
|
"oops",
|
|
42,
|
|
None,
|
|
# Root is fine but ``presets`` is the wrong type.
|
|
{"schema_version": "1.0", "presets": []},
|
|
{"schema_version": "1.0", "presets": "oops"},
|
|
{"schema_version": "1.0", "presets": None},
|
|
],
|
|
)
|
|
def test_fetch_catalog_rejects_malformed_payload(self, project_dir, payload):
|
|
"""Legacy ``fetch_catalog`` reuses the same shape-validation helper.
|
|
|
|
Before this change ``fetch_catalog`` only checked key presence —
|
|
so a payload like ``42`` would crash with
|
|
``TypeError: argument of type 'int' is not iterable`` during the
|
|
``"schema_version" in catalog_data`` check, and an entry mapping
|
|
of the wrong type would crash downstream. Reusing
|
|
``_validate_catalog_payload`` keeps the network-side behaviour of
|
|
the legacy single-catalog method consistent with the multi-catalog
|
|
``_fetch_single_catalog`` path.
|
|
"""
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
catalog = PresetCatalog(project_dir)
|
|
mock_response = MagicMock()
|
|
mock_response.read.return_value = json.dumps(payload).encode()
|
|
mock_response.__enter__ = lambda s: s
|
|
mock_response.__exit__ = MagicMock(return_value=False)
|
|
mock_response.geturl.return_value = "https://example.com/catalog.json"
|
|
|
|
with patch.object(catalog, "_open_url", return_value=mock_response):
|
|
with pytest.raises(PresetError, match="Invalid preset catalog format"):
|
|
catalog.fetch_catalog(force_refresh=True)
|
|
|
|
def test_fetch_catalog_recovers_from_unreadable_cache(self, project_dir):
|
|
"""An unreadable / wrong-encoded cache file silently refetches.
|
|
|
|
The cache contract is best-effort: a JSON-decode failure, an OS
|
|
read failure (permissions / disk / handle limit), or an invalid
|
|
text encoding on a cache file written by an older client must
|
|
all fall through to the network fetch rather than crash the
|
|
caller. Covers Copilot's review point that the previous
|
|
``except (json.JSONDecodeError, OSError)`` was missing
|
|
``UnicodeError``.
|
|
"""
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
catalog = PresetCatalog(project_dir)
|
|
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
# Invalid UTF-8 bytes so ``read_text`` raises ``UnicodeDecodeError``
|
|
# (a subclass of ``UnicodeError``).
|
|
catalog.cache_file.write_bytes(b"\xff\xfe\x00not-utf-8")
|
|
catalog.cache_metadata_file.write_text(
|
|
json.dumps(
|
|
{
|
|
"cached_at": datetime.now(timezone.utc).isoformat(),
|
|
"catalog_url": catalog.get_catalog_url(),
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
valid = {
|
|
"schema_version": "1.0",
|
|
"presets": {"foo": {"name": "Foo", "version": "1.0.0"}},
|
|
}
|
|
mock_response = MagicMock()
|
|
mock_response.read.return_value = json.dumps(valid).encode()
|
|
mock_response.__enter__ = lambda s: s
|
|
mock_response.__exit__ = MagicMock(return_value=False)
|
|
mock_response.geturl.return_value = "https://example.com/catalog.json"
|
|
|
|
with patch.object(catalog, "_open_url", return_value=mock_response):
|
|
result = catalog.fetch_catalog(force_refresh=False)
|
|
|
|
# Recovered via network rather than crashing on the unreadable cache.
|
|
assert result == valid
|
|
|
|
def test_fetch_catalog_recovers_from_unreadable_metadata(self, project_dir):
|
|
"""A wrongly-encoded metadata file degrades to a cache miss.
|
|
|
|
``is_cache_valid`` is consulted *before* the cache payload is
|
|
read; if the metadata file itself can't be decoded (e.g. it was
|
|
written on a host whose default codec isn't UTF-8) the validity
|
|
check must return ``False`` rather than propagate
|
|
``UnicodeDecodeError``. Without that guard, a corrupted metadata
|
|
file would crash every invocation instead of falling through to
|
|
a network refetch.
|
|
"""
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
catalog = PresetCatalog(project_dir)
|
|
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
catalog.cache_file.write_text("{}", encoding="utf-8")
|
|
# Bytes that are not valid UTF-8 — ``read_text(encoding="utf-8")``
|
|
# will raise ``UnicodeDecodeError`` (subclass of ``UnicodeError``).
|
|
catalog.cache_metadata_file.write_bytes(b"\xff\xfe\x00bad")
|
|
|
|
# is_cache_valid must absorb the decode failure, not crash.
|
|
assert catalog.is_cache_valid() is False
|
|
|
|
valid = {
|
|
"schema_version": "1.0",
|
|
"presets": {"foo": {"name": "Foo", "version": "1.0.0"}},
|
|
}
|
|
mock_response = MagicMock()
|
|
mock_response.read.return_value = json.dumps(valid).encode()
|
|
mock_response.__enter__ = lambda s: s
|
|
mock_response.__exit__ = MagicMock(return_value=False)
|
|
mock_response.geturl.return_value = "https://example.com/catalog.json"
|
|
|
|
with patch.object(catalog, "_open_url", return_value=mock_response):
|
|
result = catalog.fetch_catalog(force_refresh=False)
|
|
|
|
assert result == valid
|
|
|
|
@pytest.mark.parametrize(
|
|
"non_mapping_metadata",
|
|
[
|
|
"[]", # JSON array
|
|
'"oops"', # JSON string
|
|
"42", # JSON number
|
|
"true", # JSON bool
|
|
"null", # JSON null
|
|
],
|
|
)
|
|
def test_is_cache_valid_handles_non_mapping_metadata(
|
|
self, project_dir, non_mapping_metadata
|
|
):
|
|
"""Metadata that parses to a non-mapping degrades to cache-invalid.
|
|
|
|
The cache-validity check calls ``metadata.get("cached_at", "")``
|
|
immediately after ``json.loads``. If the metadata file is valid
|
|
JSON but parses to a non-mapping (``[]``, ``"oops"``, ``42``,
|
|
``true``, ``null``), ``.get`` raises ``AttributeError`` — which
|
|
previously slipped past the except tuple and crashed the
|
|
caller. The contract documented on ``is_cache_valid`` says any
|
|
decode/shape failure should return ``False`` so ``fetch_catalog``
|
|
falls through to a network refetch. This test pins that
|
|
contract across every JSON non-mapping root type.
|
|
"""
|
|
catalog = PresetCatalog(project_dir)
|
|
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
catalog.cache_file.write_text("{}", encoding="utf-8")
|
|
catalog.cache_metadata_file.write_text(
|
|
non_mapping_metadata, encoding="utf-8"
|
|
)
|
|
|
|
# Must not raise — the contract is "any decode/shape failure → False".
|
|
assert catalog.is_cache_valid() is False
|
|
|
|
def test_fetch_catalog_writes_cache_as_utf8(self, project_dir, monkeypatch):
|
|
"""Cache + metadata writes pass ``encoding="utf-8"``, observably.
|
|
|
|
The earlier version of this test claimed to assert UTF-8 at the
|
|
byte level but actually only round-tripped a non-ASCII string
|
|
through ``json.dumps`` and ``read_text(encoding="utf-8")``.
|
|
Because ``json.dumps`` defaults to ``ensure_ascii=True``, "café"
|
|
was serialized as the all-ASCII escape ``caf\\u00e9`` before it
|
|
ever reached ``write_text`` — the bytes on disk were identical
|
|
regardless of the encoding kwarg. The drift Copilot's review
|
|
flagged wasn't actually being caught.
|
|
|
|
Fix: directly observe the ``encoding`` argument passed to every
|
|
``write_text`` call made against the cache directory. This is
|
|
the production code's encoding choice, which is exactly what
|
|
the regression guard cares about.
|
|
"""
|
|
from unittest.mock import patch, MagicMock
|
|
from pathlib import Path as _PathCls
|
|
|
|
catalog = PresetCatalog(project_dir)
|
|
payload = {
|
|
"schema_version": "1.0",
|
|
"presets": {"foo": {"name": "Foo", "version": "1.0.0"}},
|
|
}
|
|
mock_response = MagicMock()
|
|
mock_response.read.return_value = json.dumps(payload).encode("utf-8")
|
|
mock_response.__enter__ = lambda s: s
|
|
mock_response.__exit__ = MagicMock(return_value=False)
|
|
mock_response.geturl.return_value = "https://example.com/catalog.json"
|
|
|
|
# Record every ``write_text`` call's encoding kwarg so the
|
|
# assertion observes the production writer's argument directly.
|
|
recorded: list[dict] = []
|
|
real_write_text = _PathCls.write_text
|
|
|
|
def recording_write_text(self, data, *args, **kwargs):
|
|
recorded.append(
|
|
{"path": str(self), "encoding": kwargs.get("encoding")}
|
|
)
|
|
return real_write_text(self, data, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(_PathCls, "write_text", recording_write_text)
|
|
|
|
with patch.object(catalog, "_open_url", return_value=mock_response):
|
|
catalog.fetch_catalog(force_refresh=True)
|
|
|
|
cache_writes = [
|
|
r for r in recorded if str(catalog.cache_dir) in r["path"]
|
|
]
|
|
assert cache_writes, "fetch_catalog made no writes to the cache dir"
|
|
for record in cache_writes:
|
|
assert record["encoding"] == "utf-8", (
|
|
f"write_text on {record['path']} used encoding "
|
|
f"{record['encoding']!r}; expected 'utf-8'"
|
|
)
|
|
|
|
def test_fetch_catalog_survives_unwritable_cache(self, project_dir, monkeypatch):
|
|
"""An unwritable cache dir doesn't fail a successful fetch.
|
|
|
|
Cache writes are best-effort, mirroring the read side and the
|
|
``integrations/catalog.py`` precedent: if ``mkdir``/``write_text``
|
|
raises ``OSError`` (read-only checkout, permissions), the
|
|
already-fetched-and-validated payload must still be returned —
|
|
not swallowed into the broad except and re-raised as a
|
|
``PresetError``.
|
|
"""
|
|
from unittest.mock import patch, MagicMock
|
|
from pathlib import Path as _PathCls
|
|
|
|
catalog = PresetCatalog(project_dir)
|
|
valid = {
|
|
"schema_version": "1.0",
|
|
"presets": {"foo": {"name": "Foo", "version": "1.0.0"}},
|
|
}
|
|
mock_response = MagicMock()
|
|
mock_response.read.return_value = json.dumps(valid).encode()
|
|
mock_response.__enter__ = lambda s: s
|
|
mock_response.__exit__ = MagicMock(return_value=False)
|
|
mock_response.geturl.return_value = catalog.DEFAULT_CATALOG_URL
|
|
|
|
# Simulate an unwritable cache dir: every write_text under the
|
|
# cache directory raises PermissionError (an OSError subclass).
|
|
real_write_text = _PathCls.write_text
|
|
|
|
def failing_write_text(self, data, *args, **kwargs):
|
|
if str(catalog.cache_dir) in str(self):
|
|
raise PermissionError("cache dir is read-only")
|
|
return real_write_text(self, data, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(_PathCls, "write_text", failing_write_text)
|
|
|
|
with patch.object(catalog, "_open_url", return_value=mock_response):
|
|
# Legacy single-catalog path.
|
|
assert catalog.fetch_catalog(force_refresh=True) == valid
|
|
|
|
# Multi-catalog path.
|
|
entry = PresetCatalogEntry(
|
|
url=catalog.DEFAULT_CATALOG_URL,
|
|
name="default",
|
|
priority=1,
|
|
install_allowed=True,
|
|
)
|
|
assert (
|
|
catalog._fetch_single_catalog(entry, force_refresh=True) == valid
|
|
)
|
|
|
|
def test_get_merged_packs_skips_non_mapping_entries(self, project_dir):
|
|
"""Per-entry guard: one malformed entry shouldn't poison the merge.
|
|
|
|
``_fetch_single_catalog`` validates that ``presets`` is a mapping,
|
|
but it doesn't (and shouldn't) validate every entry inside it — a
|
|
single bad entry in an otherwise-valid catalog should be skipped,
|
|
not crash the whole resolve path. Mirrors the per-entry skip in
|
|
``integrations/catalog.py``: a malformed entry returns no error,
|
|
valid entries continue to merge normally.
|
|
"""
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
catalog = PresetCatalog(project_dir)
|
|
payload = {
|
|
"schema_version": "1.0",
|
|
"presets": {
|
|
"good": {"name": "Good", "version": "1.0.0"},
|
|
"bad-list": [],
|
|
"bad-str": "oops",
|
|
},
|
|
}
|
|
mock_response = MagicMock()
|
|
mock_response.read.return_value = json.dumps(payload).encode()
|
|
mock_response.__enter__ = lambda s: s
|
|
mock_response.__exit__ = MagicMock(return_value=False)
|
|
mock_response.geturl.return_value = "https://example.com/catalog.json"
|
|
|
|
entry = PresetCatalogEntry(
|
|
url="https://example.com/catalog.json",
|
|
name="default",
|
|
priority=1,
|
|
install_allowed=True,
|
|
)
|
|
|
|
with patch.object(catalog, "_open_url", return_value=mock_response), \
|
|
patch.object(catalog, "get_active_catalogs", return_value=[entry]):
|
|
merged = catalog._get_merged_packs(force_refresh=True)
|
|
|
|
# Only the well-formed entry survives; the two malformed entries are
|
|
# silently dropped rather than raising or crashing.
|
|
assert list(merged.keys()) == ["good"]
|
|
|
|
def test_download_pack_sends_auth_header(self, project_dir, monkeypatch):
|
|
"""download_pack passes Authorization header when configured."""
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
|
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
|
catalog = PresetCatalog(project_dir)
|
|
|
|
import io
|
|
zip_buf = io.BytesIO()
|
|
with zipfile.ZipFile(zip_buf, "w") as zf:
|
|
zf.writestr("preset.yml", "id: test-pack\nname: Test\nversion: 1.0.0\n")
|
|
zip_bytes = zip_buf.getvalue()
|
|
|
|
release_response = MagicMock()
|
|
release_response.read.side_effect = io.BytesIO(json.dumps(
|
|
{
|
|
"assets": [
|
|
{
|
|
"name": "test-pack.zip",
|
|
"url": "https://api.github.com/repos/org/repo/releases/assets/1",
|
|
}
|
|
]
|
|
}
|
|
).encode()).read
|
|
release_response.__enter__ = lambda s: s
|
|
release_response.__exit__ = MagicMock(return_value=False)
|
|
|
|
asset_response = MagicMock()
|
|
asset_response.read.side_effect = io.BytesIO(zip_bytes).read
|
|
asset_response.__enter__ = lambda s: s
|
|
asset_response.__exit__ = MagicMock(return_value=False)
|
|
|
|
captured = []
|
|
mock_opener = MagicMock()
|
|
|
|
def fake_open(req, timeout=None):
|
|
captured.append(req)
|
|
if req.full_url.endswith("/releases/tags/v1"):
|
|
return release_response
|
|
return asset_response
|
|
|
|
mock_opener.open.side_effect = fake_open
|
|
|
|
pack_info = {
|
|
"id": "test-pack",
|
|
"name": "Test Pack",
|
|
"version": "1.0.0",
|
|
"download_url": "https://github.com/org/repo/releases/download/v1/test-pack.zip",
|
|
"_install_allowed": True,
|
|
}
|
|
|
|
with patch.object(catalog, "get_pack_info", return_value=pack_info), \
|
|
patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
|
catalog.download_pack("test-pack", target_dir=project_dir)
|
|
|
|
assert captured[0].full_url == "https://api.github.com/repos/org/repo/releases/tags/v1"
|
|
assert captured[0].get_header("Authorization") == "Bearer ghp_testtoken"
|
|
assert captured[1].full_url == "https://api.github.com/repos/org/repo/releases/assets/1"
|
|
assert captured[1].get_header("Authorization") == "Bearer ghp_testtoken"
|
|
assert captured[1].get_header("Accept") == "application/octet-stream"
|
|
|
|
def _pack_zip_and_response(self):
|
|
"""Build a minimal preset ZIP and a context-manager mock response."""
|
|
from unittest.mock import MagicMock
|
|
import io
|
|
|
|
zip_buf = io.BytesIO()
|
|
with zipfile.ZipFile(zip_buf, "w") as zf:
|
|
zf.writestr("preset.yml", "id: test-pack\nname: Test\nversion: 1.0.0\n")
|
|
zip_bytes = zip_buf.getvalue()
|
|
|
|
resp = MagicMock()
|
|
resp.read.return_value = zip_bytes
|
|
# Configure the context-manager protocol explicitly so `with resp`
|
|
# yields `resp` itself, independent of how the protocol is invoked.
|
|
resp.__enter__.return_value = resp
|
|
resp.__exit__.return_value = False
|
|
return zip_bytes, resp
|
|
|
|
def test_download_pack_accepts_matching_sha256(self, project_dir):
|
|
"""A catalog ``sha256`` that matches the preset archive is accepted."""
|
|
import hashlib
|
|
from unittest.mock import patch
|
|
|
|
catalog = PresetCatalog(project_dir)
|
|
zip_bytes, resp = self._pack_zip_and_response()
|
|
pack_info = {
|
|
"id": "test-pack",
|
|
"name": "Test Pack",
|
|
"version": "1.0.0",
|
|
"download_url": "https://example.com/test-pack.zip",
|
|
"sha256": hashlib.sha256(zip_bytes).hexdigest(),
|
|
"_install_allowed": True,
|
|
}
|
|
|
|
with patch.object(catalog, "get_pack_info", return_value=pack_info), \
|
|
patch.object(catalog, "_open_url", return_value=resp):
|
|
zip_path = catalog.download_pack("test-pack", target_dir=project_dir)
|
|
|
|
assert zip_path.read_bytes() == zip_bytes
|
|
|
|
def test_download_pack_rejects_sha256_mismatch(self, project_dir):
|
|
"""A catalog ``sha256`` that does not match the archive aborts install."""
|
|
from unittest.mock import patch
|
|
|
|
catalog = PresetCatalog(project_dir)
|
|
_zip_bytes, resp = self._pack_zip_and_response()
|
|
pack_info = {
|
|
"id": "test-pack",
|
|
"name": "Test Pack",
|
|
"version": "1.0.0",
|
|
"download_url": "https://example.com/test-pack.zip",
|
|
"sha256": "0" * 64, # deliberately wrong
|
|
"_install_allowed": True,
|
|
}
|
|
|
|
with patch.object(catalog, "get_pack_info", return_value=pack_info), \
|
|
patch.object(catalog, "_open_url", return_value=resp):
|
|
with pytest.raises(PresetError, match="[Ii]ntegrity"):
|
|
catalog.download_pack("test-pack", target_dir=project_dir)
|
|
|
|
def test_download_pack_malformed_url_raises_preset_error(self, project_dir):
|
|
"""A catalog ``download_url`` with a malformed authority (e.g. an
|
|
unterminated IPv6 bracket) surfaces a clean ``PresetError`` rather than
|
|
leaking a raw ``ValueError`` from ``urlparse``/``.hostname`` past the
|
|
command handler (which only catches ``PresetError``). Mirrors the
|
|
extensions coverage.
|
|
"""
|
|
from unittest.mock import patch
|
|
|
|
catalog = PresetCatalog(project_dir)
|
|
for bad_url in ("https://[::1", "https://[not-an-ip]/x"):
|
|
pack_info = {
|
|
"id": "test-pack",
|
|
"name": "Test Pack",
|
|
"version": "1.0.0",
|
|
"download_url": bad_url,
|
|
"_install_allowed": True,
|
|
}
|
|
with patch.object(catalog, "get_pack_info", return_value=pack_info):
|
|
with pytest.raises(PresetError, match="malformed"):
|
|
catalog.download_pack("test-pack", target_dir=project_dir)
|
|
|
|
def test_download_pack_without_sha256_skips_verification(self, project_dir):
|
|
"""A catalog entry with no ``sha256`` keeps working: verification is
|
|
opt-in, so the backwards-compatible path (``pack_info.get("sha256")``
|
|
is ``None``) must download without aborting — mirrors the extensions
|
|
coverage so the helper never silently becomes mandatory for presets.
|
|
"""
|
|
from unittest.mock import patch
|
|
|
|
catalog = PresetCatalog(project_dir)
|
|
zip_bytes, resp = self._pack_zip_and_response()
|
|
pack_info = {
|
|
"id": "test-pack",
|
|
"name": "Test Pack",
|
|
"version": "1.0.0",
|
|
"download_url": "https://example.com/test-pack.zip",
|
|
"_install_allowed": True,
|
|
}
|
|
|
|
with patch.object(catalog, "get_pack_info", return_value=pack_info), \
|
|
patch.object(catalog, "_open_url", return_value=resp):
|
|
zip_path = catalog.download_pack("test-pack", target_dir=project_dir)
|
|
|
|
assert zip_path.read_bytes() == zip_bytes
|
|
|
|
def test_download_pack_accepts_direct_github_rest_asset_url(self, project_dir, monkeypatch):
|
|
"""download_pack can use a GitHub REST release asset URL directly."""
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
|
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
|
catalog = PresetCatalog(project_dir)
|
|
|
|
import io
|
|
zip_buf = io.BytesIO()
|
|
with zipfile.ZipFile(zip_buf, "w") as zf:
|
|
zf.writestr("preset.yml", "id: test-pack\nname: Test\nversion: 1.0.0\n")
|
|
zip_bytes = zip_buf.getvalue()
|
|
|
|
asset_response = MagicMock()
|
|
asset_response.read.return_value = zip_bytes
|
|
asset_response.__enter__ = lambda s: s
|
|
asset_response.__exit__ = MagicMock(return_value=False)
|
|
|
|
captured = []
|
|
mock_opener = MagicMock()
|
|
|
|
def fake_open(req, timeout=None):
|
|
captured.append(req)
|
|
return asset_response
|
|
|
|
mock_opener.open.side_effect = fake_open
|
|
|
|
pack_info = {
|
|
"id": "test-pack",
|
|
"name": "Test Pack",
|
|
"version": "1.0.0",
|
|
"download_url": "https://api.github.com/repos/org/repo/releases/assets/1",
|
|
"_install_allowed": True,
|
|
}
|
|
|
|
with patch.object(catalog, "get_pack_info", return_value=pack_info), \
|
|
patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
|
catalog.download_pack("test-pack", target_dir=project_dir)
|
|
|
|
assert len(captured) == 1
|
|
assert captured[0].full_url == "https://api.github.com/repos/org/repo/releases/assets/1"
|
|
assert captured[0].get_header("Authorization") == "Bearer ghp_testtoken"
|
|
assert captured[0].get_header("Accept") == "application/octet-stream"
|
|
|
|
|
|
# ===== Integration Tests =====
|
|
|
|
|
|
class TestIntegration:
|
|
"""Integration tests for complete preset workflows."""
|
|
|
|
def test_full_install_resolve_remove_cycle(self, project_dir, pack_dir):
|
|
"""Test complete lifecycle: install → resolve → remove."""
|
|
# Install
|
|
manager = PresetManager(project_dir)
|
|
manifest = manager.install_from_directory(pack_dir, "0.1.5")
|
|
assert manifest.id == "test-pack"
|
|
|
|
# Resolve — pack template should win over core
|
|
resolver = PresetResolver(project_dir)
|
|
result = resolver.resolve("spec-template")
|
|
assert result is not None
|
|
assert "Custom Spec Template" in result.read_text()
|
|
|
|
# Remove
|
|
manager.remove("test-pack")
|
|
|
|
# Resolve — should fall back to core
|
|
result = resolver.resolve("spec-template")
|
|
assert result is not None
|
|
assert "Core Spec Template" in result.read_text()
|
|
|
|
def test_override_beats_pack_beats_extension_beats_core(self, project_dir, pack_dir):
|
|
"""Test the full priority stack: override > pack > extension > core."""
|
|
resolver = PresetResolver(project_dir)
|
|
|
|
# Core should resolve
|
|
result = resolver.resolve_with_source("spec-template")
|
|
assert result["source"] == "core"
|
|
|
|
# Add extension template
|
|
ext_dir = project_dir / ".specify" / "extensions" / "my-ext"
|
|
ext_templates_dir = ext_dir / "templates"
|
|
ext_templates_dir.mkdir(parents=True)
|
|
(ext_templates_dir / "spec-template.md").write_text("# Extension\n")
|
|
|
|
# Register extension in registry
|
|
extensions_dir = project_dir / ".specify" / "extensions"
|
|
ext_registry = ExtensionRegistry(extensions_dir)
|
|
ext_registry.add("my-ext", {"version": "1.0.0", "priority": 10})
|
|
|
|
result = resolver.resolve_with_source("spec-template")
|
|
assert result["source"] == "extension:my-ext v1.0.0"
|
|
|
|
# Install pack — should win over extension
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(pack_dir, "0.1.5")
|
|
|
|
result = resolver.resolve_with_source("spec-template")
|
|
assert "test-pack" in result["source"]
|
|
|
|
# Add override — should win over pack
|
|
overrides_dir = project_dir / ".specify" / "templates" / "overrides"
|
|
overrides_dir.mkdir(parents=True)
|
|
(overrides_dir / "spec-template.md").write_text("# Override\n")
|
|
|
|
result = resolver.resolve_with_source("spec-template")
|
|
assert result["source"] == "project override"
|
|
|
|
def test_install_from_zip_then_resolve(self, project_dir, pack_dir, temp_dir):
|
|
"""Test installing from ZIP and then resolving."""
|
|
# Create ZIP
|
|
zip_path = temp_dir / "test-pack.zip"
|
|
with zipfile.ZipFile(zip_path, 'w') as zf:
|
|
for file_path in pack_dir.rglob('*'):
|
|
if file_path.is_file():
|
|
arcname = file_path.relative_to(pack_dir)
|
|
zf.write(file_path, arcname)
|
|
|
|
# Install
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_zip(zip_path, "0.1.5")
|
|
|
|
# Resolve
|
|
resolver = PresetResolver(project_dir)
|
|
result = resolver.resolve("spec-template")
|
|
assert result is not None
|
|
assert "Custom Spec Template" in result.read_text()
|
|
|
|
|
|
# ===== PresetCatalogEntry Tests =====
|
|
|
|
|
|
class TestPresetCatalogEntry:
|
|
"""Test PresetCatalogEntry dataclass."""
|
|
|
|
def test_create_entry(self):
|
|
"""Test creating a catalog entry."""
|
|
entry = PresetCatalogEntry(
|
|
url="https://example.com/catalog.json",
|
|
name="test",
|
|
priority=1,
|
|
install_allowed=True,
|
|
description="Test catalog",
|
|
)
|
|
assert entry.url == "https://example.com/catalog.json"
|
|
assert entry.name == "test"
|
|
assert entry.priority == 1
|
|
assert entry.install_allowed is True
|
|
assert entry.description == "Test catalog"
|
|
|
|
def test_default_description(self):
|
|
"""Test default empty description."""
|
|
entry = PresetCatalogEntry(
|
|
url="https://example.com/catalog.json",
|
|
name="test",
|
|
priority=1,
|
|
install_allowed=False,
|
|
)
|
|
assert entry.description == ""
|
|
|
|
|
|
# ===== Multi-Catalog Tests =====
|
|
|
|
|
|
class TestPresetCatalogMultiCatalog:
|
|
"""Test multi-catalog support in PresetCatalog."""
|
|
|
|
def test_default_active_catalogs(self, project_dir):
|
|
"""Test that default catalogs are returned when no config exists."""
|
|
catalog = PresetCatalog(project_dir)
|
|
active = catalog.get_active_catalogs()
|
|
assert len(active) == 2
|
|
assert active[0].name == "default"
|
|
assert active[0].priority == 1
|
|
assert active[0].install_allowed is True
|
|
assert active[1].name == "community"
|
|
assert active[1].priority == 2
|
|
assert active[1].install_allowed is False
|
|
|
|
def test_catalog_list_escapes_rich_markup(self, project_dir):
|
|
"""User-editable catalog name/url/description must not be parsed as Rich markup."""
|
|
from typer.testing import CliRunner
|
|
from unittest.mock import patch
|
|
from specify_cli import app
|
|
|
|
entry = PresetCatalogEntry(
|
|
url="https://example.com/[cat].json",
|
|
name="Bracket [Catalog]",
|
|
priority=1,
|
|
install_allowed=True,
|
|
description="desc [with] brackets",
|
|
)
|
|
runner = CliRunner()
|
|
with patch.object(Path, "cwd", return_value=project_dir), \
|
|
patch.object(PresetCatalog, "get_active_catalogs", return_value=[entry]):
|
|
result = runner.invoke(app, ["preset", "catalog", "list"])
|
|
assert result.exit_code == 0, result.output
|
|
assert "Bracket [Catalog]" in result.output
|
|
assert "https://example.com/[cat].json" in result.output
|
|
assert "desc [with] brackets" in result.output
|
|
|
|
def test_env_var_overrides_catalogs(self, project_dir, monkeypatch):
|
|
"""Test that SPECKIT_PRESET_CATALOG_URL env var overrides defaults."""
|
|
monkeypatch.setenv(
|
|
"SPECKIT_PRESET_CATALOG_URL",
|
|
"https://custom.example.com/catalog.json",
|
|
)
|
|
catalog = PresetCatalog(project_dir)
|
|
active = catalog.get_active_catalogs()
|
|
assert len(active) == 1
|
|
assert active[0].name == "custom"
|
|
assert active[0].url == "https://custom.example.com/catalog.json"
|
|
assert active[0].install_allowed is True
|
|
|
|
def test_project_config_overrides_defaults(self, project_dir):
|
|
"""Test that project-level config overrides built-in defaults."""
|
|
config_path = project_dir / ".specify" / "preset-catalogs.yml"
|
|
config_path.write_text(yaml.dump({
|
|
"catalogs": [
|
|
{
|
|
"name": "my-catalog",
|
|
"url": "https://my.example.com/catalog.json",
|
|
"priority": 1,
|
|
"install_allowed": True,
|
|
}
|
|
]
|
|
}))
|
|
|
|
catalog = PresetCatalog(project_dir)
|
|
active = catalog.get_active_catalogs()
|
|
assert len(active) == 1
|
|
assert active[0].name == "my-catalog"
|
|
assert active[0].url == "https://my.example.com/catalog.json"
|
|
|
|
def test_load_catalog_config_nonexistent(self, project_dir):
|
|
"""Test loading config from nonexistent file returns None."""
|
|
catalog = PresetCatalog(project_dir)
|
|
result = catalog._load_catalog_config(
|
|
project_dir / ".specify" / "nonexistent.yml"
|
|
)
|
|
assert result is None
|
|
|
|
def test_load_catalog_config_empty(self, project_dir):
|
|
"""Test loading empty config returns None."""
|
|
config_path = project_dir / ".specify" / "preset-catalogs.yml"
|
|
config_path.write_text("")
|
|
|
|
catalog = PresetCatalog(project_dir)
|
|
result = catalog._load_catalog_config(config_path)
|
|
assert result is None
|
|
|
|
def test_load_catalog_config_invalid_yaml(self, project_dir):
|
|
"""Test loading invalid YAML raises error."""
|
|
config_path = project_dir / ".specify" / "preset-catalogs.yml"
|
|
config_path.write_text(": invalid: {{{")
|
|
|
|
catalog = PresetCatalog(project_dir)
|
|
with pytest.raises(PresetValidationError, match="Failed to read"):
|
|
catalog._load_catalog_config(config_path)
|
|
|
|
def test_load_catalog_config_not_a_list(self, project_dir):
|
|
"""Test that non-list catalogs key raises error."""
|
|
config_path = project_dir / ".specify" / "preset-catalogs.yml"
|
|
config_path.write_text(yaml.dump({"catalogs": "not-a-list"}))
|
|
|
|
catalog = PresetCatalog(project_dir)
|
|
with pytest.raises(PresetValidationError, match="must be a list"):
|
|
catalog._load_catalog_config(config_path)
|
|
|
|
def test_load_catalog_config_invalid_entry(self, project_dir):
|
|
"""Test that non-dict entry raises error."""
|
|
config_path = project_dir / ".specify" / "preset-catalogs.yml"
|
|
config_path.write_text(yaml.dump({"catalogs": ["not-a-dict"]}))
|
|
|
|
catalog = PresetCatalog(project_dir)
|
|
with pytest.raises(PresetValidationError, match="expected a mapping"):
|
|
catalog._load_catalog_config(config_path)
|
|
|
|
def test_load_catalog_config_http_url_rejected(self, project_dir):
|
|
"""Test that HTTP URLs are rejected."""
|
|
config_path = project_dir / ".specify" / "preset-catalogs.yml"
|
|
config_path.write_text(yaml.dump({
|
|
"catalogs": [
|
|
{
|
|
"name": "bad",
|
|
"url": "http://insecure.example.com/catalog.json",
|
|
"priority": 1,
|
|
}
|
|
]
|
|
}))
|
|
|
|
catalog = PresetCatalog(project_dir)
|
|
with pytest.raises(PresetValidationError, match="must use HTTPS"):
|
|
catalog._load_catalog_config(config_path)
|
|
|
|
def test_load_catalog_config_priority_sorting(self, project_dir):
|
|
"""Test that catalogs are sorted by priority."""
|
|
config_path = project_dir / ".specify" / "preset-catalogs.yml"
|
|
config_path.write_text(yaml.dump({
|
|
"catalogs": [
|
|
{
|
|
"name": "low-priority",
|
|
"url": "https://low.example.com/catalog.json",
|
|
"priority": 10,
|
|
"install_allowed": False,
|
|
},
|
|
{
|
|
"name": "high-priority",
|
|
"url": "https://high.example.com/catalog.json",
|
|
"priority": 1,
|
|
"install_allowed": True,
|
|
},
|
|
]
|
|
}))
|
|
|
|
catalog = PresetCatalog(project_dir)
|
|
entries = catalog._load_catalog_config(config_path)
|
|
assert entries is not None
|
|
assert len(entries) == 2
|
|
assert entries[0].name == "high-priority"
|
|
assert entries[1].name == "low-priority"
|
|
|
|
def test_load_catalog_config_invalid_priority(self, project_dir):
|
|
"""Test that invalid priority raises error."""
|
|
config_path = project_dir / ".specify" / "preset-catalogs.yml"
|
|
config_path.write_text(yaml.dump({
|
|
"catalogs": [
|
|
{
|
|
"name": "bad",
|
|
"url": "https://example.com/catalog.json",
|
|
"priority": "not-a-number",
|
|
}
|
|
]
|
|
}))
|
|
|
|
catalog = PresetCatalog(project_dir)
|
|
with pytest.raises(PresetValidationError, match="Invalid priority"):
|
|
catalog._load_catalog_config(config_path)
|
|
|
|
def test_load_catalog_config_rejects_boolean_priority(self, project_dir):
|
|
"""A YAML ``priority: true`` is a typo, not a request for priority 1.
|
|
|
|
``bool`` is a subclass of ``int`` in Python, so ``int(True)`` silently
|
|
returns ``1``. Without an explicit guard a malformed config like
|
|
``priority: yes`` would be accepted as a valid priority of 1 and
|
|
silently change catalog ordering. The sibling integration-catalog
|
|
reader rejects this case (see ``catalogs.py``); the preset catalog
|
|
reader must stay consistent.
|
|
"""
|
|
config_path = project_dir / ".specify" / "preset-catalogs.yml"
|
|
config_path.write_text(yaml.dump({
|
|
"catalogs": [
|
|
{
|
|
"name": "bool-priority",
|
|
"url": "https://example.com/catalog.json",
|
|
"priority": True,
|
|
}
|
|
]
|
|
}))
|
|
|
|
catalog = PresetCatalog(project_dir)
|
|
with pytest.raises(PresetValidationError, match="Invalid priority|expected integer"):
|
|
catalog._load_catalog_config(config_path)
|
|
|
|
def test_load_catalog_config_rejects_infinite_priority(self, project_dir):
|
|
"""A ``priority: .inf`` yields a clean validation error, not an uncaught
|
|
OverflowError from int(float('inf'))."""
|
|
config_path = project_dir / ".specify" / "preset-catalogs.yml"
|
|
config_path.write_text(yaml.dump({
|
|
"catalogs": [
|
|
{
|
|
"name": "inf-priority",
|
|
"url": "https://example.com/catalog.json",
|
|
"priority": float("inf"),
|
|
}
|
|
]
|
|
}))
|
|
|
|
catalog = PresetCatalog(project_dir)
|
|
with pytest.raises(PresetValidationError, match="Invalid priority|expected integer"):
|
|
catalog._load_catalog_config(config_path)
|
|
|
|
def test_load_catalog_config_install_allowed_string(self, project_dir):
|
|
"""Test that install_allowed accepts string values."""
|
|
config_path = project_dir / ".specify" / "preset-catalogs.yml"
|
|
config_path.write_text(yaml.dump({
|
|
"catalogs": [
|
|
{
|
|
"name": "test",
|
|
"url": "https://example.com/catalog.json",
|
|
"priority": 1,
|
|
"install_allowed": "true",
|
|
}
|
|
]
|
|
}))
|
|
|
|
catalog = PresetCatalog(project_dir)
|
|
entries = catalog._load_catalog_config(config_path)
|
|
assert entries is not None
|
|
assert entries[0].install_allowed is True
|
|
|
|
def test_get_catalog_url_uses_highest_priority(self, project_dir):
|
|
"""Test that get_catalog_url returns URL of highest priority catalog."""
|
|
config_path = project_dir / ".specify" / "preset-catalogs.yml"
|
|
config_path.write_text(yaml.dump({
|
|
"catalogs": [
|
|
{
|
|
"name": "secondary",
|
|
"url": "https://secondary.example.com/catalog.json",
|
|
"priority": 5,
|
|
},
|
|
{
|
|
"name": "primary",
|
|
"url": "https://primary.example.com/catalog.json",
|
|
"priority": 1,
|
|
},
|
|
]
|
|
}))
|
|
|
|
catalog = PresetCatalog(project_dir)
|
|
assert catalog.get_catalog_url() == "https://primary.example.com/catalog.json"
|
|
|
|
def test_cache_paths_default_url(self, project_dir):
|
|
"""Test cache paths for default catalog URL use legacy locations."""
|
|
catalog = PresetCatalog(project_dir)
|
|
cache_file, metadata_file = catalog._get_cache_paths(
|
|
PresetCatalog.DEFAULT_CATALOG_URL
|
|
)
|
|
assert cache_file == catalog.cache_file
|
|
assert metadata_file == catalog.cache_metadata_file
|
|
|
|
def test_cache_paths_custom_url(self, project_dir):
|
|
"""Test cache paths for custom URLs use hash-based files."""
|
|
catalog = PresetCatalog(project_dir)
|
|
cache_file, metadata_file = catalog._get_cache_paths(
|
|
"https://custom.example.com/catalog.json"
|
|
)
|
|
assert cache_file != catalog.cache_file
|
|
assert "catalog-" in cache_file.name
|
|
assert cache_file.name.endswith(".json")
|
|
|
|
def test_url_cache_valid(self, project_dir):
|
|
"""Test URL-specific cache validation."""
|
|
catalog = PresetCatalog(project_dir)
|
|
url = "https://custom.example.com/catalog.json"
|
|
cache_file, metadata_file = catalog._get_cache_paths(url)
|
|
|
|
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
cache_file.write_text(json.dumps({"schema_version": "1.0", "presets": {}}))
|
|
metadata_file.write_text(json.dumps({
|
|
"cached_at": datetime.now(timezone.utc).isoformat(),
|
|
}))
|
|
|
|
assert catalog._is_url_cache_valid(url) is True
|
|
|
|
def test_url_cache_expired(self, project_dir):
|
|
"""Test URL-specific cache expiration."""
|
|
catalog = PresetCatalog(project_dir)
|
|
url = "https://custom.example.com/catalog.json"
|
|
cache_file, metadata_file = catalog._get_cache_paths(url)
|
|
|
|
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
cache_file.write_text(json.dumps({"schema_version": "1.0", "presets": {}}))
|
|
metadata_file.write_text(json.dumps({
|
|
"cached_at": "2020-01-01T00:00:00+00:00",
|
|
}))
|
|
|
|
assert catalog._is_url_cache_valid(url) is False
|
|
|
|
|
|
# ===== Self-Test Preset Tests =====
|
|
|
|
|
|
SELF_TEST_PRESET_DIR = Path(__file__).parent.parent / "presets" / "self-test"
|
|
SELF_TEST_WRAP_WARNING = (
|
|
r"Cannot compose command 'speckit\.wrap-test': no base layer\. "
|
|
r"Stale command files may remain\."
|
|
)
|
|
|
|
CORE_TEMPLATE_NAMES = [
|
|
"spec-template",
|
|
"plan-template",
|
|
"tasks-template",
|
|
"checklist-template",
|
|
"constitution-template",
|
|
]
|
|
|
|
|
|
def install_self_test_preset(manager: PresetManager, speckit_version: str = "0.1.5") -> PresetManifest:
|
|
"""Install self-test while filtering its intentionally missing wrap base."""
|
|
with warnings.catch_warnings():
|
|
warnings.filterwarnings(
|
|
"ignore",
|
|
message=SELF_TEST_WRAP_WARNING,
|
|
category=UserWarning,
|
|
module=r"specify_cli\.presets",
|
|
)
|
|
return manager.install_from_directory(SELF_TEST_PRESET_DIR, speckit_version)
|
|
|
|
|
|
def _make_convention_constitution_preset(temp_dir: Path) -> Path:
|
|
"""Create a preset whose constitution is found by convention, not its manifest."""
|
|
preset_dir = temp_dir / "convention-constitution"
|
|
(preset_dir / "templates").mkdir(parents=True)
|
|
(preset_dir / "templates" / "constitution-template.md").write_text(
|
|
"# Convention Constitution\n"
|
|
)
|
|
(preset_dir / "templates" / "spec-template.md").write_text("# Spec\n")
|
|
(preset_dir / "preset.yml").write_text(
|
|
yaml.dump(
|
|
{
|
|
"schema_version": "1.0",
|
|
"preset": {
|
|
"id": "convention-constitution",
|
|
"name": "Convention Constitution",
|
|
"version": "1.0.0",
|
|
"description": "Convention-based constitution for testing",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"templates": [
|
|
{
|
|
"type": "template",
|
|
"name": "spec-template",
|
|
"file": "templates/spec-template.md",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
)
|
|
)
|
|
return preset_dir
|
|
|
|
|
|
class TestSelfTestPreset:
|
|
"""Tests using the self-test preset that ships with the repo.
|
|
|
|
The self-test preset ships a wrap-strategy command (``speckit.wrap-test``)
|
|
without a corresponding core base layer; reconciliation deliberately
|
|
surfaces a UserWarning in that case. Tests install via
|
|
``install_self_test_preset`` (defined above), which scopes a narrow
|
|
``warnings.filterwarnings`` block to that specific message and
|
|
``UserWarning`` category — so the expected warning stays quiet without
|
|
masking unrelated warnings or real reconciliation failures.
|
|
"""
|
|
|
|
def test_self_test_preset_exists(self):
|
|
"""Verify the self-test preset directory and manifest exist."""
|
|
assert SELF_TEST_PRESET_DIR.exists()
|
|
assert (SELF_TEST_PRESET_DIR / "preset.yml").exists()
|
|
|
|
def test_self_test_manifest_valid(self):
|
|
"""Verify the self-test preset manifest is valid."""
|
|
manifest = PresetManifest(SELF_TEST_PRESET_DIR / "preset.yml")
|
|
assert manifest.id == "self-test"
|
|
assert manifest.name == "Self-Test Preset"
|
|
assert manifest.version == "1.0.0"
|
|
assert len(manifest.templates) == 7 # 5 templates + 2 commands
|
|
|
|
def test_self_test_provides_all_core_templates(self):
|
|
"""Verify the self-test preset provides an override for every core template."""
|
|
manifest = PresetManifest(SELF_TEST_PRESET_DIR / "preset.yml")
|
|
provided_names = {t["name"] for t in manifest.templates}
|
|
for name in CORE_TEMPLATE_NAMES:
|
|
assert name in provided_names, f"Self-test preset missing template: {name}"
|
|
|
|
def test_self_test_template_files_exist(self):
|
|
"""Verify that all declared template files actually exist on disk."""
|
|
manifest = PresetManifest(SELF_TEST_PRESET_DIR / "preset.yml")
|
|
for tmpl in manifest.templates:
|
|
tmpl_path = SELF_TEST_PRESET_DIR / tmpl["file"]
|
|
assert tmpl_path.exists(), f"Missing template file: {tmpl['file']}"
|
|
|
|
def test_self_test_templates_have_marker(self):
|
|
"""Verify each template contains the preset:self-test marker."""
|
|
for name in CORE_TEMPLATE_NAMES:
|
|
tmpl_path = SELF_TEST_PRESET_DIR / "templates" / f"{name}.md"
|
|
content = tmpl_path.read_text()
|
|
assert "preset:self-test" in content, f"{name}.md missing preset:self-test marker"
|
|
|
|
def test_install_self_test_preset(self, project_dir):
|
|
"""Test installing the self-test preset from its directory."""
|
|
manager = PresetManager(project_dir)
|
|
manifest = install_self_test_preset(manager)
|
|
assert manifest.id == "self-test"
|
|
assert manager.registry.is_installed("self-test")
|
|
|
|
def test_self_test_overrides_all_core_templates(self, project_dir):
|
|
"""Test that installing self-test overrides every core template."""
|
|
# Set up core templates in the project
|
|
templates_dir = project_dir / ".specify" / "templates"
|
|
for name in CORE_TEMPLATE_NAMES:
|
|
(templates_dir / f"{name}.md").write_text(f"# Core {name}\n")
|
|
|
|
# Install self-test preset
|
|
manager = PresetManager(project_dir)
|
|
install_self_test_preset(manager)
|
|
|
|
# Every core template should now resolve from the preset
|
|
resolver = PresetResolver(project_dir)
|
|
for name in CORE_TEMPLATE_NAMES:
|
|
result = resolver.resolve(name)
|
|
assert result is not None, f"{name} did not resolve"
|
|
content = result.read_text()
|
|
assert "preset:self-test" in content, (
|
|
f"{name} resolved but not from self-test preset"
|
|
)
|
|
|
|
def test_self_test_resolve_with_source(self, project_dir):
|
|
"""Test that resolve_with_source attributes templates to self-test."""
|
|
templates_dir = project_dir / ".specify" / "templates"
|
|
for name in CORE_TEMPLATE_NAMES:
|
|
(templates_dir / f"{name}.md").write_text(f"# Core {name}\n")
|
|
|
|
manager = PresetManager(project_dir)
|
|
install_self_test_preset(manager)
|
|
|
|
resolver = PresetResolver(project_dir)
|
|
for name in CORE_TEMPLATE_NAMES:
|
|
result = resolver.resolve_with_source(name)
|
|
assert result is not None, f"{name} did not resolve"
|
|
assert "self-test" in result["source"], (
|
|
f"{name} source is '{result['source']}', expected self-test"
|
|
)
|
|
|
|
def test_self_test_removal_restores_core(self, project_dir):
|
|
"""Test that removing self-test falls back to core templates."""
|
|
templates_dir = project_dir / ".specify" / "templates"
|
|
for name in CORE_TEMPLATE_NAMES:
|
|
(templates_dir / f"{name}.md").write_text(f"# Core {name}\n")
|
|
|
|
manager = PresetManager(project_dir)
|
|
install_self_test_preset(manager)
|
|
manager.remove("self-test")
|
|
|
|
resolver = PresetResolver(project_dir)
|
|
for name in CORE_TEMPLATE_NAMES:
|
|
result = resolver.resolve_with_source(name)
|
|
assert result is not None
|
|
assert result["source"] == "core"
|
|
|
|
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
|
assert memory.read_text() == "# Core constitution-template\n"
|
|
|
|
def test_self_test_removal_preserves_edited_constitution(self, project_dir):
|
|
"""Removing a preset does not overwrite an edited generated constitution."""
|
|
templates_dir = project_dir / ".specify" / "templates"
|
|
(templates_dir / "constitution-template.md").write_text("# Core Constitution\n")
|
|
|
|
manager = PresetManager(project_dir)
|
|
install_self_test_preset(manager)
|
|
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
|
edited = memory.read_text() + "\n## Authored amendment\n"
|
|
memory.write_text(edited)
|
|
|
|
manager.remove("self-test")
|
|
|
|
assert memory.read_text() == edited
|
|
|
|
def test_self_test_not_in_catalog(self):
|
|
"""Verify the self-test preset is NOT in the catalog (it's local-only)."""
|
|
catalog_path = Path(__file__).parent.parent / "presets" / "catalog.json"
|
|
catalog_data = json.loads(catalog_path.read_text())
|
|
assert "self-test" not in catalog_data["presets"]
|
|
|
|
def test_self_test_has_command(self):
|
|
"""Verify the self-test preset includes a command override."""
|
|
manifest = PresetManifest(SELF_TEST_PRESET_DIR / "preset.yml")
|
|
commands = [t for t in manifest.templates if t["type"] == "command"]
|
|
assert len(commands) >= 1
|
|
assert commands[0]["name"] == "speckit.specify"
|
|
|
|
def test_self_test_command_file_exists(self):
|
|
"""Verify the self-test command file exists on disk."""
|
|
cmd_path = SELF_TEST_PRESET_DIR / "commands" / "speckit.specify.md"
|
|
assert cmd_path.exists()
|
|
content = cmd_path.read_text()
|
|
assert "preset:self-test" in content
|
|
|
|
def test_self_test_registers_commands_for_claude(self, project_dir):
|
|
"""Test that installing self-test registers skills in .claude/skills/."""
|
|
# Create Claude skills directory to simulate Claude being set up
|
|
claude_dir = project_dir / ".claude" / "skills"
|
|
claude_dir.mkdir(parents=True)
|
|
|
|
manager = PresetManager(project_dir)
|
|
install_self_test_preset(manager)
|
|
|
|
# Check the skill was registered
|
|
cmd_file = claude_dir / "speckit-specify" / "SKILL.md"
|
|
assert cmd_file.exists(), "Skill not registered in .claude/skills/"
|
|
content = cmd_file.read_text()
|
|
assert "self-test" in content
|
|
assert "source:" in content # skill frontmatter includes metadata.source
|
|
|
|
def test_self_test_registers_commands_for_gemini(self, project_dir):
|
|
"""Test that installing self-test registers commands in .gemini/commands/ as TOML."""
|
|
# Create Gemini agent directory
|
|
gemini_dir = project_dir / ".gemini" / "commands"
|
|
gemini_dir.mkdir(parents=True)
|
|
|
|
manager = PresetManager(project_dir)
|
|
install_self_test_preset(manager)
|
|
|
|
# Check the command was registered in TOML format
|
|
cmd_file = gemini_dir / "speckit.specify.toml"
|
|
assert cmd_file.exists(), "Command not registered in .gemini/commands/"
|
|
content = cmd_file.read_text()
|
|
assert "prompt" in content # TOML format has a prompt field
|
|
assert "{{args}}" in content # Gemini uses {{args}} placeholder
|
|
|
|
def test_self_test_unregisters_commands_on_remove(self, project_dir):
|
|
"""Test that removing self-test cleans up registered commands."""
|
|
claude_dir = project_dir / ".claude" / "skills"
|
|
claude_dir.mkdir(parents=True)
|
|
|
|
manager = PresetManager(project_dir)
|
|
install_self_test_preset(manager)
|
|
|
|
cmd_file = claude_dir / "speckit-specify" / "SKILL.md"
|
|
assert cmd_file.exists()
|
|
|
|
manager.remove("self-test")
|
|
assert not cmd_file.exists(), "Command not cleaned up after preset removal"
|
|
|
|
def test_self_test_no_commands_without_agent_dirs(self, project_dir):
|
|
"""Test that no commands are registered when no agent dirs exist."""
|
|
manager = PresetManager(project_dir)
|
|
install_self_test_preset(manager)
|
|
|
|
metadata = manager.registry.get("self-test")
|
|
assert metadata["registered_commands"] == {}
|
|
|
|
def test_self_test_seeds_constitution_when_memory_absent(self, project_dir):
|
|
"""Installing a preset seeds memory/constitution.md from its template."""
|
|
manager = PresetManager(project_dir)
|
|
install_self_test_preset(manager)
|
|
|
|
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
|
assert memory.exists(), "constitution.md was not seeded from the preset"
|
|
assert "preset:self-test" in memory.read_text(), (
|
|
"constitution.md was not seeded from the self-test preset template"
|
|
)
|
|
|
|
def test_self_test_reseeds_exact_core_constitution(self, project_dir):
|
|
"""An unchanged core constitution is re-seeded from the preset template."""
|
|
resolver = PresetResolver(project_dir)
|
|
bundled_core = resolver._find_bundled_core(
|
|
"constitution-template", "template", ".md"
|
|
)
|
|
assert bundled_core is not None
|
|
core = bundled_core.read_bytes()
|
|
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
|
memory.parent.mkdir(parents=True, exist_ok=True)
|
|
memory.write_bytes(core)
|
|
|
|
manager = PresetManager(project_dir)
|
|
install_self_test_preset(manager)
|
|
|
|
content = memory.read_text()
|
|
assert "preset:self-test" in content, "placeholder constitution was not re-seeded"
|
|
assert "[PROJECT_NAME]" not in content
|
|
|
|
@pytest.mark.parametrize(
|
|
"provenance_content",
|
|
[
|
|
'{"sha256": "does-not-match", "source": "old-preset"}\n',
|
|
"{not valid json",
|
|
],
|
|
ids=["hash-mismatch", "malformed"],
|
|
)
|
|
def test_self_test_preserves_core_content_with_existing_invalid_provenance(
|
|
self, project_dir, provenance_content
|
|
):
|
|
"""A present invalid sidecar disables legacy core-template migration."""
|
|
resolver = PresetResolver(project_dir)
|
|
bundled_core = resolver._find_bundled_core(
|
|
"constitution-template", "template", ".md"
|
|
)
|
|
assert bundled_core is not None
|
|
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
|
memory.parent.mkdir(parents=True, exist_ok=True)
|
|
memory.write_bytes(bundled_core.read_bytes())
|
|
(memory.parent / ".constitution-template.json").write_text(
|
|
provenance_content
|
|
)
|
|
original = memory.read_bytes()
|
|
|
|
manager = PresetManager(project_dir)
|
|
install_self_test_preset(manager)
|
|
|
|
assert memory.read_bytes() == original
|
|
|
|
def test_self_test_preserves_mutable_project_core_copy(self, project_dir):
|
|
"""A project template copy does not establish generated provenance."""
|
|
authored = "# Acme Organization Constitution\n\nOrganization policy.\n"
|
|
project_template = (
|
|
project_dir / ".specify" / "templates" / "constitution-template.md"
|
|
)
|
|
project_template.write_text(authored)
|
|
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
|
memory.parent.mkdir(parents=True, exist_ok=True)
|
|
memory.write_text(authored)
|
|
|
|
manager = PresetManager(project_dir)
|
|
install_self_test_preset(manager)
|
|
|
|
assert memory.read_text() == authored
|
|
assert not (memory.parent / ".constitution-template.json").exists()
|
|
|
|
def test_core_prefixed_preset_does_not_establish_generated_provenance(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""A preset ID beginning with core is not an immutable core source."""
|
|
authored = "# Acme Organization Constitution\n\nOrganization policy.\n"
|
|
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
|
memory.parent.mkdir(parents=True, exist_ok=True)
|
|
memory.write_text(authored)
|
|
|
|
preset_dir = temp_dir / "core-company"
|
|
(preset_dir / "templates").mkdir(parents=True)
|
|
(preset_dir / "templates" / "constitution-template.md").write_text(authored)
|
|
(preset_dir / "preset.yml").write_text(
|
|
yaml.safe_dump(
|
|
{
|
|
"schema_version": "1.0",
|
|
"preset": {
|
|
"id": "core-company",
|
|
"name": "Core Company",
|
|
"version": "1.0.0",
|
|
"description": "Company constitution preset",
|
|
"author": "Test Author",
|
|
"repository": "https://github.com/test/core-company",
|
|
"license": "MIT",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"templates": [
|
|
{
|
|
"type": "template",
|
|
"name": "constitution-template",
|
|
"file": "templates/constitution-template.md",
|
|
"description": "Company constitution",
|
|
"replaces": "constitution-template",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
)
|
|
)
|
|
|
|
PresetManager(project_dir).install_from_directory(preset_dir, "0.1.5")
|
|
|
|
assert memory.read_text() == authored
|
|
assert not (memory.parent / ".constitution-template.json").exists()
|
|
|
|
def test_self_test_preserves_authored_constitution_with_placeholder(
|
|
self, project_dir
|
|
):
|
|
"""A placeholder mention does not establish generated provenance."""
|
|
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
|
memory.parent.mkdir(parents=True, exist_ok=True)
|
|
authored = "# Acme Constitution\n\nGuidance for [PROJECT_NAME].\n"
|
|
memory.write_text(authored)
|
|
|
|
manager = PresetManager(project_dir)
|
|
install_self_test_preset(manager)
|
|
|
|
assert memory.read_text() == authored
|
|
|
|
def test_self_test_preserves_authored_constitution(self, project_dir):
|
|
"""An authored (placeholder-free) constitution is never overwritten."""
|
|
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
|
memory.parent.mkdir(parents=True, exist_ok=True)
|
|
authored = "# Acme Constitution\n\n### I. Ship It\nAuthored by a human.\n"
|
|
memory.write_text(authored)
|
|
|
|
manager = PresetManager(project_dir)
|
|
install_self_test_preset(manager)
|
|
|
|
assert memory.read_text() == authored, "authored constitution was overwritten"
|
|
|
|
def test_self_test_override_resolves_constitution_template(self, project_dir):
|
|
"""The preset override of constitution-template resolves to the preset file."""
|
|
templates_dir = project_dir / ".specify" / "templates"
|
|
(templates_dir / "constitution-template.md").write_text("# Core constitution\n")
|
|
|
|
manager = PresetManager(project_dir)
|
|
install_self_test_preset(manager)
|
|
|
|
resolver = PresetResolver(project_dir)
|
|
result = resolver.resolve("constitution-template", "template")
|
|
assert result is not None
|
|
assert "preset:self-test" in result.read_text()
|
|
|
|
def test_constitution_seed_composes_wrap_strategy(self, project_dir, temp_dir):
|
|
"""Seeding memory composes wrap constitution-template layers."""
|
|
templates_dir = project_dir / ".specify" / "templates"
|
|
templates_dir.mkdir(parents=True, exist_ok=True)
|
|
(templates_dir / "constitution-template.md").write_text(
|
|
"# Core Constitution\n\n## Core Principle\n"
|
|
)
|
|
|
|
preset_dir = temp_dir / "constitution-wrap"
|
|
(preset_dir / "templates").mkdir(parents=True)
|
|
(preset_dir / "templates" / "constitution-template.md").write_text(
|
|
"# Wrapper Constitution\n\n{CORE_TEMPLATE}\n\n## Wrapper Footer\n"
|
|
)
|
|
(preset_dir / "preset.yml").write_text(
|
|
yaml.dump(
|
|
{
|
|
"schema_version": "1.0",
|
|
"preset": {
|
|
"id": "constitution-wrap",
|
|
"name": "Constitution Wrap",
|
|
"version": "1.0.0",
|
|
"description": "Wrap constitution template for testing",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"templates": [
|
|
{
|
|
"type": "template",
|
|
"name": "constitution-template",
|
|
"file": "templates/constitution-template.md",
|
|
"strategy": "wrap",
|
|
"description": "Wrapped constitution template",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
)
|
|
)
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
|
content = memory.read_text()
|
|
assert "{CORE_TEMPLATE}" not in content
|
|
assert "# Wrapper Constitution" in content
|
|
assert "## Core Principle" in content
|
|
|
|
def test_constitution_follows_priority_when_winning_preset_removed(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""An unchanged generated constitution follows priority and fallback layers."""
|
|
manager = PresetManager(project_dir)
|
|
install_self_test_preset(manager)
|
|
|
|
preset_dir = temp_dir / "higher-priority"
|
|
(preset_dir / "templates").mkdir(parents=True)
|
|
(preset_dir / "templates" / "constitution-template.md").write_text(
|
|
"# Higher Priority Constitution\n"
|
|
)
|
|
(preset_dir / "preset.yml").write_text(
|
|
yaml.dump(
|
|
{
|
|
"schema_version": "1.0",
|
|
"preset": {
|
|
"id": "higher-priority",
|
|
"name": "Higher Priority",
|
|
"version": "1.0.0",
|
|
"description": "Higher-priority constitution",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"templates": [
|
|
{
|
|
"type": "template",
|
|
"name": "constitution-template",
|
|
"file": "templates/constitution-template.md",
|
|
"strategy": "replace",
|
|
"description": "Higher-priority constitution",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
)
|
|
)
|
|
|
|
manager.install_from_directory(preset_dir, "0.1.5", priority=1)
|
|
|
|
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
|
assert memory.read_text() == "# Higher Priority Constitution\n"
|
|
|
|
manager.remove("higher-priority")
|
|
|
|
assert "preset:self-test" in memory.read_text()
|
|
|
|
def test_convention_constitution_removal_restores_remaining_layer(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""Removing a convention layer rematerializes the remaining resolver layer."""
|
|
manager = PresetManager(project_dir)
|
|
install_self_test_preset(manager)
|
|
manager.install_from_directory(
|
|
_make_convention_constitution_preset(temp_dir), "0.1.5", priority=1
|
|
)
|
|
|
|
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
|
assert memory.read_text() == "# Convention Constitution\n"
|
|
|
|
manager.remove("convention-constitution")
|
|
|
|
assert "preset:self-test" in memory.read_text()
|
|
|
|
def test_convention_constitution_removal_preserves_edited_content(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""Removing a convention layer does not overwrite edited generated content."""
|
|
from specify_cli.commands.init import ensure_constitution_from_template
|
|
|
|
templates_dir = project_dir / ".specify" / "templates"
|
|
(templates_dir / "constitution-template.md").write_text("# Core Constitution\n")
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(
|
|
_make_convention_constitution_preset(temp_dir), "0.1.5"
|
|
)
|
|
ensure_constitution_from_template(project_dir)
|
|
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
|
edited = memory.read_text() + "\n## Authored amendment\n"
|
|
memory.write_text(edited)
|
|
|
|
manager.remove("convention-constitution")
|
|
|
|
assert memory.read_text() == edited
|
|
|
|
def test_custom_constitution_removal_recovers_with_invalid_manifest(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""Provenance triggers fallback when a custom-path manifest is invalid."""
|
|
manager = PresetManager(project_dir)
|
|
install_self_test_preset(manager)
|
|
|
|
preset_dir = temp_dir / "custom-constitution"
|
|
(preset_dir / "policy").mkdir(parents=True)
|
|
(preset_dir / "policy" / "charter.md").write_text("# Custom Constitution\n")
|
|
(preset_dir / "preset.yml").write_text(
|
|
yaml.dump(
|
|
{
|
|
"schema_version": "1.0",
|
|
"preset": {
|
|
"id": "custom-constitution",
|
|
"name": "Custom Constitution",
|
|
"version": "1.0.0",
|
|
"description": "Custom-path constitution for testing",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"templates": [
|
|
{
|
|
"type": "template",
|
|
"name": "constitution-template",
|
|
"file": "policy/charter.md",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
)
|
|
)
|
|
manager.install_from_directory(preset_dir, "0.1.5", priority=1)
|
|
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
|
assert memory.read_text() == "# Custom Constitution\n"
|
|
|
|
installed_manifest = (
|
|
project_dir
|
|
/ ".specify"
|
|
/ "presets"
|
|
/ "custom-constitution"
|
|
/ "preset.yml"
|
|
)
|
|
installed_manifest.write_text("invalid: [")
|
|
|
|
manager.remove("custom-constitution")
|
|
|
|
assert "preset:self-test" in memory.read_text()
|
|
|
|
def test_constitution_seed_rejects_symlinked_memory_directory(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""Preset installation cannot seed through a symlinked memory directory."""
|
|
outside = temp_dir / "outside"
|
|
outside.mkdir()
|
|
try:
|
|
(project_dir / ".specify" / "memory").symlink_to(
|
|
outside, target_is_directory=True
|
|
)
|
|
except OSError:
|
|
pytest.skip("symlinks are unavailable")
|
|
|
|
manager = PresetManager(project_dir)
|
|
with pytest.warns(UserWarning, match="symlinked"):
|
|
install_self_test_preset(manager)
|
|
|
|
assert manager.registry.is_installed("self-test")
|
|
assert not (outside / "constitution.md").exists()
|
|
|
|
def test_constitution_seed_rejects_dangling_destination_symlink(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""Preset installation cannot seed through a dangling destination symlink."""
|
|
memory = project_dir / ".specify" / "memory"
|
|
memory.mkdir(parents=True)
|
|
outside = temp_dir / "outside-constitution.md"
|
|
try:
|
|
(memory / "constitution.md").symlink_to(outside)
|
|
except OSError:
|
|
pytest.skip("symlinks are unavailable")
|
|
|
|
manager = PresetManager(project_dir)
|
|
with pytest.warns(UserWarning, match="symlinked"):
|
|
install_self_test_preset(manager)
|
|
|
|
assert manager.registry.is_installed("self-test")
|
|
assert not outside.exists()
|
|
|
|
def test_constitution_materialization_error_is_nonfatal(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""An invalid wrap warns without reporting an uninstalled preset."""
|
|
preset_dir = temp_dir / "invalid-wrap"
|
|
(preset_dir / "templates").mkdir(parents=True)
|
|
(preset_dir / "templates" / "constitution-template.md").write_text(
|
|
"# Missing core placeholder\n"
|
|
)
|
|
(preset_dir / "preset.yml").write_text(
|
|
yaml.dump(
|
|
{
|
|
"schema_version": "1.0",
|
|
"preset": {
|
|
"id": "invalid-wrap",
|
|
"name": "Invalid Wrap",
|
|
"version": "1.0.0",
|
|
"description": "Invalid wrapping constitution",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"templates": [
|
|
{
|
|
"type": "template",
|
|
"name": "constitution-template",
|
|
"file": "templates/constitution-template.md",
|
|
"strategy": "wrap",
|
|
"description": "Invalid wrap",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
)
|
|
)
|
|
|
|
manager = PresetManager(project_dir)
|
|
with pytest.warns(UserWarning, match="Failed to seed constitution"):
|
|
manifest = manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
assert manifest.id == "invalid-wrap"
|
|
assert manager.registry.is_installed("invalid-wrap")
|
|
|
|
def test_extension_command_skipped_when_extension_missing(self, project_dir, temp_dir):
|
|
"""Test that extension command overrides are skipped if the extension isn't installed."""
|
|
claude_dir = project_dir / ".claude" / "skills"
|
|
claude_dir.mkdir(parents=True)
|
|
|
|
preset_dir = temp_dir / "ext-override-preset"
|
|
preset_dir.mkdir()
|
|
(preset_dir / "commands").mkdir()
|
|
(preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text(
|
|
"---\ndescription: Override fakeext cmd\n---\nOverridden content"
|
|
)
|
|
manifest_data = {
|
|
"schema_version": "1.0",
|
|
"preset": {
|
|
"id": "ext-override",
|
|
"name": "Ext Override",
|
|
"version": "1.0.0",
|
|
"description": "Test",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"templates": [
|
|
{
|
|
"type": "command",
|
|
"name": "speckit.fakeext.cmd",
|
|
"file": "commands/speckit.fakeext.cmd.md",
|
|
"description": "Override fakeext cmd",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
with open(preset_dir / "preset.yml", "w") as f:
|
|
yaml.dump(manifest_data, f)
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
# Extension not installed — command should NOT be registered
|
|
cmd_file = claude_dir / "speckit.fakeext.cmd.md"
|
|
assert not cmd_file.exists(), "Command registered for missing extension"
|
|
metadata = manager.registry.get("ext-override")
|
|
assert metadata["registered_commands"] == {}
|
|
|
|
def test_extension_command_registered_when_extension_present(self, project_dir, temp_dir):
|
|
"""Test that extension command overrides ARE registered when the extension is installed."""
|
|
claude_dir = project_dir / ".claude" / "skills"
|
|
claude_dir.mkdir(parents=True)
|
|
(project_dir / ".specify" / "extensions" / "fakeext").mkdir(parents=True)
|
|
|
|
preset_dir = temp_dir / "ext-override-preset2"
|
|
preset_dir.mkdir()
|
|
(preset_dir / "commands").mkdir()
|
|
(preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text(
|
|
"---\ndescription: Override fakeext cmd\n---\nOverridden content"
|
|
)
|
|
manifest_data = {
|
|
"schema_version": "1.0",
|
|
"preset": {
|
|
"id": "ext-override2",
|
|
"name": "Ext Override",
|
|
"version": "1.0.0",
|
|
"description": "Test",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"templates": [
|
|
{
|
|
"type": "command",
|
|
"name": "speckit.fakeext.cmd",
|
|
"file": "commands/speckit.fakeext.cmd.md",
|
|
"description": "Override fakeext cmd",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
with open(preset_dir / "preset.yml", "w") as f:
|
|
yaml.dump(manifest_data, f)
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
cmd_file = claude_dir / "speckit-fakeext-cmd" / "SKILL.md"
|
|
assert cmd_file.exists(), "Skill not registered despite extension being present"
|
|
|
|
|
|
# ===== Init Options and Skills Tests =====
|
|
|
|
|
|
class TestInitOptions:
|
|
"""Tests for save_init_options / load_init_options helpers."""
|
|
|
|
def test_save_and_load_round_trip(self, project_dir):
|
|
from specify_cli import save_init_options, load_init_options
|
|
|
|
opts = {"ai": "claude", "ai_skills": True, "here": False}
|
|
save_init_options(project_dir, opts)
|
|
|
|
loaded = load_init_options(project_dir)
|
|
assert loaded["ai"] == "claude"
|
|
assert loaded["ai_skills"] is True
|
|
|
|
def test_save_and_load_available_from_init_options_module(self, project_dir):
|
|
from specify_cli._init_options import load_init_options, save_init_options
|
|
|
|
opts = {"ai": "codex", "ai_skills": True, "script": "sh"}
|
|
save_init_options(project_dir, opts)
|
|
|
|
assert load_init_options(project_dir) == opts
|
|
|
|
def test_save_uses_utf8_encoding(self, project_dir, monkeypatch):
|
|
from specify_cli import save_init_options
|
|
|
|
original_write_text = Path.write_text
|
|
seen: dict[str, str | None] = {}
|
|
|
|
def spy_write_text(path, data, *args, **kwargs):
|
|
if path == project_dir / ".specify" / "init-options.json":
|
|
seen["encoding"] = kwargs.get("encoding")
|
|
return original_write_text(path, data, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(Path, "write_text", spy_write_text)
|
|
|
|
save_init_options(project_dir, {"label": "中文测试"})
|
|
|
|
assert seen["encoding"] == "utf-8"
|
|
|
|
def test_load_uses_utf8_encoding(self, project_dir, monkeypatch):
|
|
from specify_cli import load_init_options
|
|
|
|
opts_file = project_dir / ".specify" / "init-options.json"
|
|
opts_file.parent.mkdir(parents=True, exist_ok=True)
|
|
opts_file.write_text('{"ai": "codex"}', encoding="utf-8")
|
|
|
|
original_read_text = Path.read_text
|
|
seen: dict[str, str | None] = {}
|
|
|
|
def spy_read_text(path, *args, **kwargs):
|
|
if path == opts_file:
|
|
seen["encoding"] = kwargs.get("encoding")
|
|
return original_read_text(path, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(Path, "read_text", spy_read_text)
|
|
|
|
assert load_init_options(project_dir) == {"ai": "codex"}
|
|
assert seen["encoding"] == "utf-8"
|
|
|
|
def test_load_returns_empty_when_missing(self, project_dir):
|
|
from specify_cli import load_init_options
|
|
|
|
assert load_init_options(project_dir) == {}
|
|
|
|
def test_load_returns_empty_on_invalid_json(self, project_dir):
|
|
from specify_cli import load_init_options
|
|
|
|
opts_file = project_dir / ".specify" / "init-options.json"
|
|
opts_file.parent.mkdir(parents=True, exist_ok=True)
|
|
opts_file.write_text("{bad json")
|
|
|
|
assert load_init_options(project_dir) == {}
|
|
|
|
@pytest.mark.parametrize(
|
|
"value",
|
|
["名前-プロジェクト", "café-résumé", "Ωmega-Δelta", "🚀-launch"],
|
|
)
|
|
def test_save_load_round_trip_preserves_non_ascii(self, project_dir, value):
|
|
"""Non-ASCII values round-trip via explicit UTF-8 encoding.
|
|
|
|
``Path.write_text`` / ``Path.read_text`` default to the system
|
|
locale codec on Windows (cp1252 / gb2312 / cp932). Without
|
|
``encoding="utf-8"`` pinned on both ends, a project name like
|
|
``café`` written on a UTF-8 host becomes garbled or unreadable on
|
|
a cp1252 host (and vice versa). Pin UTF-8 explicitly so init
|
|
options round-trip across machines and CI.
|
|
|
|
Note: this test only meaningfully exercises the encoding pin
|
|
because ``save_init_options`` now writes JSON with
|
|
``ensure_ascii=False`` — otherwise ``json.dumps`` would output
|
|
ASCII-only ``\\uXXXX`` escapes and the encoding pin would be a
|
|
no-op for any value here. ``test_save_writes_real_utf8_bytes``
|
|
below asserts that contract directly.
|
|
"""
|
|
from specify_cli import save_init_options, load_init_options
|
|
|
|
save_init_options(project_dir, {"ai": "claude", "project_name": value})
|
|
|
|
loaded = load_init_options(project_dir)
|
|
assert loaded["project_name"] == value
|
|
|
|
def test_save_writes_real_utf8_bytes(self, project_dir):
|
|
"""The on-disk file contains real UTF-8 bytes, not ``\\uXXXX`` escapes.
|
|
|
|
Pinning ``encoding="utf-8"`` on ``write_text`` only makes a
|
|
difference when the serialiser actually emits non-ASCII
|
|
characters. With ``ensure_ascii=False`` on ``json.dumps`` the
|
|
non-ASCII bytes hit the file, so the encoding pin is the thing
|
|
that decides between cp1252 garbage and clean UTF-8 on Windows.
|
|
|
|
This test pins that behaviour: the on-disk bytes are valid UTF-8
|
|
and contain the multi-byte encoding of ``café``, not its
|
|
``\\u00e9`` escape form. Reviewers can verify that removing
|
|
``ensure_ascii=False`` or ``encoding="utf-8"`` from the writer
|
|
breaks this test, which is what Copilot's review pointed out the
|
|
original round-trip test failed to do.
|
|
"""
|
|
from specify_cli import save_init_options
|
|
|
|
save_init_options(project_dir, {"project_name": "café"})
|
|
|
|
opts_file = project_dir / ".specify" / "init-options.json"
|
|
raw = opts_file.read_bytes()
|
|
# 'café' in UTF-8 ends with bytes 0xC3 0xA9 ('é'). The cp1252
|
|
# encoding of 'é' is the single byte 0xE9. The JSON-escape form
|
|
# would be the 6-byte literal '\\u00e9'. We assert the UTF-8 form
|
|
# is present so the test pins the actual contract.
|
|
assert b"caf\xc3\xa9" in raw, (
|
|
"Expected UTF-8 bytes for 'café' in the on-disk file, "
|
|
f"got: {raw!r}"
|
|
)
|
|
# And the whole file decodes cleanly as UTF-8.
|
|
raw.decode("utf-8")
|
|
|
|
def test_load_returns_empty_on_locale_corrupted_file(self, project_dir):
|
|
"""A file written in a non-UTF-8 codec falls back to {}, not crash.
|
|
|
|
Simulates a file produced by an old client (or by a peer machine
|
|
with a different default locale) that contains bytes invalid as
|
|
UTF-8. ``load_init_options`` should fall back to ``{}`` per the
|
|
existing contract — never propagate a raw ``UnicodeDecodeError``
|
|
to the CLI surface.
|
|
"""
|
|
from specify_cli import load_init_options
|
|
|
|
opts_file = project_dir / ".specify" / "init-options.json"
|
|
opts_file.parent.mkdir(parents=True, exist_ok=True)
|
|
# 0xE9 is 'é' in cp1252 but an invalid lead byte in UTF-8.
|
|
opts_file.write_bytes(b'{"project_name": "caf\xe9"}')
|
|
|
|
assert load_init_options(project_dir) == {}
|
|
|
|
@pytest.mark.parametrize("payload", ["[]", '"value"', "42", "true", "null"])
|
|
def test_load_returns_empty_on_non_object_json(self, project_dir, payload):
|
|
from specify_cli import load_init_options
|
|
|
|
opts_file = project_dir / ".specify" / "init-options.json"
|
|
opts_file.parent.mkdir(parents=True, exist_ok=True)
|
|
opts_file.write_text(payload, encoding="utf-8")
|
|
|
|
assert load_init_options(project_dir) == {}
|
|
|
|
def test_load_returns_empty_on_unicode_decode_error(self, project_dir, monkeypatch):
|
|
from specify_cli import load_init_options
|
|
|
|
opts_file = project_dir / ".specify" / "init-options.json"
|
|
opts_file.parent.mkdir(parents=True, exist_ok=True)
|
|
opts_file.write_bytes(b"{}")
|
|
|
|
original_read_text = Path.read_text
|
|
|
|
def raise_decode_error(path, *args, **kwargs):
|
|
if path == opts_file:
|
|
raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte")
|
|
return original_read_text(path, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(Path, "read_text", raise_decode_error)
|
|
|
|
assert load_init_options(project_dir) == {}
|
|
|
|
@pytest.mark.parametrize(
|
|
("value", "expected"),
|
|
[
|
|
(True, True),
|
|
(False, False),
|
|
("true", False),
|
|
("false", False),
|
|
(1, False),
|
|
(0, False),
|
|
(None, False),
|
|
],
|
|
)
|
|
def test_is_ai_skills_enabled_requires_boolean_true(self, value, expected):
|
|
from specify_cli._init_options import is_ai_skills_enabled
|
|
|
|
assert is_ai_skills_enabled({"ai_skills": value}) is expected
|
|
|
|
|
|
class TestResolveActiveAgentForRegistration:
|
|
"""Tests for the shared #2948 active-agent resolution helper.
|
|
|
|
``load_init_options`` collapses "no file", "corrupted file", and
|
|
"valid file with no active agent" into the same ``{}``. Extensions and
|
|
presets both need to tell those apart: no file means "legacy project,
|
|
fall back to all detected agents"; a corrupted or malformed file means
|
|
"fail closed, register nothing" so a corrupted init-options.json can't
|
|
silently reintroduce all-agent registration.
|
|
"""
|
|
|
|
def test_missing_file_returns_sentinel(self, project_dir):
|
|
from specify_cli._init_options import (
|
|
MISSING_INIT_OPTIONS_FILE,
|
|
resolve_active_agent_for_registration,
|
|
)
|
|
|
|
assert (
|
|
resolve_active_agent_for_registration(project_dir)
|
|
is MISSING_INIT_OPTIONS_FILE
|
|
)
|
|
|
|
def test_valid_active_agent_returns_string(self, project_dir):
|
|
from specify_cli import save_init_options
|
|
from specify_cli._init_options import resolve_active_agent_for_registration
|
|
|
|
save_init_options(project_dir, {"ai": "claude"})
|
|
|
|
assert resolve_active_agent_for_registration(project_dir) == "claude"
|
|
|
|
def test_corrupted_json_fails_closed(self, project_dir):
|
|
"""A present-but-unparseable file must not behave like "no file"."""
|
|
from specify_cli._init_options import resolve_active_agent_for_registration
|
|
|
|
opts_file = project_dir / ".specify" / "init-options.json"
|
|
opts_file.parent.mkdir(parents=True, exist_ok=True)
|
|
opts_file.write_text("{bad json", encoding="utf-8")
|
|
|
|
assert resolve_active_agent_for_registration(project_dir) is None
|
|
|
|
@pytest.mark.parametrize("value", [[], {}, "", 0, None, ["claude"]])
|
|
def test_malformed_ai_value_fails_closed(self, project_dir, value):
|
|
"""A recorded but non-string/empty ``ai`` value fails closed too."""
|
|
from specify_cli import save_init_options
|
|
from specify_cli._init_options import resolve_active_agent_for_registration
|
|
|
|
save_init_options(project_dir, {"ai": value})
|
|
|
|
assert resolve_active_agent_for_registration(project_dir) is None
|
|
|
|
def test_dangling_symlink_fails_closed(self, project_dir):
|
|
"""A dangling init-options.json symlink must fail closed, not fall
|
|
back to "no file" (#2948).
|
|
|
|
``Path.exists()`` follows symlinks and returns False for a broken
|
|
symlink whose target is missing, so a naive presence check treats a
|
|
dangling symlink the same as "no file at all" and falls back to
|
|
legacy all-agent registration. The path is present (just broken),
|
|
so it must be treated as a corrupted file and fail closed instead.
|
|
"""
|
|
from specify_cli._init_options import resolve_active_agent_for_registration
|
|
|
|
opts_file = project_dir / ".specify" / "init-options.json"
|
|
opts_file.parent.mkdir(parents=True, exist_ok=True)
|
|
opts_file.symlink_to(project_dir / ".specify" / "does-not-exist.json")
|
|
|
|
assert not opts_file.exists() # sanity: this is what makes it dangling
|
|
assert opts_file.is_symlink()
|
|
assert resolve_active_agent_for_registration(project_dir) is None
|
|
|
|
|
|
class TestPresetSkills:
|
|
"""Tests for preset skill registration and unregistration.
|
|
|
|
Tests that install the self-test preset use ``install_self_test_preset``
|
|
which scopes a narrow filter to the expected wrap-strategy warning.
|
|
Reconciliation failures remain audible so real regressions surface.
|
|
"""
|
|
|
|
def _write_init_options(self, project_dir, ai="claude", ai_skills=True, script="sh"):
|
|
from specify_cli import save_init_options
|
|
|
|
save_init_options(project_dir, {"ai": ai, "ai_skills": ai_skills, "script": script})
|
|
|
|
def _create_skill(self, skills_dir, skill_name, body="original body"):
|
|
skill_dir = skills_dir / skill_name
|
|
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
(skill_dir / "SKILL.md").write_text(
|
|
f"---\nname: {skill_name}\n---\n\n{body}\n"
|
|
)
|
|
return skill_dir
|
|
|
|
def _create_command_preset(self, temp_dir, preset_id, command_name, description, body):
|
|
preset_dir = temp_dir / preset_id
|
|
preset_dir.mkdir()
|
|
(preset_dir / "commands").mkdir()
|
|
command_file = f"{command_name}.md"
|
|
(preset_dir / "commands" / command_file).write_text(
|
|
f"---\ndescription: {description}\n---\n\n{body}\n"
|
|
)
|
|
manifest_data = {
|
|
"schema_version": "1.0",
|
|
"preset": {
|
|
"id": preset_id,
|
|
"name": preset_id,
|
|
"version": "1.0.0",
|
|
"description": "Test",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"templates": [
|
|
{
|
|
"type": "command",
|
|
"name": command_name,
|
|
"file": f"commands/{command_file}",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
with open(preset_dir / "preset.yml", "w") as f:
|
|
yaml.dump(manifest_data, f)
|
|
return preset_dir
|
|
|
|
def _create_multi_command_preset(self, temp_dir, preset_id, command_names):
|
|
"""Install-directory helper for a preset with more than one command.
|
|
|
|
Used to prove partial-result handling: a command's own template
|
|
entry can genuinely be skipped by registration (missing source
|
|
file, safety-validation rejection) while sibling commands in the
|
|
same preset still succeed.
|
|
"""
|
|
preset_dir = temp_dir / preset_id
|
|
preset_dir.mkdir()
|
|
(preset_dir / "commands").mkdir()
|
|
templates = []
|
|
for command_name in command_names:
|
|
command_file = f"{command_name}.md"
|
|
(preset_dir / "commands" / command_file).write_text(
|
|
f"---\ndescription: {command_name} test command\n---\n\n"
|
|
f"{command_name} body\n"
|
|
)
|
|
templates.append({
|
|
"type": "command",
|
|
"name": command_name,
|
|
"file": f"commands/{command_file}",
|
|
})
|
|
manifest_data = {
|
|
"schema_version": "1.0",
|
|
"preset": {
|
|
"id": preset_id,
|
|
"name": preset_id,
|
|
"version": "1.0.0",
|
|
"description": "Test",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {"templates": templates},
|
|
}
|
|
with open(preset_dir / "preset.yml", "w") as f:
|
|
yaml.dump(manifest_data, f)
|
|
return preset_dir
|
|
|
|
def _create_multi_command_preset_with_aliases(self, temp_dir, preset_id, command_specs):
|
|
"""Install-directory helper for a preset whose commands carry aliases.
|
|
|
|
``command_specs`` is a list of ``(primary_name, [alias, ...])``
|
|
tuples. Each command gets its own source file (aliases share the
|
|
same source/content as their primary — CommandRegistrar renders
|
|
them from the same command file, just under a different output
|
|
name (#2948)).
|
|
"""
|
|
preset_dir = temp_dir / preset_id
|
|
preset_dir.mkdir()
|
|
(preset_dir / "commands").mkdir()
|
|
templates = []
|
|
for primary_name, aliases in command_specs:
|
|
command_file = f"{primary_name}.md"
|
|
(preset_dir / "commands" / command_file).write_text(
|
|
f"---\ndescription: {primary_name} test command\n---\n\n"
|
|
f"{primary_name} body\n"
|
|
)
|
|
templates.append({
|
|
"type": "command",
|
|
"name": primary_name,
|
|
"file": f"commands/{command_file}",
|
|
"aliases": list(aliases),
|
|
})
|
|
manifest_data = {
|
|
"schema_version": "1.0",
|
|
"preset": {
|
|
"id": preset_id,
|
|
"name": preset_id,
|
|
"version": "1.0.0",
|
|
"description": "Test",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {"templates": templates},
|
|
}
|
|
with open(preset_dir / "preset.yml", "w") as f:
|
|
yaml.dump(manifest_data, f)
|
|
return preset_dir
|
|
|
|
def test_skill_overridden_on_preset_install(self, project_dir, temp_dir):
|
|
"""When skills mode was used, a preset command override should update the skill."""
|
|
# Simulate skills mode having been used: write init-options + create skill
|
|
self._write_init_options(project_dir, ai="claude")
|
|
skills_dir = project_dir / ".claude" / "skills"
|
|
self._create_skill(skills_dir, "speckit-specify")
|
|
|
|
# Also create the claude commands dir so commands get registered
|
|
(project_dir / ".claude" / "skills").mkdir(parents=True, exist_ok=True)
|
|
|
|
# Install self-test preset (has a command override for speckit.specify)
|
|
manager = PresetManager(project_dir)
|
|
install_self_test_preset(manager)
|
|
|
|
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
|
|
assert skill_file.exists()
|
|
content = skill_file.read_text()
|
|
assert "preset:self-test" in content, "Skill should reference preset source"
|
|
assert "disable-model-invocation: false" in content
|
|
|
|
# Verify it was recorded in registry, keyed by the active agent
|
|
metadata = manager.registry.get("self-test")
|
|
assert "speckit-specify" in metadata.get("registered_skills", {}).get("claude", [])
|
|
|
|
def _install_arg_hint_preset(self, project_dir, temp_dir, ai, skills_dir, description, arg_hint):
|
|
"""Install a preset whose command declares argument-hint; return the SKILL.md path."""
|
|
self._write_init_options(project_dir, ai=ai)
|
|
self._create_skill(skills_dir, "speckit-hinttest-cmd")
|
|
(project_dir / ".specify" / "extensions" / "hinttest").mkdir(parents=True, exist_ok=True)
|
|
|
|
preset_dir = temp_dir / f"hint-preset-{ai}"
|
|
preset_dir.mkdir()
|
|
(preset_dir / "commands").mkdir()
|
|
(preset_dir / "commands" / "speckit.hinttest.cmd.md").write_text(
|
|
"---\n"
|
|
f'description: "{description}"\n'
|
|
f'argument-hint: "{arg_hint}"\n'
|
|
"---\n\n"
|
|
"Preset command body.\n",
|
|
encoding="utf-8",
|
|
)
|
|
manifest_data = {
|
|
"schema_version": "1.0",
|
|
"preset": {
|
|
"id": f"hint-preset-{ai}",
|
|
"name": "Hint Preset",
|
|
"version": "1.0.0",
|
|
"description": "Test",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"templates": [
|
|
{
|
|
"type": "command",
|
|
"name": "speckit.hinttest.cmd",
|
|
"file": "commands/speckit.hinttest.cmd.md",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
with open(preset_dir / "preset.yml", "w") as f:
|
|
yaml.dump(manifest_data, f)
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
return skills_dir / "speckit-hinttest-cmd" / "SKILL.md"
|
|
|
|
def test_argument_hint_preserved_for_preset_command(self, project_dir, temp_dir):
|
|
"""argument-hint from a preset command must survive into the SKILL.md.
|
|
|
|
Follow-up to #2903/#2916 for the preset skill generator. The
|
|
description is long enough to fold across lines when serialized,
|
|
guarding against an in-place string injection that would split the
|
|
folded scalar into invalid YAML.
|
|
"""
|
|
long_description = (
|
|
"Build and maintain a lean, static context/ knowledge folder so "
|
|
"coding agents load only what is relevant and save tokens"
|
|
)
|
|
arg_hint = "<init | update | list | check> [area] [slug] [-- notes]"
|
|
skills_dir = project_dir / ".claude" / "skills"
|
|
|
|
skill_file = self._install_arg_hint_preset(
|
|
project_dir, temp_dir, "claude", skills_dir, long_description, arg_hint
|
|
)
|
|
assert skill_file.exists()
|
|
parsed = yaml.safe_load(skill_file.read_text(encoding="utf-8").split("---", 2)[1])
|
|
assert parsed["argument-hint"] == arg_hint
|
|
assert parsed["description"] == long_description
|
|
|
|
def test_argument_hint_not_added_for_non_claude_preset_command(self, project_dir, temp_dir):
|
|
"""Non-Claude skills agents must not receive argument-hint in preset skills."""
|
|
arg_hint = "<init | update | list | check> [area]"
|
|
skills_dir = project_dir / ".agents" / "skills"
|
|
|
|
skill_file = self._install_arg_hint_preset(
|
|
project_dir, temp_dir, "codex", skills_dir, "Build context", arg_hint
|
|
)
|
|
assert skill_file.exists()
|
|
parsed = yaml.safe_load(skill_file.read_text(encoding="utf-8").split("---", 2)[1])
|
|
assert "argument-hint" not in parsed
|
|
|
|
def test_register_skills_resolves_command_refs(self, project_dir, temp_dir):
|
|
"""Preset skill overrides must resolve __SPECKIT_COMMAND_*__ tokens (issue #2717).
|
|
|
|
``_register_skills()`` previously ran only ``resolve_skill_placeholders()``,
|
|
so command cross-references leaked into SKILL.md as raw placeholders
|
|
instead of rendering as ``/speckit-<cmd>`` like the command layer.
|
|
"""
|
|
self._write_init_options(project_dir, ai="claude")
|
|
skills_dir = project_dir / ".claude" / "skills"
|
|
self._create_skill(skills_dir, "speckit-specify")
|
|
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir,
|
|
"cmdref-install",
|
|
"speckit.specify",
|
|
"Override specify",
|
|
"Run `__SPECKIT_COMMAND_SPECIFY__` then `__SPECKIT_COMMAND_PLAN__`.\n",
|
|
)
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
content = (skills_dir / "speckit-specify" / "SKILL.md").read_text()
|
|
assert "__SPECKIT_COMMAND_" not in content, "raw command token leaked into SKILL.md"
|
|
# Claude's invoke_separator is "-", so tokens render as /speckit-<cmd>.
|
|
assert "/speckit-specify" in content
|
|
assert "/speckit-plan" in content
|
|
|
|
def test_restore_skill_resolves_command_refs(self, project_dir, temp_dir):
|
|
"""Skill restore on preset removal must also resolve command tokens (issue #2717)."""
|
|
self._write_init_options(project_dir, ai="claude")
|
|
skills_dir = project_dir / ".claude" / "skills"
|
|
self._create_skill(skills_dir, "speckit-specify")
|
|
|
|
core_cmds = project_dir / ".specify" / "templates" / "commands"
|
|
core_cmds.mkdir(parents=True, exist_ok=True)
|
|
(core_cmds / "specify.md").write_text(
|
|
"---\ndescription: Core specify\n---\n\n"
|
|
"Then run `__SPECKIT_COMMAND_PLAN__`.\n"
|
|
)
|
|
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir,
|
|
"cmdref-restore",
|
|
"speckit.specify",
|
|
"Override specify",
|
|
"Override body\n",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
manager.remove("cmdref-restore")
|
|
|
|
content = (skills_dir / "speckit-specify" / "SKILL.md").read_text()
|
|
assert "__SPECKIT_COMMAND_" not in content, "raw command token leaked on restore"
|
|
assert "/speckit-plan" in content
|
|
|
|
def test_reconcile_override_skill_resolves_command_refs(self, project_dir, temp_dir):
|
|
"""Reconcile's project-override restore must resolve command tokens (issue #2717).
|
|
|
|
When a preset that overrode a command is removed and a project override
|
|
becomes the winning layer, ``_reconcile_skills`` rewrites the skill from
|
|
the override body — which must also render ``__SPECKIT_COMMAND_*__`` tokens.
|
|
"""
|
|
self._write_init_options(project_dir, ai="claude")
|
|
skills_dir = project_dir / ".claude" / "skills"
|
|
self._create_skill(skills_dir, "speckit-specify")
|
|
|
|
# Project override wins once the preset is removed; its body carries a
|
|
# command cross-reference token. No core template exists for "specify",
|
|
# so the skill is restored exclusively via the reconcile override branch.
|
|
overrides_dir = project_dir / ".specify" / "templates" / "overrides"
|
|
overrides_dir.mkdir(parents=True, exist_ok=True)
|
|
(overrides_dir / "speckit.specify.md").write_text(
|
|
"---\ndescription: Override specify\n---\n\n"
|
|
"Then run `__SPECKIT_COMMAND_PLAN__`.\n"
|
|
)
|
|
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir,
|
|
"cmdref-reconcile",
|
|
"speckit.specify",
|
|
"Preset specify",
|
|
"Preset body\n",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
manager.remove("cmdref-reconcile")
|
|
|
|
content = (skills_dir / "speckit-specify" / "SKILL.md").read_text()
|
|
assert "override:speckit.specify" in content, "skill should be restored from the project override"
|
|
assert "__SPECKIT_COMMAND_" not in content, "raw command token leaked on reconcile"
|
|
assert "/speckit-plan" in content
|
|
|
|
def test_extension_restore_resolves_command_refs(self, project_dir, temp_dir):
|
|
"""Extension-backed skill restore must resolve command tokens (issue #2717).
|
|
|
|
When a preset override is removed and the skill is restored from an
|
|
extension command body, ``__SPECKIT_COMMAND_*__`` tokens in that body
|
|
must render as slash-command invocations like the core-template path.
|
|
"""
|
|
self._write_init_options(project_dir, ai="claude")
|
|
skills_dir = project_dir / ".claude" / "skills"
|
|
self._create_skill(skills_dir, "speckit-fakeext-cmd", body="original extension skill")
|
|
|
|
extension_dir = project_dir / ".specify" / "extensions" / "fakeext"
|
|
(extension_dir / "commands").mkdir(parents=True, exist_ok=True)
|
|
(extension_dir / "commands" / "cmd.md").write_text(
|
|
"---\ndescription: Extension fakeext cmd\n---\n\n"
|
|
"Then run `__SPECKIT_COMMAND_PLAN__`.\n"
|
|
)
|
|
extension_manifest = {
|
|
"schema_version": "1.0",
|
|
"extension": {
|
|
"id": "fakeext",
|
|
"name": "Fake Extension",
|
|
"version": "1.0.0",
|
|
"description": "Test",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"commands": [
|
|
{
|
|
"name": "speckit.fakeext.cmd",
|
|
"file": "commands/cmd.md",
|
|
"description": "Fake extension command",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
with open(extension_dir / "extension.yml", "w") as f:
|
|
yaml.dump(extension_manifest, f)
|
|
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir,
|
|
"cmdref-ext-restore",
|
|
"speckit.fakeext.cmd",
|
|
"Override fakeext cmd",
|
|
"Override body\n",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
manager.remove("cmdref-ext-restore")
|
|
|
|
content = (skills_dir / "speckit-fakeext-cmd" / "SKILL.md").read_text()
|
|
assert "source: extension:fakeext" in content, "skill should be restored from the extension"
|
|
assert "__SPECKIT_COMMAND_" not in content, "raw command token leaked on extension restore"
|
|
assert "/speckit-plan" in content
|
|
|
|
def test_core_command_override_skill_uses_preset_command_description(self, project_dir, temp_dir):
|
|
"""Preset skill overrides for core commands should keep preset frontmatter descriptions."""
|
|
self._write_init_options(project_dir, ai="claude")
|
|
skills_dir = project_dir / ".claude" / "skills"
|
|
self._create_skill(skills_dir, "speckit-taskstoissues")
|
|
|
|
preset_dir = temp_dir / "taskstoissues-description"
|
|
preset_dir.mkdir()
|
|
(preset_dir / "commands").mkdir()
|
|
(preset_dir / "commands" / "speckit.repro.taskstoissues.md").write_text(
|
|
"---\n"
|
|
"description: COMMAND-FRONTMATTER-DESCRIPTION\n"
|
|
"---\n\n"
|
|
"# Repro command body\n"
|
|
)
|
|
manifest_data = {
|
|
"schema_version": "1.0",
|
|
"preset": {
|
|
"id": "taskstoissues-description",
|
|
"name": "Taskstoissues Description",
|
|
"version": "1.0.0",
|
|
"description": "Test",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"templates": [
|
|
{
|
|
"type": "command",
|
|
"name": "speckit.taskstoissues",
|
|
"file": "commands/speckit.repro.taskstoissues.md",
|
|
"description": "MANIFEST-DESCRIPTION",
|
|
"replaces": "speckit.taskstoissues",
|
|
"strategy": "replace",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
with open(preset_dir / "preset.yml", "w") as f:
|
|
yaml.dump(manifest_data, f)
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
skill_file = skills_dir / "speckit-taskstoissues" / "SKILL.md"
|
|
content = skill_file.read_text()
|
|
assert "description: COMMAND-FRONTMATTER-DESCRIPTION" in content
|
|
assert "Convert tasks from tasks.md into GitHub issues." not in content
|
|
assert "source: preset:taskstoissues-description" in content
|
|
|
|
def test_core_skill_restore_uses_core_command_description(self, project_dir, temp_dir):
|
|
"""Core skill restore should keep core command frontmatter descriptions."""
|
|
self._write_init_options(project_dir, ai="claude")
|
|
skills_dir = project_dir / ".claude" / "skills"
|
|
self._create_skill(skills_dir, "speckit-taskstoissues")
|
|
|
|
core_cmds = project_dir / ".specify" / "templates" / "commands"
|
|
core_cmds.mkdir(parents=True, exist_ok=True)
|
|
(core_cmds / "taskstoissues.md").write_text(
|
|
"---\n"
|
|
"description: CORE-FRONTMATTER-DESCRIPTION\n"
|
|
"---\n\n"
|
|
"core taskstoissues body\n"
|
|
)
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir,
|
|
"taskstoissues-restore",
|
|
"speckit.taskstoissues",
|
|
"PRESET-FRONTMATTER-DESCRIPTION",
|
|
"preset taskstoissues body\n",
|
|
)
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
manager.remove("taskstoissues-restore")
|
|
|
|
skill_file = skills_dir / "speckit-taskstoissues" / "SKILL.md"
|
|
content = skill_file.read_text()
|
|
assert "description: CORE-FRONTMATTER-DESCRIPTION" in content
|
|
assert "Convert tasks from tasks.md into GitHub issues." not in content
|
|
assert "source: templates/commands/taskstoissues.md" in content
|
|
assert "core taskstoissues body" in content
|
|
|
|
def test_override_skill_reconcile_uses_override_command_description(self, project_dir, temp_dir):
|
|
"""Override skill reconciliation should keep override frontmatter descriptions."""
|
|
self._write_init_options(project_dir, ai="claude")
|
|
skills_dir = project_dir / ".claude" / "skills"
|
|
self._create_skill(skills_dir, "speckit-taskstoissues")
|
|
|
|
overrides_dir = project_dir / ".specify" / "templates" / "overrides"
|
|
overrides_dir.mkdir(parents=True)
|
|
(overrides_dir / "speckit.taskstoissues.md").write_text(
|
|
"---\n"
|
|
"description: OVERRIDE-FRONTMATTER-DESCRIPTION\n"
|
|
"---\n\n"
|
|
"override taskstoissues body\n"
|
|
)
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir,
|
|
"taskstoissues-reconcile",
|
|
"speckit.taskstoissues",
|
|
"PRESET-FRONTMATTER-DESCRIPTION",
|
|
"preset taskstoissues body\n",
|
|
)
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
skill_file = skills_dir / "speckit-taskstoissues" / "SKILL.md"
|
|
content = skill_file.read_text()
|
|
assert "description: OVERRIDE-FRONTMATTER-DESCRIPTION" in content
|
|
assert "Convert tasks from tasks.md into GitHub issues." not in content
|
|
assert "source: override:speckit.taskstoissues" in content
|
|
assert "override taskstoissues body" in content
|
|
|
|
def test_skill_not_updated_when_ai_skills_disabled(self, project_dir, temp_dir):
|
|
"""When skills mode was NOT used, preset install should not touch skills."""
|
|
self._write_init_options(project_dir, ai="qwen", ai_skills=False)
|
|
skills_dir = project_dir / ".qwen" / "skills"
|
|
self._create_skill(skills_dir, "speckit-specify", body="untouched")
|
|
|
|
manager = PresetManager(project_dir)
|
|
install_self_test_preset(manager)
|
|
|
|
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
|
|
content = skill_file.read_text()
|
|
assert "untouched" in content, "Skill should not be modified when ai_skills=False"
|
|
|
|
def test_get_skills_dir_returns_none_for_non_string_ai(self, project_dir):
|
|
"""Corrupted init-options ai values should not crash preset skill resolution."""
|
|
init_options = project_dir / ".specify" / "init-options.json"
|
|
init_options.parent.mkdir(parents=True, exist_ok=True)
|
|
init_options.write_text('{"ai":["codex"],"ai_skills":true,"script":"sh"}')
|
|
|
|
manager = PresetManager(project_dir)
|
|
|
|
assert manager._get_skills_dir() is None
|
|
|
|
def test_get_skills_dir_returns_none_for_non_dict_init_options(self, project_dir):
|
|
"""Corrupted non-dict init-options payloads should fail closed."""
|
|
init_options = project_dir / ".specify" / "init-options.json"
|
|
init_options.parent.mkdir(parents=True, exist_ok=True)
|
|
init_options.write_text("[]")
|
|
|
|
manager = PresetManager(project_dir)
|
|
|
|
assert manager._get_skills_dir() is None
|
|
|
|
def test_skill_not_updated_without_init_options(self, project_dir, temp_dir):
|
|
"""When no init-options.json exists, preset install should not touch skills."""
|
|
skills_dir = project_dir / ".qwen" / "skills"
|
|
self._create_skill(skills_dir, "speckit-specify", body="untouched")
|
|
|
|
manager = PresetManager(project_dir)
|
|
install_self_test_preset(manager)
|
|
|
|
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
|
|
file_content = skill_file.read_text()
|
|
assert "untouched" in file_content
|
|
|
|
def test_skill_restored_on_preset_remove(self, project_dir, temp_dir):
|
|
"""When a preset is removed, skills should be restored from core templates."""
|
|
self._write_init_options(project_dir, ai="claude")
|
|
skills_dir = project_dir / ".claude" / "skills"
|
|
self._create_skill(skills_dir, "speckit-specify")
|
|
|
|
(project_dir / ".claude" / "skills").mkdir(parents=True, exist_ok=True)
|
|
|
|
# Set up core command template in the project so restoration works
|
|
core_cmds = project_dir / ".specify" / "templates" / "commands"
|
|
core_cmds.mkdir(parents=True, exist_ok=True)
|
|
(core_cmds / "specify.md").write_text("---\ndescription: Core specify command\n---\n\nCore specify body\n")
|
|
|
|
manager = PresetManager(project_dir)
|
|
install_self_test_preset(manager)
|
|
|
|
# Verify preset content is in the skill
|
|
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
|
|
assert "preset:self-test" in skill_file.read_text()
|
|
|
|
# Remove the preset
|
|
manager.remove("self-test")
|
|
|
|
# Skill should be restored (core specify.md template exists)
|
|
assert skill_file.exists(), "Skill should still exist after preset removal"
|
|
content = skill_file.read_text()
|
|
assert "preset:self-test" not in content, "Preset content should be gone"
|
|
assert "templates/commands/specify.md" in content, "Should reference core template"
|
|
assert "disable-model-invocation: false" in content
|
|
|
|
def test_skill_restored_on_remove_resolves_script_placeholders(self, project_dir):
|
|
"""Core restore should resolve {SCRIPT}/{ARGS} placeholders like other skill paths."""
|
|
self._write_init_options(project_dir, ai="claude", ai_skills=True, script="sh")
|
|
skills_dir = project_dir / ".claude" / "skills"
|
|
self._create_skill(skills_dir, "speckit-specify", body="old")
|
|
(project_dir / ".claude" / "skills").mkdir(parents=True, exist_ok=True)
|
|
|
|
core_cmds = project_dir / ".specify" / "templates" / "commands"
|
|
core_cmds.mkdir(parents=True, exist_ok=True)
|
|
(core_cmds / "specify.md").write_text(
|
|
"---\n"
|
|
"description: Core specify command\n"
|
|
"scripts:\n"
|
|
" sh: .specify/scripts/bash/create-new-feature.sh --json \"{ARGS}\"\n"
|
|
"---\n\n"
|
|
"Run:\n"
|
|
"{SCRIPT}\n"
|
|
)
|
|
|
|
manager = PresetManager(project_dir)
|
|
install_self_test_preset(manager)
|
|
manager.remove("self-test")
|
|
|
|
content = (skills_dir / "speckit-specify" / "SKILL.md").read_text()
|
|
assert "{SCRIPT}" not in content
|
|
assert "{ARGS}" not in content
|
|
assert ".specify/scripts/bash/create-new-feature.sh --json \"$ARGUMENTS\"" in content
|
|
|
|
def test_skill_not_overridden_when_skill_path_is_file(self, project_dir):
|
|
"""Preset install should skip non-directory skill targets."""
|
|
self._write_init_options(project_dir, ai="qwen")
|
|
skills_dir = project_dir / ".qwen" / "skills"
|
|
skills_dir.mkdir(parents=True, exist_ok=True)
|
|
(skills_dir / "speckit-specify").write_text("not-a-directory")
|
|
|
|
manager = PresetManager(project_dir)
|
|
install_self_test_preset(manager)
|
|
|
|
assert (skills_dir / "speckit-specify").is_file()
|
|
metadata = manager.registry.get("self-test")
|
|
assert "speckit-specify" not in metadata.get("registered_skills", {}).get("qwen", [])
|
|
|
|
def test_no_skills_registered_when_skills_mode_disabled(self, project_dir, temp_dir):
|
|
"""Skills should not be created when skills mode is disabled."""
|
|
self._write_init_options(project_dir, ai="claude", ai_skills=False)
|
|
|
|
manager = PresetManager(project_dir)
|
|
install_self_test_preset(manager)
|
|
|
|
metadata = manager.registry.get("self-test")
|
|
assert metadata.get("registered_skills", {}) == {}
|
|
|
|
def test_extension_skill_override_matches_hyphenated_multisegment_name(self, project_dir, temp_dir):
|
|
"""Preset overrides for speckit.<ext>.<cmd> should target speckit-<ext>-<cmd> skills."""
|
|
self._write_init_options(project_dir, ai="codex")
|
|
skills_dir = project_dir / ".agents" / "skills"
|
|
self._create_skill(skills_dir, "speckit-fakeext-cmd", body="untouched")
|
|
(project_dir / ".specify" / "extensions" / "fakeext").mkdir(parents=True, exist_ok=True)
|
|
|
|
preset_dir = temp_dir / "ext-skill-override"
|
|
preset_dir.mkdir()
|
|
(preset_dir / "commands").mkdir()
|
|
(preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text(
|
|
"---\ndescription: Override fakeext cmd\n---\n\npreset:ext-skill-override\n"
|
|
)
|
|
manifest_data = {
|
|
"schema_version": "1.0",
|
|
"preset": {
|
|
"id": "ext-skill-override",
|
|
"name": "Ext Skill Override",
|
|
"version": "1.0.0",
|
|
"description": "Test",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"templates": [
|
|
{
|
|
"type": "command",
|
|
"name": "speckit.fakeext.cmd",
|
|
"file": "commands/speckit.fakeext.cmd.md",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
with open(preset_dir / "preset.yml", "w") as f:
|
|
yaml.dump(manifest_data, f)
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
skill_file = skills_dir / "speckit-fakeext-cmd" / "SKILL.md"
|
|
assert skill_file.exists()
|
|
content = skill_file.read_text()
|
|
assert "preset:ext-skill-override" in content
|
|
assert "name: speckit-fakeext-cmd" in content
|
|
assert "# Speckit Fakeext Cmd Skill" in content
|
|
|
|
metadata = manager.registry.get("ext-skill-override")
|
|
assert "speckit-fakeext-cmd" in metadata.get("registered_skills", {}).get("codex", [])
|
|
|
|
def test_extension_skill_restored_on_preset_remove(self, project_dir, temp_dir):
|
|
"""Preset removal should restore an extension-backed skill instead of deleting it."""
|
|
self._write_init_options(project_dir, ai="codex")
|
|
skills_dir = project_dir / ".agents" / "skills"
|
|
self._create_skill(skills_dir, "speckit-fakeext-cmd", body="original extension skill")
|
|
|
|
extension_dir = project_dir / ".specify" / "extensions" / "fakeext"
|
|
(extension_dir / "commands").mkdir(parents=True, exist_ok=True)
|
|
(extension_dir / "agents" / "control").mkdir(parents=True, exist_ok=True)
|
|
(extension_dir / "agents" / "control" / "commander.md").write_text("# Commander\n")
|
|
(extension_dir / "commands" / "cmd.md").write_text(
|
|
"---\n"
|
|
"description: Extension fakeext cmd\n"
|
|
"scripts:\n"
|
|
" sh: ../../scripts/bash/setup-plan.sh --json \"{ARGS}\"\n"
|
|
"---\n\n"
|
|
"extension:fakeext\n"
|
|
"Run {SCRIPT}\n"
|
|
"Read agents/control/commander.md for context.\n"
|
|
)
|
|
extension_manifest = {
|
|
"schema_version": "1.0",
|
|
"extension": {
|
|
"id": "fakeext",
|
|
"name": "Fake Extension",
|
|
"version": "1.0.0",
|
|
"description": "Test",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"commands": [
|
|
{
|
|
"name": "speckit.fakeext.cmd",
|
|
"file": "commands/cmd.md",
|
|
"description": "Fake extension command",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
with open(extension_dir / "extension.yml", "w") as f:
|
|
yaml.dump(extension_manifest, f)
|
|
|
|
preset_dir = temp_dir / "ext-skill-restore"
|
|
preset_dir.mkdir()
|
|
(preset_dir / "commands").mkdir()
|
|
(preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text(
|
|
"---\ndescription: Override fakeext cmd\n---\n\npreset:ext-skill-restore\n"
|
|
)
|
|
preset_manifest = {
|
|
"schema_version": "1.0",
|
|
"preset": {
|
|
"id": "ext-skill-restore",
|
|
"name": "Ext Skill Restore",
|
|
"version": "1.0.0",
|
|
"description": "Test",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"templates": [
|
|
{
|
|
"type": "command",
|
|
"name": "speckit.fakeext.cmd",
|
|
"file": "commands/speckit.fakeext.cmd.md",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
with open(preset_dir / "preset.yml", "w") as f:
|
|
yaml.dump(preset_manifest, f)
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
skill_file = skills_dir / "speckit-fakeext-cmd" / "SKILL.md"
|
|
assert "preset:ext-skill-restore" in skill_file.read_text()
|
|
|
|
manager.remove("ext-skill-restore")
|
|
|
|
assert skill_file.exists()
|
|
content = skill_file.read_text()
|
|
assert "preset:ext-skill-restore" not in content
|
|
assert "source: extension:fakeext" in content
|
|
assert "extension:fakeext" in content
|
|
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
|
|
# Extension-relative subdir references must resolve to their
|
|
# installed location on restore too (#2101), not just on first
|
|
# registration.
|
|
assert ".specify/extensions/fakeext/agents/control/commander.md" in content
|
|
assert "Read agents/control" not in content
|
|
assert "# Fakeext Cmd Skill" in content
|
|
|
|
def test_skill_composed_over_extension_base_rewrites_subdir_paths(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""When a preset composes (append) over an extension-provided base
|
|
command, the resulting skill (read from the .composed output) must
|
|
still resolve the extension's own subdir references (#2101), not
|
|
just when the extension wins outright (replace)."""
|
|
self._write_init_options(project_dir, ai="codex")
|
|
skills_dir = project_dir / ".agents" / "skills"
|
|
self._create_skill(skills_dir, "speckit-fakeext-cmd", body="original extension skill")
|
|
|
|
extension_dir = project_dir / ".specify" / "extensions" / "fakeext"
|
|
(extension_dir / "commands").mkdir(parents=True, exist_ok=True)
|
|
(extension_dir / "agents" / "control").mkdir(parents=True, exist_ok=True)
|
|
(extension_dir / "agents" / "control" / "commander.md").write_text("# Commander\n")
|
|
(extension_dir / "commands" / "cmd.md").write_text(
|
|
"---\ndescription: Extension fakeext cmd\n---\n\n"
|
|
"Read agents/control/commander.md for context.\n"
|
|
)
|
|
extension_manifest = {
|
|
"schema_version": "1.0",
|
|
"extension": {
|
|
"id": "fakeext",
|
|
"name": "Fake Extension",
|
|
"version": "1.0.0",
|
|
"description": "Test",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"commands": [
|
|
{
|
|
"name": "speckit.fakeext.cmd",
|
|
"file": "commands/cmd.md",
|
|
"description": "Fake extension command",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
with open(extension_dir / "extension.yml", "w") as f:
|
|
yaml.dump(extension_manifest, f)
|
|
|
|
preset_dir = temp_dir / "ext-base-append-skill"
|
|
preset_dir.mkdir()
|
|
(preset_dir / "commands").mkdir()
|
|
(preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text(
|
|
"---\ndescription: Preset overlay\n---\n\n## Extra\n"
|
|
)
|
|
preset_manifest = {
|
|
"schema_version": "1.0",
|
|
"preset": {
|
|
"id": "ext-base-append-skill",
|
|
"name": "Ext Base Append Skill",
|
|
"version": "1.0.0",
|
|
"description": "Test",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"templates": [
|
|
{
|
|
"type": "command",
|
|
"name": "speckit.fakeext.cmd",
|
|
"file": "commands/speckit.fakeext.cmd.md",
|
|
"strategy": "append",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
with open(preset_dir / "preset.yml", "w") as f:
|
|
yaml.dump(preset_manifest, f)
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
skill_file = skills_dir / "speckit-fakeext-cmd" / "SKILL.md"
|
|
content = skill_file.read_text()
|
|
assert ".specify/extensions/fakeext/agents/control/commander.md" in content
|
|
assert "Read agents/control" not in content
|
|
assert "## Extra" in content
|
|
|
|
def test_preset_remove_skips_skill_dir_without_skill_file(self, project_dir, temp_dir):
|
|
"""Preset removal should not delete arbitrary directories missing SKILL.md."""
|
|
self._write_init_options(project_dir, ai="codex")
|
|
skills_dir = project_dir / ".agents" / "skills"
|
|
stray_skill_dir = skills_dir / "speckit-fakeext-cmd"
|
|
stray_skill_dir.mkdir(parents=True, exist_ok=True)
|
|
note_file = stray_skill_dir / "notes.txt"
|
|
note_file.write_text("user content", encoding="utf-8")
|
|
|
|
preset_dir = temp_dir / "ext-skill-missing-file"
|
|
preset_dir.mkdir()
|
|
(preset_dir / "commands").mkdir()
|
|
(preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text(
|
|
"---\ndescription: Override fakeext cmd\n---\n\npreset:ext-skill-missing-file\n"
|
|
)
|
|
preset_manifest = {
|
|
"schema_version": "1.0",
|
|
"preset": {
|
|
"id": "ext-skill-missing-file",
|
|
"name": "Ext Skill Missing File",
|
|
"version": "1.0.0",
|
|
"description": "Test",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"templates": [
|
|
{
|
|
"type": "command",
|
|
"name": "speckit.fakeext.cmd",
|
|
"file": "commands/speckit.fakeext.cmd.md",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
with open(preset_dir / "preset.yml", "w") as f:
|
|
yaml.dump(preset_manifest, f)
|
|
|
|
manager = PresetManager(project_dir)
|
|
installed_preset_dir = manager.presets_dir / "ext-skill-missing-file"
|
|
shutil.copytree(preset_dir, installed_preset_dir)
|
|
manager.registry.add(
|
|
"ext-skill-missing-file",
|
|
{
|
|
"version": "1.0.0",
|
|
"source": str(preset_dir),
|
|
"provides_templates": ["speckit.fakeext.cmd"],
|
|
"registered_skills": ["speckit-fakeext-cmd"],
|
|
"priority": 10,
|
|
},
|
|
)
|
|
|
|
manager.remove("ext-skill-missing-file")
|
|
|
|
assert stray_skill_dir.is_dir()
|
|
assert note_file.read_text(encoding="utf-8") == "user content"
|
|
|
|
def test_kimi_legacy_dotted_skill_override_still_applies(self, project_dir, temp_dir):
|
|
"""Preset overrides should still target legacy dotted-named skill dirs.
|
|
|
|
This exercises legacy *naming* (``speckit.specify``) under the current
|
|
``.kimi-code/`` base — distinct from the legacy ``.kimi/`` *location*.
|
|
"""
|
|
self._write_init_options(project_dir, ai="kimi")
|
|
skills_dir = project_dir / ".kimi-code" / "skills"
|
|
self._create_skill(skills_dir, "speckit.specify", body="untouched")
|
|
|
|
(project_dir / ".kimi-code" / "commands").mkdir(parents=True, exist_ok=True)
|
|
|
|
manager = PresetManager(project_dir)
|
|
install_self_test_preset(manager)
|
|
|
|
skill_file = skills_dir / "speckit.specify" / "SKILL.md"
|
|
assert skill_file.exists()
|
|
content = skill_file.read_text()
|
|
assert "preset:self-test" in content
|
|
assert "name: speckit.specify" in content
|
|
|
|
metadata = manager.registry.get("self-test")
|
|
assert "speckit.specify" in metadata.get("registered_skills", {}).get("kimi", [])
|
|
|
|
def test_kimi_legacy_dotted_skill_reconciles_priority_winner(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""Reconciliation must carry forward recorded legacy skill names."""
|
|
self._write_init_options(project_dir, ai="kimi")
|
|
skills_dir = project_dir / ".kimi-code" / "skills"
|
|
self._create_skill(skills_dir, "speckit.specify", body="untouched")
|
|
(project_dir / ".kimi-code" / "commands").mkdir(
|
|
parents=True, exist_ok=True
|
|
)
|
|
|
|
higher_dir = self._create_command_preset(
|
|
temp_dir,
|
|
"higher-kimi-preset",
|
|
"speckit.specify",
|
|
"Higher preset",
|
|
"Higher body",
|
|
)
|
|
lower_dir = self._create_command_preset(
|
|
temp_dir,
|
|
"lower-kimi-preset",
|
|
"speckit.specify",
|
|
"Lower preset",
|
|
"Lower body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(higher_dir, "0.1.5", priority=10)
|
|
manager.install_from_directory(lower_dir, "0.1.5", priority=20)
|
|
|
|
skill_file = skills_dir / "speckit.specify" / "SKILL.md"
|
|
for preset_id in ("higher-kimi-preset", "lower-kimi-preset"):
|
|
manager.registry.update(
|
|
preset_id,
|
|
{"registered_skills": {"kimi": ["speckit.specify"]}},
|
|
)
|
|
skill_file.write_text(
|
|
"---\nname: speckit.specify\n---\n\nLower body\n",
|
|
encoding="utf-8",
|
|
)
|
|
manager._reconcile_skills(["speckit.specify"])
|
|
|
|
assert "Higher body" in skill_file.read_text(encoding="utf-8"), (
|
|
"reconciliation must replace lower-priority raw content in a "
|
|
"recorded legacy dotted skill directory"
|
|
)
|
|
|
|
def test_kimi_legacy_dotted_skill_receives_project_override(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""Project overrides must update the recorded legacy path in place."""
|
|
self._write_init_options(project_dir, ai="kimi")
|
|
skills_dir = project_dir / ".kimi-code" / "skills"
|
|
self._create_skill(skills_dir, "speckit.specify", body="untouched")
|
|
(project_dir / ".kimi-code" / "commands").mkdir(
|
|
parents=True, exist_ok=True
|
|
)
|
|
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir,
|
|
"kimi-override-preset",
|
|
"speckit.specify",
|
|
"Preset",
|
|
"Preset body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
manager.registry.update(
|
|
"kimi-override-preset",
|
|
{"registered_skills": {"kimi": ["speckit.specify"]}},
|
|
)
|
|
modern_skill_dir = skills_dir / "speckit-specify"
|
|
if modern_skill_dir.exists():
|
|
shutil.rmtree(modern_skill_dir)
|
|
|
|
overrides_dir = (
|
|
project_dir / ".specify" / "templates" / "overrides"
|
|
)
|
|
overrides_dir.mkdir(parents=True)
|
|
(overrides_dir / "speckit.specify.md").write_text(
|
|
"---\ndescription: Project override\n---\n\nOverride body\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
manager._reconcile_skills(["speckit.specify"])
|
|
|
|
legacy_file = skills_dir / "speckit.specify" / "SKILL.md"
|
|
assert "Override body" in legacy_file.read_text(encoding="utf-8")
|
|
assert not modern_skill_dir.exists(), (
|
|
"legacy-only ownership must not create an untracked modern path"
|
|
)
|
|
|
|
def test_kimi_skill_updated_even_when_ai_skills_disabled(self, project_dir, temp_dir):
|
|
"""Kimi presets should still propagate command overrides to existing skills."""
|
|
self._write_init_options(project_dir, ai="kimi", ai_skills=False)
|
|
skills_dir = project_dir / ".kimi-code" / "skills"
|
|
self._create_skill(skills_dir, "speckit-specify", body="untouched")
|
|
|
|
(project_dir / ".kimi-code" / "commands").mkdir(parents=True, exist_ok=True)
|
|
|
|
manager = PresetManager(project_dir)
|
|
install_self_test_preset(manager)
|
|
|
|
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
|
|
assert skill_file.exists()
|
|
content = skill_file.read_text()
|
|
assert "preset:self-test" in content
|
|
assert "name: speckit-specify" in content
|
|
|
|
metadata = manager.registry.get("self-test")
|
|
assert "speckit-specify" in metadata.get("registered_skills", {}).get("kimi", [])
|
|
|
|
def test_kimi_new_skill_created_even_when_ai_skills_disabled(self, project_dir, temp_dir):
|
|
"""Kimi native skills should still receive brand-new preset commands."""
|
|
self._write_init_options(project_dir, ai="kimi", ai_skills=False)
|
|
skills_dir = project_dir / ".kimi-code" / "skills"
|
|
skills_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
preset_dir = temp_dir / "kimi-new-skill"
|
|
preset_dir.mkdir()
|
|
(preset_dir / "commands").mkdir()
|
|
(preset_dir / "commands" / "speckit.research.md").write_text(
|
|
"---\n"
|
|
"description: Kimi research workflow\n"
|
|
"---\n\n"
|
|
"preset:kimi-new-skill\n"
|
|
)
|
|
manifest_data = {
|
|
"schema_version": "1.0",
|
|
"preset": {
|
|
"id": "kimi-new-skill",
|
|
"name": "Kimi New Skill",
|
|
"version": "1.0.0",
|
|
"description": "Test",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"templates": [
|
|
{
|
|
"type": "command",
|
|
"name": "speckit.research",
|
|
"file": "commands/speckit.research.md",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
with open(preset_dir / "preset.yml", "w") as f:
|
|
yaml.dump(manifest_data, f)
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
skill_file = skills_dir / "speckit-research" / "SKILL.md"
|
|
assert skill_file.exists()
|
|
content = skill_file.read_text()
|
|
assert "preset:kimi-new-skill" in content
|
|
assert "name: speckit-research" in content
|
|
|
|
metadata = manager.registry.get("kimi-new-skill")
|
|
assert "speckit-research" in metadata.get("registered_skills", {}).get("kimi", [])
|
|
|
|
def test_kimi_preset_skill_override_resolves_script_placeholders(self, project_dir, temp_dir):
|
|
"""Kimi preset skill overrides should resolve placeholders and rewrite project paths."""
|
|
self._write_init_options(project_dir, ai="kimi", ai_skills=False, script="sh")
|
|
skills_dir = project_dir / ".kimi-code" / "skills"
|
|
self._create_skill(skills_dir, "speckit-specify", body="untouched")
|
|
(project_dir / ".kimi-code" / "commands").mkdir(parents=True, exist_ok=True)
|
|
|
|
preset_dir = temp_dir / "kimi-placeholder-override"
|
|
preset_dir.mkdir()
|
|
(preset_dir / "commands").mkdir()
|
|
(preset_dir / "commands" / "speckit.specify.md").write_text(
|
|
"---\n"
|
|
"description: Kimi placeholder override\n"
|
|
"scripts:\n"
|
|
" sh: scripts/bash/create-new-feature.sh --json \"{ARGS}\"\n"
|
|
"---\n\n"
|
|
"Execute `{SCRIPT}` for __AGENT__\n"
|
|
"Review templates/checklist.md and memory/constitution.md\n"
|
|
)
|
|
manifest_data = {
|
|
"schema_version": "1.0",
|
|
"preset": {
|
|
"id": "kimi-placeholder-override",
|
|
"name": "Kimi Placeholder Override",
|
|
"version": "1.0.0",
|
|
"description": "Test",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"templates": [
|
|
{
|
|
"type": "command",
|
|
"name": "speckit.specify",
|
|
"file": "commands/speckit.specify.md",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
with open(preset_dir / "preset.yml", "w") as f:
|
|
yaml.dump(manifest_data, f)
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
content = (skills_dir / "speckit-specify" / "SKILL.md").read_text()
|
|
assert "{SCRIPT}" not in content
|
|
assert "__AGENT__" not in content
|
|
assert ".specify/scripts/bash/create-new-feature.sh --json \"$ARGUMENTS\"" in content
|
|
assert ".specify/templates/checklist.md" in content
|
|
assert ".specify/memory/constitution.md" in content
|
|
assert "for kimi" in content
|
|
|
|
def test_agy_skill_restored_on_preset_remove(self, project_dir, temp_dir):
|
|
"""Agy preset removal should restore native skills instead of deleting them."""
|
|
self._write_init_options(project_dir, ai="agy", ai_skills=True)
|
|
skills_dir = project_dir / ".agents" / "skills"
|
|
self._create_skill(skills_dir, "speckit-specify", body="before override")
|
|
|
|
core_command = project_dir / ".specify" / "templates" / "commands" / "specify.md"
|
|
core_command.write_text(
|
|
"---\n"
|
|
"description: Restored core specify workflow\n"
|
|
"---\n\n"
|
|
"restored core body\n"
|
|
)
|
|
|
|
preset_dir = temp_dir / "agy-override"
|
|
preset_dir.mkdir()
|
|
(preset_dir / "commands").mkdir()
|
|
(preset_dir / "commands" / "speckit.specify.md").write_text(
|
|
"---\n"
|
|
"description: Agy override\n"
|
|
"---\n\n"
|
|
"preset agy body\n"
|
|
)
|
|
manifest_data = {
|
|
"schema_version": "1.0",
|
|
"preset": {
|
|
"id": "agy-override",
|
|
"name": "Agy Override",
|
|
"version": "1.0.0",
|
|
"description": "Test",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"templates": [
|
|
{
|
|
"type": "command",
|
|
"name": "speckit.specify",
|
|
"file": "commands/speckit.specify.md",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
with open(preset_dir / "preset.yml", "w") as f:
|
|
yaml.dump(manifest_data, f)
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
|
|
assert "preset agy body" in skill_file.read_text()
|
|
|
|
assert manager.remove("agy-override") is True
|
|
assert skill_file.exists()
|
|
restored = skill_file.read_text()
|
|
assert "restored core body" in restored
|
|
assert "name: speckit-specify" in restored
|
|
|
|
def test_preset_skill_registration_handles_non_dict_init_options(self, project_dir, temp_dir):
|
|
"""Non-dict init-options payloads should not crash preset install/remove flows."""
|
|
init_options = project_dir / ".specify" / "init-options.json"
|
|
init_options.parent.mkdir(parents=True, exist_ok=True)
|
|
init_options.write_text("[]")
|
|
|
|
skills_dir = project_dir / ".qwen" / "skills"
|
|
self._create_skill(skills_dir, "speckit-specify", body="untouched")
|
|
|
|
manager = PresetManager(project_dir)
|
|
install_self_test_preset(manager)
|
|
|
|
skill_content = (skills_dir / "speckit-specify" / "SKILL.md").read_text()
|
|
assert "untouched" in skill_content
|
|
|
|
def test_preset_add_corrupted_init_options_fails_closed(self, project_dir, temp_dir):
|
|
"""Corrupted (but present) init-options.json must not back-fill every
|
|
detected agent for preset command registration.
|
|
|
|
Before the shared ``resolve_active_agent_for_registration`` fix,
|
|
``load_init_options`` returning ``{}`` for a corrupted file was
|
|
indistinguishable from "no file at all", so ``_register_commands``
|
|
treated it like a legacy pre-init-options project and registered
|
|
the preset's command override for every detected agent (#2948).
|
|
"""
|
|
init_options = project_dir / ".specify" / "init-options.json"
|
|
init_options.parent.mkdir(parents=True, exist_ok=True)
|
|
init_options.write_text("{not valid json", encoding="utf-8")
|
|
|
|
gemini_dir = project_dir / ".gemini" / "commands"
|
|
gemini_dir.mkdir(parents=True)
|
|
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir, "corrupt-init-preset", "speckit.specify",
|
|
"Corrupt init test", "preset body",
|
|
)
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
metadata = manager.registry.get("corrupt-init-preset")
|
|
assert metadata.get("registered_commands") == {}, (
|
|
"a corrupted init-options.json must fail closed, not "
|
|
"back-fill every detected agent (#2948)"
|
|
)
|
|
assert not list(gemini_dir.glob("*specify*")), (
|
|
"no command file should be written for any agent when "
|
|
"init-options.json is corrupted"
|
|
)
|
|
|
|
def test_reconciliation_restricted_to_active_agent(self, project_dir, temp_dir):
|
|
"""Reconciliation after install/remove must also respect the
|
|
single-active rule, not just the initial registration.
|
|
|
|
``_reconcile_composed_commands`` (invoked after
|
|
``install_from_directory``/``remove``) resolves composition winners
|
|
via ``register_commands_for_non_skill_agents``, a separate code
|
|
path from ``_register_commands``'s initial registration. Before the
|
|
fix it ignored the active-agent restriction entirely and wrote the
|
|
winning content for every detected non-skill agent, leaving
|
|
untracked orphaned artifacts in inactive integrations (#2948).
|
|
"""
|
|
self._write_init_options(project_dir, ai="claude", ai_skills=True)
|
|
gemini_dir = project_dir / ".gemini" / "commands"
|
|
gemini_dir.mkdir(parents=True)
|
|
|
|
# A non-replace (append) strategy command forces reconciliation to
|
|
# run register_commands_for_non_skill_agents for every non-skill
|
|
# agent directory it detects.
|
|
preset_dir = temp_dir / "reconcile-active-only"
|
|
preset_dir.mkdir()
|
|
(preset_dir / "commands").mkdir()
|
|
(preset_dir / "commands" / "speckit.specify.md").write_text(
|
|
"---\ndescription: Appended\nstrategy: append\n---\n\nAppended body\n",
|
|
encoding="utf-8",
|
|
)
|
|
manifest_data = {
|
|
"schema_version": "1.0",
|
|
"preset": {
|
|
"id": "reconcile-active-only",
|
|
"name": "Reconcile Active Only",
|
|
"version": "1.0.0",
|
|
"description": "Test",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"templates": [{
|
|
"type": "command",
|
|
"name": "speckit.specify",
|
|
"file": "commands/speckit.specify.md",
|
|
"strategy": "append",
|
|
}]
|
|
},
|
|
}
|
|
with open(preset_dir / "preset.yml", "w") as f:
|
|
yaml.dump(manifest_data, f)
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
assert not list(gemini_dir.glob("*specify*")), (
|
|
"reconciliation must not write command files for a detected "
|
|
"but inactive non-skill agent (#2948)"
|
|
)
|
|
|
|
def test_use_rescaffold_reconciles_project_override(self, project_dir, temp_dir):
|
|
"""``integration use``/``switch`` rescaffolding must reconcile the
|
|
full priority stack, not just write each preset's own content.
|
|
|
|
Project overrides are the highest-priority layer, above every
|
|
preset. ``register_enabled_presets_for_agent`` (invoked by
|
|
``integration use``/``switch``) calls ``_register_commands`` for
|
|
each enabled preset directly, the same as ``install_from_directory``
|
|
— but unlike install/remove, it never followed up with
|
|
``_reconcile_composed_commands``. Before the fix, rescaffolding a
|
|
newly activated agent could leave the preset's raw content in
|
|
place instead of resolving the real winner (the project override)
|
|
from the full stack (#2948).
|
|
"""
|
|
self._write_init_options(project_dir, ai="claude", ai_skills=True)
|
|
|
|
overrides_dir = project_dir / ".specify" / "templates" / "overrides"
|
|
overrides_dir.mkdir(parents=True, exist_ok=True)
|
|
(overrides_dir / "speckit.specify.md").write_text(
|
|
"---\ndescription: Override specify\n---\n\nOverride body\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
gemini_dir = project_dir / ".gemini" / "commands"
|
|
gemini_dir.mkdir(parents=True)
|
|
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir, "use-reconcile-preset", "speckit.specify",
|
|
"Preset specify", "Preset body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
# Simulate `integration use gemini`: switch the active agent and
|
|
# rescaffold enabled presets for it, mirroring what the CLI does.
|
|
self._write_init_options(project_dir, ai="gemini", ai_skills=False)
|
|
manager.register_enabled_presets_for_agent("gemini")
|
|
|
|
cmd_file = gemini_dir / "speckit.specify.toml"
|
|
assert cmd_file.exists(), "sanity: gemini should get a command file at all"
|
|
content = cmd_file.read_text()
|
|
assert "Override body" in content, (
|
|
"the project override must still win after rescaffold "
|
|
"reconciliation, not the preset's raw content (#2948)"
|
|
)
|
|
assert "Preset body" not in content
|
|
|
|
def test_hermes_rescaffold_reconciles_global_skill_output(
|
|
self, project_dir, temp_dir, monkeypatch
|
|
):
|
|
home = temp_dir / "home"
|
|
home.mkdir()
|
|
monkeypatch.setattr(Path, "home", lambda: home)
|
|
(home / ".hermes" / "skills").mkdir(parents=True)
|
|
self._write_init_options(project_dir, ai="hermes", ai_skills=True)
|
|
(project_dir / ".hermes" / "skills").mkdir(parents=True)
|
|
|
|
overrides_dir = project_dir / ".specify" / "templates" / "overrides"
|
|
overrides_dir.mkdir(parents=True, exist_ok=True)
|
|
(overrides_dir / "speckit.specify.md").write_text(
|
|
"---\ndescription: Override specify\n---\n\nOverride body\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir, "hermes-reconcile-preset", "speckit.specify",
|
|
"Preset specify", "Preset body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
manager.register_enabled_presets_for_agent("hermes")
|
|
|
|
skill_file = (
|
|
home / ".hermes" / "skills" / "speckit-specify" / "SKILL.md"
|
|
)
|
|
assert skill_file.exists()
|
|
content = skill_file.read_text(encoding="utf-8")
|
|
assert "Override body" in content
|
|
assert "Preset body" not in content
|
|
assert not list(
|
|
(project_dir / ".hermes" / "skills").glob("speckit-*/SKILL.md")
|
|
)
|
|
|
|
(overrides_dir / "speckit.specify.md").unlink()
|
|
assert manager.remove("hermes-reconcile-preset") is True
|
|
if skill_file.exists():
|
|
restored = skill_file.read_text(encoding="utf-8")
|
|
assert "Override body" not in restored
|
|
assert "Preset body" not in restored
|
|
|
|
def test_rescaffold_persists_commands_before_fallible_skills_phase(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""A failure in the skills phase must not lose track of command
|
|
files the commands phase already wrote to disk.
|
|
|
|
``register_enabled_presets_for_agent`` computes both
|
|
``registered_commands`` and ``registered_skills`` and persists them
|
|
together in a single ``registry.update()`` call after both phases
|
|
run. If ``_register_skills`` raises, the whole per-preset ``try``
|
|
block is caught and ``registry.update()`` is never reached — even
|
|
though ``_register_commands`` already wrote a real command file to
|
|
disk. That file becomes untracked and preset removal can no longer
|
|
clean it up. ``install_from_directory`` avoids this by persisting
|
|
``registered_commands`` immediately after the commands phase,
|
|
before starting the independently fallible skills phase; rescaffold
|
|
must do the same (#2948).
|
|
"""
|
|
self._write_init_options(project_dir, ai="claude", ai_skills=True)
|
|
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir, "rescaffold-persist-preset", "speckit.specify",
|
|
"Rescaffold persist test", "preset body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
# Switch to gemini (a plain command-file agent, so _register_commands
|
|
# writes a real file) and make the *skills* phase blow up.
|
|
self._write_init_options(project_dir, ai="gemini", ai_skills=False)
|
|
gemini_commands_dir = project_dir / ".gemini" / "commands"
|
|
gemini_commands_dir.mkdir(parents=True)
|
|
|
|
from unittest.mock import patch
|
|
|
|
with patch.object(
|
|
PresetManager, "_register_skills",
|
|
side_effect=RuntimeError("simulated skills failure"),
|
|
):
|
|
manager.register_enabled_presets_for_agent("gemini")
|
|
|
|
cmd_file = gemini_commands_dir / "speckit.specify.toml"
|
|
assert cmd_file.exists(), (
|
|
"sanity: the commands phase must have written the file before "
|
|
"the skills phase raised"
|
|
)
|
|
|
|
metadata = manager.registry.get("rescaffold-persist-preset")
|
|
assert metadata["registered_commands"].get("gemini"), (
|
|
"registered_commands must be persisted immediately after the "
|
|
"commands phase, not only after the (fallible) skills phase "
|
|
"also succeeds — otherwise the file written above is untracked "
|
|
"and preset removal can't clean it up (#2948)"
|
|
)
|
|
|
|
def test_rescaffold_reconciles_override_even_when_skills_phase_fails(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""A project override must still win after rescaffold even if the
|
|
independently-fallible skills phase raises for that preset.
|
|
|
|
``register_enabled_presets_for_agent`` only records a preset's
|
|
command names into ``affected_cmd_names`` — the set later passed to
|
|
``_reconcile_composed_commands``/``_reconcile_skills`` — in the
|
|
``for tmpl in manifest.templates`` loop that runs *after*
|
|
``_register_skills`` inside the per-preset ``try`` block. If
|
|
``_register_skills`` raises, the per-preset ``except`` catches it
|
|
and ``continue``s before that loop ever runs, so this preset's
|
|
command names never make it into ``affected_cmd_names`` even though
|
|
``_register_commands`` already wrote its raw content to disk. The
|
|
final reconciliation call is skipped for this preset entirely,
|
|
leaving the raw preset content in place instead of the project
|
|
override that should win (#2948).
|
|
"""
|
|
self._write_init_options(project_dir, ai="claude", ai_skills=True)
|
|
|
|
overrides_dir = project_dir / ".specify" / "templates" / "overrides"
|
|
overrides_dir.mkdir(parents=True, exist_ok=True)
|
|
(overrides_dir / "speckit.specify.md").write_text(
|
|
"---\ndescription: Override specify\n---\n\nOverride body\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
gemini_dir = project_dir / ".gemini" / "commands"
|
|
gemini_dir.mkdir(parents=True)
|
|
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir, "reconcile-despite-skills-failure", "speckit.specify",
|
|
"Preset specify", "Preset body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
# Simulate `integration use gemini` with the skills phase failing
|
|
# for this preset (e.g. a symlink/permission error unrelated to the
|
|
# commands phase, which already succeeded).
|
|
self._write_init_options(project_dir, ai="gemini", ai_skills=False)
|
|
|
|
from unittest.mock import patch
|
|
|
|
with patch.object(
|
|
PresetManager, "_register_skills",
|
|
side_effect=RuntimeError("simulated skills failure"),
|
|
):
|
|
manager.register_enabled_presets_for_agent("gemini")
|
|
|
|
cmd_file = gemini_dir / "speckit.specify.toml"
|
|
assert cmd_file.exists(), "sanity: gemini should get a command file at all"
|
|
content = cmd_file.read_text()
|
|
assert "Override body" in content, (
|
|
"the project override must still win after rescaffold, even "
|
|
"though this preset's skills phase raised — a fallible skills "
|
|
"phase must not skip reconciliation for command writes that "
|
|
"already succeeded (#2948)"
|
|
)
|
|
assert "Preset body" not in content
|
|
|
|
def test_rescaffold_reconciles_partial_command_write_after_failure(
|
|
self, project_dir, temp_dir, monkeypatch
|
|
):
|
|
"""A command written before _register_commands raises must still be
|
|
included in final priority-stack reconciliation."""
|
|
self._write_init_options(project_dir, ai="claude", ai_skills=True)
|
|
|
|
overrides_dir = project_dir / ".specify" / "templates" / "overrides"
|
|
overrides_dir.mkdir(parents=True, exist_ok=True)
|
|
(overrides_dir / "speckit.specify.md").write_text(
|
|
"---\ndescription: Override specify\n---\n\nOverride body\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir,
|
|
"partial-command-failure-preset",
|
|
"speckit.specify",
|
|
"Preset specify",
|
|
"Preset body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
self._write_init_options(project_dir, ai="gemini", ai_skills=False)
|
|
gemini_dir = project_dir / ".gemini" / "commands"
|
|
gemini_dir.mkdir(parents=True)
|
|
cmd_file = gemini_dir / "speckit.specify.toml"
|
|
|
|
def partial_register(manifest, pack_dir):
|
|
cmd_file.write_text("Partially written preset body\n", encoding="utf-8")
|
|
raise RuntimeError("simulated partial command failure")
|
|
|
|
monkeypatch.setattr(manager, "_register_commands", partial_register)
|
|
manager.register_enabled_presets_for_agent("gemini")
|
|
|
|
content = cmd_file.read_text(encoding="utf-8")
|
|
assert "Override body" in content
|
|
assert "Partially written preset body" not in content
|
|
|
|
def test_copilot_skills_mode_skips_command_registration(self, project_dir, temp_dir):
|
|
"""``integration use copilot`` with skills mode enabled must only
|
|
write the SKILL.md mirror, not also copilot's static command file.
|
|
|
|
Copilot is command-backed (``extension: ".agent.md"``), but when
|
|
``ai_skills`` is enabled its preset overrides are meant to render
|
|
exclusively as skills via ``_register_skills``. Before the fix,
|
|
``_register_commands`` had no ``ai_skills`` guard (unlike the
|
|
extensions path), so both a stale ``.agent.md`` command file and
|
|
the ``SKILL.md`` mirror were written for the same override (#2948).
|
|
"""
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
|
|
copilot_commands_dir = project_dir / ".github" / "agents"
|
|
copilot_commands_dir.mkdir(parents=True)
|
|
skills_dir = project_dir / ".github" / "skills"
|
|
self._create_skill(skills_dir, "speckit-specify")
|
|
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir, "copilot-skills-preset", "speckit.specify",
|
|
"Copilot skills test", "preset body",
|
|
)
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
assert not list(copilot_commands_dir.glob("*specify*")), (
|
|
"command-mode and skills-mode artifacts are mutually exclusive: "
|
|
"no .agent.md command file should be written when copilot is "
|
|
"running in skills mode (#2948)"
|
|
)
|
|
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
|
|
assert "preset:copilot-skills-preset" in skill_file.read_text()
|
|
|
|
def test_rescaffold_toggle_command_to_skills_removes_stale_command_file(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""Toggling the *same* agent from command mode to skills mode must
|
|
remove the stale command-mode artifact, not just add the new one.
|
|
|
|
Copilot stays the active agent throughout (``integration upgrade
|
|
copilot`` after flipping ``ai_skills``, not a switch to a different
|
|
agent). Before the fix, ``_register_commands``'s ``ai_skills`` guard
|
|
made rescaffold a no-op for the commands phase once skills mode was
|
|
on, leaving the previously written ``.agent.md`` file and its
|
|
``registered_commands`` entry behind even though ``_register_skills``
|
|
went on to also write the ``SKILL.md`` mirror — violating the
|
|
command/skill mutual-exclusion invariant this PR otherwise enforces
|
|
(#2948).
|
|
"""
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=False)
|
|
copilot_commands_dir = project_dir / ".github" / "agents"
|
|
copilot_commands_dir.mkdir(parents=True)
|
|
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir, "toggle-cmd-to-skill-preset", "speckit.specify",
|
|
"Toggle test", "preset body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
cmd_file = copilot_commands_dir / "speckit.specify.agent.md"
|
|
assert cmd_file.exists(), (
|
|
"sanity: command mode should have written copilot's command file"
|
|
)
|
|
metadata = manager.registry.get("toggle-cmd-to-skill-preset")
|
|
assert metadata["registered_commands"].get("copilot"), (
|
|
"sanity: the command-mode write should be tracked for copilot"
|
|
)
|
|
|
|
# Flip ai_skills on for the *same* active agent and rescaffold, as
|
|
# `integration upgrade copilot` would after the mode toggle.
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
|
|
manager.register_enabled_presets_for_agent("copilot")
|
|
|
|
assert not cmd_file.exists(), (
|
|
"the stale command-mode file must be removed once copilot has "
|
|
"toggled to skills mode for the same agent (#2948)"
|
|
)
|
|
metadata = manager.registry.get("toggle-cmd-to-skill-preset")
|
|
assert not metadata["registered_commands"].get("copilot"), (
|
|
"registered_commands must stop tracking copilot once its "
|
|
"artifact has been unregistered, or removal will try to clean "
|
|
"up a file that no longer exists (#2948)"
|
|
)
|
|
skill_file = project_dir / ".github" / "skills" / "speckit-specify" / "SKILL.md"
|
|
assert "preset:toggle-cmd-to-skill-preset" in skill_file.read_text(), (
|
|
"sanity: the new skills-mode artifact should still be written"
|
|
)
|
|
|
|
def test_rescaffold_skips_extension_commands_when_extension_not_installed(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""Rescaffold must not materialize extension-scoped commands
|
|
(``speckit.<ext>.<cmd>``) when the extension isn't installed.
|
|
|
|
``_register_commands`` refuses them, but the rescaffold seeded its
|
|
final reconciliation pass with every command template name
|
|
unfiltered, so ``_reconcile_composed_commands`` wrote the command
|
|
file anyway — an artifact no registry entry tracks (review
|
|
3623357358).
|
|
"""
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=False)
|
|
commands_dir = project_dir / ".github" / "agents"
|
|
commands_dir.mkdir(parents=True)
|
|
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir, "ext-scoped-preset", "speckit.git.feature",
|
|
"Ext override", "ext body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
ext_cmd = commands_dir / "speckit.git.feature.agent.md"
|
|
assert not ext_cmd.exists(), (
|
|
"sanity: install must not write an extension command when the "
|
|
"extension isn't installed"
|
|
)
|
|
|
|
manager.register_enabled_presets_for_agent("copilot")
|
|
|
|
assert not ext_cmd.exists(), (
|
|
"rescaffold must not materialize an extension-scoped command "
|
|
"whose extension isn't installed"
|
|
)
|
|
metadata = manager.registry.get("ext-scoped-preset")
|
|
assert not (metadata.get("registered_commands") or {}).get("copilot")
|
|
|
|
def test_rescaffold_skips_extension_skills_when_extension_not_installed(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""Historical tracking must not recreate a missing extension's skill."""
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
|
|
skills_dir = project_dir / ".github" / "skills"
|
|
skills_dir.mkdir(parents=True)
|
|
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir,
|
|
"ext-scoped-skill-preset",
|
|
"speckit.git.feature",
|
|
"Ext override",
|
|
"preset body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
skill_name = "speckit-git-feature"
|
|
skill_file = skills_dir / skill_name / "SKILL.md"
|
|
assert not skill_file.exists()
|
|
|
|
manager.registry.update(
|
|
"ext-scoped-skill-preset",
|
|
{"registered_skills": {"copilot": [skill_name]}},
|
|
)
|
|
overrides_dir = (
|
|
project_dir / ".specify" / "templates" / "overrides"
|
|
)
|
|
overrides_dir.mkdir(parents=True)
|
|
(overrides_dir / "speckit.git.feature.md").write_text(
|
|
"---\ndescription: Project override\n---\n\nOverride body\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
manager.register_enabled_presets_for_agent("copilot")
|
|
|
|
assert not skill_file.exists(), (
|
|
"rescaffold must not materialize an extension-scoped skill "
|
|
"whose extension isn't installed"
|
|
)
|
|
|
|
def test_same_mode_partial_command_rescaffold_keeps_skipped_tracking(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""A partial command refresh must keep still-live skipped artifacts tracked."""
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=False)
|
|
commands_dir = project_dir / ".github" / "agents"
|
|
commands_dir.mkdir(parents=True)
|
|
preset_dir = self._create_multi_command_preset(
|
|
temp_dir,
|
|
"same-mode-partial-command-preset",
|
|
["speckit.specify", "speckit.plan"],
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
installed_dir = manager.presets_dir / "same-mode-partial-command-preset"
|
|
(installed_dir / "commands" / "speckit.plan.md").unlink()
|
|
manager.register_enabled_presets_for_agent("copilot")
|
|
|
|
metadata = manager.registry.get("same-mode-partial-command-preset")
|
|
assert set(metadata["registered_commands"]["copilot"]) == {
|
|
"speckit.specify",
|
|
"speckit.plan",
|
|
}
|
|
assert (commands_dir / "speckit.plan.agent.md").exists()
|
|
|
|
def test_same_mode_partial_skill_rescaffold_keeps_skipped_tracking(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""A partial skill refresh must keep still-live skipped artifacts tracked."""
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
|
|
skills_dir = project_dir / ".github" / "skills"
|
|
self._create_skill(skills_dir, "speckit-specify")
|
|
self._create_skill(skills_dir, "speckit-plan")
|
|
preset_dir = self._create_multi_command_preset(
|
|
temp_dir,
|
|
"same-mode-partial-skill-preset",
|
|
["speckit.specify", "speckit.plan"],
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
installed_dir = manager.presets_dir / "same-mode-partial-skill-preset"
|
|
(installed_dir / "commands" / "speckit.plan.md").unlink()
|
|
manager.register_enabled_presets_for_agent("copilot")
|
|
|
|
metadata = manager.registry.get("same-mode-partial-skill-preset")
|
|
assert set(metadata["registered_skills"]["copilot"]) == {
|
|
"speckit-specify",
|
|
"speckit-plan",
|
|
}
|
|
|
|
def test_toggle_command_to_skills_preserves_old_command_on_skills_failure(
|
|
self, project_dir, temp_dir, monkeypatch
|
|
):
|
|
"""A command->skills toggle must not destroy the old command
|
|
artifact before the new skill registration has actually succeeded.
|
|
|
|
Before the fix, the stale command-mode file/tracking was
|
|
unregistered unconditionally as soon as ``_register_commands``'s
|
|
``ai_skills`` guard made the commands phase a no-op — regardless
|
|
of whether the subsequent, independently-fallible
|
|
``_register_skills()`` call actually succeeded. If skills raises
|
|
(e.g. a transient I/O error), the per-preset exception handler
|
|
just logs and continues, leaving neither the old command file
|
|
nor a new skill file — the preset's command override vanishes
|
|
entirely from copilot until the next successful rescaffold
|
|
(#2948).
|
|
"""
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=False)
|
|
copilot_commands_dir = project_dir / ".github" / "agents"
|
|
copilot_commands_dir.mkdir(parents=True)
|
|
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir, "toggle-failure-preset", "speckit.specify",
|
|
"Toggle failure test", "preset body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
cmd_file = copilot_commands_dir / "speckit.specify.agent.md"
|
|
assert cmd_file.exists(), (
|
|
"sanity: command mode should have written copilot's command file"
|
|
)
|
|
metadata = manager.registry.get("toggle-failure-preset")
|
|
assert metadata["registered_commands"].get("copilot"), (
|
|
"sanity: the command-mode write should be tracked for copilot"
|
|
)
|
|
|
|
# Flip ai_skills on for the *same* active agent and rescaffold, as
|
|
# `integration upgrade copilot` would, but with skills registration
|
|
# injected to fail.
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
|
|
|
|
def _raise_register_skills(*args, **kwargs):
|
|
raise OSError("simulated skills-phase failure")
|
|
|
|
monkeypatch.setattr(manager, "_register_skills", _raise_register_skills)
|
|
manager.register_enabled_presets_for_agent("copilot")
|
|
|
|
assert cmd_file.exists(), (
|
|
"the old command-mode artifact must survive when the "
|
|
"replacement skills registration fails — deleting it before "
|
|
"the new artifact is confirmed leaves neither in place (#2948)"
|
|
)
|
|
metadata = manager.registry.get("toggle-failure-preset")
|
|
assert metadata["registered_commands"].get("copilot"), (
|
|
"registered_commands must keep tracking copilot's still-live "
|
|
"command file when the skills replacement failed, or a later "
|
|
"removal/rescaffold will believe there is nothing to clean up "
|
|
"even though the file is still on disk (#2948)"
|
|
)
|
|
|
|
def test_toggle_command_to_skills_empty_result_preserves_old_command(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""A non-raising but empty skills result must not delete the old command.
|
|
|
|
Before the fix, the stale command-mode artifact was retired as
|
|
soon as ``_register_skills()`` completed without raising —
|
|
regardless of whether it actually wrote anything for this agent.
|
|
Deleting the preset's own command source file (simulating a
|
|
missing/corrupted override) makes ``_register_skills`` return
|
|
``{}`` for copilot without raising at all, which must leave the
|
|
old command file and its tracking untouched (#2948).
|
|
"""
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=False)
|
|
copilot_commands_dir = project_dir / ".github" / "agents"
|
|
copilot_commands_dir.mkdir(parents=True)
|
|
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir, "toggle-empty-result-preset", "speckit.specify",
|
|
"Toggle empty-result test", "preset body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
cmd_file = copilot_commands_dir / "speckit.specify.agent.md"
|
|
assert cmd_file.exists(), (
|
|
"sanity: command mode should have written copilot's command file"
|
|
)
|
|
|
|
# Remove the *installed* copy of the preset's source file (not the
|
|
# original temp source) so _register_skills can find nothing to
|
|
# render — a real "missing source" case, not an exception — leaving
|
|
# registered_skills empty for copilot.
|
|
(manager.presets_dir / "toggle-empty-result-preset" / "commands" / "speckit.specify.md").unlink()
|
|
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
|
|
manager.register_enabled_presets_for_agent("copilot")
|
|
|
|
assert cmd_file.exists(), (
|
|
"an empty (non-raising) skills registration result must not "
|
|
"cause the old command-mode artifact to be deleted (#2948)"
|
|
)
|
|
metadata = manager.registry.get("toggle-empty-result-preset")
|
|
assert metadata["registered_commands"].get("copilot"), (
|
|
"registered_commands must keep tracking copilot's still-live "
|
|
"command file when nothing was actually replaced (#2948)"
|
|
)
|
|
skill_file = project_dir / ".github" / "skills" / "speckit-specify" / "SKILL.md"
|
|
assert not skill_file.exists(), (
|
|
"sanity: no skill should have been written when the source "
|
|
"was missing"
|
|
)
|
|
|
|
def test_toggle_command_to_skills_partial_result_only_removes_replaced_command(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""Only the command whose skill replacement actually landed is retired.
|
|
|
|
A two-command preset where one command's source file goes missing
|
|
right before the toggle: ``_register_skills`` genuinely returns a
|
|
partial result (one name present, one silently skipped) without
|
|
raising. The command whose skill was written must be retired; the
|
|
other must keep both its old command file and its registry
|
|
tracking, since no replacement for it actually landed (#2948).
|
|
"""
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=False)
|
|
copilot_commands_dir = project_dir / ".github" / "agents"
|
|
copilot_commands_dir.mkdir(parents=True)
|
|
|
|
preset_dir = self._create_multi_command_preset(
|
|
temp_dir, "toggle-partial-result-preset",
|
|
["speckit.specify", "speckit.plan"],
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
specify_cmd_file = copilot_commands_dir / "speckit.specify.agent.md"
|
|
plan_cmd_file = copilot_commands_dir / "speckit.plan.agent.md"
|
|
assert specify_cmd_file.exists() and plan_cmd_file.exists(), (
|
|
"sanity: command mode should have written both command files"
|
|
)
|
|
metadata = manager.registry.get("toggle-partial-result-preset")
|
|
assert set(metadata["registered_commands"].get("copilot", [])) == {
|
|
"speckit.specify", "speckit.plan",
|
|
}, "sanity: both commands should be tracked for copilot"
|
|
|
|
# Remove only the plan command's *installed* source so its skill
|
|
# replacement is silently skipped (missing source), while
|
|
# specify's succeeds — a genuine partial result, not an injected
|
|
# exception.
|
|
(manager.presets_dir / "toggle-partial-result-preset" / "commands" / "speckit.plan.md").unlink()
|
|
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
|
|
manager.register_enabled_presets_for_agent("copilot")
|
|
|
|
assert not specify_cmd_file.exists(), (
|
|
"the specify command's old artifact must be retired since its "
|
|
"skill replacement actually landed (#2948)"
|
|
)
|
|
assert plan_cmd_file.exists(), (
|
|
"the plan command's old artifact must survive since its skill "
|
|
"replacement never landed (missing source) (#2948)"
|
|
)
|
|
metadata = manager.registry.get("toggle-partial-result-preset")
|
|
tracked_commands = metadata["registered_commands"].get("copilot", [])
|
|
assert "speckit.specify" not in tracked_commands, (
|
|
"specify must stop being tracked as a command once its "
|
|
"artifact has been unregistered (#2948)"
|
|
)
|
|
assert "speckit.plan" in tracked_commands, (
|
|
"plan must keep being tracked as a command since its old "
|
|
"artifact is still on disk (#2948)"
|
|
)
|
|
specify_skill_file = (
|
|
project_dir / ".github" / "skills" / "speckit-specify" / "SKILL.md"
|
|
)
|
|
assert "preset:toggle-partial-result-preset" in specify_skill_file.read_text(), (
|
|
"sanity: specify's new skill artifact should exist"
|
|
)
|
|
plan_skill_file = project_dir / ".github" / "skills" / "speckit-plan"
|
|
assert not plan_skill_file.exists(), (
|
|
"sanity: no skill should have been written for plan since its "
|
|
"source was missing"
|
|
)
|
|
|
|
def test_remove_after_partial_command_to_skills_toggle_keeps_skills_mode_agent_command_free(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""Removal must not recreate a command file for a skills-mode agent.
|
|
|
|
A partially failed command→skills toggle leaves the active agent's
|
|
stale ``registered_commands`` entry behind. Removing that preset
|
|
records the agent in ``extra_agents`` for post-removal
|
|
reconciliation, and ``register_commands_for_non_skill_agents``
|
|
admits every ``extra_agents`` member even when the active-only
|
|
``only_agent`` guard excludes the agent — so the surviving
|
|
lower-priority preset's command file was recreated for an agent
|
|
now running in skills mode (#2948).
|
|
"""
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=False)
|
|
copilot_commands_dir = project_dir / ".github" / "agents"
|
|
copilot_commands_dir.mkdir(parents=True)
|
|
|
|
lower_dir = self._create_command_preset(
|
|
temp_dir, "stale-toggle-lower-preset", "speckit.plan",
|
|
"Lower preset", "Lower body",
|
|
)
|
|
higher_dir = self._create_command_preset(
|
|
temp_dir, "stale-toggle-higher-preset", "speckit.plan",
|
|
"Higher preset", "Higher body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(lower_dir, "0.1.5", priority=20)
|
|
manager.install_from_directory(higher_dir, "0.1.5", priority=10)
|
|
|
|
command_file = copilot_commands_dir / "speckit.plan.agent.md"
|
|
assert "Higher body" in command_file.read_text(encoding="utf-8"), (
|
|
"sanity: command mode should have written the winning preset"
|
|
)
|
|
|
|
# Break the higher preset's installed source so its skill
|
|
# replacement is silently skipped during the toggle — a genuine
|
|
# partial command→skills toggle that leaves the stale
|
|
# registered_commands entry for copilot behind.
|
|
(manager.presets_dir / "stale-toggle-higher-preset" / "commands" / "speckit.plan.md").unlink()
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
|
|
manager.register_enabled_presets_for_agent("copilot")
|
|
|
|
metadata = manager.registry.get("stale-toggle-higher-preset")
|
|
assert "speckit.plan" in metadata["registered_commands"].get("copilot", []), (
|
|
"sanity: the partial toggle must leave the stale command "
|
|
"tracking behind"
|
|
)
|
|
|
|
manager.remove("stale-toggle-higher-preset")
|
|
|
|
assert not command_file.exists(), (
|
|
"removing the preset while copilot runs in skills mode must "
|
|
"not recreate its command file from the surviving lower "
|
|
"preset via the stale extra_agents entry (#2948)"
|
|
)
|
|
|
|
def test_remove_after_partial_skills_to_command_toggle_deletes_stale_skill(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""Removal must delete, not restore, a command-mode agent's stale skill.
|
|
|
|
The inverse partial toggle: a skills→command conversion that could
|
|
not replace one command leaves that skill tracked in
|
|
``registered_skills``. Removing the preset while the agent is now
|
|
in command mode sent it through ``_unregister_skills()``, which
|
|
restored a core/extension ``SKILL.md``, and ``extra_skills_dirs``
|
|
then let ``_reconcile_skills`` reapply the surviving lower preset —
|
|
leaving the active command-mode agent with a skill artifact it
|
|
must not have (#2948).
|
|
"""
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
|
|
copilot_commands_dir = project_dir / ".github" / "agents"
|
|
copilot_commands_dir.mkdir(parents=True)
|
|
|
|
lower_dir = self._create_command_preset(
|
|
temp_dir, "inverse-toggle-lower-preset", "speckit.plan",
|
|
"Lower preset", "Lower body",
|
|
)
|
|
higher_dir = self._create_command_preset(
|
|
temp_dir, "inverse-toggle-higher-preset", "speckit.plan",
|
|
"Higher preset", "Higher body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(lower_dir, "0.1.5", priority=20)
|
|
manager.install_from_directory(higher_dir, "0.1.5", priority=10)
|
|
|
|
skill_dir = project_dir / ".github" / "skills" / "speckit-plan"
|
|
assert (skill_dir / "SKILL.md").exists(), (
|
|
"sanity: skills mode should have written the skill"
|
|
)
|
|
|
|
# Break the higher preset's installed source so its command
|
|
# replacement never lands during the skills→command toggle,
|
|
# leaving the skill tracked for copilot.
|
|
(manager.presets_dir / "inverse-toggle-higher-preset" / "commands" / "speckit.plan.md").unlink()
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=False)
|
|
manager.register_enabled_presets_for_agent("copilot")
|
|
|
|
metadata = manager.registry.get("inverse-toggle-higher-preset")
|
|
registered_skills = metadata.get("registered_skills") or {}
|
|
assert registered_skills.get("copilot"), (
|
|
"sanity: the partial toggle must leave the stale skill "
|
|
"tracking behind"
|
|
)
|
|
assert (skill_dir / "SKILL.md").exists(), (
|
|
"sanity: the stale skill artifact must survive the partial toggle"
|
|
)
|
|
|
|
manager.remove("inverse-toggle-higher-preset")
|
|
|
|
assert not skill_dir.exists(), (
|
|
"removing the preset while copilot runs in command mode must "
|
|
"delete the stale preset-owned skill instead of restoring core "
|
|
"content or reapplying the surviving lower preset (#2948)"
|
|
)
|
|
|
|
def test_lower_priority_skill_does_not_remove_failed_winner_command(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""Only a successfully rendered winning layer may retire a command."""
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=False)
|
|
commands_dir = project_dir / ".github" / "agents"
|
|
commands_dir.mkdir(parents=True)
|
|
|
|
lower_dir = self._create_command_preset(
|
|
temp_dir,
|
|
"lower-toggle-preset",
|
|
"speckit.specify",
|
|
"Lower preset",
|
|
"Lower body",
|
|
)
|
|
higher_dir = self._create_command_preset(
|
|
temp_dir,
|
|
"higher-toggle-preset",
|
|
"speckit.specify",
|
|
"Higher preset",
|
|
"Higher body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(lower_dir, "0.1.5", priority=20)
|
|
manager.install_from_directory(higher_dir, "0.1.5", priority=10)
|
|
|
|
command_file = commands_dir / "speckit.specify.agent.md"
|
|
assert "Higher body" in command_file.read_text(encoding="utf-8")
|
|
|
|
higher_source = (
|
|
manager.presets_dir
|
|
/ "higher-toggle-preset"
|
|
/ "commands"
|
|
/ "speckit.specify.md"
|
|
)
|
|
higher_source.unlink()
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
|
|
|
|
manager.register_enabled_presets_for_agent("copilot")
|
|
|
|
assert command_file.exists(), (
|
|
"a lower-priority skill replacement must not remove the existing "
|
|
"higher-priority command when the winning layer did not render"
|
|
)
|
|
assert "Higher body" in command_file.read_text(encoding="utf-8")
|
|
|
|
higher_source.write_text(
|
|
"---\ndescription: Higher preset\n---\n\nHigher body\n",
|
|
encoding="utf-8",
|
|
)
|
|
manager.register_enabled_presets_for_agent("copilot")
|
|
|
|
assert not command_file.exists(), (
|
|
"the old command should be retired after the winning skill "
|
|
"layer renders successfully"
|
|
)
|
|
for preset_id in ("lower-toggle-preset", "higher-toggle-preset"):
|
|
metadata = manager.registry.get(preset_id)
|
|
assert not metadata["registered_commands"].get("copilot"), (
|
|
"all layers sharing the retired command output must drop "
|
|
"their stale command tracking"
|
|
)
|
|
|
|
def test_successful_winner_without_stale_tracking_retires_lower_command(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""Winner success is independent of whether that layer tracked a command."""
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=False)
|
|
commands_dir = project_dir / ".github" / "agents"
|
|
commands_dir.mkdir(parents=True)
|
|
|
|
lower_dir = self._create_command_preset(
|
|
temp_dir,
|
|
"lower-command-preset",
|
|
"speckit.specify",
|
|
"Lower preset",
|
|
"Lower body",
|
|
)
|
|
higher_dir = self._create_command_preset(
|
|
temp_dir,
|
|
"higher-skill-preset",
|
|
"speckit.specify",
|
|
"Higher preset",
|
|
"Higher body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(lower_dir, "0.1.5", priority=20)
|
|
|
|
command_file = commands_dir / "speckit.specify.agent.md"
|
|
assert command_file.exists()
|
|
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
|
|
manager.install_from_directory(higher_dir, "0.1.5", priority=10)
|
|
higher_metadata = manager.registry.get("higher-skill-preset")
|
|
assert not higher_metadata["registered_commands"].get("copilot")
|
|
|
|
manager.register_enabled_presets_for_agent("copilot")
|
|
|
|
assert not command_file.exists(), (
|
|
"a successfully rendered winning skill must retire a lower "
|
|
"layer's stale command even when the winner never tracked one"
|
|
)
|
|
lower_metadata = manager.registry.get("lower-command-preset")
|
|
assert not lower_metadata["registered_commands"].get("copilot")
|
|
|
|
def test_lower_priority_command_does_not_remove_failed_winner_skill(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""Only a successfully rendered winning command may retire a skill."""
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
|
|
commands_dir = project_dir / ".github" / "agents"
|
|
commands_dir.mkdir(parents=True)
|
|
|
|
lower_dir = self._create_command_preset(
|
|
temp_dir,
|
|
"lower-skill-preset",
|
|
"speckit.specify",
|
|
"Lower preset",
|
|
"Lower body",
|
|
)
|
|
higher_dir = self._create_command_preset(
|
|
temp_dir,
|
|
"higher-command-preset",
|
|
"speckit.specify",
|
|
"Higher preset",
|
|
"Higher body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(lower_dir, "0.1.5", priority=20)
|
|
manager.install_from_directory(higher_dir, "0.1.5", priority=10)
|
|
|
|
skill_file = (
|
|
project_dir
|
|
/ ".github"
|
|
/ "skills"
|
|
/ "speckit-specify"
|
|
/ "SKILL.md"
|
|
)
|
|
assert "Higher body" in skill_file.read_text(encoding="utf-8")
|
|
|
|
higher_source = (
|
|
manager.presets_dir
|
|
/ "higher-command-preset"
|
|
/ "commands"
|
|
/ "speckit.specify.md"
|
|
)
|
|
higher_source.unlink()
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=False)
|
|
|
|
manager.register_enabled_presets_for_agent("copilot")
|
|
|
|
assert skill_file.exists(), (
|
|
"a lower-priority command replacement must not remove the "
|
|
"existing higher-priority skill when the winner did not render"
|
|
)
|
|
assert "Higher body" in skill_file.read_text(encoding="utf-8")
|
|
|
|
higher_source.write_text(
|
|
"---\ndescription: Higher preset\n---\n\nHigher body\n",
|
|
encoding="utf-8",
|
|
)
|
|
manager.register_enabled_presets_for_agent("copilot")
|
|
|
|
assert not skill_file.exists(), (
|
|
"the old skill should be retired after the winning command "
|
|
"layer renders successfully"
|
|
)
|
|
for preset_id in ("lower-skill-preset", "higher-command-preset"):
|
|
metadata = manager.registry.get(preset_id)
|
|
assert not metadata["registered_skills"].get("copilot"), (
|
|
"all layers sharing the retired skill output must drop "
|
|
"their stale skill tracking"
|
|
)
|
|
|
|
def test_project_override_command_retires_stale_preset_skill(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""A reconciled project override can replace a stale preset skill."""
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
|
|
commands_dir = project_dir / ".github" / "agents"
|
|
commands_dir.mkdir(parents=True)
|
|
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir,
|
|
"override-toggle-preset",
|
|
"speckit.specify",
|
|
"Preset",
|
|
"Preset body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
skill_file = (
|
|
project_dir
|
|
/ ".github"
|
|
/ "skills"
|
|
/ "speckit-specify"
|
|
/ "SKILL.md"
|
|
)
|
|
assert skill_file.exists()
|
|
|
|
overrides_dir = (
|
|
project_dir / ".specify" / "templates" / "overrides"
|
|
)
|
|
overrides_dir.mkdir(parents=True)
|
|
(overrides_dir / "speckit.specify.md").write_text(
|
|
"---\ndescription: Project override\n---\n\nOverride body\n",
|
|
encoding="utf-8",
|
|
)
|
|
(
|
|
manager.presets_dir
|
|
/ "override-toggle-preset"
|
|
/ "commands"
|
|
/ "speckit.specify.md"
|
|
).unlink()
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=False)
|
|
|
|
manager.register_enabled_presets_for_agent("copilot")
|
|
|
|
command_file = commands_dir / "speckit.specify.agent.md"
|
|
assert "Override body" in command_file.read_text(encoding="utf-8")
|
|
assert not skill_file.exists(), (
|
|
"the stale preset skill must be retired once the project "
|
|
"override command is successfully reconciled"
|
|
)
|
|
metadata = manager.registry.get("override-toggle-preset")
|
|
assert not metadata["registered_skills"].get("copilot")
|
|
|
|
def test_project_override_command_retires_reconciled_override_skill(
|
|
self, project_dir, temp_dir
|
|
):
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
|
|
commands_dir = project_dir / ".github" / "agents"
|
|
commands_dir.mkdir(parents=True)
|
|
skills_dir = project_dir / ".github" / "skills"
|
|
self._create_skill(skills_dir, "speckit-specify")
|
|
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir,
|
|
"reconciled-override-toggle-preset",
|
|
"speckit.specify",
|
|
"Preset",
|
|
"Preset body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
overrides_dir = (
|
|
project_dir / ".specify" / "templates" / "overrides"
|
|
)
|
|
overrides_dir.mkdir(parents=True)
|
|
(overrides_dir / "speckit.specify.md").write_text(
|
|
"---\ndescription: Project override\n---\n\nOverride body\n",
|
|
encoding="utf-8",
|
|
)
|
|
(
|
|
manager.presets_dir
|
|
/ "reconciled-override-toggle-preset"
|
|
/ "commands"
|
|
/ "speckit.specify.md"
|
|
).unlink()
|
|
|
|
manager.register_enabled_presets_for_agent("copilot")
|
|
|
|
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
|
|
assert "override:speckit.specify" in skill_file.read_text(
|
|
encoding="utf-8"
|
|
)
|
|
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=False)
|
|
manager.register_enabled_presets_for_agent("copilot")
|
|
|
|
assert "Override body" in (
|
|
commands_dir / "speckit.specify.agent.md"
|
|
).read_text(encoding="utf-8")
|
|
assert not skill_file.exists()
|
|
metadata = manager.registry.get(
|
|
"reconciled-override-toggle-preset"
|
|
)
|
|
assert not metadata["registered_skills"].get("copilot")
|
|
|
|
def test_project_override_skill_retires_stale_preset_command(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""A reconciled override skill may retire a stale preset command."""
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=False)
|
|
commands_dir = project_dir / ".github" / "agents"
|
|
commands_dir.mkdir(parents=True)
|
|
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir,
|
|
"override-skill-toggle-preset",
|
|
"speckit.specify",
|
|
"Preset",
|
|
"Preset body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
command_file = commands_dir / "speckit.specify.agent.md"
|
|
assert command_file.exists()
|
|
skills_dir = project_dir / ".github" / "skills"
|
|
self._create_skill(skills_dir, "speckit-specify")
|
|
manager.registry.update(
|
|
"override-skill-toggle-preset",
|
|
{"registered_skills": {"copilot": ["speckit-specify"]}},
|
|
)
|
|
|
|
overrides_dir = (
|
|
project_dir / ".specify" / "templates" / "overrides"
|
|
)
|
|
overrides_dir.mkdir(parents=True)
|
|
(overrides_dir / "speckit.specify.md").write_text(
|
|
"---\ndescription: Project override\n---\n\nOverride body\n",
|
|
encoding="utf-8",
|
|
)
|
|
(
|
|
manager.presets_dir
|
|
/ "override-skill-toggle-preset"
|
|
/ "commands"
|
|
/ "speckit.specify.md"
|
|
).unlink()
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
|
|
|
|
manager.register_enabled_presets_for_agent("copilot")
|
|
|
|
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
|
|
assert "Override body" in skill_file.read_text(encoding="utf-8")
|
|
assert not command_file.exists(), (
|
|
"the stale preset command must be retired after the project "
|
|
"override skill is successfully reconciled"
|
|
)
|
|
metadata = manager.registry.get("override-skill-toggle-preset")
|
|
assert not metadata["registered_commands"].get("copilot")
|
|
|
|
def test_partial_skill_registration_is_persisted_before_later_failure(
|
|
self, project_dir, temp_dir, monkeypatch
|
|
):
|
|
"""A successful earlier skill write remains tracked if a later read fails."""
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=False)
|
|
commands_dir = project_dir / ".github" / "agents"
|
|
commands_dir.mkdir(parents=True)
|
|
|
|
preset_dir = self._create_multi_command_preset(
|
|
temp_dir,
|
|
"partial-skill-failure-preset",
|
|
["speckit.specify", "speckit.plan"],
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
|
|
|
|
original_read_text = Path.read_text
|
|
|
|
def fail_plan_source(path, *args, **kwargs):
|
|
if (
|
|
path.name == "speckit.plan.md"
|
|
and path.parent.name == "commands"
|
|
and "partial-skill-failure-preset" in path.parts
|
|
):
|
|
raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid")
|
|
return original_read_text(path, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(Path, "read_text", fail_plan_source)
|
|
manager.register_enabled_presets_for_agent("copilot")
|
|
|
|
metadata = manager.registry.get("partial-skill-failure-preset")
|
|
assert "speckit-specify" in metadata["registered_skills"].get(
|
|
"copilot", []
|
|
), (
|
|
"the first successful write must be persisted before the later "
|
|
"template failure aborts the registration call"
|
|
)
|
|
monkeypatch.setattr(Path, "read_text", original_read_text)
|
|
assert manager.remove("partial-skill-failure-preset") is True
|
|
remaining_skill = (
|
|
project_dir
|
|
/ ".github"
|
|
/ "skills"
|
|
/ "speckit-specify"
|
|
/ "SKILL.md"
|
|
)
|
|
assert (
|
|
not remaining_skill.exists()
|
|
or "preset:partial-skill-failure-preset"
|
|
not in remaining_skill.read_text(encoding="utf-8")
|
|
), "persisted partial ownership must remain removable"
|
|
|
|
def test_remove_cleans_native_skill_missing_from_partial_skill_map(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""Command cleanup must cover native skills absent from a partial map."""
|
|
self._write_init_options(project_dir, ai="claude", ai_skills=True)
|
|
(project_dir / ".claude" / "skills").mkdir(parents=True)
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir,
|
|
"partial-agent-skill-map-preset",
|
|
"speckit.partial-native",
|
|
"Partial native skill",
|
|
"preset body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
metadata = manager.registry.get("partial-agent-skill-map-preset")
|
|
assert metadata["registered_skills"].get("claude")
|
|
|
|
self._write_init_options(project_dir, ai="codex", ai_skills=True)
|
|
(project_dir / ".agents" / "skills").mkdir(parents=True)
|
|
|
|
from unittest.mock import patch
|
|
|
|
with patch.object(
|
|
PresetManager,
|
|
"_register_skills",
|
|
side_effect=RuntimeError("simulated skills phase failure"),
|
|
):
|
|
manager.register_enabled_presets_for_agent("codex")
|
|
|
|
codex_skill = (
|
|
project_dir
|
|
/ ".agents"
|
|
/ "skills"
|
|
/ "speckit-partial-native"
|
|
/ "SKILL.md"
|
|
)
|
|
assert codex_skill.exists()
|
|
metadata = manager.registry.get("partial-agent-skill-map-preset")
|
|
assert "speckit.partial-native" in metadata[
|
|
"registered_commands"
|
|
].get("codex", [])
|
|
assert not metadata["registered_skills"].get("codex")
|
|
|
|
assert manager.remove("partial-agent-skill-map-preset") is True
|
|
assert not codex_skill.exists(), (
|
|
"native skill written by the commands phase must not be orphaned "
|
|
"when another agent makes registered_skills globally non-empty"
|
|
)
|
|
|
|
def test_partial_skill_install_failure_rolls_back_persisted_writes(
|
|
self, project_dir, temp_dir, monkeypatch
|
|
):
|
|
"""Install rollback must reload partial skill ownership before removal."""
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
|
|
(project_dir / ".github" / "agents").mkdir(parents=True)
|
|
preset_dir = self._create_multi_command_preset(
|
|
temp_dir,
|
|
"partial-install-failure-preset",
|
|
["speckit.specify", "speckit.plan"],
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
original_read_text = Path.read_text
|
|
|
|
def fail_plan_source(path, *args, **kwargs):
|
|
if (
|
|
path.name == "speckit.plan.md"
|
|
and path.parent.name == "commands"
|
|
and "partial-install-failure-preset" in path.parts
|
|
):
|
|
raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid")
|
|
return original_read_text(path, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(Path, "read_text", fail_plan_source)
|
|
with pytest.raises(UnicodeDecodeError):
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
assert not manager.registry.is_installed(
|
|
"partial-install-failure-preset"
|
|
)
|
|
skill_file = (
|
|
project_dir
|
|
/ ".github"
|
|
/ "skills"
|
|
/ "speckit-specify"
|
|
/ "SKILL.md"
|
|
)
|
|
assert (
|
|
not skill_file.exists()
|
|
or "preset:partial-install-failure-preset"
|
|
not in original_read_text(skill_file, encoding="utf-8")
|
|
), "rollback must not orphan a skill written before the later failure"
|
|
|
|
def test_toggle_skills_to_command_empty_result_preserves_old_skill(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""A non-raising but empty command result must not delete the old skill.
|
|
|
|
Mirror image of the empty-result command->skills case: deleting the
|
|
preset's own source file makes ``_register_commands`` return ``{}``
|
|
for copilot without raising, which must leave the old SKILL.md and
|
|
its tracking untouched (#2948).
|
|
"""
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
|
|
copilot_commands_dir = project_dir / ".github" / "agents"
|
|
copilot_commands_dir.mkdir(parents=True)
|
|
skills_dir = project_dir / ".github" / "skills"
|
|
self._create_skill(skills_dir, "speckit-specify")
|
|
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir, "toggle-skill-empty-result-preset", "speckit.specify",
|
|
"Toggle empty-result test", "preset body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
|
|
assert "preset:toggle-skill-empty-result-preset" in skill_file.read_text(), (
|
|
"sanity: skills mode should have written the SKILL.md mirror"
|
|
)
|
|
|
|
# Remove the preset's own *installed* source file so
|
|
# _register_commands can find nothing to render — a real "missing
|
|
# source" case, not an exception — leaving registered_commands
|
|
# empty for copilot.
|
|
(manager.presets_dir / "toggle-skill-empty-result-preset" / "commands" / "speckit.specify.md").unlink()
|
|
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=False)
|
|
manager.register_enabled_presets_for_agent("copilot")
|
|
|
|
assert "preset:toggle-skill-empty-result-preset" in skill_file.read_text(), (
|
|
"an empty (non-raising) command registration result must not "
|
|
"cause the old skills-mode artifact to be deleted/reverted "
|
|
"(#2948)"
|
|
)
|
|
metadata = manager.registry.get("toggle-skill-empty-result-preset")
|
|
assert "speckit-specify" in metadata["registered_skills"].get("copilot", []), (
|
|
"registered_skills must keep tracking copilot's still-live "
|
|
"skill file when nothing was actually replaced (#2948)"
|
|
)
|
|
# Note: a command file may still exist here — general command
|
|
# reconciliation independently restores the next-best (e.g. core)
|
|
# layer for the *command name*, regardless of this preset's own
|
|
# missing source. That's an orthogonal, existing behaviour; the
|
|
# invariant under test is specifically that the *skill* mirror and
|
|
# its tracking survive the empty registration result.
|
|
|
|
def test_toggle_skills_to_command_partial_result_only_removes_replaced_skill(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""Only the skill whose command replacement actually landed is retired.
|
|
|
|
Mirror image of the partial-result command->skills case: a
|
|
two-command preset where one command's source file goes missing
|
|
right before the toggle, so ``_register_commands`` genuinely
|
|
returns a partial result. The skill whose command was written
|
|
must be retired; the other must keep both its old SKILL.md and
|
|
its registry tracking, since no replacement for it landed (#2948).
|
|
"""
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
|
|
copilot_commands_dir = project_dir / ".github" / "agents"
|
|
copilot_commands_dir.mkdir(parents=True)
|
|
skills_dir = project_dir / ".github" / "skills"
|
|
self._create_skill(skills_dir, "speckit-specify")
|
|
self._create_skill(skills_dir, "speckit-plan")
|
|
|
|
# A core template lets the retired skill restore to core content
|
|
# instead of being removed entirely (it has nothing else to fall
|
|
# back to), matching _unregister_skills's behaviour elsewhere.
|
|
core_cmds = project_dir / ".specify" / "templates" / "commands"
|
|
core_cmds.mkdir(parents=True, exist_ok=True)
|
|
(core_cmds / "specify.md").write_text(
|
|
"---\ndescription: Core specify command\n---\n\nCore specify body\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
preset_dir = self._create_multi_command_preset(
|
|
temp_dir, "toggle-skill-partial-result-preset",
|
|
["speckit.specify", "speckit.plan"],
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
specify_skill_file = skills_dir / "speckit-specify" / "SKILL.md"
|
|
plan_skill_file = skills_dir / "speckit-plan" / "SKILL.md"
|
|
assert "preset:toggle-skill-partial-result-preset" in specify_skill_file.read_text()
|
|
assert "preset:toggle-skill-partial-result-preset" in plan_skill_file.read_text()
|
|
metadata = manager.registry.get("toggle-skill-partial-result-preset")
|
|
assert set(metadata["registered_skills"].get("copilot", [])) == {
|
|
"speckit-specify", "speckit-plan",
|
|
}, "sanity: both skills should be tracked for copilot"
|
|
|
|
# Remove only the plan command's *installed* source so its command
|
|
# replacement is silently skipped (missing source), while
|
|
# specify's succeeds.
|
|
(manager.presets_dir / "toggle-skill-partial-result-preset" / "commands" / "speckit.plan.md").unlink()
|
|
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=False)
|
|
manager.register_enabled_presets_for_agent("copilot")
|
|
|
|
assert "preset:toggle-skill-partial-result-preset" not in specify_skill_file.read_text(), (
|
|
"the specify skill's old artifact must be retired/reverted "
|
|
"since its command replacement actually landed (#2948)"
|
|
)
|
|
assert "preset:toggle-skill-partial-result-preset" in plan_skill_file.read_text(), (
|
|
"the plan skill's old artifact must survive since its command "
|
|
"replacement never landed (missing source) (#2948)"
|
|
)
|
|
metadata = manager.registry.get("toggle-skill-partial-result-preset")
|
|
tracked_skills = metadata["registered_skills"].get("copilot", [])
|
|
assert "speckit-specify" not in tracked_skills, (
|
|
"specify must stop being tracked as a skill once its artifact "
|
|
"has been unregistered/reverted (#2948)"
|
|
)
|
|
assert "speckit-plan" in tracked_skills, (
|
|
"plan must keep being tracked as a skill since its old "
|
|
"artifact is still on disk (#2948)"
|
|
)
|
|
assert (copilot_commands_dir / "speckit.specify.agent.md").exists(), (
|
|
"sanity: specify's new command artifact should exist"
|
|
)
|
|
|
|
def test_toggle_command_to_skills_retires_alias_group_when_primary_skill_lands(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""A command's aliases must be retired together with its primary
|
|
once the primary's skill replacement lands.
|
|
|
|
``CommandRegistrar.register_commands()`` tracks and returns
|
|
primary + alias names flattened together, but ``_register_skills()``
|
|
only ever renders/returns the *primary* command name's skill. The
|
|
alias's own name run through ``_skill_names_for_command()`` never
|
|
matches anything real, so without grouping by primary, the alias
|
|
command artifact and its tracking entry would survive forever even
|
|
after mutual exclusion is otherwise enforced for the primary (#2948).
|
|
"""
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=False)
|
|
copilot_commands_dir = project_dir / ".github" / "agents"
|
|
copilot_commands_dir.mkdir(parents=True)
|
|
|
|
preset_dir = self._create_multi_command_preset_with_aliases(
|
|
temp_dir, "alias-group-success-preset",
|
|
[("speckit.specify", ["speckit.spec"])],
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
primary_cmd_file = copilot_commands_dir / "speckit.specify.agent.md"
|
|
alias_cmd_file = copilot_commands_dir / "speckit.spec.agent.md"
|
|
assert primary_cmd_file.exists() and alias_cmd_file.exists(), (
|
|
"sanity: command mode should have written both the primary "
|
|
"and alias command files"
|
|
)
|
|
metadata = manager.registry.get("alias-group-success-preset")
|
|
assert set(metadata["registered_commands"].get("copilot", [])) == {
|
|
"speckit.specify", "speckit.spec",
|
|
}, "sanity: both primary and alias should be tracked for copilot"
|
|
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
|
|
manager.register_enabled_presets_for_agent("copilot")
|
|
|
|
assert not primary_cmd_file.exists(), (
|
|
"the primary's old command artifact must be retired once its "
|
|
"skill replacement lands (#2948)"
|
|
)
|
|
assert not alias_cmd_file.exists(), (
|
|
"the alias's old command artifact must be retired together "
|
|
"with its primary once the primary's skill replacement lands "
|
|
"(#2948)"
|
|
)
|
|
metadata = manager.registry.get("alias-group-success-preset")
|
|
registered_commands = metadata.get("registered_commands", {})
|
|
assert not registered_commands.get("copilot"), (
|
|
"neither the primary nor the alias should remain tracked as "
|
|
"commands once both artifacts are retired (#2948)"
|
|
)
|
|
skill_file = project_dir / ".github" / "skills" / "speckit-specify" / "SKILL.md"
|
|
assert skill_file.exists(), "sanity: the primary's skill should have been written"
|
|
|
|
def test_toggle_command_to_skills_keeps_alias_group_when_primary_skill_missing(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""A command's aliases must survive together with its primary when
|
|
the primary's skill replacement never lands.
|
|
|
|
Mirror image of the group-retirement case: deleting the preset's
|
|
own installed command source makes ``_register_skills`` genuinely
|
|
return nothing for ``speckit.specify``, so neither the primary nor
|
|
its alias have a real replacement — both old command artifacts and
|
|
their tracking must survive (#2948).
|
|
"""
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=False)
|
|
copilot_commands_dir = project_dir / ".github" / "agents"
|
|
copilot_commands_dir.mkdir(parents=True)
|
|
|
|
preset_dir = self._create_multi_command_preset_with_aliases(
|
|
temp_dir, "alias-group-failure-preset",
|
|
[("speckit.specify", ["speckit.spec"])],
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
primary_cmd_file = copilot_commands_dir / "speckit.specify.agent.md"
|
|
alias_cmd_file = copilot_commands_dir / "speckit.spec.agent.md"
|
|
assert primary_cmd_file.exists() and alias_cmd_file.exists(), (
|
|
"sanity: command mode should have written both the primary "
|
|
"and alias command files"
|
|
)
|
|
|
|
# Remove the installed source so _register_skills can find nothing
|
|
# to render for the primary — a real "missing source" case.
|
|
(manager.presets_dir / "alias-group-failure-preset" / "commands" / "speckit.specify.md").unlink()
|
|
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
|
|
manager.register_enabled_presets_for_agent("copilot")
|
|
|
|
assert primary_cmd_file.exists(), (
|
|
"the primary's old command artifact must survive since its "
|
|
"skill replacement never landed (#2948)"
|
|
)
|
|
assert alias_cmd_file.exists(), (
|
|
"the alias's old command artifact must survive together with "
|
|
"its primary since neither has a real replacement (#2948)"
|
|
)
|
|
metadata = manager.registry.get("alias-group-failure-preset")
|
|
assert set(metadata["registered_commands"].get("copilot", [])) == {
|
|
"speckit.specify", "speckit.spec",
|
|
}, (
|
|
"both the primary and alias must keep being tracked since "
|
|
"nothing was actually replaced (#2948)"
|
|
)
|
|
skill_file = project_dir / ".github" / "skills" / "speckit-specify" / "SKILL.md"
|
|
assert not skill_file.exists(), (
|
|
"sanity: no skill should have been written when the source "
|
|
"was missing"
|
|
)
|
|
|
|
def test_toggle_command_to_skills_partial_multi_group_only_retires_successful_group(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""With two independent alias groups, only the group whose primary
|
|
skill actually lands is retired; the other survives intact.
|
|
|
|
A preset with two commands (``speckit.specify`` with alias
|
|
``speckit.spec``, and ``speckit.plan`` with alias
|
|
``speckit.plan-alt``) where only ``speckit.plan``'s installed
|
|
source goes missing: ``speckit.specify``'s group (primary + alias)
|
|
must be fully retired, while ``speckit.plan``'s entire group
|
|
(primary + alias) must survive together, since grouping is
|
|
per-primary, not per-individual-name (#2948).
|
|
"""
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=False)
|
|
copilot_commands_dir = project_dir / ".github" / "agents"
|
|
copilot_commands_dir.mkdir(parents=True)
|
|
|
|
preset_dir = self._create_multi_command_preset_with_aliases(
|
|
temp_dir, "alias-group-partial-preset",
|
|
[
|
|
("speckit.specify", ["speckit.spec"]),
|
|
("speckit.plan", ["speckit.plan-alt"]),
|
|
],
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
specify_cmd = copilot_commands_dir / "speckit.specify.agent.md"
|
|
spec_alias_cmd = copilot_commands_dir / "speckit.spec.agent.md"
|
|
plan_cmd = copilot_commands_dir / "speckit.plan.agent.md"
|
|
plan_alias_cmd = copilot_commands_dir / "speckit.plan-alt.agent.md"
|
|
assert all(
|
|
f.exists() for f in (specify_cmd, spec_alias_cmd, plan_cmd, plan_alias_cmd)
|
|
), "sanity: command mode should have written all four command files"
|
|
|
|
# Remove only plan's installed source so its group's skill
|
|
# replacement is silently skipped, while specify's group succeeds.
|
|
(manager.presets_dir / "alias-group-partial-preset" / "commands" / "speckit.plan.md").unlink()
|
|
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
|
|
manager.register_enabled_presets_for_agent("copilot")
|
|
|
|
assert not specify_cmd.exists() and not spec_alias_cmd.exists(), (
|
|
"specify's whole group (primary + alias) must be retired "
|
|
"since its skill replacement landed (#2948)"
|
|
)
|
|
assert plan_cmd.exists() and plan_alias_cmd.exists(), (
|
|
"plan's whole group (primary + alias) must survive together "
|
|
"since its skill replacement never landed (#2948)"
|
|
)
|
|
metadata = manager.registry.get("alias-group-partial-preset")
|
|
tracked_commands = set(metadata["registered_commands"].get("copilot", []))
|
|
assert tracked_commands == {"speckit.plan", "speckit.plan-alt"}, (
|
|
"only plan's group should remain tracked as commands; "
|
|
"specify's group must be fully untracked (#2948)"
|
|
)
|
|
|
|
def test_rescaffold_toggle_skills_to_command_removes_stale_skill_file(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""Toggling the *same* agent from skills mode to command mode must
|
|
remove the stale skills-mode artifact, not just add the new one.
|
|
|
|
Mirror image of the command-to-skills toggle: once ``ai_skills`` is
|
|
turned off for copilot (still the active agent), ``_get_skills_dir``
|
|
stops resolving a skills directory for it, so ``_register_skills``
|
|
becomes a no-op — but the ``SKILL.md`` written while skills mode was
|
|
on, and its ``registered_skills`` entry, were left behind even
|
|
though ``_register_commands`` went on to (re)write the ``.agent.md``
|
|
command file, again breaking mutual exclusion (#2948).
|
|
"""
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
|
|
copilot_commands_dir = project_dir / ".github" / "agents"
|
|
copilot_commands_dir.mkdir(parents=True)
|
|
skills_dir = project_dir / ".github" / "skills"
|
|
self._create_skill(skills_dir, "speckit-specify")
|
|
|
|
# A core template lets the stale skill restore to core content
|
|
# (instead of being removed entirely, since it has nothing to fall
|
|
# back to), matching how `_unregister_skills` behaves elsewhere.
|
|
core_cmds = project_dir / ".specify" / "templates" / "commands"
|
|
core_cmds.mkdir(parents=True, exist_ok=True)
|
|
(core_cmds / "specify.md").write_text(
|
|
"---\ndescription: Core specify command\n---\n\nCore specify body\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir, "toggle-skill-to-cmd-preset", "speckit.specify",
|
|
"Toggle test", "preset body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
|
|
assert "preset:toggle-skill-to-cmd-preset" in skill_file.read_text(), (
|
|
"sanity: skills mode should have written the SKILL.md mirror"
|
|
)
|
|
metadata = manager.registry.get("toggle-skill-to-cmd-preset")
|
|
assert metadata["registered_skills"].get("copilot"), (
|
|
"sanity: the skills-mode write should be tracked for copilot"
|
|
)
|
|
|
|
# Flip ai_skills off for the *same* active agent and rescaffold, as
|
|
# `integration upgrade copilot` would after the mode toggle.
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=False)
|
|
manager.register_enabled_presets_for_agent("copilot")
|
|
|
|
restored_content = skill_file.read_text()
|
|
assert "preset:toggle-skill-to-cmd-preset" not in restored_content, (
|
|
"the stale skills-mode artifact must be reverted once copilot "
|
|
"has toggled to command mode for the same agent (#2948)"
|
|
)
|
|
assert "Core specify body" in restored_content, (
|
|
"sanity: the skill should fall back to core content, not just "
|
|
"lose the preset's override"
|
|
)
|
|
metadata = manager.registry.get("toggle-skill-to-cmd-preset")
|
|
assert not metadata["registered_skills"].get("copilot"), (
|
|
"registered_skills must stop tracking copilot once its "
|
|
"artifact has been unregistered/restored (#2948)"
|
|
)
|
|
cmd_file = copilot_commands_dir / "speckit.specify.agent.md"
|
|
assert cmd_file.exists(), (
|
|
"sanity: the new command-mode artifact should still be written"
|
|
)
|
|
assert "preset body" in cmd_file.read_text()
|
|
|
|
def test_skill_switch_then_remove_restores_every_skill_agent_dir(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""Switching between two skill-mode agents before removing a preset
|
|
must restore both agents' directories, not just the currently
|
|
active one.
|
|
|
|
``registered_skills`` records exactly which agent directories the
|
|
preset wrote to (``{agent_name: [skill_name, ...]}``); switching to
|
|
codex and re-registering adds a "codex" entry alongside the
|
|
original "claude" entry, so removal restores both. Before the
|
|
provenance fix, ``_unregister_skills`` only restored the currently
|
|
active agent's skills directory; a preset used first under Claude
|
|
and later switched to Codex would have its Claude override left
|
|
behind permanently on removal (#2948).
|
|
"""
|
|
self._write_init_options(project_dir, ai="claude", ai_skills=True)
|
|
|
|
core_cmds = project_dir / ".specify" / "templates" / "commands"
|
|
core_cmds.mkdir(parents=True, exist_ok=True)
|
|
(core_cmds / "specify.md").write_text(
|
|
"---\ndescription: Core specify command\n---\n\nCore specify body\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
# Native skill agents only materialize a *brand-new* preset skill
|
|
# when their skills directory already exists (mirrors every other
|
|
# skill test in this class); pre-create both agents' directories so
|
|
# install and the later switch both find an existing skill to
|
|
# overwrite via _register_commands/_register_skills.
|
|
claude_skills_dir = project_dir / ".claude" / "skills"
|
|
self._create_skill(claude_skills_dir, "speckit-specify")
|
|
codex_skills_dir = project_dir / ".agents" / "skills"
|
|
self._create_skill(codex_skills_dir, "speckit-specify")
|
|
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir, "multi-skill-agent-preset", "speckit.specify",
|
|
"Multi skill agent test", "preset body",
|
|
)
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
claude_skill = claude_skills_dir / "speckit-specify" / "SKILL.md"
|
|
assert "preset:multi-skill-agent-preset" in claude_skill.read_text()
|
|
|
|
# Switch the active agent to codex (a different skill-mode agent)
|
|
# and re-register enabled presets for it, mirroring what
|
|
# `integration use codex` does.
|
|
self._write_init_options(project_dir, ai="codex", ai_skills=True)
|
|
manager.register_enabled_presets_for_agent("codex")
|
|
|
|
codex_skill = codex_skills_dir / "speckit-specify" / "SKILL.md"
|
|
assert "preset:multi-skill-agent-preset" in codex_skill.read_text(), (
|
|
"sanity: switching to codex should rescaffold the preset there"
|
|
)
|
|
assert "preset:multi-skill-agent-preset" in claude_skill.read_text(), (
|
|
"sanity: the previous agent's registration is preserved on switch"
|
|
)
|
|
|
|
metadata = manager.registry.get("multi-skill-agent-preset")
|
|
registered_skills = metadata.get("registered_skills", {})
|
|
assert set(registered_skills) == {"claude", "codex"}, (
|
|
"registered_skills must record both agent directories this "
|
|
"preset actually wrote to (#2948)"
|
|
)
|
|
|
|
assert manager.remove("multi-skill-agent-preset") is True
|
|
|
|
for skill_file, label in ((claude_skill, "claude"), (codex_skill, "codex")):
|
|
assert skill_file.exists(), f"{label} skill file should still exist after removal"
|
|
content = skill_file.read_text()
|
|
assert "preset:multi-skill-agent-preset" not in content, (
|
|
f"{label}'s preset override must be restored on removal, "
|
|
"not orphaned permanently (#2948)"
|
|
)
|
|
assert "Core specify body" in content
|
|
|
|
def test_native_skill_activation_recreates_deleted_skills_root(
|
|
self, project_dir, temp_dir
|
|
):
|
|
self._write_init_options(project_dir, ai="claude", ai_skills=True)
|
|
claude_skills_dir = project_dir / ".claude" / "skills"
|
|
self._create_skill(claude_skills_dir, "speckit-specify")
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir,
|
|
"native-root-recovery-preset",
|
|
"speckit.specify",
|
|
"Native root recovery",
|
|
"preset body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
codex_skills_dir = project_dir / ".agents" / "skills"
|
|
assert not codex_skills_dir.exists()
|
|
self._write_init_options(project_dir, ai="codex", ai_skills=True)
|
|
manager.register_enabled_presets_for_agent("codex")
|
|
|
|
skill_file = codex_skills_dir / "speckit-specify" / "SKILL.md"
|
|
assert "preset:native-root-recovery-preset" in skill_file.read_text()
|
|
metadata = manager.registry.get("native-root-recovery-preset")
|
|
assert "speckit.specify" in metadata["registered_commands"]["codex"]
|
|
|
|
def test_rescaffold_migrates_legacy_flat_list_registered_skills(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""Rescaffolding a preset with a legacy flat-list ``registered_skills``
|
|
entry must persist the migrated per-agent dict even when the
|
|
rescaffolded skill names are unchanged from before.
|
|
|
|
``_normalize_registered_skills`` converts a legacy flat ``List[str]``
|
|
(predating per-agent provenance) into ``{agent_name: [...]}`` in
|
|
memory, but the persistence check compared only the *normalized*
|
|
``merged_skills`` against the *normalized* ``existing_skills`` —
|
|
both derived from the same raw legacy list. When the freshly
|
|
registered names are identical to what the legacy list already
|
|
held (the common case: 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 the legacy best-effort restore path (only the
|
|
currently active agent's directory) instead of the per-agent
|
|
provenance path, orphaning the first agent's override (#2948).
|
|
"""
|
|
self._write_init_options(project_dir, ai="claude", ai_skills=True)
|
|
|
|
core_cmds = project_dir / ".specify" / "templates" / "commands"
|
|
core_cmds.mkdir(parents=True, exist_ok=True)
|
|
(core_cmds / "specify.md").write_text(
|
|
"---\ndescription: Core specify command\n---\n\nCore specify body\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
claude_skills_dir = project_dir / ".claude" / "skills"
|
|
self._create_skill(claude_skills_dir, "speckit-specify")
|
|
codex_skills_dir = project_dir / ".agents" / "skills"
|
|
self._create_skill(codex_skills_dir, "speckit-specify")
|
|
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir, "legacy-skills-preset", "speckit.specify",
|
|
"Legacy skills test", "preset body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
# Simulate a registry entry written by a pre-#2948 spec-kit version:
|
|
# registered_skills stored as a flat list with no per-agent
|
|
# provenance, rather than the dict shape install_from_directory
|
|
# writes today.
|
|
manager.registry.update(
|
|
"legacy-skills-preset", {"registered_skills": ["speckit-specify"]},
|
|
)
|
|
metadata = manager.registry.get("legacy-skills-preset")
|
|
assert isinstance(metadata["registered_skills"], list), (
|
|
"sanity: the injected legacy format is a flat list"
|
|
)
|
|
|
|
# Rescaffold for the *same* active agent (claude) with no actual
|
|
# change to the registered skill names, mirroring `integration
|
|
# upgrade claude` re-running registration for the active
|
|
# integration.
|
|
manager.register_enabled_presets_for_agent("claude")
|
|
|
|
metadata = manager.registry.get("legacy-skills-preset")
|
|
registered_skills = metadata.get("registered_skills")
|
|
assert isinstance(registered_skills, dict), (
|
|
"rescaffold must migrate a legacy flat-list registered_skills "
|
|
"entry to the per-agent dict format even when the "
|
|
"rescaffolded names are unchanged, or the raw registry stays "
|
|
"un-migrated and later removal loses per-agent provenance "
|
|
"(#2948)"
|
|
)
|
|
assert registered_skills.get("claude") == ["speckit-specify"]
|
|
|
|
# Switch to a different skill-mode agent and rescaffold again —
|
|
# with the dict format now in place, both directories should be
|
|
# tracked and therefore restorable on removal.
|
|
self._write_init_options(project_dir, ai="codex", ai_skills=True)
|
|
manager.register_enabled_presets_for_agent("codex")
|
|
|
|
metadata = manager.registry.get("legacy-skills-preset")
|
|
assert set(metadata.get("registered_skills", {})) == {"claude", "codex"}, (
|
|
"the migrated dict must keep recording every agent directory "
|
|
"the preset actually wrote to, exactly like a preset that was "
|
|
"always in dict format (#2948)"
|
|
)
|
|
|
|
assert manager.remove("legacy-skills-preset") is True
|
|
|
|
claude_skill = claude_skills_dir / "speckit-specify" / "SKILL.md"
|
|
codex_skill = codex_skills_dir / "speckit-specify" / "SKILL.md"
|
|
for skill_file, label in ((claude_skill, "claude"), (codex_skill, "codex")):
|
|
assert skill_file.exists(), f"{label} skill file should still exist after removal"
|
|
content = skill_file.read_text()
|
|
assert "preset:legacy-skills-preset" not in content, (
|
|
f"{label}'s preset override must be restored on removal, "
|
|
"not orphaned because the registry stayed in legacy "
|
|
"flat-list format (#2948)"
|
|
)
|
|
assert "Core specify body" in content
|
|
|
|
def test_rescaffold_legacy_flat_list_direct_switch_preserves_original_agent(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""A legacy flat-list ``registered_skills`` entry must not be
|
|
misattributed to the wrong agent when the *first* post-upgrade
|
|
operation is a direct switch to a different skill-mode agent.
|
|
|
|
Blindly attributing every legacy flat-list name to ``agent_name`` —
|
|
the agent currently being (re)activated — loses the actual writer
|
|
whenever that first operation is ``integration use codex`` (or
|
|
``switch``) run directly against a legacy Claude override, without
|
|
an intervening same-agent rescaffold for Claude first. The
|
|
migrated dict then only records ``{"codex": [...]}``, so a later
|
|
``remove()`` restores Codex but permanently orphans the Claude
|
|
override that was never in the registry to begin with (#2948).
|
|
"""
|
|
self._write_init_options(project_dir, ai="claude", ai_skills=True)
|
|
|
|
core_cmds = project_dir / ".specify" / "templates" / "commands"
|
|
core_cmds.mkdir(parents=True, exist_ok=True)
|
|
(core_cmds / "specify.md").write_text(
|
|
"---\ndescription: Core specify command\n---\n\nCore specify body\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
claude_skills_dir = project_dir / ".claude" / "skills"
|
|
self._create_skill(claude_skills_dir, "speckit-specify")
|
|
codex_skills_dir = project_dir / ".agents" / "skills"
|
|
self._create_skill(codex_skills_dir, "speckit-specify")
|
|
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir, "legacy-direct-switch-preset", "speckit.specify",
|
|
"Legacy direct switch test", "preset body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
# install_from_directory wrote the preset's override to Claude's
|
|
# skill directory (the active agent at install time) — sanity-check
|
|
# that the marker is actually there before simulating the legacy
|
|
# registry format.
|
|
claude_skill = claude_skills_dir / "speckit-specify" / "SKILL.md"
|
|
assert "preset:legacy-direct-switch-preset" in claude_skill.read_text(), (
|
|
"sanity: install should have written the override under claude"
|
|
)
|
|
|
|
# Simulate a pre-#2948 registry: a flat list with no per-agent
|
|
# provenance, even though the file on disk was actually written
|
|
# under claude's directory.
|
|
manager.registry.update(
|
|
"legacy-direct-switch-preset",
|
|
{"registered_skills": ["speckit-specify"]},
|
|
)
|
|
|
|
# Directly switch to codex — no intervening rescaffold for claude —
|
|
# mirroring `integration use codex` / `switch codex` run right after
|
|
# upgrading spec-kit versions.
|
|
self._write_init_options(project_dir, ai="codex", ai_skills=True)
|
|
manager.register_enabled_presets_for_agent("codex")
|
|
|
|
metadata = manager.registry.get("legacy-direct-switch-preset")
|
|
registered_skills = metadata.get("registered_skills")
|
|
assert isinstance(registered_skills, dict)
|
|
assert set(registered_skills) == {"claude", "codex"}, (
|
|
"migrating a legacy flat-list entry on a direct switch must "
|
|
"infer the actual writer (claude) from the existing on-disk "
|
|
"SKILL.md provenance, not attribute every name to whichever "
|
|
"agent happens to be activated first after the upgrade "
|
|
"(#2948)"
|
|
)
|
|
|
|
assert manager.remove("legacy-direct-switch-preset") is True
|
|
|
|
codex_skill = codex_skills_dir / "speckit-specify" / "SKILL.md"
|
|
for skill_file, label in ((claude_skill, "claude"), (codex_skill, "codex")):
|
|
assert skill_file.exists(), f"{label} skill file should still exist after removal"
|
|
content = skill_file.read_text()
|
|
assert "preset:legacy-direct-switch-preset" not in content, (
|
|
f"{label}'s preset override must be restored on removal, "
|
|
"not permanently orphaned by a misattributed legacy "
|
|
"migration (#2948)"
|
|
)
|
|
assert "Core specify body" in content
|
|
|
|
def test_rescaffold_legacy_flat_list_infers_command_backed_skills_owner(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""Legacy provenance inference must also probe command-backed agents
|
|
that were running in skills mode, not only agents whose command
|
|
registrar config is statically ``/SKILL.md``-only.
|
|
|
|
Copilot is command-backed (``extension: ".agent.md"``), but with
|
|
``ai_skills`` enabled its preset overrides render as ``SKILL.md``
|
|
files under ``.github/skills`` exactly like a native skill-only
|
|
agent (claude, codex, ...). Before the fix,
|
|
``_infer_legacy_skill_provenance`` only probed agents whose
|
|
registrar config has a static ``extension == "/SKILL.md"``, so a
|
|
real preset-owned ``.github/skills/.../SKILL.md`` written while
|
|
Copilot was the active, skills-mode agent was never found — the
|
|
legacy flat list was misattributed entirely to whichever agent the
|
|
first post-upgrade switch happened to activate, permanently
|
|
orphaning Copilot's override on later removal (#2948).
|
|
"""
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
|
|
|
|
core_cmds = project_dir / ".specify" / "templates" / "commands"
|
|
core_cmds.mkdir(parents=True, exist_ok=True)
|
|
(core_cmds / "specify.md").write_text(
|
|
"---\ndescription: Core specify command\n---\n\nCore specify body\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
copilot_skills_dir = project_dir / ".github" / "skills"
|
|
self._create_skill(copilot_skills_dir, "speckit-specify")
|
|
claude_skills_dir = project_dir / ".claude" / "skills"
|
|
self._create_skill(claude_skills_dir, "speckit-specify")
|
|
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir, "legacy-copilot-skills-preset", "speckit.specify",
|
|
"Legacy copilot skills test", "preset body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
copilot_skill = copilot_skills_dir / "speckit-specify" / "SKILL.md"
|
|
assert "preset:legacy-copilot-skills-preset" in copilot_skill.read_text(), (
|
|
"sanity: install should have written the override under "
|
|
"copilot's skills directory while copilot was active in "
|
|
"skills mode"
|
|
)
|
|
# Sanity: no command-mode artifact was written either — copilot's
|
|
# command file and skills file are mutually exclusive.
|
|
assert not list((project_dir / ".github" / "agents").glob("*specify*")), (
|
|
"sanity: copilot in skills mode must not also write a command "
|
|
"file that could be falsely attributed instead"
|
|
)
|
|
|
|
# Simulate a pre-#2948 registry: a flat list with no per-agent
|
|
# provenance, even though the file on disk was actually written
|
|
# under copilot's skills directory.
|
|
manager.registry.update(
|
|
"legacy-copilot-skills-preset",
|
|
{"registered_skills": ["speckit-specify"]},
|
|
)
|
|
|
|
# Directly switch to claude — no intervening rescaffold for
|
|
# copilot — mirroring `integration use claude` run right after
|
|
# upgrading spec-kit versions.
|
|
self._write_init_options(project_dir, ai="claude", ai_skills=True)
|
|
manager.register_enabled_presets_for_agent("claude")
|
|
|
|
metadata = manager.registry.get("legacy-copilot-skills-preset")
|
|
registered_skills = metadata.get("registered_skills")
|
|
assert isinstance(registered_skills, dict)
|
|
assert set(registered_skills) == {"copilot", "claude"}, (
|
|
"migrating a legacy flat-list entry on a direct switch must "
|
|
"infer the actual writer (copilot, running in skills mode) "
|
|
"even though copilot's registrar config is command-backed, "
|
|
"not just agents with a static /SKILL.md extension (#2948)"
|
|
)
|
|
|
|
assert manager.remove("legacy-copilot-skills-preset") is True
|
|
|
|
claude_skill = claude_skills_dir / "speckit-specify" / "SKILL.md"
|
|
for skill_file, label in ((copilot_skill, "copilot"), (claude_skill, "claude")):
|
|
assert skill_file.exists(), f"{label} skill file should still exist after removal"
|
|
content = skill_file.read_text()
|
|
assert "preset:legacy-copilot-skills-preset" not in content, (
|
|
f"{label}'s preset override must be restored on removal, "
|
|
"not permanently orphaned by a legacy migration that "
|
|
"failed to probe command-backed skills-mode agents (#2948)"
|
|
)
|
|
assert "Core specify body" in content
|
|
|
|
def test_infer_legacy_skill_provenance_does_not_falsely_attribute_command_mode_copilot(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""Broadening provenance inference to command-backed agents must not
|
|
falsely attribute ownership to an agent's directory that has no
|
|
preset-owned marker.
|
|
|
|
Copilot stays in plain command mode throughout (no skills ever
|
|
rendered there), so ``.github/skills`` never receives this
|
|
preset's ``SKILL.md``. Probing copilot's skills directory anyway
|
|
(now that inference isn't restricted to static ``/SKILL.md``
|
|
agents) must find nothing there and must not invent a false
|
|
``"copilot"`` entry (#2948).
|
|
"""
|
|
self._write_init_options(project_dir, ai="claude", ai_skills=True)
|
|
|
|
core_cmds = project_dir / ".specify" / "templates" / "commands"
|
|
core_cmds.mkdir(parents=True, exist_ok=True)
|
|
(core_cmds / "specify.md").write_text(
|
|
"---\ndescription: Core specify command\n---\n\nCore specify body\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
claude_skills_dir = project_dir / ".claude" / "skills"
|
|
self._create_skill(claude_skills_dir, "speckit-specify")
|
|
# Copilot has never been active; its command directory holds an
|
|
# unrelated file so the directory exists, but no skills directory
|
|
# or SKILL.md was ever written for it.
|
|
copilot_commands_dir = project_dir / ".github" / "agents"
|
|
copilot_commands_dir.mkdir(parents=True)
|
|
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir, "no-false-attribution-preset", "speckit.specify",
|
|
"No false attribution test", "preset body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
manager.registry.update(
|
|
"no-false-attribution-preset",
|
|
{"registered_skills": ["speckit-specify"]},
|
|
)
|
|
|
|
# Rescaffold again for the same agent (claude) with unchanged
|
|
# names, triggering the legacy migration path.
|
|
manager.register_enabled_presets_for_agent("claude")
|
|
|
|
metadata = manager.registry.get("no-false-attribution-preset")
|
|
registered_skills = metadata.get("registered_skills")
|
|
assert isinstance(registered_skills, dict)
|
|
assert set(registered_skills) == {"claude"}, (
|
|
"copilot must not appear in the migrated registry when it has "
|
|
"never actually rendered this preset's skill — probing its "
|
|
"directory for a marker match must not create a false "
|
|
"attribution (#2948)"
|
|
)
|
|
assert not (project_dir / ".github" / "skills").exists(), (
|
|
"no .github/skills directory should have been created as a "
|
|
"side effect of probing for provenance (#2948)"
|
|
)
|
|
|
|
def test_infer_legacy_skill_provenance_skips_invalid_utf8(
|
|
self, project_dir
|
|
):
|
|
self._write_init_options(project_dir, ai="claude", ai_skills=True)
|
|
skill_dir = (
|
|
project_dir / ".claude" / "skills" / "speckit-specify"
|
|
)
|
|
skill_dir.mkdir(parents=True)
|
|
(skill_dir / "SKILL.md").write_bytes(b"\xff")
|
|
|
|
manager = PresetManager(project_dir)
|
|
|
|
assert manager._infer_legacy_skill_provenance(
|
|
["speckit-specify"], "some-pack", "claude"
|
|
) == {"claude": ["speckit-specify"]}
|
|
|
|
def test_infer_legacy_skill_provenance_excludes_home_outputs(
|
|
self, project_dir, temp_dir, monkeypatch
|
|
):
|
|
home = temp_dir / "home"
|
|
monkeypatch.setattr(Path, "home", lambda: home)
|
|
skill_dir = home / ".hermes" / "skills" / "speckit-specify"
|
|
skill_dir.mkdir(parents=True)
|
|
(skill_dir / "SKILL.md").write_text(
|
|
"---\n"
|
|
"metadata:\n"
|
|
" source: preset:some-pack\n"
|
|
"---\n\n"
|
|
"Other project\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
manager = PresetManager(project_dir)
|
|
inferred = manager._infer_legacy_skill_provenance(
|
|
["speckit-specify"], "some-pack", "claude"
|
|
)
|
|
|
|
assert inferred == {"claude": ["speckit-specify"]}
|
|
assert skill_dir.exists()
|
|
|
|
def test_remove_infers_legacy_flat_list_provenance_without_prior_rescaffold(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""``preset remove`` on a legacy flat-list registry must restore
|
|
every previously active agent's directory, not just the currently
|
|
active one, even when it is the *very first* post-upgrade
|
|
operation (no intervening ``use``/``upgrade``/rescaffold).
|
|
|
|
Pre-#2948 registries recorded a flat ``registered_skills`` list
|
|
because presets were rendered for every detected skill-mode agent
|
|
at once, not just the active one. Migrating that legacy format to
|
|
the per-agent dict form previously only happened as a side effect
|
|
of ``register_enabled_presets_for_agent`` (i.e. a rescaffold or
|
|
``integration use``/``switch``). If the user's first action after
|
|
upgrading is instead directly running ``preset remove``, the
|
|
legacy branch of ``_unregister_skills`` restored only the
|
|
currently active agent's directory (via ``_get_skills_dir()``),
|
|
permanently leaving this preset's override in every other,
|
|
previously active agent's directory (#2948).
|
|
"""
|
|
self._write_init_options(project_dir, ai="claude", ai_skills=True)
|
|
|
|
core_cmds = project_dir / ".specify" / "templates" / "commands"
|
|
core_cmds.mkdir(parents=True, exist_ok=True)
|
|
(core_cmds / "specify.md").write_text(
|
|
"---\ndescription: Core specify command\n---\n\nCore specify body\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
claude_skills_dir = project_dir / ".claude" / "skills"
|
|
self._create_skill(claude_skills_dir, "speckit-specify")
|
|
codex_skills_dir = project_dir / ".agents" / "skills"
|
|
self._create_skill(codex_skills_dir, "speckit-specify")
|
|
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir, "remove-legacy-no-rescaffold-preset", "speckit.specify",
|
|
"Remove legacy no rescaffold test", "preset body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
claude_skill = claude_skills_dir / "speckit-specify" / "SKILL.md"
|
|
assert "preset:remove-legacy-no-rescaffold-preset" in claude_skill.read_text(), (
|
|
"sanity: install should have written the override under "
|
|
"claude's skill directory"
|
|
)
|
|
# Simulate the pre-#2948 "register for every detected agent"
|
|
# install behaviour by also placing the marker under codex's
|
|
# directory directly (mirroring the old, non-active-only
|
|
# rendering that predates this PR).
|
|
codex_skill = codex_skills_dir / "speckit-specify" / "SKILL.md"
|
|
codex_skill.write_text(claude_skill.read_text(), encoding="utf-8")
|
|
|
|
# Simulate a pre-#2948 registry: a flat list with no per-agent
|
|
# provenance, even though both directories actually hold this
|
|
# preset's marker on disk.
|
|
manager.registry.update(
|
|
"remove-legacy-no-rescaffold-preset",
|
|
{"registered_skills": ["speckit-specify"]},
|
|
)
|
|
|
|
# No intervening use/upgrade/rescaffold: remove() is the very
|
|
# first operation run after the legacy registry was written.
|
|
assert manager.remove("remove-legacy-no-rescaffold-preset") is True
|
|
|
|
for skill_file, label in ((claude_skill, "claude"), (codex_skill, "codex")):
|
|
assert skill_file.exists(), f"{label} skill file should still exist after removal"
|
|
content = skill_file.read_text()
|
|
assert "preset:remove-legacy-no-rescaffold-preset" not in content, (
|
|
f"{label}'s preset override must be restored on removal "
|
|
"even with no prior rescaffold to migrate the legacy "
|
|
"flat-list format first — remove() must infer real "
|
|
"per-agent ownership from on-disk provenance itself "
|
|
"(#2948)"
|
|
)
|
|
assert "Core specify body" in content
|
|
|
|
def test_symlinked_skills_dir_rejected_on_removal(self, project_dir, temp_dir):
|
|
"""Removal must validate a recorded skill directory before touching it.
|
|
|
|
If an agent's skills directory is replaced with a symlink escaping
|
|
the project root between install and removal, restoration must
|
|
refuse to write/rmtree through it rather than trusting the
|
|
recorded agent name blindly. The unsafe directory is skipped
|
|
best-effort; removal still succeeds and doesn't crash (#2948).
|
|
"""
|
|
self._write_init_options(project_dir, ai="claude", ai_skills=True)
|
|
claude_skills_dir = project_dir / ".claude" / "skills"
|
|
self._create_skill(claude_skills_dir, "speckit-specify")
|
|
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir, "symlink-guard-preset", "speckit.specify",
|
|
"Symlink guard test", "preset body",
|
|
)
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
metadata = manager.registry.get("symlink-guard-preset")
|
|
assert "speckit-specify" in metadata.get("registered_skills", {}).get("claude", [])
|
|
|
|
# Simulate the claude skills directory being replaced with a symlink
|
|
# that escapes the project root, containing an external
|
|
# "speckit-specify" directory that must not be touched.
|
|
outside_target = temp_dir / "outside-claude-skills"
|
|
outside_skill_dir = outside_target / "speckit-specify"
|
|
outside_skill_dir.mkdir(parents=True)
|
|
sentinel = outside_skill_dir / "SKILL.md"
|
|
sentinel.write_text("do-not-touch")
|
|
shutil.rmtree(claude_skills_dir)
|
|
claude_skills_dir.symlink_to(outside_target, target_is_directory=True)
|
|
|
|
assert manager.remove("symlink-guard-preset") is True
|
|
|
|
assert sentinel.read_text() == "do-not-touch", (
|
|
"removal must not follow a symlinked skills directory outside "
|
|
"the project root (#2948)"
|
|
)
|
|
assert outside_skill_dir.is_dir(), (
|
|
"the external directory must not be rmtree'd through a "
|
|
"symlinked skills path"
|
|
)
|
|
assert claude_skills_dir.is_symlink(), (
|
|
"the symlink itself should be left alone, not rmtree'd through"
|
|
)
|
|
|
|
def test_preset_removal_does_not_touch_other_presets_skill_dir(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""Removing a preset must only touch directories it actually wrote to.
|
|
|
|
Preset A is installed while Claude is active and preset B is
|
|
installed while Codex is active; both override the same command
|
|
name, so both materialize a ``speckit-specify`` skill, but in
|
|
*different* agent directories. Before the provenance fix, removing
|
|
B enumerated every existing skill-mode directory (including
|
|
Claude's) and restored/overwrote anything named ``speckit-specify``
|
|
found there, corrupting A's override even though B never touched
|
|
Claude's directory (#2948).
|
|
"""
|
|
self._write_init_options(project_dir, ai="claude", ai_skills=True)
|
|
claude_skills_dir = project_dir / ".claude" / "skills"
|
|
self._create_skill(claude_skills_dir, "speckit-specify")
|
|
|
|
preset_a_dir = self._create_command_preset(
|
|
temp_dir, "preset-a", "speckit.specify", "Preset A", "preset A body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_a_dir, "0.1.5")
|
|
|
|
claude_skill_file = claude_skills_dir / "speckit-specify" / "SKILL.md"
|
|
assert "preset:preset-a" in claude_skill_file.read_text()
|
|
|
|
# Switch to codex and install a second preset overriding the same
|
|
# command; codex's skills directory is entirely separate.
|
|
self._write_init_options(project_dir, ai="codex", ai_skills=True)
|
|
codex_skills_dir = project_dir / ".agents" / "skills"
|
|
self._create_skill(codex_skills_dir, "speckit-specify")
|
|
|
|
preset_b_dir = self._create_command_preset(
|
|
temp_dir, "preset-b", "speckit.specify", "Preset B", "preset B body",
|
|
)
|
|
manager.install_from_directory(preset_b_dir, "0.1.5")
|
|
|
|
metadata_b = manager.registry.get("preset-b")
|
|
assert "claude" not in metadata_b.get("registered_skills", {}), (
|
|
"preset B never wrote to claude's skills directory and must "
|
|
"not record it as touched"
|
|
)
|
|
|
|
assert manager.remove("preset-b") is True
|
|
|
|
assert "preset:preset-a" in claude_skill_file.read_text(), (
|
|
"removing preset B must not disturb preset A's Claude override (#2948)"
|
|
)
|
|
|
|
def test_remove_does_not_recreate_empty_skill_dir(
|
|
self, project_dir, temp_dir
|
|
):
|
|
self._write_init_options(project_dir, ai="claude", ai_skills=True)
|
|
skills_dir = project_dir / ".claude" / "skills"
|
|
self._create_skill(skills_dir, "speckit-orphan")
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir,
|
|
"orphan-skill-preset",
|
|
"speckit.orphan",
|
|
"Orphan",
|
|
"Preset-only body",
|
|
)
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
skill_dir = skills_dir / "speckit-orphan"
|
|
assert skill_dir.exists()
|
|
assert manager.remove("orphan-skill-preset") is True
|
|
assert not skill_dir.exists()
|
|
|
|
def test_remove_preserves_non_owned_skill_during_reconciliation(
|
|
self, project_dir, temp_dir
|
|
):
|
|
self._write_init_options(project_dir, ai="claude", ai_skills=True)
|
|
skills_dir = project_dir / ".claude" / "skills"
|
|
self._create_skill(skills_dir, "speckit-specify")
|
|
|
|
lower_dir = self._create_command_preset(
|
|
temp_dir,
|
|
"non-owned-lower-preset",
|
|
"speckit.specify",
|
|
"Lower",
|
|
"Lower preset body",
|
|
)
|
|
higher_dir = self._create_command_preset(
|
|
temp_dir,
|
|
"non-owned-higher-preset",
|
|
"speckit.specify",
|
|
"Higher",
|
|
"Higher preset body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(lower_dir, "0.1.5", priority=10)
|
|
manager.install_from_directory(higher_dir, "0.1.5", priority=1)
|
|
|
|
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
|
|
skill_file.write_text(
|
|
"---\nname: speckit-specify\n---\n\nUser-owned body\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
assert manager.remove("non-owned-higher-preset") is True
|
|
assert skill_file.read_text(encoding="utf-8") == (
|
|
"---\nname: speckit-specify\n---\n\nUser-owned body\n"
|
|
)
|
|
|
|
def test_shared_skills_dir_restored_once_using_active_agent(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""Removal must restore a physical skills directory shared by
|
|
multiple agents exactly once, using the active agent's renderer.
|
|
|
|
Codex and Antigravity (agy) both resolve their skills directory to
|
|
``.agents/skills``. Registering a preset under codex, switching to
|
|
agy, then switching back to codex records provenance for *both*
|
|
agent keys even though they share one physical directory. Before
|
|
the fix, ``_unregister_skills`` restored once per recorded agent
|
|
key rather than once per unique directory, so the directory was
|
|
written twice on removal with whichever agent was iterated *last*
|
|
silently winning — regardless of which agent is actually active
|
|
(#2948).
|
|
"""
|
|
self._write_init_options(project_dir, ai="codex", ai_skills=True)
|
|
shared_skills_dir = project_dir / ".agents" / "skills"
|
|
self._create_skill(shared_skills_dir, "speckit-specify")
|
|
|
|
core_cmds = project_dir / ".specify" / "templates" / "commands"
|
|
core_cmds.mkdir(parents=True, exist_ok=True)
|
|
(core_cmds / "specify.md").write_text(
|
|
"---\ndescription: Core specify command\n---\n\nCore specify body\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir, "shared-dir-preset", "speckit.specify",
|
|
"Shared dir test", "preset body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
# Switch to agy (shares .agents/skills with codex) and back to
|
|
# codex, mirroring `integration use agy` then `integration use
|
|
# codex`. Both agent keys end up recorded in registered_skills even
|
|
# though they refer to the same physical directory.
|
|
self._write_init_options(project_dir, ai="agy", ai_skills=True)
|
|
manager.register_enabled_presets_for_agent("agy")
|
|
self._write_init_options(project_dir, ai="codex", ai_skills=True)
|
|
manager.register_enabled_presets_for_agent("codex")
|
|
|
|
metadata = manager.registry.get("shared-dir-preset")
|
|
registered_skills = metadata.get("registered_skills", {})
|
|
assert set(registered_skills) == {"codex", "agy"}, (
|
|
"both agent keys must be recorded even though they share one "
|
|
"physical directory (#2948)"
|
|
)
|
|
|
|
from unittest.mock import patch
|
|
|
|
# Exercise `_unregister_skills` directly (the method this fix
|
|
# changed) rather than the full `remove()` flow, which separately
|
|
# triggers post-removal reconciliation that may also touch the
|
|
# active agent's directory — an unrelated call this test isn't
|
|
# targeting.
|
|
with patch.object(
|
|
manager,
|
|
"_unregister_skills_in_dir",
|
|
wraps=manager._unregister_skills_in_dir,
|
|
) as spy:
|
|
manager._unregister_skills(registered_skills, preset_dir)
|
|
|
|
assert spy.call_count == 1, (
|
|
"a physical directory shared by multiple recorded agents must "
|
|
"be restored exactly once, not once per agent key (#2948)"
|
|
)
|
|
(_names, called_dir, called_agent), _kwargs = spy.call_args
|
|
assert called_dir == shared_skills_dir
|
|
assert called_agent == "codex", (
|
|
"the currently active agent must be used as the renderer when "
|
|
"it shares the restored directory, not whichever agent was "
|
|
"recorded last (#2948)"
|
|
)
|
|
|
|
skill_file = shared_skills_dir / "speckit-specify" / "SKILL.md"
|
|
content = skill_file.read_text()
|
|
assert "preset:shared-dir-preset" not in content
|
|
assert "Core specify body" in content
|
|
|
|
def test_remove_higher_priority_skills_only_preset_restores_lower_preset(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""Removing a skills-mode preset must reconcile against the surviving
|
|
stack, not fall back to core/extension content.
|
|
|
|
Copilot in skills mode never populates ``registered_commands`` for
|
|
its overrides (``_register_commands``'s ``ai_skills`` guard skips
|
|
command-file registration entirely), so with two presets overriding
|
|
the same command, the removed preset's command name was never added
|
|
to ``removed_cmd_names`` and reconciliation was skipped outright.
|
|
``_unregister_skills`` then restored straight to core/extension
|
|
content instead of resolving the lower-priority preset that should
|
|
now win (#2948).
|
|
"""
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
|
|
|
|
preset_a_dir = self._create_command_preset(
|
|
temp_dir, "skills-preset-a", "speckit.specify",
|
|
"Preset A", "preset A body",
|
|
)
|
|
preset_b_dir = self._create_command_preset(
|
|
temp_dir, "skills-preset-b", "speckit.specify",
|
|
"Preset B", "preset B body",
|
|
)
|
|
|
|
manager = PresetManager(project_dir)
|
|
# Lower priority number = higher precedence.
|
|
manager.install_from_directory(preset_a_dir, "0.1.5", priority=5)
|
|
manager.install_from_directory(preset_b_dir, "0.1.5", priority=10)
|
|
|
|
skills_dir = project_dir / ".github" / "skills"
|
|
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
|
|
assert "preset:skills-preset-a" in skill_file.read_text(), (
|
|
"sanity: the higher-precedence preset should win initially"
|
|
)
|
|
|
|
assert manager.remove("skills-preset-a") is True
|
|
|
|
content = skill_file.read_text()
|
|
assert "preset:skills-preset-b" in content, (
|
|
"removing the higher-precedence skills-mode preset must "
|
|
"restore the surviving lower-precedence preset's override, "
|
|
"not fall back to core/extension content (#2948)"
|
|
)
|
|
assert "preset:skills-preset-a" not in content
|
|
|
|
def test_composed_none_unregister_respects_active_agent(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""Unregistering a stale composed command must only touch the
|
|
active agent's directory, not every configured non-skill agent.
|
|
|
|
When a wrap preset's base layer is removed, ``resolve_content`` can
|
|
no longer find a replace layer to compose onto and returns
|
|
``None``, triggering the "composed is None" branch of
|
|
``_reconcile_composed_commands``. Before the fix, that
|
|
unregistration mapping covered every configured non-skill agent
|
|
regardless of ``only_agent``, deleting historical artifacts from
|
|
integrations that were never active for this preset (#2948).
|
|
"""
|
|
self._write_init_options(project_dir, ai="gemini", ai_skills=False)
|
|
gemini_commands_dir = project_dir / ".gemini" / "commands"
|
|
gemini_commands_dir.mkdir(parents=True)
|
|
|
|
# A made-up command name with no bundled/core equivalent, so the
|
|
# *only* base layer is the "compose-base" preset installed below —
|
|
# once it's removed, no base remains for the wrap preset to compose
|
|
# onto.
|
|
cmd_name = "speckit.fake-compose-test"
|
|
base_dir = self._create_command_preset(
|
|
temp_dir, "compose-base", cmd_name, "Base", "base body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(base_dir, "0.1.5", priority=10)
|
|
|
|
wrap_dir = temp_dir / "compose-wrap"
|
|
wrap_dir.mkdir()
|
|
(wrap_dir / "commands").mkdir()
|
|
(wrap_dir / "commands" / f"{cmd_name}.md").write_text(
|
|
"---\ndescription: Wrap\nstrategy: wrap\n---\n\n"
|
|
"wrap start\n{CORE_TEMPLATE}\nwrap end\n"
|
|
)
|
|
manifest_data = {
|
|
"schema_version": "1.0",
|
|
"preset": {
|
|
"id": "compose-wrap",
|
|
"name": "compose-wrap",
|
|
"version": "1.0.0",
|
|
"description": "Test",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"templates": [
|
|
{
|
|
"type": "command",
|
|
"name": cmd_name,
|
|
"file": f"commands/{cmd_name}.md",
|
|
"strategy": "wrap",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
with open(wrap_dir / "preset.yml", "w") as f:
|
|
yaml.dump(manifest_data, f)
|
|
manager.install_from_directory(wrap_dir, "0.1.5", priority=5)
|
|
|
|
cmd_file = gemini_commands_dir / f"{cmd_name}.toml"
|
|
assert cmd_file.exists(), (
|
|
"sanity: the composed command should register for the active agent"
|
|
)
|
|
|
|
# Simulate a pre-existing artifact for an inactive agent, predating
|
|
# this preset entirely — active-only unregistration must never
|
|
# touch it.
|
|
opencode_dir = project_dir / ".opencode" / "commands"
|
|
opencode_dir.mkdir(parents=True, exist_ok=True)
|
|
opencode_stale_file = opencode_dir / f"{cmd_name}.md"
|
|
opencode_stale_file.write_text("stale opencode content\n")
|
|
|
|
assert manager.remove("compose-base") is True
|
|
|
|
assert not cmd_file.exists(), (
|
|
"sanity: the active agent's now-uncomposable command file must "
|
|
"be unregistered"
|
|
)
|
|
assert opencode_stale_file.read_text() == "stale opencode content\n", (
|
|
"unregistering a stale composed command must not touch an "
|
|
"inactive agent's directory (#2948)"
|
|
)
|
|
|
|
def test_remove_reconciles_command_for_every_historical_agent(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""Removing a preset must reconcile every historical agent its
|
|
``registered_commands`` actually targeted, not only the currently
|
|
active one.
|
|
|
|
Preset B (lower precedence, survives) is installed while gemini is
|
|
active, then preset A (higher precedence) overrides the same
|
|
command while gemini is still active. Switching the active
|
|
integration to opencode and rescaffolding re-registers both
|
|
presets under opencode too, so preset A's ``registered_commands``
|
|
now spans two agents: gemini (now inactive) and opencode (active).
|
|
Removing A deletes its command file from *both* directories via
|
|
``_unregister_commands``, but active-only reconciliation used to
|
|
recreate the surviving preset B's content only for the active
|
|
agent (opencode), leaving gemini's directory with a stale/missing
|
|
file (#2948).
|
|
"""
|
|
self._write_init_options(project_dir, ai="gemini", ai_skills=False)
|
|
gemini_dir = project_dir / ".gemini" / "commands"
|
|
gemini_dir.mkdir(parents=True)
|
|
|
|
preset_b_dir = self._create_command_preset(
|
|
temp_dir, "hist-preset-b", "speckit.specify",
|
|
"Preset B", "preset B body",
|
|
)
|
|
preset_a_dir = self._create_command_preset(
|
|
temp_dir, "hist-preset-a", "speckit.specify",
|
|
"Preset A", "preset A body",
|
|
)
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_b_dir, "0.1.5", priority=10)
|
|
manager.install_from_directory(preset_a_dir, "0.1.5", priority=1)
|
|
|
|
gemini_cmd_files = list(gemini_dir.glob("*specify*"))
|
|
assert gemini_cmd_files, "sanity: gemini should have the command file"
|
|
assert "preset A body" in gemini_cmd_files[0].read_text(), (
|
|
"sanity: preset A (higher precedence) should win initially"
|
|
)
|
|
|
|
# Switch the active integration to opencode and rescaffold, mirroring
|
|
# `integration use opencode`. This merges opencode into both
|
|
# presets' registered_commands alongside the pre-existing gemini
|
|
# entry recorded while gemini was active.
|
|
self._write_init_options(project_dir, ai="opencode", ai_skills=False)
|
|
opencode_dir = project_dir / ".opencode" / "commands"
|
|
opencode_dir.mkdir(parents=True, exist_ok=True)
|
|
manager.register_enabled_presets_for_agent("opencode")
|
|
|
|
metadata_a = manager.registry.get("hist-preset-a")
|
|
assert set(metadata_a.get("registered_commands", {})) == {"gemini", "opencode"}, (
|
|
"sanity: preset A's registered_commands must span both the "
|
|
"historical (gemini) and currently active (opencode) agents"
|
|
)
|
|
|
|
assert manager.remove("hist-preset-a") is True
|
|
|
|
gemini_cmd_files = list(gemini_dir.glob("*specify*"))
|
|
opencode_cmd_files = list(opencode_dir.glob("*specify*"))
|
|
assert gemini_cmd_files, "gemini's command file must still exist after removal"
|
|
assert opencode_cmd_files, "opencode's command file must still exist after removal"
|
|
assert "preset B body" in gemini_cmd_files[0].read_text(), (
|
|
"removing the higher-precedence preset must restore the "
|
|
"surviving preset's content in the historical (inactive) "
|
|
"agent's directory too, not only the active agent's (#2948)"
|
|
)
|
|
assert "preset B body" in opencode_cmd_files[0].read_text(), (
|
|
"the surviving preset's content must also be restored for the "
|
|
"currently active agent"
|
|
)
|
|
|
|
def test_remove_reconciliation_tracks_new_historical_agent_for_survivor(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""Historical-agent reconciliation writes must be recorded in the
|
|
surviving preset's own ``registered_commands``, not just written
|
|
to disk and forgotten.
|
|
|
|
Preset A is installed while gemini is active, then survives to be
|
|
active under opencode too (so A's ``registered_commands`` spans
|
|
both gemini and opencode). Preset B is installed *only* while
|
|
opencode is active — B's ``registered_commands`` is
|
|
``{"opencode": [...]}`` and never mentions gemini. Removing A
|
|
triggers reconciliation that writes B's content into gemini's
|
|
directory (an agent B never wrote to before) via ``extra_agents``,
|
|
but if that write isn't merged back into B's own
|
|
``registered_commands``, B's registry entry still only says
|
|
``{"opencode": [...]}`` even though B's content now lives in
|
|
gemini's directory too. A later ``remove('b')`` then only cleans
|
|
up opencode, leaving the gemini file — reconciled there entirely
|
|
by side effect of removing A — as a permanent orphan with no
|
|
preset tracking it (#2948).
|
|
"""
|
|
self._write_init_options(project_dir, ai="gemini", ai_skills=False)
|
|
gemini_dir = project_dir / ".gemini" / "commands"
|
|
gemini_dir.mkdir(parents=True)
|
|
|
|
preset_a_dir = self._create_command_preset(
|
|
temp_dir, "orphan-preset-a", "speckit.specify",
|
|
"Preset A", "preset A body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_a_dir, "0.1.5", priority=1)
|
|
|
|
self._write_init_options(project_dir, ai="opencode", ai_skills=False)
|
|
opencode_dir = project_dir / ".opencode" / "commands"
|
|
opencode_dir.mkdir(parents=True, exist_ok=True)
|
|
manager.register_enabled_presets_for_agent("opencode")
|
|
|
|
metadata_a = manager.registry.get("orphan-preset-a")
|
|
assert set(metadata_a.get("registered_commands", {})) == {"gemini", "opencode"}, (
|
|
"sanity: preset A must be tracked under both agents"
|
|
)
|
|
|
|
# Preset B is installed only now, while opencode is the sole
|
|
# active agent — it never writes to or tracks gemini.
|
|
preset_b_dir = self._create_command_preset(
|
|
temp_dir, "orphan-preset-b", "speckit.specify",
|
|
"Preset B", "preset B body",
|
|
)
|
|
manager.install_from_directory(preset_b_dir, "0.1.5", priority=10)
|
|
|
|
metadata_b = manager.registry.get("orphan-preset-b")
|
|
assert set(metadata_b.get("registered_commands", {})) == {"opencode"}, (
|
|
"sanity: preset B must only be tracked for opencode before "
|
|
"preset A is removed"
|
|
)
|
|
|
|
assert manager.remove("orphan-preset-a") is True
|
|
|
|
# B is now written into gemini's directory as a side effect of
|
|
# reconciling A's removal, via the historical-agent extra_agents
|
|
# pass.
|
|
gemini_cmd_files = list(gemini_dir.glob("*specify*"))
|
|
assert gemini_cmd_files, "sanity: gemini's directory must have B's restored content"
|
|
assert "preset B body" in gemini_cmd_files[0].read_text(encoding="utf-8")
|
|
|
|
metadata_b = manager.registry.get("orphan-preset-b")
|
|
assert set(metadata_b.get("registered_commands", {})) == {"gemini", "opencode"}, (
|
|
"preset B's own registered_commands must be updated to "
|
|
"include gemini once reconciliation actually writes content "
|
|
"there on its behalf — otherwise B's registry entry silently "
|
|
"lies about which directories it owns (#2948)"
|
|
)
|
|
|
|
assert manager.remove("orphan-preset-b") is True
|
|
|
|
# No preset is installed any more, so gemini's file must have been
|
|
# reconciled down to the core bundled template (or removed
|
|
# entirely) — but it must NOT still contain B's stale content,
|
|
# which would mean B's write there was never tracked for cleanup.
|
|
remaining_gemini_files = list(gemini_dir.glob("*specify*"))
|
|
for f in remaining_gemini_files:
|
|
assert "preset B body" not in f.read_text(encoding="utf-8"), (
|
|
"removing preset B must clean up gemini's directory too, "
|
|
"since B's registered_commands was updated to include it "
|
|
"— otherwise B's stale content is orphaned there forever "
|
|
"with no preset left to track or clean it up (#2948)"
|
|
)
|
|
|
|
def test_extension_reconciliation_tracks_new_historical_agent(
|
|
self, project_dir
|
|
):
|
|
from specify_cli.extensions import ExtensionRegistry
|
|
|
|
self._write_init_options(project_dir, ai="opencode", ai_skills=False)
|
|
(project_dir / ".opencode" / "commands").mkdir(parents=True)
|
|
(project_dir / ".gemini" / "commands").mkdir(parents=True)
|
|
|
|
ext_dir = project_dir / ".specify" / "extensions" / "tracked-ext"
|
|
(ext_dir / "commands").mkdir(parents=True)
|
|
(ext_dir / "commands" / "tracked.md").write_text(
|
|
"---\ndescription: tracked\n---\n\nExtension body\n",
|
|
encoding="utf-8",
|
|
)
|
|
(ext_dir / "extension.yml").write_text(
|
|
"schema_version: '1.0'\n"
|
|
"extension:\n id: tracked-ext\n name: Tracked\n version: 1.0.0\n"
|
|
" description: test\n author: test\n repository: https://example.com\n"
|
|
" license: MIT\n"
|
|
"requires:\n speckit_version: '>=0.2.0'\n"
|
|
"provides:\n"
|
|
" commands:\n"
|
|
" - name: speckit.tracked\n"
|
|
" file: commands/tracked.md\n"
|
|
" description: Tracked command\n",
|
|
encoding="utf-8",
|
|
)
|
|
ExtensionRegistry(ext_dir.parent).add(
|
|
"tracked-ext",
|
|
{
|
|
"version": "1.0.0",
|
|
"source": "dev",
|
|
"enabled": True,
|
|
"registered_commands": {
|
|
"opencode": ["speckit.tracked-ext.tracked"]
|
|
},
|
|
},
|
|
)
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager._reconcile_composed_commands(
|
|
["speckit.tracked-ext.tracked"], extra_agents={"gemini"}
|
|
)
|
|
|
|
assert list((project_dir / ".gemini" / "commands").glob("*tracked*"))
|
|
metadata = ExtensionRegistry(ext_dir.parent).get("tracked-ext")
|
|
assert set(metadata["registered_commands"]) == {"gemini", "opencode"}
|
|
|
|
def test_remove_reconciles_skill_for_every_historical_agent(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""Removing a preset must reconcile every historical skills
|
|
directory its ``registered_skills`` actually targeted, not only
|
|
the currently active one.
|
|
|
|
Preset B (survives) is installed while claude is active, then
|
|
preset A (higher precedence) overrides the same command while
|
|
claude is still active. Switching to codex and rescaffolding
|
|
records codex too, so preset A's ``registered_skills`` spans both
|
|
claude (now inactive) and codex (active) directories. Removing A
|
|
restores both directories to core/extension via
|
|
``_unregister_skills``, but ``_reconcile_skills`` used to only
|
|
resolve/apply the surviving winner for the currently active
|
|
skills directory, leaving claude's directory reverted to
|
|
core/extension content instead of preset B's override (#2948).
|
|
"""
|
|
self._write_init_options(project_dir, ai="claude", ai_skills=True)
|
|
claude_skills_dir = project_dir / ".claude" / "skills"
|
|
|
|
# A core template fallback is required so unregistering the
|
|
# top-priority preset's SKILL.md restores core content rather than
|
|
# deleting the skill directory outright when no preset remains to
|
|
# apply on top of it (mirrors the pre-existing skills-reconciliation
|
|
# fixtures elsewhere in this file).
|
|
core_cmds = project_dir / ".specify" / "templates" / "commands"
|
|
core_cmds.mkdir(parents=True, exist_ok=True)
|
|
(core_cmds / "specify.md").write_text(
|
|
"---\ndescription: Core specify command\n---\n\nCore specify body\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
preset_b_dir = self._create_command_preset(
|
|
temp_dir, "hist-skill-preset-b", "speckit.specify",
|
|
"Preset B", "preset B body",
|
|
)
|
|
preset_a_dir = self._create_command_preset(
|
|
temp_dir, "hist-skill-preset-a", "speckit.specify",
|
|
"Preset A", "preset A body",
|
|
)
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_b_dir, "0.1.5", priority=10)
|
|
manager.install_from_directory(preset_a_dir, "0.1.5", priority=1)
|
|
|
|
claude_skill_file = claude_skills_dir / "speckit-specify" / "SKILL.md"
|
|
assert "preset:hist-skill-preset-a" in claude_skill_file.read_text(), (
|
|
"sanity: preset A (higher precedence) should win initially"
|
|
)
|
|
|
|
# Switch the active integration to codex (a distinct skills
|
|
# directory) and rescaffold, mirroring `integration use codex`.
|
|
self._write_init_options(project_dir, ai="codex", ai_skills=True)
|
|
codex_skills_dir = project_dir / ".agents" / "skills"
|
|
manager.register_enabled_presets_for_agent("codex")
|
|
|
|
metadata_a = manager.registry.get("hist-skill-preset-a")
|
|
assert set(metadata_a.get("registered_skills", {})) == {"claude", "codex"}, (
|
|
"sanity: preset A's registered_skills must span both the "
|
|
"historical (claude) and currently active (codex) agents"
|
|
)
|
|
|
|
assert manager.remove("hist-skill-preset-a") is True
|
|
|
|
codex_skill_file = codex_skills_dir / "speckit-specify" / "SKILL.md"
|
|
assert claude_skill_file.exists(), "claude's skill file must still exist after removal"
|
|
assert codex_skill_file.exists(), "codex's skill file must still exist after removal"
|
|
assert "preset:hist-skill-preset-b" in claude_skill_file.read_text(), (
|
|
"removing the higher-precedence preset must restore the "
|
|
"surviving preset's override in the historical (inactive) "
|
|
"agent's directory too, not only the active agent's (#2948)"
|
|
)
|
|
assert "preset:hist-skill-preset-b" in codex_skill_file.read_text(), (
|
|
"the surviving preset's override must also be restored for "
|
|
"the currently active agent"
|
|
)
|
|
|
|
def test_skill_reconciliation_preserves_per_directory_names(
|
|
self, project_dir, temp_dir
|
|
):
|
|
self._write_init_options(project_dir, ai="claude", ai_skills=True)
|
|
claude_dir = project_dir / ".claude" / "skills"
|
|
self._create_skill(claude_dir, "speckit-alpha")
|
|
alpha_dir = self._create_command_preset(
|
|
temp_dir, "partial-alpha", "speckit.alpha",
|
|
"Alpha", "alpha body",
|
|
)
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(alpha_dir, "0.1.5")
|
|
|
|
self._write_init_options(project_dir, ai="codex", ai_skills=True)
|
|
codex_dir = project_dir / ".agents" / "skills"
|
|
self._create_skill(codex_dir, "speckit-beta")
|
|
beta_dir = self._create_command_preset(
|
|
temp_dir, "partial-beta", "speckit.beta",
|
|
"Beta", "beta body",
|
|
)
|
|
manager.install_from_directory(beta_dir, "0.1.5")
|
|
|
|
affected = manager._unregister_skills(
|
|
{
|
|
"claude": ["speckit-alpha"],
|
|
"codex": ["speckit-beta"],
|
|
},
|
|
manager.presets_dir / "partial-alpha",
|
|
)
|
|
manager._reconcile_skills(
|
|
["speckit.alpha", "speckit.beta"],
|
|
extra_skills_dirs=affected,
|
|
)
|
|
|
|
assert (claude_dir / "speckit-alpha" / "SKILL.md").exists()
|
|
assert (codex_dir / "speckit-beta" / "SKILL.md").exists()
|
|
assert not (claude_dir / "speckit-beta").exists()
|
|
assert not (codex_dir / "speckit-alpha").exists()
|
|
|
|
def test_skill_reconciliation_rejects_unsafe_managed_names(
|
|
self, project_dir, temp_dir
|
|
):
|
|
self._write_init_options(project_dir, ai="claude", ai_skills=True)
|
|
skills_dir = project_dir / ".claude" / "skills"
|
|
skills_dir.mkdir(parents=True)
|
|
core_cmds = project_dir / ".specify" / "templates" / "commands"
|
|
core_cmds.mkdir(parents=True, exist_ok=True)
|
|
(core_cmds / "specify.md").write_text(
|
|
"---\ndescription: Core specify\n---\n\nCore body\n",
|
|
encoding="utf-8",
|
|
)
|
|
absolute_escape = temp_dir / "absolute-escape"
|
|
traversal_escape = project_dir / ".claude" / "traversal-escape"
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager._reconcile_skills(
|
|
["speckit.specify"],
|
|
extra_skills_dirs={
|
|
skills_dir: (
|
|
"claude",
|
|
[str(absolute_escape), "../traversal-escape"],
|
|
)
|
|
},
|
|
)
|
|
|
|
assert not absolute_escape.exists()
|
|
assert not traversal_escape.exists()
|
|
|
|
def test_remove_reconciliation_tracks_new_historical_skill_agent_for_survivor(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""Historical-agent skill reconciliation writes must be recorded in
|
|
the surviving preset's own ``registered_skills``, mirroring
|
|
``test_remove_reconciliation_tracks_new_historical_agent_for_survivor``
|
|
for the command side.
|
|
|
|
Preset A is installed while claude is active, then survives to be
|
|
active under codex too (so A's ``registered_skills`` spans both
|
|
claude and codex). Preset B is installed *only* while codex is
|
|
active — B's ``registered_skills`` is ``{"codex": [...]}`` and
|
|
never mentions claude. Removing A triggers reconciliation that
|
|
renders B's SKILL.md into claude's directory (an agent B never
|
|
wrote to before) via ``extra_skills_dirs``, but if that write
|
|
isn't merged back into B's own ``registered_skills``, a later
|
|
``remove('b')`` only cleans up codex, leaving claude's SKILL.md —
|
|
rendered there entirely by side effect of removing A — untracked
|
|
by any preset (#2948).
|
|
"""
|
|
self._write_init_options(project_dir, ai="claude", ai_skills=True)
|
|
claude_skills_dir = project_dir / ".claude" / "skills"
|
|
# Pre-create the skill so _register_commands/_register_skills find
|
|
# an existing skill to overwrite (mirrors every other skill test
|
|
# in this class — native skill agents only overwrite already
|
|
# existing skill directories, they don't materialize brand-new
|
|
# ones outside of active-agent creation).
|
|
self._create_skill(claude_skills_dir, "speckit-specify")
|
|
|
|
core_cmds = project_dir / ".specify" / "templates" / "commands"
|
|
core_cmds.mkdir(parents=True, exist_ok=True)
|
|
(core_cmds / "specify.md").write_text(
|
|
"---\ndescription: Core specify command\n---\n\nCore specify body\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
preset_a_dir = self._create_command_preset(
|
|
temp_dir, "orphan-skill-preset-a", "speckit.specify",
|
|
"Preset A", "preset A body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_a_dir, "0.1.5", priority=1)
|
|
|
|
self._write_init_options(project_dir, ai="codex", ai_skills=True)
|
|
codex_skills_dir = project_dir / ".agents" / "skills"
|
|
self._create_skill(codex_skills_dir, "speckit-specify")
|
|
manager.register_enabled_presets_for_agent("codex")
|
|
|
|
metadata_a = manager.registry.get("orphan-skill-preset-a")
|
|
assert set(metadata_a.get("registered_skills", {})) == {"claude", "codex"}, (
|
|
"sanity: preset A must be tracked under both agents"
|
|
)
|
|
|
|
# Preset B is installed only now, while codex is the sole active
|
|
# agent — it never writes to or tracks claude.
|
|
preset_b_dir = self._create_command_preset(
|
|
temp_dir, "orphan-skill-preset-b", "speckit.specify",
|
|
"Preset B", "preset B body",
|
|
)
|
|
manager.install_from_directory(preset_b_dir, "0.1.5", priority=10)
|
|
|
|
metadata_b = manager.registry.get("orphan-skill-preset-b")
|
|
assert set(metadata_b.get("registered_skills", {})) == {"codex"}, (
|
|
"sanity: preset B must only be tracked for codex before "
|
|
"preset A is removed"
|
|
)
|
|
|
|
assert manager.remove("orphan-skill-preset-a") is True
|
|
|
|
claude_skill_file = claude_skills_dir / "speckit-specify" / "SKILL.md"
|
|
assert claude_skill_file.exists(), (
|
|
"sanity: claude's skill file must have been restored by "
|
|
"reconciliation"
|
|
)
|
|
assert "preset:orphan-skill-preset-b" in claude_skill_file.read_text(), (
|
|
"sanity: claude's SKILL.md must reflect preset B's content "
|
|
"after preset A is removed"
|
|
)
|
|
|
|
metadata_b = manager.registry.get("orphan-skill-preset-b")
|
|
assert set(metadata_b.get("registered_skills", {})) == {"claude", "codex"}, (
|
|
"preset B's own registered_skills must be updated to include "
|
|
"claude once reconciliation actually renders content there "
|
|
"on its behalf — otherwise B's registry entry silently lies "
|
|
"about which directories it owns (#2948)"
|
|
)
|
|
|
|
assert manager.remove("orphan-skill-preset-b") is True
|
|
|
|
# No preset is installed any more, so claude's SKILL.md must have
|
|
# been reconciled down to the core bundled template (or removed
|
|
# entirely) — but it must NOT still contain B's stale content,
|
|
# which would mean B's write there was never tracked for cleanup.
|
|
if claude_skill_file.exists():
|
|
assert "preset:orphan-skill-preset-b" not in claude_skill_file.read_text(), (
|
|
"removing preset B must clean up claude's directory too, "
|
|
"since B's registered_skills was updated to include it — "
|
|
"otherwise B's stale content is orphaned there forever "
|
|
"with no preset left to track or clean it up (#2948)"
|
|
)
|
|
|
|
def test_symlinked_skill_subdir_rejected_on_restore(self, project_dir, temp_dir):
|
|
"""Restore must validate each per-skill subdirectory, not just its parent.
|
|
|
|
``_safe_skills_dir_for_agent`` only validates the parent skills
|
|
directory (e.g. ``.claude/skills``); a symlink planted one level
|
|
deeper at the individual skill's own subdirectory (e.g.
|
|
``.claude/skills/speckit-specify``) has a perfectly safe parent and
|
|
would otherwise slip past that check, since ``is_dir()`` follows
|
|
symlinks. Restoration must refuse to write/rmtree through it (#2948).
|
|
"""
|
|
self._write_init_options(project_dir, ai="claude", ai_skills=True)
|
|
claude_skills_dir = project_dir / ".claude" / "skills"
|
|
claude_skills_dir.mkdir(parents=True)
|
|
|
|
outside_target = temp_dir / "outside-skill-subdir"
|
|
outside_target.mkdir()
|
|
sentinel = outside_target / "SKILL.md"
|
|
sentinel.write_text("do-not-touch")
|
|
(claude_skills_dir / "speckit-specify").symlink_to(
|
|
outside_target, target_is_directory=True
|
|
)
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager._unregister_skills_in_dir(
|
|
["speckit-specify"], claude_skills_dir, "claude"
|
|
)
|
|
|
|
assert sentinel.read_text() == "do-not-touch", (
|
|
"restoration must not follow a symlinked skill subdirectory "
|
|
"to write/delete outside the project (#2948)"
|
|
)
|
|
assert (claude_skills_dir / "speckit-specify").is_symlink(), (
|
|
"the symlink itself should be left alone, not rmtree'd through"
|
|
)
|
|
|
|
def test_symlinked_skill_subdir_rejected_on_write(self, project_dir, temp_dir):
|
|
"""Registration must validate each per-skill subdirectory before writing.
|
|
|
|
A symlink planted at an individual skill's own subdirectory (safe
|
|
parent, unsafe leaf) would otherwise pass the existing
|
|
``skill_subdir.exists() and not skill_subdir.is_dir()`` guard
|
|
(``is_dir()`` follows symlinks) and have ``SKILL.md`` written
|
|
through it to an arbitrary location (#2948).
|
|
"""
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
|
|
copilot_commands_dir = project_dir / ".github" / "agents"
|
|
copilot_commands_dir.mkdir(parents=True)
|
|
skills_dir = project_dir / ".github" / "skills"
|
|
skills_dir.mkdir(parents=True)
|
|
|
|
outside_target = temp_dir / "outside-skill-write-target"
|
|
outside_target.mkdir()
|
|
(skills_dir / "speckit-specify").symlink_to(
|
|
outside_target, target_is_directory=True
|
|
)
|
|
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir, "symlink-write-preset", "speckit.specify",
|
|
"Symlink write test", "preset body",
|
|
)
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
assert not (outside_target / "SKILL.md").exists(), (
|
|
"registration must not follow a symlinked skill subdirectory "
|
|
"to write outside the project (#2948)"
|
|
)
|
|
assert (skills_dir / "speckit-specify").is_symlink(), (
|
|
"the symlink itself should be left alone"
|
|
)
|
|
|
|
def test_symlinked_skill_file_rejected_on_write(self, project_dir, temp_dir):
|
|
"""Registration must not follow a symlinked SKILL.md destination."""
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
|
|
(project_dir / ".github" / "agents").mkdir(parents=True)
|
|
skill_dir = (
|
|
project_dir / ".github" / "skills" / "speckit-specify"
|
|
)
|
|
skill_dir.mkdir(parents=True)
|
|
outside_file = temp_dir / "outside-registration.md"
|
|
outside_file.write_text("do-not-touch", encoding="utf-8")
|
|
(skill_dir / "SKILL.md").symlink_to(outside_file)
|
|
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir,
|
|
"symlink-file-write-preset",
|
|
"speckit.specify",
|
|
"Symlink file write",
|
|
"preset body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
with pytest.raises(ValueError):
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
assert outside_file.read_text(encoding="utf-8") == "do-not-touch"
|
|
assert (skill_dir / "SKILL.md").is_symlink()
|
|
|
|
def test_symlinked_skill_file_rejected_on_restore(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""Restoration must not follow a symlinked SKILL.md destination."""
|
|
self._write_init_options(project_dir, ai="claude", ai_skills=True)
|
|
core_commands = project_dir / ".specify" / "templates" / "commands"
|
|
(core_commands / "specify.md").write_text(
|
|
"---\ndescription: Core specify\n---\n\nCore body\n",
|
|
encoding="utf-8",
|
|
)
|
|
skill_dir = (
|
|
project_dir / ".claude" / "skills" / "speckit-specify"
|
|
)
|
|
skill_dir.mkdir(parents=True)
|
|
outside_file = temp_dir / "outside-restoration.md"
|
|
outside_file.write_text("do-not-touch", encoding="utf-8")
|
|
(skill_dir / "SKILL.md").symlink_to(outside_file)
|
|
|
|
manager = PresetManager(project_dir)
|
|
with pytest.raises(ValueError):
|
|
manager._unregister_skills_in_dir(
|
|
["speckit-specify"], skill_dir.parent, "claude"
|
|
)
|
|
|
|
assert outside_file.read_text(encoding="utf-8") == "do-not-touch"
|
|
assert (skill_dir / "SKILL.md").is_symlink()
|
|
|
|
def test_symlinked_skill_file_rejected_on_override_reconcile(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""Project-override reconciliation must not follow SKILL.md symlinks."""
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
|
|
skill_dir = (
|
|
project_dir / ".github" / "skills" / "speckit-specify"
|
|
)
|
|
skill_dir.mkdir(parents=True)
|
|
outside_file = temp_dir / "outside-reconciliation.md"
|
|
outside_file.write_text("do-not-touch", encoding="utf-8")
|
|
(skill_dir / "SKILL.md").symlink_to(outside_file)
|
|
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir,
|
|
"symlink-override-preset",
|
|
"speckit.specify",
|
|
"Preset",
|
|
"Preset body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.registry.add(
|
|
"symlink-override-preset",
|
|
{
|
|
"version": "1.0.0",
|
|
"source": "local",
|
|
"enabled": True,
|
|
"priority": 10,
|
|
"registered_commands": {},
|
|
"registered_skills": {
|
|
"copilot": ["speckit-specify"]
|
|
},
|
|
},
|
|
)
|
|
installed_dir = (
|
|
manager.presets_dir / "symlink-override-preset"
|
|
)
|
|
shutil.copytree(preset_dir, installed_dir)
|
|
overrides_dir = (
|
|
project_dir / ".specify" / "templates" / "overrides"
|
|
)
|
|
overrides_dir.mkdir(parents=True)
|
|
(overrides_dir / "speckit.specify.md").write_text(
|
|
"---\ndescription: Override\n---\n\nOverride body\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
manager._reconcile_skills(["speckit.specify"])
|
|
|
|
assert outside_file.read_text(encoding="utf-8") == "do-not-touch"
|
|
assert (skill_dir / "SKILL.md").is_symlink()
|
|
|
|
def test_is_safe_registry_skill_name_rejects_unsafe_values(self, project_dir):
|
|
"""Unit-test the centralized registry skill-name boundary guard.
|
|
|
|
``registered_skills`` entries are persisted registry data, not
|
|
manifest-derived, so every preset cleanup/provenance loop that
|
|
joins one onto a directory must first reject: non-strings, empty
|
|
strings, absolute paths, multi-component paths (containing ``/``),
|
|
and the literal traversal components ``"."``/``".."`` — the last
|
|
of which is *not* caught by a naive ``is_absolute() or
|
|
len(parts) != 1`` check alone, since ``Path("..").parts`` is a
|
|
single-element tuple (#2948).
|
|
"""
|
|
manager = PresetManager(project_dir)
|
|
is_safe = manager._is_safe_registry_skill_name
|
|
|
|
assert is_safe("speckit-specify") is True
|
|
assert is_safe("") is False
|
|
assert is_safe(None) is False
|
|
assert is_safe(123) is False
|
|
assert is_safe(["speckit-specify"]) is False
|
|
assert is_safe(".") is False
|
|
assert is_safe("..") is False
|
|
assert is_safe("/etc/passwd") is False
|
|
assert is_safe(str(project_dir / "important-data")) is False
|
|
assert is_safe("foo/bar") is False
|
|
assert is_safe("foo/..") is False
|
|
assert is_safe("../foo") is False
|
|
|
|
def test_unregister_skills_rejects_unknown_agent_provenance(
|
|
self, project_dir
|
|
):
|
|
"""Unknown registry agent keys must not fall back to shared skills."""
|
|
shared_skills_dir = project_dir / ".agents" / "skills"
|
|
skill_dir = self._create_skill(
|
|
shared_skills_dir, "speckit-specify", "user-owned content"
|
|
)
|
|
core_commands = project_dir / ".specify" / "templates" / "commands"
|
|
(core_commands / "specify.md").write_text(
|
|
"---\ndescription: Core specify\n---\n\nCore body\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager._unregister_skills(
|
|
{"unknown": ["speckit-specify"]}, project_dir
|
|
)
|
|
|
|
assert (skill_dir / "SKILL.md").read_text(encoding="utf-8") == (
|
|
"---\nname: speckit-specify\n---\n\nuser-owned content\n"
|
|
)
|
|
|
|
def test_unregister_legacy_fallback_skips_non_owned_skill(
|
|
self, project_dir
|
|
):
|
|
"""Legacy fallback provenance must not overwrite a user-owned skill."""
|
|
self._write_init_options(project_dir, ai="claude", ai_skills=True)
|
|
skills_dir = project_dir / ".claude" / "skills"
|
|
skill_dir = self._create_skill(
|
|
skills_dir, "speckit-specify", "user-owned content"
|
|
)
|
|
core_commands = project_dir / ".specify" / "templates" / "commands"
|
|
(core_commands / "specify.md").write_text(
|
|
"---\ndescription: Core specify\n---\n\nCore body\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager._unregister_skills(
|
|
["speckit-specify"], "removed-preset"
|
|
)
|
|
|
|
assert (skill_dir / "SKILL.md").read_text(encoding="utf-8") == (
|
|
"---\nname: speckit-specify\n---\n\nuser-owned content\n"
|
|
)
|
|
|
|
def test_unregister_skills_in_dir_rejects_absolute_registry_name(
|
|
self, project_dir
|
|
):
|
|
"""A corrupted ``registered_skills`` entry with an absolute path must not escape.
|
|
|
|
``Path`` join with an absolute right-hand operand discards the
|
|
left side entirely (``skills_dir / "/abs/path"`` == ``"/abs/path"``),
|
|
so an absolute in-project path stored in the registry would bypass
|
|
``skills_dir`` altogether if not rejected before the join (#2948).
|
|
"""
|
|
self._write_init_options(project_dir, ai="claude", ai_skills=True)
|
|
claude_skills_dir = project_dir / ".claude" / "skills"
|
|
claude_skills_dir.mkdir(parents=True)
|
|
|
|
precious_dir = project_dir / "important-data"
|
|
precious_dir.mkdir()
|
|
precious_file = precious_dir / "SKILL.md"
|
|
precious_file.write_text("precious-absolute-target-marker")
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager._unregister_skills_in_dir(
|
|
[str(precious_dir)], claude_skills_dir, "claude"
|
|
)
|
|
|
|
assert precious_dir.is_dir(), (
|
|
"an absolute registry entry must not let cleanup escape "
|
|
"skills_dir to an unrelated project directory (#2948)"
|
|
)
|
|
assert precious_file.read_text() == "precious-absolute-target-marker"
|
|
|
|
def test_infer_legacy_skill_provenance_rejects_absolute_registry_name(
|
|
self, project_dir
|
|
):
|
|
"""Legacy provenance inference must reject an absolute registry name.
|
|
|
|
``_infer_legacy_skill_provenance`` receives its ``skill_names``
|
|
directly from a legacy flat-list ``registered_skills`` value —
|
|
registry data, not manifest-derived — and joins each name onto a
|
|
candidate agent's resolved skills directory the same way
|
|
``_unregister_skills_in_dir`` does. An absolute in-project name
|
|
discards the candidate directory entirely (Python's ``/`` operator
|
|
drops the left side for an absolute right side), so it can read
|
|
an unrelated project directory's ``SKILL.md`` and, if its
|
|
frontmatter happens to carry a matching preset source marker,
|
|
falsely attribute an unrelated directory as this preset's own
|
|
skill override under whichever agent is being probed (#2948).
|
|
"""
|
|
self._write_init_options(project_dir, ai="claude", ai_skills=True)
|
|
claude_skills_dir = project_dir / ".claude" / "skills"
|
|
claude_skills_dir.mkdir(parents=True)
|
|
|
|
precious_dir = project_dir / "important-data"
|
|
precious_dir.mkdir()
|
|
(precious_dir / "SKILL.md").write_text(
|
|
"---\n"
|
|
"metadata:\n"
|
|
" source: preset:some-pack\n"
|
|
"---\n\n"
|
|
"# Unrelated directory, not a real preset skill\n"
|
|
)
|
|
|
|
manager = PresetManager(project_dir)
|
|
inferred = manager._infer_legacy_skill_provenance(
|
|
[str(precious_dir)], "some-pack", "claude"
|
|
)
|
|
|
|
for names in inferred.values():
|
|
assert str(precious_dir) not in names, (
|
|
"an absolute registry entry must not be falsely attributed "
|
|
"as preset-owned provenance by probing outside the "
|
|
"intended skills subtree (#2948)"
|
|
)
|
|
|
|
def test_copilot_skills_registration_restored_after_process_restart(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""Copilot skills-mode registrations must restore even when the
|
|
transient ``_skills_mode`` integration attribute has been reset,
|
|
simulating a fresh CLI process.
|
|
|
|
``_skills_mode`` is set during ``setup()`` and is never persisted;
|
|
after switching the active agent and running ``preset remove`` in
|
|
a brand-new process, a naive "is this integration currently in
|
|
skills mode" check would be False even though Copilot's
|
|
``.github/skills`` directory holds a live override this preset
|
|
wrote. Restoration must rely on the persisted per-agent provenance
|
|
recorded at write time, not on runtime integration state (#2948).
|
|
"""
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
|
|
core_cmds = project_dir / ".specify" / "templates" / "commands"
|
|
core_cmds.mkdir(parents=True, exist_ok=True)
|
|
(core_cmds / "specify.md").write_text(
|
|
"---\ndescription: Core specify command\n---\n\nCore specify body\n",
|
|
encoding="utf-8",
|
|
)
|
|
copilot_skills_dir = project_dir / ".github" / "skills"
|
|
self._create_skill(copilot_skills_dir, "speckit-specify")
|
|
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir, "copilot-fresh-process-preset", "speckit.specify",
|
|
"Copilot fresh process test", "preset body",
|
|
)
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
skill_file = copilot_skills_dir / "speckit-specify" / "SKILL.md"
|
|
assert "preset:copilot-fresh-process-preset" in skill_file.read_text()
|
|
|
|
metadata = manager.registry.get("copilot-fresh-process-preset")
|
|
assert "copilot" in metadata.get("registered_skills", {})
|
|
|
|
# Switch the active agent away from copilot, then simulate a fresh
|
|
# CLI process (a brand-new PresetManager, so any transient
|
|
# `_skills_mode` state set during a prior setup() call is gone)
|
|
# removing the preset.
|
|
self._write_init_options(project_dir, ai="claude", ai_skills=True)
|
|
fresh_manager = PresetManager(project_dir)
|
|
|
|
assert fresh_manager.remove("copilot-fresh-process-preset") is True
|
|
|
|
assert "preset:copilot-fresh-process-preset" not in skill_file.read_text(), (
|
|
"removal must restore copilot's .github/skills override even "
|
|
"when copilot's transient skills-mode state isn't set in this "
|
|
"process (#2948)"
|
|
)
|
|
assert "Core specify body" in skill_file.read_text()
|
|
|
|
def test_unregister_agent_artifacts_scoped_to_target_agent_only(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""``unregister_agent_artifacts`` must remove only the target
|
|
agent's own tracked command/skill artifacts.
|
|
|
|
Used by ``integration switch`` when deactivating the previous
|
|
integration for a not-yet-installed target (#2948): without this,
|
|
a preset's command override -- including a custom preset command --
|
|
and skill mirror rendered for the old agent remain orphaned once a
|
|
different integration becomes active. Another agent's own
|
|
registrations (files and registry tracking) must survive
|
|
untouched, and no priority-stack reconciliation should run as a
|
|
side effect.
|
|
"""
|
|
self._write_init_options(project_dir, ai="auggie", ai_skills=False)
|
|
# Registration only writes to an agent's directory once it's
|
|
# "detected" on disk (mirroring a real `integration install`
|
|
# having already created it), so pre-create both agents'
|
|
# directories before installing the preset.
|
|
(project_dir / ".augment" / "commands").mkdir(parents=True)
|
|
(project_dir / ".opencode" / "commands").mkdir(parents=True)
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir, "switch-cleanup-preset", "speckit.specify",
|
|
"Custom preset command", "preset body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
auggie_cmd = project_dir / ".augment" / "commands" / "speckit.specify.md"
|
|
assert auggie_cmd.exists(), "sanity: preset command registered for auggie"
|
|
|
|
# Simulate a later `integration use opencode` rescaffold that also
|
|
# registered the preset for opencode, while auggie's own
|
|
# registration (from before the switch) is still present in the
|
|
# registry.
|
|
self._write_init_options(project_dir, ai="opencode", ai_skills=False)
|
|
manager.register_enabled_presets_for_agent("opencode")
|
|
|
|
opencode_cmd = project_dir / ".opencode" / "commands" / "speckit.specify.md"
|
|
assert opencode_cmd.exists(), "sanity: preset command registered for opencode"
|
|
|
|
metadata = manager.registry.get("switch-cleanup-preset")
|
|
registered_commands = metadata.get("registered_commands", {})
|
|
assert "auggie" in registered_commands and "opencode" in registered_commands
|
|
|
|
manager.unregister_agent_artifacts("auggie")
|
|
|
|
assert not auggie_cmd.exists(), (
|
|
"auggie's own preset command must be removed when switching "
|
|
"away from auggie to a not-yet-installed integration (#2948)"
|
|
)
|
|
assert opencode_cmd.exists(), (
|
|
"opencode's preset command must survive unregistering auggie's "
|
|
"artifacts -- cleanup must stay scoped to the target agent"
|
|
)
|
|
|
|
metadata = manager.registry.get("switch-cleanup-preset")
|
|
registered_commands = metadata.get("registered_commands", {})
|
|
assert "auggie" not in registered_commands, (
|
|
"auggie's tracking must be dropped after unregistering its artifacts"
|
|
)
|
|
assert "opencode" in registered_commands, (
|
|
"opencode's tracking must be preserved untouched"
|
|
)
|
|
|
|
def test_unregister_agent_artifacts_deletes_marker_owned_skill(
|
|
self, project_dir, temp_dir
|
|
):
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
|
|
core_cmds = project_dir / ".specify" / "templates" / "commands"
|
|
core_cmds.mkdir(parents=True, exist_ok=True)
|
|
(core_cmds / "specify.md").write_text(
|
|
"---\ndescription: Core specify\n---\n\nCore body\n",
|
|
encoding="utf-8",
|
|
)
|
|
skills_dir = project_dir / ".github" / "skills"
|
|
self._create_skill(skills_dir, "speckit-specify")
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir,
|
|
"deactivated-skill-preset",
|
|
"speckit.specify",
|
|
"Deactivation cleanup",
|
|
"preset body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
skill_dir = skills_dir / "speckit-specify"
|
|
assert "preset:deactivated-skill-preset" in (
|
|
skill_dir / "SKILL.md"
|
|
).read_text()
|
|
|
|
manager.unregister_agent_artifacts("copilot")
|
|
|
|
assert not skill_dir.exists()
|
|
|
|
def test_unregister_agent_artifacts_deletes_reconciled_override_skill(
|
|
self, project_dir, temp_dir
|
|
):
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
|
|
skills_dir = project_dir / ".github" / "skills"
|
|
self._create_skill(skills_dir, "speckit-specify")
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir,
|
|
"deactivated-override-preset",
|
|
"speckit.specify",
|
|
"Deactivation override cleanup",
|
|
"preset body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
overrides_dir = (
|
|
project_dir / ".specify" / "templates" / "overrides"
|
|
)
|
|
overrides_dir.mkdir(parents=True)
|
|
(overrides_dir / "speckit.specify.md").write_text(
|
|
"---\ndescription: Project override\n---\n\nOverride body\n",
|
|
encoding="utf-8",
|
|
)
|
|
(
|
|
manager.presets_dir
|
|
/ "deactivated-override-preset"
|
|
/ "commands"
|
|
/ "speckit.specify.md"
|
|
).unlink()
|
|
manager.register_enabled_presets_for_agent("copilot")
|
|
|
|
skill_dir = skills_dir / "speckit-specify"
|
|
assert "override:speckit.specify" in (
|
|
skill_dir / "SKILL.md"
|
|
).read_text(encoding="utf-8")
|
|
|
|
manager.unregister_agent_artifacts("copilot")
|
|
|
|
assert not skill_dir.exists()
|
|
metadata = manager.registry.get("deactivated-override-preset")
|
|
assert "copilot" not in metadata.get("registered_skills", {})
|
|
|
|
def test_unregister_native_agent_persists_skills_metadata_pop(
|
|
self, project_dir, temp_dir
|
|
):
|
|
self._write_init_options(project_dir, ai="agy", ai_skills=True)
|
|
(project_dir / ".agents" / "skills").mkdir(parents=True)
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir,
|
|
"native-metadata-cleanup-preset",
|
|
"speckit.shared-cleanup",
|
|
"Shared cleanup",
|
|
"preset body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
metadata = manager.registry.get("native-metadata-cleanup-preset")
|
|
assert "agy" in metadata.get("registered_commands", {})
|
|
manager.registry.update(
|
|
"native-metadata-cleanup-preset",
|
|
{"registered_skills": {"agy": ["speckit-shared-cleanup"]}},
|
|
)
|
|
|
|
manager.unregister_agent_artifacts("agy")
|
|
|
|
metadata = manager.registry.get("native-metadata-cleanup-preset")
|
|
assert "agy" not in metadata.get("registered_commands", {})
|
|
assert "agy" not in metadata.get("registered_skills", {})
|
|
|
|
def test_unregister_native_agent_preserves_shared_output_owner(
|
|
self, project_dir, temp_dir
|
|
):
|
|
self._write_init_options(project_dir, ai="agy", ai_skills=True)
|
|
(project_dir / ".agents" / "skills").mkdir(parents=True)
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir,
|
|
"shared-native-output-preset",
|
|
"speckit.shared-owner",
|
|
"Shared owner",
|
|
"preset body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
self._write_init_options(project_dir, ai="codex", ai_skills=True)
|
|
manager.register_enabled_presets_for_agent("codex")
|
|
skill_file = (
|
|
project_dir
|
|
/ ".agents"
|
|
/ "skills"
|
|
/ "speckit-shared-owner"
|
|
/ "SKILL.md"
|
|
)
|
|
assert skill_file.exists()
|
|
metadata = manager.registry.get("shared-native-output-preset")
|
|
registered_commands = metadata.get("registered_commands", {})
|
|
assert "agy" in registered_commands and "codex" in registered_commands
|
|
|
|
manager.unregister_agent_artifacts("agy")
|
|
|
|
assert skill_file.exists(), (
|
|
"shared SKILL.md must survive while codex still owns the same "
|
|
"physical output"
|
|
)
|
|
metadata = manager.registry.get("shared-native-output-preset")
|
|
registered_commands = metadata.get("registered_commands", {})
|
|
assert "agy" not in registered_commands
|
|
assert "codex" in registered_commands
|
|
|
|
def test_unregister_agent_artifacts_migrates_legacy_skill_list_scoped(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""Unregistering an agent's artifacts from a legacy flat-list
|
|
``registered_skills`` entry must infer real per-agent ownership
|
|
before removing anything, so only the target agent's own share is
|
|
cleaned up and any other agent's still-live mirror survives,
|
|
rather than either guessing every name belongs to the target agent
|
|
or dropping all tracking wholesale (#2948).
|
|
|
|
Uses Copilot (command-backed, rendering skills via ``ai_skills``)
|
|
as the agent being switched away from, and Claude (a native
|
|
SKILL.md agent) as the separate, still-live owner — mirroring the
|
|
established legacy-provenance test pattern used elsewhere for
|
|
this exact registry shape.
|
|
"""
|
|
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
|
|
core_cmds = project_dir / ".specify" / "templates" / "commands"
|
|
core_cmds.mkdir(parents=True, exist_ok=True)
|
|
(core_cmds / "specify.md").write_text(
|
|
"---\ndescription: Core specify command\n---\n\nCore specify body\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
copilot_skills_dir = project_dir / ".github" / "skills"
|
|
claude_skills_dir = project_dir / ".claude" / "skills"
|
|
self._create_skill(copilot_skills_dir, "speckit-specify")
|
|
self._create_skill(claude_skills_dir, "speckit-specify")
|
|
|
|
preset_dir = self._create_command_preset(
|
|
temp_dir, "switch-legacy-skill-preset", "speckit.specify",
|
|
"Legacy skill switch test", "preset body",
|
|
)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
copilot_skill = copilot_skills_dir / "speckit-specify" / "SKILL.md"
|
|
assert "preset:switch-legacy-skill-preset" in copilot_skill.read_text(), (
|
|
"sanity: install wrote the override under copilot"
|
|
)
|
|
|
|
# A separate activation under claude (before provenance tracking
|
|
# existed) also left a live, marker-verified mirror there.
|
|
claude_skill = claude_skills_dir / "speckit-specify" / "SKILL.md"
|
|
claude_skill.write_text(
|
|
"---\nname: speckit-specify\nmetadata:\n source: preset:switch-legacy-skill-preset\n"
|
|
"---\n\npreset body\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
# Simulate a pre-#2948 registry: a flat list with no per-agent
|
|
# provenance for either writer.
|
|
manager.registry.update(
|
|
"switch-legacy-skill-preset",
|
|
{"registered_skills": ["speckit-specify"]},
|
|
)
|
|
|
|
manager.unregister_agent_artifacts("copilot")
|
|
|
|
assert not copilot_skill.parent.exists(), (
|
|
"copilot's marker-owned preset skill must be deleted when "
|
|
"switching away from copilot"
|
|
)
|
|
|
|
assert "preset:switch-legacy-skill-preset" in claude_skill.read_text(), (
|
|
"claude's own, separately-written mirror must survive "
|
|
"unregistering copilot's artifacts -- legacy-list inference "
|
|
"must not misattribute or drop claude's real ownership (#2948)"
|
|
)
|
|
|
|
metadata = manager.registry.get("switch-legacy-skill-preset")
|
|
registered_skills = metadata.get("registered_skills")
|
|
assert isinstance(registered_skills, dict), (
|
|
"legacy flat-list value must migrate to per-agent form"
|
|
)
|
|
assert "copilot" not in registered_skills
|
|
assert "claude" in registered_skills and "speckit-specify" in registered_skills["claude"], (
|
|
"claude's real ownership must be preserved in the migrated tracking"
|
|
)
|
|
|
|
|
|
class TestPresetSetPriority:
|
|
"""Test preset set-priority CLI command."""
|
|
|
|
def test_set_priority_changes_priority(self, project_dir, pack_dir):
|
|
"""Test set-priority command changes preset priority."""
|
|
from typer.testing import CliRunner
|
|
from unittest.mock import patch
|
|
from specify_cli import app
|
|
|
|
runner = CliRunner()
|
|
|
|
# Install preset with default priority
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(pack_dir, "0.1.5")
|
|
|
|
# Verify default priority
|
|
assert manager.registry.get("test-pack")["priority"] == 10
|
|
|
|
with patch.object(Path, "cwd", return_value=project_dir):
|
|
result = runner.invoke(app, ["preset", "set-priority", "test-pack", "5"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
plain = strip_ansi(result.output)
|
|
assert "priority changed: 10 → 5" in plain
|
|
|
|
# Reload registry to see updated value
|
|
manager2 = PresetManager(project_dir)
|
|
assert manager2.registry.get("test-pack")["priority"] == 5
|
|
|
|
def test_set_priority_reconciles_generated_constitution(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""Changing priority rematerializes an unchanged generated constitution."""
|
|
from typer.testing import CliRunner
|
|
from unittest.mock import patch
|
|
from specify_cli import app
|
|
|
|
manager = PresetManager(project_dir)
|
|
install_self_test_preset(manager)
|
|
manager.install_from_directory(
|
|
_make_convention_constitution_preset(temp_dir), "0.1.5", priority=20
|
|
)
|
|
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
|
assert "preset:self-test" in memory.read_text()
|
|
|
|
with patch.object(Path, "cwd", return_value=project_dir):
|
|
result = CliRunner().invoke(
|
|
app,
|
|
["preset", "set-priority", "convention-constitution", "1"],
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert memory.read_text() == "# Convention Constitution\n"
|
|
|
|
def test_set_priority_same_value_no_change(self, project_dir, pack_dir):
|
|
"""Test set-priority with same value shows already set message."""
|
|
from typer.testing import CliRunner
|
|
from unittest.mock import patch
|
|
from specify_cli import app
|
|
|
|
runner = CliRunner()
|
|
|
|
# Install preset with priority 5
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(pack_dir, "0.1.5", priority=5)
|
|
|
|
with patch.object(Path, "cwd", return_value=project_dir):
|
|
result = runner.invoke(app, ["preset", "set-priority", "test-pack", "5"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
plain = strip_ansi(result.output)
|
|
assert "already has priority 5" in plain
|
|
|
|
def test_set_priority_repairs_corrupted_bool(self, project_dir, pack_dir):
|
|
"""A corrupted boolean priority must be repaired, not skipped.
|
|
|
|
``isinstance(True, int)`` is True and ``True == 1`` in Python, so a
|
|
stored ``True`` priority would short-circuit the ``already has
|
|
priority 1`` skip path and never get rewritten to a real int —
|
|
contradicting the comment that promises corrupted values are
|
|
repaired. The guard must exclude bools (like normalize_priority).
|
|
"""
|
|
from typer.testing import CliRunner
|
|
from unittest.mock import patch
|
|
from specify_cli import app
|
|
|
|
runner = CliRunner()
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(pack_dir, "0.1.5", priority=5)
|
|
# Inject a corrupted boolean priority (True == 1).
|
|
manager.registry.update("test-pack", {"priority": True})
|
|
|
|
with patch.object(Path, "cwd", return_value=project_dir):
|
|
result = runner.invoke(app, ["preset", "set-priority", "test-pack", "1"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
plain = strip_ansi(result.output)
|
|
# The corrupted bool must be repaired, not reported as already-set.
|
|
assert "already has priority" not in plain
|
|
assert "priority changed" in plain
|
|
|
|
# The stored value is now a real int, not a bool.
|
|
reloaded = PresetManager(project_dir).registry.get("test-pack")
|
|
assert reloaded["priority"] == 1
|
|
assert not isinstance(reloaded["priority"], bool)
|
|
|
|
def test_set_priority_invalid_value(self, project_dir, pack_dir):
|
|
"""Test set-priority rejects invalid priority values."""
|
|
from typer.testing import CliRunner
|
|
from unittest.mock import patch
|
|
from specify_cli import app
|
|
|
|
runner = CliRunner()
|
|
|
|
# Install preset
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(pack_dir, "0.1.5")
|
|
|
|
with patch.object(Path, "cwd", return_value=project_dir):
|
|
result = runner.invoke(app, ["preset", "set-priority", "test-pack", "0"])
|
|
|
|
assert result.exit_code == 1, result.output
|
|
assert "Priority must be a positive integer" in result.output
|
|
|
|
def test_set_priority_not_installed(self, project_dir):
|
|
"""Test set-priority fails for non-installed preset."""
|
|
from typer.testing import CliRunner
|
|
from unittest.mock import patch
|
|
from specify_cli import app
|
|
|
|
runner = CliRunner()
|
|
|
|
with patch.object(Path, "cwd", return_value=project_dir):
|
|
result = runner.invoke(app, ["preset", "set-priority", "nonexistent", "5"])
|
|
|
|
assert result.exit_code == 1, result.output
|
|
assert "not installed" in result.output.lower()
|
|
|
|
|
|
class TestPresetPriorityBackwardsCompatibility:
|
|
"""Test backwards compatibility for presets installed before priority feature."""
|
|
|
|
def test_legacy_preset_without_priority_field(self, temp_dir):
|
|
"""Presets installed before priority feature should default to 10."""
|
|
presets_dir = temp_dir / ".specify" / "presets"
|
|
presets_dir.mkdir(parents=True)
|
|
|
|
# Simulate legacy registry entry without priority field
|
|
registry = PresetRegistry(presets_dir)
|
|
registry.data["presets"]["legacy-pack"] = {
|
|
"version": "1.0.0",
|
|
"source": "local",
|
|
"enabled": True,
|
|
"installed_at": "2025-01-01T00:00:00Z",
|
|
# No "priority" field - simulates pre-feature preset
|
|
}
|
|
registry._save()
|
|
|
|
# Reload registry
|
|
registry2 = PresetRegistry(presets_dir)
|
|
|
|
# list_by_priority should use default of 10
|
|
result = registry2.list_by_priority()
|
|
assert len(result) == 1
|
|
assert result[0][0] == "legacy-pack"
|
|
# Priority defaults to 10 and is normalized in returned metadata
|
|
assert result[0][1]["priority"] == 10
|
|
|
|
def test_legacy_preset_in_list_installed(self, project_dir, pack_dir):
|
|
"""list_installed returns priority=10 for legacy presets without priority field."""
|
|
manager = PresetManager(project_dir)
|
|
|
|
# Install preset normally
|
|
manager.install_from_directory(pack_dir, "0.1.5")
|
|
|
|
# Manually remove priority to simulate legacy preset
|
|
pack_data = manager.registry.data["presets"]["test-pack"]
|
|
del pack_data["priority"]
|
|
manager.registry._save()
|
|
|
|
# list_installed should still return priority=10
|
|
installed = manager.list_installed()
|
|
assert len(installed) == 1
|
|
assert installed[0]["priority"] == 10
|
|
|
|
def test_mixed_legacy_and_new_presets_ordering(self, temp_dir):
|
|
"""Legacy presets (no priority) sort with default=10 among prioritized presets."""
|
|
presets_dir = temp_dir / ".specify" / "presets"
|
|
presets_dir.mkdir(parents=True)
|
|
|
|
registry = PresetRegistry(presets_dir)
|
|
|
|
# Add preset with explicit priority=5
|
|
registry.add("pack-with-priority", {"version": "1.0.0", "priority": 5})
|
|
|
|
# Add legacy preset without priority (manually)
|
|
registry.data["presets"]["legacy-pack"] = {
|
|
"version": "1.0.0",
|
|
"source": "local",
|
|
"enabled": True,
|
|
# No priority field
|
|
}
|
|
|
|
# Add another preset with priority=15
|
|
registry.add("low-priority-pack", {"version": "1.0.0", "priority": 15})
|
|
registry._save()
|
|
|
|
# Reload and check ordering
|
|
registry2 = PresetRegistry(presets_dir)
|
|
sorted_presets = registry2.list_by_priority()
|
|
|
|
# Should be: pack-with-priority (5), legacy-pack (default 10), low-priority-pack (15)
|
|
assert [p[0] for p in sorted_presets] == [
|
|
"pack-with-priority",
|
|
"legacy-pack",
|
|
"low-priority-pack",
|
|
]
|
|
|
|
|
|
class TestPresetEnableDisable:
|
|
"""Test preset enable/disable CLI commands."""
|
|
|
|
def test_disable_preset(self, project_dir, pack_dir):
|
|
"""Test disable command sets enabled=False."""
|
|
from typer.testing import CliRunner
|
|
from unittest.mock import patch
|
|
from specify_cli import app
|
|
|
|
runner = CliRunner()
|
|
|
|
# Install preset
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(pack_dir, "0.1.5")
|
|
|
|
# Verify initially enabled
|
|
assert manager.registry.get("test-pack").get("enabled", True) is True
|
|
|
|
with patch.object(Path, "cwd", return_value=project_dir):
|
|
result = runner.invoke(app, ["preset", "disable", "test-pack"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "disabled" in result.output.lower()
|
|
|
|
# Reload registry to see updated value
|
|
manager2 = PresetManager(project_dir)
|
|
assert manager2.registry.get("test-pack")["enabled"] is False
|
|
|
|
def test_enable_preset(self, project_dir, pack_dir):
|
|
"""Test enable command sets enabled=True."""
|
|
from typer.testing import CliRunner
|
|
from unittest.mock import patch
|
|
from specify_cli import app
|
|
|
|
runner = CliRunner()
|
|
|
|
# Install preset and disable it
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(pack_dir, "0.1.5")
|
|
manager.registry.update("test-pack", {"enabled": False})
|
|
|
|
# Verify disabled
|
|
assert manager.registry.get("test-pack")["enabled"] is False
|
|
|
|
with patch.object(Path, "cwd", return_value=project_dir):
|
|
result = runner.invoke(app, ["preset", "enable", "test-pack"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "enabled" in result.output.lower()
|
|
|
|
# Reload registry to see updated value
|
|
manager2 = PresetManager(project_dir)
|
|
assert manager2.registry.get("test-pack")["enabled"] is True
|
|
|
|
def test_enable_disable_reconciles_generated_constitution(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""Enable and disable rematerialize the winning constitution layer."""
|
|
from typer.testing import CliRunner
|
|
from unittest.mock import patch
|
|
from specify_cli import app
|
|
|
|
manager = PresetManager(project_dir)
|
|
install_self_test_preset(manager)
|
|
manager.install_from_directory(
|
|
_make_convention_constitution_preset(temp_dir), "0.1.5", priority=1
|
|
)
|
|
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
|
assert memory.read_text() == "# Convention Constitution\n"
|
|
runner = CliRunner()
|
|
|
|
with patch.object(Path, "cwd", return_value=project_dir):
|
|
disabled = runner.invoke(
|
|
app, ["preset", "disable", "convention-constitution"]
|
|
)
|
|
|
|
assert disabled.exit_code == 0, disabled.output
|
|
assert "preset:self-test" in memory.read_text()
|
|
|
|
with patch.object(Path, "cwd", return_value=project_dir):
|
|
enabled = runner.invoke(
|
|
app, ["preset", "enable", "convention-constitution"]
|
|
)
|
|
|
|
assert enabled.exit_code == 0, enabled.output
|
|
assert memory.read_text() == "# Convention Constitution\n"
|
|
|
|
def test_stack_changes_do_not_create_missing_constitution(
|
|
self, project_dir, pack_dir
|
|
):
|
|
"""Stack changes for non-providers do not seed a missing constitution."""
|
|
from typer.testing import CliRunner
|
|
from unittest.mock import patch
|
|
from specify_cli import app
|
|
|
|
PresetManager(project_dir).install_from_directory(pack_dir, "0.1.5")
|
|
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
|
runner = CliRunner()
|
|
|
|
for args in (
|
|
["preset", "set-priority", "test-pack", "5"],
|
|
["preset", "disable", "test-pack"],
|
|
["preset", "enable", "test-pack"],
|
|
):
|
|
with patch.object(Path, "cwd", return_value=project_dir):
|
|
result = runner.invoke(app, args)
|
|
assert result.exit_code == 0, result.output
|
|
assert not memory.exists()
|
|
|
|
def test_disable_already_disabled(self, project_dir, pack_dir):
|
|
"""Test disable on already disabled preset shows warning."""
|
|
from typer.testing import CliRunner
|
|
from unittest.mock import patch
|
|
from specify_cli import app
|
|
|
|
runner = CliRunner()
|
|
|
|
# Install preset and disable it
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(pack_dir, "0.1.5")
|
|
manager.registry.update("test-pack", {"enabled": False})
|
|
|
|
with patch.object(Path, "cwd", return_value=project_dir):
|
|
result = runner.invoke(app, ["preset", "disable", "test-pack"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "already disabled" in result.output.lower()
|
|
|
|
def test_enable_already_enabled(self, project_dir, pack_dir):
|
|
"""Test enable on already enabled preset shows warning."""
|
|
from typer.testing import CliRunner
|
|
from unittest.mock import patch
|
|
from specify_cli import app
|
|
|
|
runner = CliRunner()
|
|
|
|
# Install preset (enabled by default)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(pack_dir, "0.1.5")
|
|
|
|
with patch.object(Path, "cwd", return_value=project_dir):
|
|
result = runner.invoke(app, ["preset", "enable", "test-pack"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "already enabled" in result.output.lower()
|
|
|
|
def test_disable_not_installed(self, project_dir):
|
|
"""Test disable fails for non-installed preset."""
|
|
from typer.testing import CliRunner
|
|
from unittest.mock import patch
|
|
from specify_cli import app
|
|
|
|
runner = CliRunner()
|
|
|
|
with patch.object(Path, "cwd", return_value=project_dir):
|
|
result = runner.invoke(app, ["preset", "disable", "nonexistent"])
|
|
|
|
assert result.exit_code == 1, result.output
|
|
assert "not installed" in result.output.lower()
|
|
|
|
def test_enable_not_installed(self, project_dir):
|
|
"""Test enable fails for non-installed preset."""
|
|
from typer.testing import CliRunner
|
|
from unittest.mock import patch
|
|
from specify_cli import app
|
|
|
|
runner = CliRunner()
|
|
|
|
with patch.object(Path, "cwd", return_value=project_dir):
|
|
result = runner.invoke(app, ["preset", "enable", "nonexistent"])
|
|
|
|
assert result.exit_code == 1, result.output
|
|
assert "not installed" in result.output.lower()
|
|
|
|
def test_disabled_preset_excluded_from_resolution(self, project_dir, pack_dir):
|
|
"""Test that disabled presets are excluded from template resolution."""
|
|
# Install preset with a template
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(pack_dir, "0.1.5")
|
|
|
|
# Create a template in the preset directory
|
|
preset_template = project_dir / ".specify" / "presets" / "test-pack" / "templates" / "test-template.md"
|
|
preset_template.parent.mkdir(parents=True, exist_ok=True)
|
|
preset_template.write_text("# Template from test-pack")
|
|
|
|
resolver = PresetResolver(project_dir)
|
|
|
|
# Template should be found when enabled
|
|
result = resolver.resolve("test-template", "template")
|
|
assert result is not None
|
|
assert "test-pack" in str(result)
|
|
|
|
# Disable the preset
|
|
manager.registry.update("test-pack", {"enabled": False})
|
|
|
|
# Template should NOT be found when disabled
|
|
resolver2 = PresetResolver(project_dir)
|
|
result2 = resolver2.resolve("test-template", "template")
|
|
assert result2 is None
|
|
|
|
def test_enable_corrupted_registry_entry(self, project_dir, pack_dir):
|
|
"""Test enable fails gracefully for corrupted registry entry."""
|
|
from typer.testing import CliRunner
|
|
from unittest.mock import patch
|
|
from specify_cli import app
|
|
|
|
runner = CliRunner()
|
|
|
|
# Install preset then corrupt the registry entry
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(pack_dir, "0.1.5")
|
|
manager.registry.data["presets"]["test-pack"] = "corrupted-string"
|
|
manager.registry._save()
|
|
|
|
with patch.object(Path, "cwd", return_value=project_dir):
|
|
result = runner.invoke(app, ["preset", "enable", "test-pack"])
|
|
|
|
assert result.exit_code == 1
|
|
assert "corrupted state" in result.output.lower()
|
|
|
|
def test_disable_corrupted_registry_entry(self, project_dir, pack_dir):
|
|
"""Test disable fails gracefully for corrupted registry entry."""
|
|
from typer.testing import CliRunner
|
|
from unittest.mock import patch
|
|
from specify_cli import app
|
|
|
|
runner = CliRunner()
|
|
|
|
# Install preset then corrupt the registry entry
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(pack_dir, "0.1.5")
|
|
manager.registry.data["presets"]["test-pack"] = "corrupted-string"
|
|
manager.registry._save()
|
|
|
|
with patch.object(Path, "cwd", return_value=project_dir):
|
|
result = runner.invoke(app, ["preset", "disable", "test-pack"])
|
|
|
|
assert result.exit_code == 1
|
|
assert "corrupted state" in result.output.lower()
|
|
|
|
|
|
# ===== Lean Preset Tests =====
|
|
|
|
|
|
LEAN_PRESET_DIR = Path(__file__).parent.parent / "presets" / "lean"
|
|
CORE_CONSTITUTION_COMMAND = (
|
|
Path(__file__).parent.parent / "templates" / "commands" / "constitution.md"
|
|
)
|
|
|
|
LEAN_COMMAND_NAMES = [
|
|
"speckit.specify",
|
|
"speckit.plan",
|
|
"speckit.tasks",
|
|
"speckit.implement",
|
|
"speckit.constitution",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"command_path",
|
|
[
|
|
CORE_CONSTITUTION_COMMAND,
|
|
LEAN_PRESET_DIR / "commands" / "speckit.constitution.md",
|
|
],
|
|
ids=["core", "lean"],
|
|
)
|
|
def test_constitution_commands_guard_against_non_governance_work(command_path):
|
|
"""Constitution commands defer non-governance work instead of executing it."""
|
|
content = command_path.read_text()
|
|
lower_content = content.lower()
|
|
normalized_content = " ".join(lower_content.split())
|
|
|
|
assert "## Scope Guard" in content
|
|
assert "**MUST NOT**" in content
|
|
assert "Classify every part" in content
|
|
assert "application source files" in content
|
|
assert "non-governance intent" in content
|
|
assert "`Next Actions`" in content
|
|
assert "__SPECKIT_COMMAND_SPECIFY__" in content
|
|
assert "omit" in lower_content
|
|
assert "do not invoke it" in normalized_content or "without invoking it" in normalized_content
|
|
|
|
|
|
class TestLeanPreset:
|
|
"""Tests for the lean preset that ships with the repo."""
|
|
|
|
def test_lean_preset_exists(self):
|
|
"""Verify the lean preset directory and manifest exist."""
|
|
assert LEAN_PRESET_DIR.exists()
|
|
assert (LEAN_PRESET_DIR / "preset.yml").exists()
|
|
|
|
def test_lean_manifest_valid(self):
|
|
"""Verify the lean preset manifest is valid."""
|
|
manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml")
|
|
assert manifest.id == "lean"
|
|
assert manifest.name == "Lean Workflow"
|
|
assert manifest.version == "1.0.0"
|
|
assert len(manifest.templates) == 5 # 5 commands
|
|
|
|
def test_lean_provides_core_workflow_commands(self):
|
|
"""Verify the lean preset provides overrides for core workflow commands."""
|
|
manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml")
|
|
provided_names = {t["name"] for t in manifest.templates}
|
|
for name in LEAN_COMMAND_NAMES:
|
|
assert name in provided_names, f"Lean preset missing command: {name}"
|
|
|
|
def test_lean_command_files_exist(self):
|
|
"""Verify that all declared command files actually exist on disk."""
|
|
manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml")
|
|
for tmpl in manifest.templates:
|
|
tmpl_path = LEAN_PRESET_DIR / tmpl["file"]
|
|
assert tmpl_path.exists(), f"Missing command file: {tmpl['file']}"
|
|
|
|
def test_lean_commands_have_no_scripts(self):
|
|
"""Verify lean commands have no scripts in frontmatter."""
|
|
from specify_cli.agents import CommandRegistrar
|
|
|
|
for name in LEAN_COMMAND_NAMES:
|
|
cmd_path = LEAN_PRESET_DIR / "commands" / f"speckit.{name.split('.')[-1]}.md"
|
|
content = cmd_path.read_text()
|
|
frontmatter, _ = CommandRegistrar.parse_frontmatter(content)
|
|
assert "scripts" not in frontmatter, f"{name} should not have scripts in frontmatter"
|
|
|
|
def test_lean_commands_have_no_hooks(self):
|
|
"""Verify lean commands do not contain extension hook boilerplate."""
|
|
for name in LEAN_COMMAND_NAMES:
|
|
cmd_path = LEAN_PRESET_DIR / "commands" / f"speckit.{name.split('.')[-1]}.md"
|
|
content = cmd_path.read_text()
|
|
assert "hooks." not in content, f"{name} should not reference extension hooks"
|
|
assert "extensions.yml" not in content, f"{name} should not reference extensions.yml"
|
|
|
|
def test_install_lean_preset(self, project_dir):
|
|
"""Test installing the lean preset from its directory."""
|
|
manager = PresetManager(project_dir)
|
|
manifest = manager.install_from_directory(LEAN_PRESET_DIR, "0.6.0")
|
|
assert manifest.id == "lean"
|
|
assert manager.registry.is_installed("lean")
|
|
|
|
def test_lean_overrides_commands(self, project_dir):
|
|
"""Test that lean preset overrides are resolved correctly."""
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(LEAN_PRESET_DIR, "0.6.0")
|
|
|
|
resolver = PresetResolver(project_dir)
|
|
for name in LEAN_COMMAND_NAMES:
|
|
result = resolver.resolve(name, template_type="command")
|
|
assert result is not None, f"Lean override for {name} not resolved"
|
|
|
|
|
|
# ===== Bundled Preset Locator Tests =====
|
|
|
|
|
|
class TestBundledPresetLocator:
|
|
"""Tests for _locate_bundled_preset discovery function."""
|
|
|
|
def test_locate_bundled_lean_preset(self):
|
|
"""_locate_bundled_preset finds the lean preset."""
|
|
from specify_cli import _locate_bundled_preset
|
|
|
|
path = _locate_bundled_preset("lean")
|
|
assert path is not None
|
|
assert (path / "preset.yml").is_file()
|
|
|
|
def test_locate_bundled_preset_not_found(self):
|
|
"""_locate_bundled_preset returns None for nonexistent preset."""
|
|
from specify_cli import _locate_bundled_preset
|
|
|
|
path = _locate_bundled_preset("nonexistent-preset")
|
|
assert path is None
|
|
|
|
def test_locate_bundled_preset_rejects_invalid_id(self):
|
|
"""_locate_bundled_preset rejects IDs with invalid characters."""
|
|
from specify_cli import _locate_bundled_preset
|
|
|
|
assert _locate_bundled_preset("../escape") is None
|
|
assert _locate_bundled_preset("UPPERCASE") is None
|
|
assert _locate_bundled_preset("has spaces") is None
|
|
|
|
def test_bundled_preset_add_via_cli(self, project_dir):
|
|
"""Test that 'specify preset add lean' installs the bundled preset."""
|
|
from typer.testing import CliRunner
|
|
from unittest.mock import patch
|
|
from specify_cli import app
|
|
|
|
runner = CliRunner()
|
|
with patch.object(Path, "cwd", return_value=project_dir), \
|
|
patch("specify_cli.get_speckit_version", return_value="0.6.0"):
|
|
result = runner.invoke(app, ["preset", "add", "lean"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "Lean Workflow" in result.output
|
|
assert "installed" in result.output.lower()
|
|
|
|
def test_preset_add_from_url_rejects_insecure_redirect(self, project_dir, monkeypatch):
|
|
"""URL installs reject redirects from HTTPS to non-loopback HTTP."""
|
|
import typer
|
|
from specify_cli.presets._commands import preset_add
|
|
|
|
class FakeResponse(io.BytesIO):
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
def geturl(self):
|
|
return "http://example.com/preset.zip"
|
|
|
|
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
|
monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0")
|
|
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
|
|
assert redirect_validator is not None
|
|
redirect_validator(url, "http://example.com/preset.zip")
|
|
return FakeResponse(b"zip")
|
|
|
|
monkeypatch.setattr("specify_cli.authentication.http.open_url", fake_open_url)
|
|
|
|
installed = False
|
|
|
|
def fake_install_from_zip(self, zip_path, speckit_version, priority=10):
|
|
nonlocal installed
|
|
installed = True
|
|
|
|
monkeypatch.setattr(PresetManager, "install_from_zip", fake_install_from_zip)
|
|
|
|
with pytest.raises(typer.Exit) as exc_info:
|
|
preset_add(preset_id=None, from_url="https://example.com/preset.zip", dev=None, priority=10)
|
|
|
|
assert exc_info.value.exit_code == 1
|
|
assert installed is False
|
|
|
|
def test_preset_add_from_url_rejects_hostless_https_url(self, project_dir):
|
|
"""URL installs reject HTTPS URLs without a hostname before downloading."""
|
|
from typer.testing import CliRunner
|
|
from unittest.mock import patch
|
|
from specify_cli import app
|
|
|
|
runner = CliRunner()
|
|
with patch.object(Path, "cwd", return_value=project_dir), \
|
|
patch("specify_cli.authentication.http.open_url") as open_url:
|
|
result = runner.invoke(app, ["preset", "add", "--from", "https:///preset.zip"])
|
|
|
|
assert result.exit_code == 1
|
|
output = strip_ansi(result.output)
|
|
assert "URL must use HTTPS with a hostname" in output
|
|
assert "got https://" not in output
|
|
open_url.assert_not_called()
|
|
|
|
def test_preset_add_from_malformed_ipv6_url_exits_cleanly(self, project_dir):
|
|
"""A malformed IPv6 URL must produce a clean error, not a ValueError traceback."""
|
|
from typer.testing import CliRunner
|
|
from unittest.mock import patch
|
|
from specify_cli import app
|
|
|
|
runner = CliRunner()
|
|
with patch.object(Path, "cwd", return_value=project_dir), \
|
|
patch("specify_cli.authentication.http.open_url") as open_url:
|
|
result = runner.invoke(
|
|
app,
|
|
["preset", "add", "--from", "https://[::1/preset.zip"],
|
|
catch_exceptions=True,
|
|
)
|
|
|
|
assert result.exit_code == 1
|
|
assert result.exception is None or isinstance(result.exception, SystemExit)
|
|
output = strip_ansi(result.output)
|
|
assert "Invalid URL" in output
|
|
open_url.assert_not_called()
|
|
|
|
def test_preset_add_from_bracketed_non_ip_url_exits_cleanly(self, project_dir):
|
|
"""A bracketed-but-invalid IPv6 host in --from must exit cleanly.
|
|
|
|
"https://[not-an-ip]/preset.zip" is a malformed authority that raises
|
|
ValueError during URL validation; the try/except guard around parsing
|
|
and the .hostname read must turn that into a clean "Invalid URL" message.
|
|
"""
|
|
from typer.testing import CliRunner
|
|
from unittest.mock import patch
|
|
from specify_cli import app
|
|
|
|
runner = CliRunner()
|
|
with patch.object(Path, "cwd", return_value=project_dir), \
|
|
patch("specify_cli.authentication.http.open_url") as open_url:
|
|
result = runner.invoke(
|
|
app,
|
|
["preset", "add", "--from", "https://[not-an-ip]/preset.zip"],
|
|
catch_exceptions=True,
|
|
)
|
|
|
|
assert result.exit_code == 1
|
|
assert result.exception is None or isinstance(result.exception, SystemExit)
|
|
output = strip_ansi(result.output)
|
|
assert "Invalid URL" in output
|
|
open_url.assert_not_called()
|
|
|
|
def test_preset_add_from_url_out_of_range_port_exits_cleanly(self, project_dir):
|
|
"""An out-of-range port raises ValueError lazily on .port access.
|
|
|
|
The up-front guard reads ``_parsed.port`` (urllib validates the port
|
|
range/syntax there) inside its try/except, so "https://example.com:99999/
|
|
preset.zip" must produce a clean "Invalid URL" message rather than
|
|
leaking a raw ValueError traceback past the CLI.
|
|
"""
|
|
from typer.testing import CliRunner
|
|
from unittest.mock import patch
|
|
from specify_cli import app
|
|
|
|
runner = CliRunner()
|
|
with patch.object(Path, "cwd", return_value=project_dir), \
|
|
patch("specify_cli.authentication.http.open_url") as open_url:
|
|
result = runner.invoke(
|
|
app,
|
|
["preset", "add", "--from", "https://example.com:99999/preset.zip"],
|
|
catch_exceptions=True,
|
|
)
|
|
|
|
assert result.exit_code == 1
|
|
assert result.exception is None or isinstance(result.exception, SystemExit)
|
|
assert "Invalid URL" in strip_ansi(result.output)
|
|
open_url.assert_not_called()
|
|
|
|
def test_preset_add_bracketed_host_download_url_exits_cleanly(self, project_dir):
|
|
"""A catalog download_url with a bracketed non-IP host must render cleanly.
|
|
|
|
``download_pack`` raises ``PresetError`` whose message embeds the raw URL
|
|
(e.g. ``https://[not-an-ip]/x``). The ``preset_add`` handler must escape
|
|
that message before printing so Rich does not interpret ``[not-an-ip]``
|
|
as a markup tag and crash while rendering the error.
|
|
"""
|
|
from typer.testing import CliRunner
|
|
from unittest.mock import patch
|
|
from specify_cli import app
|
|
|
|
bad_url = "https://[not-an-ip]/x"
|
|
catalog_data = {
|
|
"test-pack": {
|
|
"name": "Test Pack",
|
|
"version": "1.0.0",
|
|
"download_url": bad_url,
|
|
}
|
|
}
|
|
|
|
runner = CliRunner()
|
|
with patch.object(Path, "cwd", return_value=project_dir), \
|
|
patch.object(PresetCatalog, "_get_merged_packs", return_value=catalog_data):
|
|
result = runner.invoke(
|
|
app,
|
|
["preset", "add", "test-pack"],
|
|
catch_exceptions=True,
|
|
)
|
|
|
|
assert result.exit_code == 1, result.output
|
|
assert result.exception is None or isinstance(result.exception, SystemExit)
|
|
output = strip_ansi(result.output)
|
|
assert "Error:" in output
|
|
# The malformed URL surfaces verbatim rather than crashing the renderer.
|
|
assert bad_url in output
|
|
|
|
@pytest.mark.parametrize(
|
|
("exc_type", "label"),
|
|
[
|
|
(PresetCompatibilityError, "Compatibility Error"),
|
|
(PresetValidationError, "Validation Error"),
|
|
(PresetError, "Error"),
|
|
],
|
|
)
|
|
def test_preset_add_exception_handlers_escape_markup(self, project_dir, exc_type, label):
|
|
"""Preset install exceptions can include catalog-controlled values.
|
|
|
|
The message must be escaped so Rich does not treat bracketed content as
|
|
markup and raise while rendering the error.
|
|
"""
|
|
from typer.testing import CliRunner
|
|
from unittest.mock import patch
|
|
from specify_cli import app
|
|
|
|
dev_dir = project_dir / "dev-pack"
|
|
dev_dir.mkdir()
|
|
|
|
runner = CliRunner()
|
|
with patch.object(Path, "cwd", return_value=project_dir), \
|
|
patch.object(
|
|
PresetManager,
|
|
"install_from_directory",
|
|
side_effect=exc_type("bad [red]preset[/red]"),
|
|
):
|
|
result = runner.invoke(
|
|
app,
|
|
["preset", "add", "--dev", str(dev_dir)],
|
|
catch_exceptions=True,
|
|
)
|
|
|
|
assert result.exit_code == 1, result.output
|
|
assert result.exception is None or isinstance(result.exception, SystemExit)
|
|
assert f"{label}:" in result.output
|
|
assert "bad [red]preset[/red]" in result.output
|
|
|
|
def test_preset_add_from_url_redirect_error_describes_disallowed_url(self, project_dir, monkeypatch, capsys):
|
|
"""Redirect rejection message covers hostless HTTPS, not only non-HTTPS URLs."""
|
|
import typer
|
|
from specify_cli.presets._commands import preset_add
|
|
|
|
class FakeResponse(io.BytesIO):
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
def geturl(self):
|
|
return "https:///preset.zip"
|
|
|
|
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
|
monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0")
|
|
monkeypatch.setattr(
|
|
"specify_cli.authentication.http.open_url",
|
|
lambda url, timeout=None, extra_headers=None, redirect_validator=None: FakeResponse(b"zip"),
|
|
)
|
|
monkeypatch.setattr(PresetManager, "install_from_zip", lambda *args, **kwargs: None)
|
|
|
|
with pytest.raises(typer.Exit) as exc_info:
|
|
preset_add(preset_id=None, from_url="https://example.com/preset.zip", dev=None, priority=10)
|
|
|
|
assert exc_info.value.exit_code == 1
|
|
output = strip_ansi(capsys.readouterr().out)
|
|
assert "redirected to a disallowed URL" in output
|
|
assert "must use HTTPS with a hostname" in output
|
|
|
|
def test_preset_add_from_url_streams_download_to_zip(self, project_dir, monkeypatch):
|
|
"""URL installs stream response bytes to disk before installing the ZIP."""
|
|
from specify_cli.presets._commands import preset_add
|
|
|
|
class FakeResponse(io.BytesIO):
|
|
def __init__(self, data):
|
|
super().__init__(data)
|
|
self.read_sizes = []
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
def geturl(self):
|
|
return "https://example.com/preset.zip"
|
|
|
|
def read(self, size=-1):
|
|
assert size not in (-1, None)
|
|
self.read_sizes.append(size)
|
|
return super().read(size)
|
|
|
|
response = FakeResponse(b"zip-bytes")
|
|
installed = {}
|
|
|
|
def fake_install_from_zip(self, zip_path, speckit_version, priority=10):
|
|
installed["zip_bytes"] = Path(zip_path).read_bytes()
|
|
installed["speckit_version"] = speckit_version
|
|
installed["priority"] = priority
|
|
return SimpleNamespace(name="Test Preset", version="1.0.0")
|
|
|
|
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
|
monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0")
|
|
monkeypatch.setattr(
|
|
"specify_cli.authentication.http.open_url",
|
|
lambda url, timeout=None, extra_headers=None, redirect_validator=None: response,
|
|
)
|
|
monkeypatch.setattr(PresetManager, "install_from_zip", fake_install_from_zip)
|
|
|
|
preset_add(preset_id=None, from_url="https://example.com/preset.zip", dev=None, priority=7)
|
|
|
|
assert response.read_sizes
|
|
assert installed == {
|
|
"zip_bytes": b"zip-bytes",
|
|
"speckit_version": "0.6.0",
|
|
"priority": 7,
|
|
}
|
|
|
|
def test_bundled_preset_in_catalog(self):
|
|
"""Verify the lean preset is listed in catalog.json with bundled marker."""
|
|
catalog_path = Path(__file__).parent.parent / "presets" / "catalog.json"
|
|
catalog = json.loads(catalog_path.read_text())
|
|
assert "lean" in catalog["presets"]
|
|
assert catalog["presets"]["lean"]["bundled"] is True
|
|
assert "download_url" not in catalog["presets"]["lean"]
|
|
|
|
def test_bundled_preset_download_raises_error(self, project_dir):
|
|
"""download_pack raises PresetError for bundled presets without download_url."""
|
|
catalog = PresetCatalog(project_dir)
|
|
|
|
catalog_data = {
|
|
"test-bundled": {
|
|
"name": "Test Bundled",
|
|
"version": "1.0.0",
|
|
"bundled": True,
|
|
}
|
|
}
|
|
from unittest.mock import patch
|
|
with patch.object(catalog, "_get_merged_packs", return_value=catalog_data):
|
|
with pytest.raises(PresetError, match="bundled with spec-kit"):
|
|
catalog.download_pack("test-bundled")
|
|
|
|
def test_bundled_preset_missing_locally_cli_error(self, project_dir):
|
|
"""CLI shows clear error when bundled preset cannot be found locally."""
|
|
from typer.testing import CliRunner
|
|
from unittest.mock import patch
|
|
from specify_cli import app
|
|
|
|
runner = CliRunner()
|
|
# Patch _locate_bundled_preset to return None (simulating missing files)
|
|
# and mock the catalog to return a bundled entry for "lean"
|
|
fake_pack_info = {
|
|
"id": "lean",
|
|
"name": "Lean Workflow",
|
|
"version": "1.0.0",
|
|
"bundled": True,
|
|
"_install_allowed": True,
|
|
}
|
|
with patch.object(Path, "cwd", return_value=project_dir), \
|
|
patch("specify_cli._locate_bundled_preset", return_value=None), \
|
|
patch("specify_cli.presets.PresetCatalog") as MockCatalog:
|
|
MockCatalog.return_value.get_pack_info.return_value = fake_pack_info
|
|
result = runner.invoke(app, ["preset", "add", "lean"])
|
|
|
|
# Should fail with a helpful error explaining this is a bundled preset
|
|
# and suggesting how to recover.
|
|
assert result.exit_code == 1
|
|
output = strip_ansi(result.output).lower()
|
|
assert "bundled" in output, result.output
|
|
assert "reinstall" in output, result.output
|
|
|
|
|
|
class TestPresetAddFromUrlResolution:
|
|
"""CLI-level tests for preset add --from <url> GitHub release resolution."""
|
|
|
|
def test_preset_add_from_github_release_url_resolves_and_downloads(self, project_dir):
|
|
"""'preset add --from <github-release-url>' resolves to API asset URL."""
|
|
from typer.testing import CliRunner
|
|
from unittest.mock import patch
|
|
from specify_cli import app
|
|
|
|
manifest_content = yaml.dump({
|
|
"schema_version": "1.0",
|
|
"preset": {"id": "my-preset", "name": "My Preset", "version": "1.0.0", "description": "Test preset", "author": "Test", "license": "MIT"},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {"templates": [{"type": "template", "name": "t", "file": "templates/t.md", "description": "t"}]},
|
|
})
|
|
zip_buf = __import__("io").BytesIO()
|
|
with zipfile.ZipFile(zip_buf, "w") as zf:
|
|
zf.writestr("preset.yml", manifest_content)
|
|
zip_bytes = zip_buf.getvalue()
|
|
|
|
captured_urls = []
|
|
|
|
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
|
|
captured_urls.append((url, extra_headers))
|
|
if "releases/tags/" in url:
|
|
return io.BytesIO(json.dumps({
|
|
"assets": [{"name": "preset.zip", "url": "https://api.github.com/repos/org/repo/releases/assets/42"}]
|
|
}).encode())
|
|
return io.BytesIO(zip_bytes)
|
|
|
|
runner = CliRunner()
|
|
with patch.object(Path, "cwd", return_value=project_dir), \
|
|
patch("specify_cli.get_speckit_version", return_value="1.0.0"), \
|
|
patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url):
|
|
result = runner.invoke(app, [
|
|
"preset", "add",
|
|
"--from", "https://github.com/org/repo/releases/download/v1.0/preset.zip",
|
|
])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "My Preset" in result.output
|
|
# First call should resolve the release tag
|
|
assert any("releases/tags/v1.0" in url for url, _ in captured_urls)
|
|
# Second call should download from the resolved asset URL with octet-stream
|
|
asset_calls = [(url, h) for url, h in captured_urls if "releases/assets/" in url]
|
|
assert len(asset_calls) >= 1
|
|
assert asset_calls[0][1] == {"Accept": "application/octet-stream"}
|
|
|
|
def test_preset_add_from_direct_api_asset_url_passes_through(self, project_dir):
|
|
"""'preset add --from <api-asset-url>' uses URL directly with octet-stream."""
|
|
from typer.testing import CliRunner
|
|
from unittest.mock import patch
|
|
from specify_cli import app
|
|
|
|
manifest_content = yaml.dump({
|
|
"schema_version": "1.0",
|
|
"preset": {"id": "my-preset", "name": "My Preset", "version": "1.0.0", "description": "Test preset", "author": "Test", "license": "MIT"},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {"templates": [{"type": "template", "name": "t", "file": "templates/t.md", "description": "t"}]},
|
|
})
|
|
zip_buf = __import__("io").BytesIO()
|
|
with zipfile.ZipFile(zip_buf, "w") as zf:
|
|
zf.writestr("preset.yml", manifest_content)
|
|
zip_bytes = zip_buf.getvalue()
|
|
|
|
captured_urls = []
|
|
|
|
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
|
|
captured_urls.append((url, extra_headers))
|
|
return io.BytesIO(zip_bytes)
|
|
|
|
runner = CliRunner()
|
|
with patch.object(Path, "cwd", return_value=project_dir), \
|
|
patch("specify_cli.get_speckit_version", return_value="1.0.0"), \
|
|
patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url):
|
|
result = runner.invoke(app, [
|
|
"preset", "add",
|
|
"--from", "https://api.github.com/repos/org/repo/releases/assets/42",
|
|
])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
# Should go directly to the asset URL with Accept header
|
|
assert len(captured_urls) == 1
|
|
assert captured_urls[0][0] == "https://api.github.com/repos/org/repo/releases/assets/42"
|
|
assert captured_urls[0][1] == {"Accept": "application/octet-stream"}
|
|
|
|
def test_preset_add_from_ghes_release_url_resolves_via_api_v3(self, project_dir, monkeypatch):
|
|
"""'preset add --from <ghes-release-url>' resolves via GHES /api/v3 endpoint."""
|
|
from typer.testing import CliRunner
|
|
from unittest.mock import patch
|
|
from specify_cli import app
|
|
from specify_cli.authentication import http as _auth_http
|
|
from specify_cli.authentication.config import AuthConfigEntry
|
|
|
|
monkeypatch.setattr(_auth_http, "_config_override", [
|
|
AuthConfigEntry(hosts=("ghes.example",), provider="github", auth="bearer", token="t"),
|
|
])
|
|
|
|
manifest_content = yaml.dump({
|
|
"schema_version": "1.0",
|
|
"preset": {"id": "my-preset", "name": "My Preset", "version": "1.0.0", "description": "Test preset", "author": "Test", "license": "MIT"},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {"templates": [{"type": "template", "name": "t", "file": "templates/t.md", "description": "t"}]},
|
|
})
|
|
zip_buf = io.BytesIO()
|
|
with zipfile.ZipFile(zip_buf, "w") as zf:
|
|
zf.writestr("preset.yml", manifest_content)
|
|
zip_bytes = zip_buf.getvalue()
|
|
|
|
captured_urls = []
|
|
|
|
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
|
|
captured_urls.append((url, extra_headers))
|
|
if "releases/tags/" in url:
|
|
return io.BytesIO(json.dumps({
|
|
"assets": [{"name": "preset.zip", "url": "https://ghes.example/api/v3/repos/org/repo/releases/assets/42"}]
|
|
}).encode())
|
|
return io.BytesIO(zip_bytes)
|
|
|
|
runner = CliRunner()
|
|
with patch.object(Path, "cwd", return_value=project_dir), \
|
|
patch("specify_cli.get_speckit_version", return_value="1.0.0"), \
|
|
patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url):
|
|
result = runner.invoke(app, [
|
|
"preset", "add",
|
|
"--from", "https://ghes.example/org/repo/releases/download/v1.0/preset.zip",
|
|
])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
# The tag-lookup call must use the GHES /api/v3 endpoint
|
|
assert any("ghes.example/api/v3/repos/org/repo/releases/tags/v1.0" in url for url, _ in captured_urls)
|
|
# The asset download call must carry Accept: application/octet-stream
|
|
asset_calls = [(url, h) for url, h in captured_urls if "releases/assets/" in url]
|
|
assert len(asset_calls) >= 1
|
|
assert asset_calls[0][1] == {"Accept": "application/octet-stream"}
|
|
|
|
|
|
class TestWrapStrategy:
|
|
"""Tests for strategy: wrap preset command substitution."""
|
|
|
|
def test_substitute_core_template_replaces_placeholder(self, project_dir):
|
|
"""Core template body replaces {CORE_TEMPLATE} in preset command body."""
|
|
from specify_cli.presets import _substitute_core_template
|
|
from specify_cli.agents import CommandRegistrar
|
|
|
|
# Set up a core command template
|
|
core_dir = project_dir / ".specify" / "templates" / "commands"
|
|
core_dir.mkdir(parents=True, exist_ok=True)
|
|
(core_dir / "specify.md").write_text(
|
|
"---\ndescription: core\n---\n\n# Core Specify\n\nDo the thing.\n"
|
|
)
|
|
|
|
registrar = CommandRegistrar()
|
|
body = "## Pre-Logic\n\nBefore stuff.\n\n{CORE_TEMPLATE}\n\n## Post-Logic\n\nAfter stuff.\n"
|
|
result, core_fm = _substitute_core_template(body, "specify", project_dir, registrar)
|
|
|
|
assert "{CORE_TEMPLATE}" not in result
|
|
assert "# Core Specify" in result
|
|
assert "## Pre-Logic" in result
|
|
assert "## Post-Logic" in result
|
|
assert core_fm.get("description") == "core"
|
|
|
|
def test_substitute_core_template_no_op_when_placeholder_absent(self, project_dir):
|
|
"""Returns body unchanged when {CORE_TEMPLATE} is not present."""
|
|
from specify_cli.presets import _substitute_core_template
|
|
from specify_cli.agents import CommandRegistrar
|
|
|
|
core_dir = project_dir / ".specify" / "templates" / "commands"
|
|
core_dir.mkdir(parents=True, exist_ok=True)
|
|
(core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCore body.\n")
|
|
|
|
registrar = CommandRegistrar()
|
|
body = "## No placeholder here.\n"
|
|
result, core_fm = _substitute_core_template(body, "specify", project_dir, registrar)
|
|
assert result == body
|
|
assert core_fm == {}
|
|
|
|
def test_substitute_core_template_no_op_when_core_missing(self, project_dir):
|
|
"""Returns body unchanged when core template file does not exist."""
|
|
from specify_cli.presets import _substitute_core_template
|
|
from specify_cli.agents import CommandRegistrar
|
|
|
|
registrar = CommandRegistrar()
|
|
body = "Pre.\n\n{CORE_TEMPLATE}\n\nPost.\n"
|
|
result, core_fm = _substitute_core_template(body, "nonexistent", project_dir, registrar)
|
|
assert result == body
|
|
assert "{CORE_TEMPLATE}" in result
|
|
assert core_fm == {}
|
|
|
|
def test_register_commands_substitutes_core_template_for_wrap_strategy(self, project_dir):
|
|
"""register_commands substitutes {CORE_TEMPLATE} when strategy: wrap."""
|
|
from specify_cli.agents import CommandRegistrar
|
|
|
|
# Set up core command template
|
|
core_dir = project_dir / ".specify" / "templates" / "commands"
|
|
core_dir.mkdir(parents=True, exist_ok=True)
|
|
(core_dir / "specify.md").write_text(
|
|
"---\ndescription: core\n---\n\n# Core Specify\n\nCore body here.\n"
|
|
)
|
|
|
|
# Create a preset command dir with a wrap-strategy command
|
|
cmd_dir = project_dir / "preset" / "commands"
|
|
cmd_dir.mkdir(parents=True, exist_ok=True)
|
|
(cmd_dir / "speckit.specify.md").write_text(
|
|
"---\ndescription: wrap test\nstrategy: wrap\n---\n\n"
|
|
"## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n"
|
|
)
|
|
|
|
commands = [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}]
|
|
registrar = CommandRegistrar()
|
|
|
|
# Use a generic agent that writes markdown to commands/
|
|
agent_dir = project_dir / ".claude" / "commands"
|
|
agent_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Patch AGENT_CONFIGS to use a simple markdown agent pointing at our dir
|
|
import copy
|
|
original = copy.deepcopy(registrar.AGENT_CONFIGS)
|
|
registrar.AGENT_CONFIGS["test-agent"] = {
|
|
"dir": str(agent_dir.relative_to(project_dir)),
|
|
"format": "markdown",
|
|
"args": "$ARGUMENTS",
|
|
"extension": ".md",
|
|
"strip_frontmatter_keys": [],
|
|
}
|
|
try:
|
|
registrar.register_commands(
|
|
"test-agent", commands, "test-preset",
|
|
project_dir / "preset", project_dir
|
|
)
|
|
finally:
|
|
CommandRegistrar.AGENT_CONFIGS.clear()
|
|
CommandRegistrar.AGENT_CONFIGS.update(original)
|
|
|
|
written = (agent_dir / "speckit.specify.md").read_text()
|
|
assert "{CORE_TEMPLATE}" not in written
|
|
assert "# Core Specify" in written
|
|
assert "## Pre" in written
|
|
assert "## Post" in written
|
|
|
|
def test_end_to_end_wrap_via_self_test_preset(self, project_dir):
|
|
"""Installing self-test preset with a wrap command substitutes {CORE_TEMPLATE}."""
|
|
from specify_cli.presets import PresetManager
|
|
|
|
# Install a core template that wrap-test will wrap around
|
|
core_dir = project_dir / ".specify" / "templates" / "commands"
|
|
core_dir.mkdir(parents=True, exist_ok=True)
|
|
(core_dir / "wrap-test.md").write_text(
|
|
"---\ndescription: core wrap-test\n---\n\n# Core Wrap-Test Body\n"
|
|
)
|
|
|
|
# Set up skills dir (simulating --integration claude)
|
|
skills_dir = project_dir / ".claude" / "skills"
|
|
skills_dir.mkdir(parents=True, exist_ok=True)
|
|
skill_subdir = skills_dir / "speckit-wrap-test"
|
|
skill_subdir.mkdir()
|
|
(skill_subdir / "SKILL.md").write_text("---\nname: speckit-wrap-test\n---\n\nold content\n")
|
|
|
|
# Write init-options so _register_skills finds the claude skills dir
|
|
import json
|
|
(project_dir / ".specify" / "init-options.json").write_text(
|
|
json.dumps({"ai": "claude", "ai_skills": True})
|
|
)
|
|
|
|
manager = PresetManager(project_dir)
|
|
install_self_test_preset(manager)
|
|
|
|
written = (skill_subdir / "SKILL.md").read_text()
|
|
assert "{CORE_TEMPLATE}" not in written
|
|
assert "# Core Wrap-Test Body" in written
|
|
assert "preset:self-test wrap-pre" in written
|
|
assert "preset:self-test wrap-post" in written
|
|
|
|
def test_substitute_core_template_returns_core_scripts(self, project_dir):
|
|
"""core_frontmatter in the returned tuple includes scripts/agent_scripts."""
|
|
from specify_cli.presets import _substitute_core_template
|
|
from specify_cli.agents import CommandRegistrar
|
|
|
|
core_dir = project_dir / ".specify" / "templates" / "commands"
|
|
core_dir.mkdir(parents=True, exist_ok=True)
|
|
(core_dir / "specify.md").write_text(
|
|
"---\ndescription: core\nscripts:\n sh: run.sh\nagent_scripts:\n sh: agent-run.sh\n---\n\n# Body\n"
|
|
)
|
|
|
|
registrar = CommandRegistrar()
|
|
body = "## Wrapper\n\n{CORE_TEMPLATE}\n"
|
|
result, core_fm = _substitute_core_template(body, "specify", project_dir, registrar)
|
|
|
|
assert "# Body" in result
|
|
assert core_fm.get("scripts") == {"sh": "run.sh"}
|
|
assert core_fm.get("agent_scripts") == {"sh": "agent-run.sh"}
|
|
|
|
def test_register_skills_inherits_scripts_from_core_when_preset_omits_them(self, project_dir):
|
|
"""_register_skills merges scripts/agent_scripts from core when preset lacks them."""
|
|
from specify_cli.presets import PresetManager
|
|
import json
|
|
|
|
# Core template with scripts
|
|
core_dir = project_dir / ".specify" / "templates" / "commands"
|
|
core_dir.mkdir(parents=True, exist_ok=True)
|
|
(core_dir / "wrap-test.md").write_text(
|
|
"---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh\n---\n\n"
|
|
"Run: {SCRIPT}\n"
|
|
)
|
|
|
|
# Skills dir for claude
|
|
skills_dir = project_dir / ".claude" / "skills"
|
|
skills_dir.mkdir(parents=True, exist_ok=True)
|
|
skill_subdir = skills_dir / "speckit-wrap-test"
|
|
skill_subdir.mkdir()
|
|
(skill_subdir / "SKILL.md").write_text("---\nname: speckit-wrap-test\n---\n\nold\n")
|
|
|
|
(project_dir / ".specify" / "init-options.json").write_text(
|
|
json.dumps({"ai": "claude", "ai_skills": True})
|
|
)
|
|
|
|
manager = PresetManager(project_dir)
|
|
install_self_test_preset(manager)
|
|
|
|
written = (skill_subdir / "SKILL.md").read_text()
|
|
# {SCRIPT} should have been resolved (not left as a literal placeholder)
|
|
assert "{SCRIPT}" not in written
|
|
|
|
def test_register_skills_preset_scripts_take_precedence_over_core(self, project_dir):
|
|
"""preset-defined scripts/agent_scripts are not overwritten by core frontmatter."""
|
|
from specify_cli.presets import _substitute_core_template
|
|
from specify_cli.agents import CommandRegistrar
|
|
|
|
core_dir = project_dir / ".specify" / "templates" / "commands"
|
|
core_dir.mkdir(parents=True, exist_ok=True)
|
|
(core_dir / "specify.md").write_text(
|
|
"---\ndescription: core\nscripts:\n sh: core-run.sh\n---\n\nCore body.\n"
|
|
)
|
|
|
|
registrar = CommandRegistrar()
|
|
body = "{CORE_TEMPLATE}"
|
|
_, core_fm = _substitute_core_template(body, "specify", project_dir, registrar)
|
|
|
|
# Simulate preset frontmatter that already defines scripts
|
|
preset_fm = {"description": "preset", "strategy": "wrap", "scripts": {"sh": "preset-run.sh"}}
|
|
for key in ("scripts", "agent_scripts"):
|
|
if key not in preset_fm and key in core_fm:
|
|
preset_fm[key] = core_fm[key]
|
|
|
|
# Preset's scripts must not be overwritten by core
|
|
assert preset_fm["scripts"] == {"sh": "preset-run.sh"}
|
|
|
|
def test_register_commands_inherits_scripts_from_core(self, project_dir):
|
|
"""register_commands merges scripts/agent_scripts from core and normalizes paths."""
|
|
from specify_cli.agents import CommandRegistrar
|
|
import copy
|
|
|
|
core_dir = project_dir / ".specify" / "templates" / "commands"
|
|
core_dir.mkdir(parents=True, exist_ok=True)
|
|
(core_dir / "specify.md").write_text(
|
|
"---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n"
|
|
"Run: {SCRIPT}\n"
|
|
)
|
|
|
|
cmd_dir = project_dir / "preset" / "commands"
|
|
cmd_dir.mkdir(parents=True, exist_ok=True)
|
|
# Preset has strategy: wrap but no scripts of its own
|
|
(cmd_dir / "speckit.specify.md").write_text(
|
|
"---\ndescription: wrap no scripts\nstrategy: wrap\n---\n\n"
|
|
"## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n"
|
|
)
|
|
|
|
agent_dir = project_dir / ".claude" / "commands"
|
|
agent_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
registrar = CommandRegistrar()
|
|
original = copy.deepcopy(registrar.AGENT_CONFIGS)
|
|
registrar.AGENT_CONFIGS["test-agent"] = {
|
|
"dir": str(agent_dir.relative_to(project_dir)),
|
|
"format": "markdown",
|
|
"args": "$ARGUMENTS",
|
|
"extension": ".md",
|
|
"strip_frontmatter_keys": [],
|
|
}
|
|
try:
|
|
registrar.register_commands(
|
|
"test-agent",
|
|
[{"name": "speckit.specify", "file": "commands/speckit.specify.md"}],
|
|
"test-preset",
|
|
project_dir / "preset",
|
|
project_dir,
|
|
)
|
|
finally:
|
|
CommandRegistrar.AGENT_CONFIGS.clear()
|
|
CommandRegistrar.AGENT_CONFIGS.update(original)
|
|
|
|
written = (agent_dir / "speckit.specify.md").read_text()
|
|
assert "{CORE_TEMPLATE}" not in written
|
|
assert "Run:" in written
|
|
assert "scripts:" in written
|
|
assert "run.sh" in written
|
|
|
|
def test_register_commands_toml_resolves_inherited_scripts(self, project_dir):
|
|
"""TOML agents resolve {SCRIPT} from inherited core scripts when preset omits them."""
|
|
from specify_cli.agents import CommandRegistrar
|
|
import copy
|
|
|
|
core_dir = project_dir / ".specify" / "templates" / "commands"
|
|
core_dir.mkdir(parents=True, exist_ok=True)
|
|
(core_dir / "specify.md").write_text(
|
|
"---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n"
|
|
"Run: {SCRIPT}\n"
|
|
)
|
|
|
|
cmd_dir = project_dir / "preset" / "commands"
|
|
cmd_dir.mkdir(parents=True, exist_ok=True)
|
|
(cmd_dir / "speckit.specify.md").write_text(
|
|
"---\ndescription: toml wrap\nstrategy: wrap\n---\n\n"
|
|
"## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n"
|
|
)
|
|
|
|
toml_dir = project_dir / ".gemini" / "commands"
|
|
toml_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
registrar = CommandRegistrar()
|
|
original = copy.deepcopy(registrar.AGENT_CONFIGS)
|
|
registrar.AGENT_CONFIGS["test-toml-agent"] = {
|
|
"dir": str(toml_dir.relative_to(project_dir)),
|
|
"format": "toml",
|
|
"args": "{{args}}",
|
|
"extension": ".toml",
|
|
"strip_frontmatter_keys": [],
|
|
}
|
|
try:
|
|
registrar.register_commands(
|
|
"test-toml-agent",
|
|
[{"name": "speckit.specify", "file": "commands/speckit.specify.md"}],
|
|
"test-preset",
|
|
project_dir / "preset",
|
|
project_dir,
|
|
)
|
|
finally:
|
|
CommandRegistrar.AGENT_CONFIGS.clear()
|
|
CommandRegistrar.AGENT_CONFIGS.update(original)
|
|
|
|
written = (toml_dir / "speckit.specify.toml").read_text()
|
|
assert "{CORE_TEMPLATE}" not in written
|
|
assert "{SCRIPT}" not in written
|
|
assert "run.sh" in written
|
|
# args token must use TOML format, not the intermediate $ARGUMENTS
|
|
assert "$ARGUMENTS" not in written
|
|
assert "{{args}}" in written
|
|
|
|
def test_register_commands_markdown_resolves_inherited_scripts(self, project_dir):
|
|
"""Markdown agents resolve {SCRIPT} from inherited core scripts when preset omits them."""
|
|
from specify_cli.agents import CommandRegistrar
|
|
import copy
|
|
|
|
core_dir = project_dir / ".specify" / "templates" / "commands"
|
|
core_dir.mkdir(parents=True, exist_ok=True)
|
|
(core_dir / "specify.md").write_text(
|
|
"---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n"
|
|
"Run: {SCRIPT}\n"
|
|
)
|
|
|
|
cmd_dir = project_dir / "preset" / "commands"
|
|
cmd_dir.mkdir(parents=True, exist_ok=True)
|
|
(cmd_dir / "speckit.specify.md").write_text(
|
|
"---\ndescription: markdown wrap\nstrategy: wrap\n---\n\n"
|
|
"## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n"
|
|
)
|
|
|
|
agent_dir = project_dir / ".claude" / "commands"
|
|
agent_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
registrar = CommandRegistrar()
|
|
original = copy.deepcopy(registrar.AGENT_CONFIGS)
|
|
registrar.AGENT_CONFIGS["test-md-agent"] = {
|
|
"dir": str(agent_dir.relative_to(project_dir)),
|
|
"format": "markdown",
|
|
"args": "$ARGUMENTS",
|
|
"extension": ".md",
|
|
"strip_frontmatter_keys": [],
|
|
}
|
|
try:
|
|
registrar.register_commands(
|
|
"test-md-agent",
|
|
[{"name": "speckit.specify", "file": "commands/speckit.specify.md"}],
|
|
"test-preset",
|
|
project_dir / "preset",
|
|
project_dir,
|
|
)
|
|
finally:
|
|
CommandRegistrar.AGENT_CONFIGS.clear()
|
|
CommandRegistrar.AGENT_CONFIGS.update(original)
|
|
|
|
written = (agent_dir / "speckit.specify.md").read_text()
|
|
assert "{CORE_TEMPLATE}" not in written
|
|
assert "{SCRIPT}" not in written
|
|
assert "run.sh" in written
|
|
assert "strategy" not in written
|
|
|
|
def test_register_commands_markdown_converts_args_after_script_resolution(self, project_dir):
|
|
"""Markdown agents re-run arg placeholder conversion after resolve_skill_placeholders.
|
|
|
|
resolve_skill_placeholders injects $ARGUMENTS (via {ARGS} expansion). A second
|
|
_convert_argument_placeholder call must convert those to the agent's native format.
|
|
"""
|
|
from specify_cli.agents import CommandRegistrar
|
|
import copy
|
|
|
|
core_dir = project_dir / ".specify" / "templates" / "commands"
|
|
core_dir.mkdir(parents=True, exist_ok=True)
|
|
(core_dir / "specify.md").write_text(
|
|
"---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n"
|
|
"Run: {SCRIPT}\n"
|
|
)
|
|
|
|
cmd_dir = project_dir / "preset" / "commands"
|
|
cmd_dir.mkdir(parents=True, exist_ok=True)
|
|
(cmd_dir / "speckit.specify.md").write_text(
|
|
"---\ndescription: forge wrap\nstrategy: wrap\n---\n\n"
|
|
"## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n"
|
|
)
|
|
|
|
agent_dir = project_dir / ".forge" / "commands"
|
|
agent_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
registrar = CommandRegistrar()
|
|
original = copy.deepcopy(registrar.AGENT_CONFIGS)
|
|
registrar.AGENT_CONFIGS["test-forge-agent"] = {
|
|
"dir": str(agent_dir.relative_to(project_dir)),
|
|
"format": "markdown",
|
|
"args": "{{parameters}}",
|
|
"extension": ".md",
|
|
"strip_frontmatter_keys": [],
|
|
}
|
|
try:
|
|
registrar.register_commands(
|
|
"test-forge-agent",
|
|
[{"name": "speckit.specify", "file": "commands/speckit.specify.md"}],
|
|
"test-preset",
|
|
project_dir / "preset",
|
|
project_dir,
|
|
)
|
|
finally:
|
|
CommandRegistrar.AGENT_CONFIGS.clear()
|
|
CommandRegistrar.AGENT_CONFIGS.update(original)
|
|
|
|
written = (agent_dir / "speckit.specify.md").read_text()
|
|
assert "{SCRIPT}" not in written
|
|
assert "run.sh" in written
|
|
# $ARGUMENTS injected by resolve_skill_placeholders must be re-converted
|
|
assert "$ARGUMENTS" not in written
|
|
assert "{{parameters}}" in written
|
|
|
|
def test_extension_command_resolves_via_extension_directory(self, project_dir):
|
|
"""Extension commands (e.g. speckit.git.feature) resolve from the extension directory.
|
|
|
|
Both _register_skills and register_commands pass the full cmd_name to
|
|
_substitute_core_template, which tries the full name first via PresetResolver
|
|
and finds speckit.git.feature.md in the extension commands directory.
|
|
"""
|
|
from specify_cli.presets import _substitute_core_template
|
|
from specify_cli.agents import CommandRegistrar
|
|
|
|
# Place the template where a real extension would install it
|
|
ext_cmd_dir = project_dir / ".specify" / "extensions" / "git" / "commands"
|
|
ext_cmd_dir.mkdir(parents=True, exist_ok=True)
|
|
(ext_cmd_dir / "speckit.git.feature.md").write_text(
|
|
"---\ndescription: git feature core\n---\n\n# Git Feature Core\n"
|
|
)
|
|
# Ensure a hyphenated or dot-separated fallback does NOT exist
|
|
assert not (project_dir / ".specify" / "templates" / "commands" / "git.feature.md").exists()
|
|
assert not (project_dir / ".specify" / "templates" / "commands" / "git-feature.md").exists()
|
|
|
|
registrar = CommandRegistrar()
|
|
body = "## Wrapper\n\n{CORE_TEMPLATE}\n"
|
|
|
|
# Both call sites now pass the full cmd_name
|
|
result, _ = _substitute_core_template(body, "speckit.git.feature", project_dir, registrar)
|
|
|
|
assert "# Git Feature Core" in result
|
|
assert "{CORE_TEMPLATE}" not in result
|
|
|
|
def test_extension_command_resolves_via_manifest_when_filename_differs(self, project_dir):
|
|
"""Extension commands whose filename differs from the command name resolve via extension.yml.
|
|
|
|
The selftest extension maps speckit.selftest.extension → commands/selftest.md.
|
|
Name-based lookup would look for commands/speckit.selftest.extension.md and fail;
|
|
manifest-based lookup must find the actual file declared in the manifest.
|
|
"""
|
|
from specify_cli.presets import _substitute_core_template
|
|
from specify_cli.agents import CommandRegistrar
|
|
|
|
ext_dir = project_dir / ".specify" / "extensions" / "selftest"
|
|
cmd_dir = ext_dir / "commands"
|
|
cmd_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# File is named selftest.md, NOT speckit.selftest.extension.md
|
|
(cmd_dir / "selftest.md").write_text(
|
|
"---\ndescription: selftest core\n---\n\n# Selftest Core\n"
|
|
)
|
|
# Manifest maps the command name to the actual file
|
|
(ext_dir / "extension.yml").write_text(
|
|
"schema_version: '1.0'\n"
|
|
"extension:\n id: selftest\n name: Self-Test\n version: 1.0.0\n"
|
|
" description: test\n author: test\n repository: https://example.com\n"
|
|
" license: MIT\n"
|
|
"requires:\n speckit_version: '>=0.2.0'\n"
|
|
"provides:\n"
|
|
" commands:\n"
|
|
" - name: speckit.selftest.extension\n"
|
|
" file: commands/selftest.md\n"
|
|
" description: Selftest command\n"
|
|
)
|
|
|
|
registrar = CommandRegistrar()
|
|
body = "## Wrapper\n\n{CORE_TEMPLATE}\n"
|
|
result, _ = _substitute_core_template(body, "speckit.selftest.extension", project_dir, registrar)
|
|
|
|
assert "# Selftest Core" in result
|
|
assert "{CORE_TEMPLATE}" not in result
|
|
|
|
|
|
# ===== _replay_wraps_for_command Tests =====
|
|
|
|
def _make_wrap_preset_dir(
|
|
base: Path,
|
|
preset_id: str,
|
|
cmd_name: str,
|
|
pre: str,
|
|
post: str,
|
|
aliases: list[str] | None = None,
|
|
file_rel: str | None = None,
|
|
) -> Path:
|
|
"""Create a minimal wrap-strategy preset directory for testing."""
|
|
preset_dir = base / preset_id
|
|
cmd_dir = preset_dir / "commands"
|
|
cmd_dir.mkdir(parents=True)
|
|
file_rel = file_rel or f"commands/{cmd_name}.md"
|
|
template = {
|
|
"type": "command",
|
|
"name": cmd_name,
|
|
"file": file_rel,
|
|
"description": f"{preset_id} wrap",
|
|
}
|
|
if aliases is not None:
|
|
template["aliases"] = aliases
|
|
manifest = {
|
|
"schema_version": "1.0",
|
|
"preset": {
|
|
"id": preset_id,
|
|
"name": preset_id,
|
|
"version": "1.0.0",
|
|
"description": f"Preset {preset_id}",
|
|
"author": "test",
|
|
"repository": "https://example.com",
|
|
"license": "MIT",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"templates": [template]
|
|
},
|
|
"tags": [],
|
|
}
|
|
import yaml as _yaml
|
|
(preset_dir / "preset.yml").write_text(_yaml.dump(manifest))
|
|
command_path = preset_dir / file_rel
|
|
command_path.parent.mkdir(parents=True, exist_ok=True)
|
|
command_path.write_text(
|
|
f"---\ndescription: {preset_id} wrap\nstrategy: wrap\n---\n\n"
|
|
f"[{pre}]\n\n{{CORE_TEMPLATE}}\n\n[{post}]\n"
|
|
)
|
|
return preset_dir
|
|
|
|
|
|
|
|
class TestCompositionStrategyValidation:
|
|
"""Test strategy field validation in PresetManifest."""
|
|
|
|
def test_valid_replace_strategy(self, temp_dir, valid_pack_data):
|
|
"""Test that replace strategy is accepted."""
|
|
valid_pack_data["provides"]["templates"][0]["strategy"] = "replace"
|
|
manifest_path = temp_dir / "preset.yml"
|
|
with open(manifest_path, 'w') as f:
|
|
yaml.dump(valid_pack_data, f)
|
|
(temp_dir / "templates").mkdir(exist_ok=True)
|
|
(temp_dir / "templates" / "spec-template.md").write_text("test")
|
|
manifest = PresetManifest(manifest_path)
|
|
assert manifest.templates[0]["strategy"] == "replace"
|
|
|
|
def test_valid_prepend_strategy(self, temp_dir, valid_pack_data):
|
|
"""Test that prepend strategy is accepted for templates."""
|
|
valid_pack_data["provides"]["templates"][0]["strategy"] = "prepend"
|
|
manifest_path = temp_dir / "preset.yml"
|
|
with open(manifest_path, 'w') as f:
|
|
yaml.dump(valid_pack_data, f)
|
|
(temp_dir / "templates").mkdir(exist_ok=True)
|
|
(temp_dir / "templates" / "spec-template.md").write_text("test")
|
|
manifest = PresetManifest(manifest_path)
|
|
assert manifest.templates[0]["strategy"] == "prepend"
|
|
|
|
def test_valid_append_strategy(self, temp_dir, valid_pack_data):
|
|
"""Test that append strategy is accepted for templates."""
|
|
valid_pack_data["provides"]["templates"][0]["strategy"] = "append"
|
|
manifest_path = temp_dir / "preset.yml"
|
|
with open(manifest_path, 'w') as f:
|
|
yaml.dump(valid_pack_data, f)
|
|
(temp_dir / "templates").mkdir(exist_ok=True)
|
|
(temp_dir / "templates" / "spec-template.md").write_text("test")
|
|
manifest = PresetManifest(manifest_path)
|
|
assert manifest.templates[0]["strategy"] == "append"
|
|
|
|
def test_valid_wrap_strategy(self, temp_dir, valid_pack_data):
|
|
"""Test that wrap strategy is accepted for templates."""
|
|
valid_pack_data["provides"]["templates"][0]["strategy"] = "wrap"
|
|
manifest_path = temp_dir / "preset.yml"
|
|
with open(manifest_path, 'w') as f:
|
|
yaml.dump(valid_pack_data, f)
|
|
(temp_dir / "templates").mkdir(exist_ok=True)
|
|
(temp_dir / "templates" / "spec-template.md").write_text("test")
|
|
manifest = PresetManifest(manifest_path)
|
|
assert manifest.templates[0]["strategy"] == "wrap"
|
|
|
|
def test_default_strategy_is_replace(self, pack_dir):
|
|
"""Test that omitting strategy defaults to replace (key is absent)."""
|
|
manifest = PresetManifest(pack_dir / "preset.yml")
|
|
# Strategy key should not be present in the manifest data
|
|
assert "strategy" not in manifest.templates[0]
|
|
# But consumers should treat missing strategy as "replace"
|
|
assert manifest.templates[0].get("strategy", "replace") == "replace"
|
|
|
|
def test_invalid_strategy_rejected(self, temp_dir, valid_pack_data):
|
|
"""Test that invalid strategy values are rejected."""
|
|
valid_pack_data["provides"]["templates"][0]["strategy"] = "merge"
|
|
manifest_path = temp_dir / "preset.yml"
|
|
with open(manifest_path, 'w') as f:
|
|
yaml.dump(valid_pack_data, f)
|
|
with pytest.raises(PresetValidationError, match="Invalid strategy"):
|
|
PresetManifest(manifest_path)
|
|
|
|
def test_prepend_rejected_for_scripts(self, temp_dir, valid_pack_data):
|
|
"""Test that prepend strategy is rejected for scripts."""
|
|
valid_pack_data["provides"]["templates"] = [{
|
|
"type": "script",
|
|
"name": "create-new-feature",
|
|
"file": "scripts/create-new-feature.sh",
|
|
"strategy": "prepend",
|
|
}]
|
|
manifest_path = temp_dir / "preset.yml"
|
|
with open(manifest_path, 'w') as f:
|
|
yaml.dump(valid_pack_data, f)
|
|
with pytest.raises(PresetValidationError, match="Invalid strategy.*for script"):
|
|
PresetManifest(manifest_path)
|
|
|
|
def test_append_rejected_for_scripts(self, temp_dir, valid_pack_data):
|
|
"""Test that append strategy is rejected for scripts."""
|
|
valid_pack_data["provides"]["templates"] = [{
|
|
"type": "script",
|
|
"name": "create-new-feature",
|
|
"file": "scripts/create-new-feature.sh",
|
|
"strategy": "append",
|
|
}]
|
|
manifest_path = temp_dir / "preset.yml"
|
|
with open(manifest_path, 'w') as f:
|
|
yaml.dump(valid_pack_data, f)
|
|
with pytest.raises(PresetValidationError, match="Invalid strategy.*for script"):
|
|
PresetManifest(manifest_path)
|
|
|
|
def test_wrap_accepted_for_scripts(self, temp_dir, valid_pack_data):
|
|
"""Test that wrap strategy is accepted for scripts."""
|
|
valid_pack_data["provides"]["templates"] = [{
|
|
"type": "script",
|
|
"name": "create-new-feature",
|
|
"file": "scripts/create-new-feature.sh",
|
|
"strategy": "wrap",
|
|
}]
|
|
manifest_path = temp_dir / "preset.yml"
|
|
with open(manifest_path, 'w') as f:
|
|
yaml.dump(valid_pack_data, f)
|
|
manifest = PresetManifest(manifest_path)
|
|
assert manifest.templates[0]["strategy"] == "wrap"
|
|
|
|
def test_replace_accepted_for_scripts(self, temp_dir, valid_pack_data):
|
|
"""Test that replace strategy is accepted for scripts."""
|
|
valid_pack_data["provides"]["templates"] = [{
|
|
"type": "script",
|
|
"name": "create-new-feature",
|
|
"file": "scripts/create-new-feature.sh",
|
|
"strategy": "replace",
|
|
}]
|
|
manifest_path = temp_dir / "preset.yml"
|
|
with open(manifest_path, 'w') as f:
|
|
yaml.dump(valid_pack_data, f)
|
|
manifest = PresetManifest(manifest_path)
|
|
assert manifest.templates[0]["strategy"] == "replace"
|
|
|
|
def test_prepend_accepted_for_commands(self, temp_dir, valid_pack_data):
|
|
"""Test that prepend strategy is accepted for commands."""
|
|
valid_pack_data["provides"]["templates"] = [{
|
|
"type": "command",
|
|
"name": "speckit.specify",
|
|
"file": "commands/speckit.specify.md",
|
|
"strategy": "prepend",
|
|
}]
|
|
manifest_path = temp_dir / "preset.yml"
|
|
with open(manifest_path, 'w') as f:
|
|
yaml.dump(valid_pack_data, f)
|
|
manifest = PresetManifest(manifest_path)
|
|
assert manifest.templates[0]["strategy"] == "prepend"
|
|
|
|
|
|
class TestResolveContent:
|
|
"""Test PresetResolver.resolve_content() composition."""
|
|
|
|
def test_resolve_content_core_template(self, project_dir):
|
|
"""Test resolve_content returns core template when no composition."""
|
|
resolver = PresetResolver(project_dir)
|
|
content = resolver.resolve_content("spec-template")
|
|
assert content is not None
|
|
assert "Core Spec Template" in content
|
|
|
|
def test_resolve_content_nonexistent(self, project_dir):
|
|
"""Test resolve_content returns None for nonexistent template."""
|
|
resolver = PresetResolver(project_dir)
|
|
content = resolver.resolve_content("nonexistent")
|
|
assert content is None
|
|
|
|
def test_resolve_content_replace_strategy(self, project_dir, temp_dir, valid_pack_data):
|
|
"""Test resolve_content with default replace strategy."""
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(
|
|
_create_pack(temp_dir, valid_pack_data, "replace-pack",
|
|
"# Replaced Content\n"),
|
|
"0.1.5"
|
|
)
|
|
|
|
resolver = PresetResolver(project_dir)
|
|
content = resolver.resolve_content("spec-template")
|
|
assert content is not None
|
|
assert "Replaced Content" in content
|
|
assert "Core Spec Template" not in content
|
|
|
|
def test_resolve_content_append_strategy(self, project_dir, temp_dir, valid_pack_data):
|
|
"""Test resolve_content with append strategy."""
|
|
pack_data = {**valid_pack_data}
|
|
pack_data["preset"] = {**valid_pack_data["preset"], "id": "append-pack", "name": "Append"}
|
|
pack_data["provides"] = {
|
|
"templates": [{
|
|
"type": "template",
|
|
"name": "spec-template",
|
|
"file": "templates/spec-template.md",
|
|
"strategy": "append",
|
|
}]
|
|
}
|
|
pack_dir = temp_dir / "append-pack"
|
|
pack_dir.mkdir()
|
|
with open(pack_dir / "preset.yml", 'w') as f:
|
|
yaml.dump(pack_data, f)
|
|
(pack_dir / "templates").mkdir()
|
|
(pack_dir / "templates" / "spec-template.md").write_text("## Appended Section\n")
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(pack_dir, "0.1.5")
|
|
|
|
resolver = PresetResolver(project_dir)
|
|
content = resolver.resolve_content("spec-template")
|
|
assert content is not None
|
|
assert "Core Spec Template" in content
|
|
assert "Appended Section" in content
|
|
# Core should come first, appended after
|
|
assert content.index("Core Spec Template") < content.index("Appended Section")
|
|
|
|
def test_resolve_content_prepend_strategy(self, project_dir, temp_dir, valid_pack_data):
|
|
"""Test resolve_content with prepend strategy."""
|
|
pack_data = {**valid_pack_data}
|
|
pack_data["preset"] = {**valid_pack_data["preset"], "id": "prepend-pack", "name": "Prepend"}
|
|
pack_data["provides"] = {
|
|
"templates": [{
|
|
"type": "template",
|
|
"name": "spec-template",
|
|
"file": "templates/spec-template.md",
|
|
"strategy": "prepend",
|
|
}]
|
|
}
|
|
pack_dir = temp_dir / "prepend-pack"
|
|
pack_dir.mkdir()
|
|
with open(pack_dir / "preset.yml", 'w') as f:
|
|
yaml.dump(pack_data, f)
|
|
(pack_dir / "templates").mkdir()
|
|
(pack_dir / "templates" / "spec-template.md").write_text("## Security Header\n")
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(pack_dir, "0.1.5")
|
|
|
|
resolver = PresetResolver(project_dir)
|
|
content = resolver.resolve_content("spec-template")
|
|
assert content is not None
|
|
assert "Security Header" in content
|
|
assert "Core Spec Template" in content
|
|
# Prepended content should come first
|
|
assert content.index("Security Header") < content.index("Core Spec Template")
|
|
|
|
def test_resolve_content_wrap_strategy(self, project_dir, temp_dir, valid_pack_data):
|
|
"""Test resolve_content with wrap strategy for templates."""
|
|
pack_data = {**valid_pack_data}
|
|
pack_data["preset"] = {**valid_pack_data["preset"], "id": "wrap-pack", "name": "Wrap"}
|
|
pack_data["provides"] = {
|
|
"templates": [{
|
|
"type": "template",
|
|
"name": "spec-template",
|
|
"file": "templates/spec-template.md",
|
|
"strategy": "wrap",
|
|
}]
|
|
}
|
|
pack_dir = temp_dir / "wrap-pack"
|
|
pack_dir.mkdir()
|
|
with open(pack_dir / "preset.yml", 'w') as f:
|
|
yaml.dump(pack_data, f)
|
|
(pack_dir / "templates").mkdir()
|
|
(pack_dir / "templates" / "spec-template.md").write_text(
|
|
"# Wrapper Start\n\n{CORE_TEMPLATE}\n\n# Wrapper End\n"
|
|
)
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(pack_dir, "0.1.5")
|
|
|
|
resolver = PresetResolver(project_dir)
|
|
content = resolver.resolve_content("spec-template")
|
|
assert content is not None
|
|
assert "Wrapper Start" in content
|
|
assert "Core Spec Template" in content
|
|
assert "Wrapper End" in content
|
|
# Wrapper should surround core
|
|
assert content.index("Wrapper Start") < content.index("Core Spec Template")
|
|
assert content.index("Core Spec Template") < content.index("Wrapper End")
|
|
|
|
def test_resolve_content_wrap_strategy_script(self, project_dir, temp_dir, valid_pack_data):
|
|
"""Test resolve_content with wrap strategy for scripts uses $CORE_SCRIPT."""
|
|
# Create core script
|
|
scripts_dir = project_dir / ".specify" / "templates" / "scripts"
|
|
scripts_dir.mkdir(parents=True, exist_ok=True)
|
|
(scripts_dir / "test-script.sh").write_text("echo 'core script'\n")
|
|
|
|
pack_data = {**valid_pack_data}
|
|
pack_data["preset"] = {**valid_pack_data["preset"], "id": "script-wrap", "name": "Script Wrap"}
|
|
pack_data["provides"] = {
|
|
"templates": [{
|
|
"type": "script",
|
|
"name": "test-script",
|
|
"file": "scripts/test-script.sh",
|
|
"strategy": "wrap",
|
|
}]
|
|
}
|
|
pack_dir = temp_dir / "script-wrap"
|
|
pack_dir.mkdir()
|
|
with open(pack_dir / "preset.yml", 'w') as f:
|
|
yaml.dump(pack_data, f)
|
|
(pack_dir / "scripts").mkdir()
|
|
(pack_dir / "scripts" / "test-script.sh").write_text(
|
|
"#!/bin/bash\necho 'before'\n$CORE_SCRIPT\necho 'after'\n"
|
|
)
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(pack_dir, "0.1.5")
|
|
|
|
resolver = PresetResolver(project_dir)
|
|
content = resolver.resolve_content("test-script", "script")
|
|
assert content is not None
|
|
assert "echo 'before'" in content
|
|
assert "echo 'core script'" in content
|
|
assert "echo 'after'" in content
|
|
|
|
def test_resolve_content_multi_preset_chain(self, project_dir, temp_dir, valid_pack_data):
|
|
"""Test multi-preset composition chain: prepend + append stacking."""
|
|
# Create preset A (priority 1): prepend security header
|
|
pack_a_data = {**valid_pack_data}
|
|
pack_a_data["preset"] = {**valid_pack_data["preset"], "id": "preset-a", "name": "A"}
|
|
pack_a_data["provides"] = {
|
|
"templates": [{
|
|
"type": "template",
|
|
"name": "spec-template",
|
|
"file": "templates/spec-template.md",
|
|
"strategy": "prepend",
|
|
}]
|
|
}
|
|
pack_a_dir = temp_dir / "preset-a"
|
|
pack_a_dir.mkdir()
|
|
with open(pack_a_dir / "preset.yml", 'w') as f:
|
|
yaml.dump(pack_a_data, f)
|
|
(pack_a_dir / "templates").mkdir()
|
|
(pack_a_dir / "templates" / "spec-template.md").write_text("## Security Header\n")
|
|
|
|
# Create preset B (priority 2): append compliance footer
|
|
pack_b_data = {**valid_pack_data}
|
|
pack_b_data["preset"] = {**valid_pack_data["preset"], "id": "preset-b", "name": "B"}
|
|
pack_b_data["provides"] = {
|
|
"templates": [{
|
|
"type": "template",
|
|
"name": "spec-template",
|
|
"file": "templates/spec-template.md",
|
|
"strategy": "append",
|
|
}]
|
|
}
|
|
pack_b_dir = temp_dir / "preset-b"
|
|
pack_b_dir.mkdir()
|
|
with open(pack_b_dir / "preset.yml", 'w') as f:
|
|
yaml.dump(pack_b_data, f)
|
|
(pack_b_dir / "templates").mkdir()
|
|
(pack_b_dir / "templates" / "spec-template.md").write_text("## Compliance Footer\n")
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(pack_a_dir, "0.1.5", priority=1)
|
|
manager.install_from_directory(pack_b_dir, "0.1.5", priority=2)
|
|
|
|
resolver = PresetResolver(project_dir)
|
|
content = resolver.resolve_content("spec-template")
|
|
assert content is not None
|
|
# Result: <security header> + <core> + <compliance footer>
|
|
assert "Security Header" in content
|
|
assert "Core Spec Template" in content
|
|
assert "Compliance Footer" in content
|
|
assert content.index("Security Header") < content.index("Core Spec Template")
|
|
assert content.index("Core Spec Template") < content.index("Compliance Footer")
|
|
|
|
def test_resolve_content_override_trumps_composition(self, project_dir, temp_dir, valid_pack_data):
|
|
"""Test that project overrides trump composition (replace at top priority)."""
|
|
# Install a composing preset
|
|
pack_data = {**valid_pack_data}
|
|
pack_data["preset"] = {**valid_pack_data["preset"], "id": "append-pack", "name": "Append"}
|
|
pack_data["provides"] = {
|
|
"templates": [{
|
|
"type": "template",
|
|
"name": "spec-template",
|
|
"file": "templates/spec-template.md",
|
|
"strategy": "append",
|
|
}]
|
|
}
|
|
pack_dir = temp_dir / "append-pack"
|
|
pack_dir.mkdir()
|
|
with open(pack_dir / "preset.yml", 'w') as f:
|
|
yaml.dump(pack_data, f)
|
|
(pack_dir / "templates").mkdir()
|
|
(pack_dir / "templates" / "spec-template.md").write_text("## Appended\n")
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(pack_dir, "0.1.5")
|
|
|
|
# Create project override (replaces everything)
|
|
overrides_dir = project_dir / ".specify" / "templates" / "overrides"
|
|
overrides_dir.mkdir(parents=True)
|
|
(overrides_dir / "spec-template.md").write_text("# Override Only\n")
|
|
|
|
resolver = PresetResolver(project_dir)
|
|
content = resolver.resolve_content("spec-template")
|
|
assert content is not None
|
|
assert "Override Only" in content
|
|
# Override replaces, so appended content should not be visible
|
|
assert "Core Spec Template" not in content
|
|
|
|
def test_resolve_content_command_type(self, project_dir, temp_dir, valid_pack_data):
|
|
"""Test resolve_content with command template type."""
|
|
# Create core command using stem naming (matches real layout: plan.md, not speckit.plan.md)
|
|
commands_dir = project_dir / ".specify" / "templates" / "commands"
|
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
|
(commands_dir / "plan.md").write_text("# Core Plan Command\n")
|
|
|
|
pack_data = {**valid_pack_data}
|
|
pack_data["preset"] = {**valid_pack_data["preset"], "id": "cmd-append", "name": "CmdAppend"}
|
|
pack_data["provides"] = {
|
|
"templates": [{
|
|
"type": "command",
|
|
"name": "speckit.plan",
|
|
"file": "commands/speckit.plan.md",
|
|
"strategy": "append",
|
|
}]
|
|
}
|
|
pack_dir = temp_dir / "cmd-append"
|
|
pack_dir.mkdir()
|
|
with open(pack_dir / "preset.yml", 'w') as f:
|
|
yaml.dump(pack_data, f)
|
|
(pack_dir / "commands").mkdir()
|
|
(pack_dir / "commands" / "speckit.plan.md").write_text("## Additional Instructions\n")
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(pack_dir, "0.1.5")
|
|
|
|
resolver = PresetResolver(project_dir)
|
|
content = resolver.resolve_content("speckit.plan", "command")
|
|
assert content is not None
|
|
assert "Core Plan Command" in content
|
|
assert "Additional Instructions" in content
|
|
|
|
def test_resolve_content_command_frontmatter_stripping(self, project_dir, temp_dir, valid_pack_data):
|
|
"""Test that command composition strips frontmatter from lower layers
|
|
and reattaches only the highest-priority frontmatter."""
|
|
# Create core command with frontmatter
|
|
commands_dir = project_dir / ".specify" / "templates" / "commands"
|
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
|
(commands_dir / "check.md").write_text(
|
|
"---\ndescription: Core check command\n---\nCore body content\n"
|
|
)
|
|
|
|
pack_data = {**valid_pack_data}
|
|
pack_data["preset"] = {**valid_pack_data["preset"], "id": "fm-test", "name": "FmTest"}
|
|
pack_data["provides"] = {
|
|
"templates": [{
|
|
"type": "command",
|
|
"name": "speckit.check",
|
|
"file": "commands/speckit.check.md",
|
|
"strategy": "append",
|
|
}]
|
|
}
|
|
pack_dir = temp_dir / "fm-test"
|
|
pack_dir.mkdir()
|
|
with open(pack_dir / "preset.yml", 'w') as f:
|
|
yaml.dump(pack_data, f)
|
|
(pack_dir / "commands").mkdir()
|
|
(pack_dir / "commands" / "speckit.check.md").write_text(
|
|
"---\ndescription: Preset check override\n---\nPreset body content\n"
|
|
)
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(pack_dir, "0.1.5")
|
|
|
|
resolver = PresetResolver(project_dir)
|
|
content = resolver.resolve_content("speckit.check", "command")
|
|
assert content is not None
|
|
# Should have the preset (highest-priority) frontmatter
|
|
assert "Preset check override" in content
|
|
# Should have both bodies
|
|
assert "Core body content" in content
|
|
assert "Preset body content" in content
|
|
# Core frontmatter should NOT appear in the body
|
|
assert content.count("---") == 2 # only one frontmatter block (opening + closing)
|
|
|
|
def test_resolve_content_blank_line_separator(self, project_dir, temp_dir, valid_pack_data):
|
|
"""Test that prepend/append use blank line separator."""
|
|
pack_data = {**valid_pack_data}
|
|
pack_data["preset"] = {**valid_pack_data["preset"], "id": "sep-test", "name": "SepTest"}
|
|
pack_data["provides"] = {
|
|
"templates": [{
|
|
"type": "template",
|
|
"name": "spec-template",
|
|
"file": "templates/spec-template.md",
|
|
"strategy": "append",
|
|
}]
|
|
}
|
|
pack_dir = temp_dir / "sep-test"
|
|
pack_dir.mkdir()
|
|
with open(pack_dir / "preset.yml", 'w') as f:
|
|
yaml.dump(pack_data, f)
|
|
(pack_dir / "templates").mkdir()
|
|
(pack_dir / "templates" / "spec-template.md").write_text("appended")
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(pack_dir, "0.1.5")
|
|
|
|
resolver = PresetResolver(project_dir)
|
|
content = resolver.resolve_content("spec-template")
|
|
# Should have blank line separator
|
|
assert "\n\n" in content
|
|
|
|
def test_resolve_content_replace_over_wrap(self, project_dir, temp_dir, valid_pack_data):
|
|
"""Top-priority replace layer should win even if a lower layer uses wrap."""
|
|
# Install a low-priority wrap preset (with no placeholder — would fail if evaluated)
|
|
wrap_data = {**valid_pack_data}
|
|
wrap_data["preset"] = {**valid_pack_data["preset"], "id": "wrap-lo", "name": "WrapLo"}
|
|
wrap_data["provides"] = {
|
|
"templates": [{
|
|
"type": "template",
|
|
"name": "spec-template",
|
|
"file": "templates/spec-template.md",
|
|
"strategy": "wrap",
|
|
}]
|
|
}
|
|
wrap_dir = temp_dir / "wrap-lo"
|
|
wrap_dir.mkdir()
|
|
with open(wrap_dir / "preset.yml", "w") as f:
|
|
yaml.dump(wrap_data, f)
|
|
(wrap_dir / "templates").mkdir()
|
|
# Intentionally missing {CORE_TEMPLATE} — would error if composition ran
|
|
(wrap_dir / "templates" / "spec-template.md").write_text("wrapper without placeholder")
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(wrap_dir, "0.1.5", priority=10)
|
|
|
|
# Install a high-priority replace preset
|
|
rep_data = {**valid_pack_data}
|
|
rep_data["preset"] = {**valid_pack_data["preset"], "id": "rep-hi", "name": "RepHi"}
|
|
rep_data["provides"] = {
|
|
"templates": [{
|
|
"type": "template",
|
|
"name": "spec-template",
|
|
"file": "templates/spec-template.md",
|
|
}]
|
|
}
|
|
rep_dir = temp_dir / "rep-hi"
|
|
rep_dir.mkdir()
|
|
with open(rep_dir / "preset.yml", "w") as f:
|
|
yaml.dump(rep_data, f)
|
|
(rep_dir / "templates").mkdir()
|
|
(rep_dir / "templates" / "spec-template.md").write_text("# Replaced content\n")
|
|
|
|
manager.install_from_directory(rep_dir, "0.1.5", priority=1)
|
|
|
|
resolver = PresetResolver(project_dir)
|
|
content = resolver.resolve_content("spec-template")
|
|
assert content == "# Replaced content\n"
|
|
|
|
@pytest.mark.parametrize("strategy", ["append", "prepend", "wrap"])
|
|
def test_resolve_content_rewrites_extension_base_subdir_paths(
|
|
self, project_dir, temp_dir, strategy
|
|
):
|
|
"""Composing over an extension-provided base command must resolve the
|
|
extension's own subdir references (agents/, knowledge-base/) to their
|
|
installed location (#2101), not just when the extension wins outright.
|
|
"""
|
|
extension_dir = project_dir / ".specify" / "extensions" / "fakeext"
|
|
(extension_dir / "commands").mkdir(parents=True, exist_ok=True)
|
|
(extension_dir / "agents" / "control").mkdir(parents=True, exist_ok=True)
|
|
(extension_dir / "agents" / "control" / "commander.md").write_text("# Commander\n")
|
|
(extension_dir / "commands" / "cmd.md").write_text(
|
|
"---\ndescription: Extension fakeext cmd\n---\n\n"
|
|
"Read agents/control/commander.md for context.\n"
|
|
)
|
|
extension_manifest = {
|
|
"schema_version": "1.0",
|
|
"extension": {
|
|
"id": "fakeext",
|
|
"name": "Fake Extension",
|
|
"version": "1.0.0",
|
|
"description": "Test",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"commands": [
|
|
{
|
|
"name": "speckit.fakeext.cmd",
|
|
"file": "commands/cmd.md",
|
|
"description": "Fake extension command",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
with open(extension_dir / "extension.yml", "w") as f:
|
|
yaml.dump(extension_manifest, f)
|
|
|
|
preset_dir = temp_dir / f"ext-base-{strategy}"
|
|
preset_dir.mkdir()
|
|
(preset_dir / "commands").mkdir()
|
|
overlay_body = (
|
|
"{CORE_TEMPLATE}\n## Extra\n" if strategy == "wrap" else "## Extra\n"
|
|
)
|
|
(preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text(
|
|
f"---\ndescription: Preset overlay\n---\n\n{overlay_body}"
|
|
)
|
|
preset_manifest = {
|
|
"schema_version": "1.0",
|
|
"preset": {
|
|
"id": f"ext-base-{strategy}",
|
|
"name": "Ext Base",
|
|
"version": "1.0.0",
|
|
"description": "Test",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"templates": [
|
|
{
|
|
"type": "command",
|
|
"name": "speckit.fakeext.cmd",
|
|
"file": "commands/speckit.fakeext.cmd.md",
|
|
"strategy": strategy,
|
|
}
|
|
]
|
|
},
|
|
}
|
|
with open(preset_dir / "preset.yml", "w") as f:
|
|
yaml.dump(preset_manifest, f)
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
resolver = PresetResolver(project_dir)
|
|
content = resolver.resolve_content("speckit.fakeext.cmd", "command")
|
|
assert content is not None
|
|
assert ".specify/extensions/fakeext/agents/control/commander.md" in content
|
|
assert "Read agents/control" not in content
|
|
assert "## Extra" in content
|
|
|
|
|
|
class TestCollectAllLayers:
|
|
"""Test PresetResolver.collect_all_layers() method."""
|
|
|
|
def test_single_core_layer(self, project_dir):
|
|
"""Test collecting layers with only core template."""
|
|
resolver = PresetResolver(project_dir)
|
|
layers = resolver.collect_all_layers("spec-template")
|
|
assert len(layers) == 1
|
|
assert layers[0]["source"] == "core"
|
|
assert layers[0]["strategy"] == "replace"
|
|
|
|
def test_layers_include_presets(self, project_dir, temp_dir, valid_pack_data):
|
|
"""Test that layers include installed preset."""
|
|
manager = PresetManager(project_dir)
|
|
pack_dir = _create_pack(temp_dir, valid_pack_data, "test-pack",
|
|
"# From Pack\n")
|
|
manager.install_from_directory(pack_dir, "0.1.5")
|
|
|
|
resolver = PresetResolver(project_dir)
|
|
layers = resolver.collect_all_layers("spec-template")
|
|
assert len(layers) == 2
|
|
# Highest priority first
|
|
assert "test-pack" in layers[0]["source"]
|
|
assert layers[1]["source"] == "core"
|
|
|
|
def test_layers_order_matches_priority(self, project_dir, temp_dir, valid_pack_data):
|
|
"""Test that layers are ordered by priority (highest first)."""
|
|
manager = PresetManager(project_dir)
|
|
for pid, prio in [("pack-lo", 10), ("pack-hi", 1)]:
|
|
d = {**valid_pack_data}
|
|
d["preset"] = {**valid_pack_data["preset"], "id": pid, "name": pid}
|
|
p = temp_dir / pid
|
|
p.mkdir()
|
|
with open(p / "preset.yml", 'w') as f:
|
|
yaml.dump(d, f)
|
|
(p / "templates").mkdir()
|
|
(p / "templates" / "spec-template.md").write_text(f"# {pid}\n")
|
|
manager.install_from_directory(p, "0.1.5", priority=prio)
|
|
|
|
resolver = PresetResolver(project_dir)
|
|
layers = resolver.collect_all_layers("spec-template")
|
|
assert len(layers) == 3 # pack-hi, pack-lo, core
|
|
assert "pack-hi" in layers[0]["source"]
|
|
assert "pack-lo" in layers[1]["source"]
|
|
assert layers[2]["source"] == "core"
|
|
|
|
def test_layers_read_strategy_from_manifest(self, project_dir, temp_dir, valid_pack_data):
|
|
"""Test that layers read strategy from preset manifest."""
|
|
pack_data = {**valid_pack_data}
|
|
pack_data["preset"] = {**valid_pack_data["preset"], "id": "strat-pack", "name": "Strat"}
|
|
pack_data["provides"] = {
|
|
"templates": [{
|
|
"type": "template",
|
|
"name": "spec-template",
|
|
"file": "templates/spec-template.md",
|
|
"strategy": "append",
|
|
}]
|
|
}
|
|
pack_dir = temp_dir / "strat-pack"
|
|
pack_dir.mkdir()
|
|
with open(pack_dir / "preset.yml", 'w') as f:
|
|
yaml.dump(pack_data, f)
|
|
(pack_dir / "templates").mkdir()
|
|
(pack_dir / "templates" / "spec-template.md").write_text("## Footer\n")
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(pack_dir, "0.1.5")
|
|
|
|
resolver = PresetResolver(project_dir)
|
|
layers = resolver.collect_all_layers("spec-template")
|
|
# Preset layer should have strategy=append
|
|
assert layers[0]["strategy"] == "append"
|
|
# Core layer should be replace
|
|
assert layers[1]["strategy"] == "replace"
|
|
|
|
|
|
class TestRemoveReconciliation:
|
|
"""Test that removing a preset re-registers the next layer's command."""
|
|
|
|
def test_remove_restores_extension_command_subdir_paths_for_non_skill_agent(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""When a preset override of an extension command is removed, the
|
|
reconciled non-skill-agent command file should have the extension's
|
|
own subdir references rewritten to their installed location (#2101),
|
|
not left as bare, unresolvable paths."""
|
|
gemini_dir = project_dir / ".gemini" / "commands"
|
|
gemini_dir.mkdir(parents=True)
|
|
|
|
extension_dir = project_dir / ".specify" / "extensions" / "fakeext"
|
|
(extension_dir / "commands").mkdir(parents=True, exist_ok=True)
|
|
(extension_dir / "agents" / "control").mkdir(parents=True, exist_ok=True)
|
|
(extension_dir / "agents" / "control" / "commander.md").write_text("# Commander\n")
|
|
(extension_dir / "commands" / "cmd.md").write_text(
|
|
"---\ndescription: Extension fakeext cmd\n---\n\n"
|
|
"Read agents/control/commander.md for context.\n"
|
|
)
|
|
extension_manifest = {
|
|
"schema_version": "1.0",
|
|
"extension": {
|
|
"id": "fakeext",
|
|
"name": "Fake Extension",
|
|
"version": "1.0.0",
|
|
"description": "Test",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"commands": [
|
|
{
|
|
"name": "speckit.fakeext.cmd",
|
|
"file": "commands/cmd.md",
|
|
"description": "Fake extension command",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
with open(extension_dir / "extension.yml", "w") as f:
|
|
yaml.dump(extension_manifest, f)
|
|
|
|
manager = PresetManager(project_dir)
|
|
|
|
preset_dir = temp_dir / "ext-cmd-override"
|
|
preset_dir.mkdir()
|
|
(preset_dir / "commands").mkdir()
|
|
(preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text(
|
|
"---\ndescription: Override fakeext cmd\n---\n\npreset override content\n"
|
|
)
|
|
preset_manifest = {
|
|
"schema_version": "1.0",
|
|
"preset": {
|
|
"id": "ext-cmd-override",
|
|
"name": "Ext Cmd Override",
|
|
"version": "1.0.0",
|
|
"description": "Test",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"templates": [
|
|
{
|
|
"type": "command",
|
|
"name": "speckit.fakeext.cmd",
|
|
"file": "commands/speckit.fakeext.cmd.md",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
with open(preset_dir / "preset.yml", "w") as f:
|
|
yaml.dump(preset_manifest, f)
|
|
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
cmd_files = list(gemini_dir.glob("*fakeext*"))
|
|
assert cmd_files, "Command file should exist in gemini dir"
|
|
assert "preset override content" in cmd_files[0].read_text()
|
|
|
|
manager.remove("ext-cmd-override")
|
|
|
|
cmd_files = list(gemini_dir.glob("*fakeext*"))
|
|
assert cmd_files, "Command file should still exist after removal"
|
|
content = cmd_files[0].read_text()
|
|
assert "preset override content" not in content
|
|
assert ".specify/extensions/fakeext/agents/control/commander.md" in content
|
|
assert "Read agents/control" not in content
|
|
|
|
def test_install_composes_extension_command_and_rewrites_subdir_paths_for_non_skill_agent(
|
|
self, project_dir, temp_dir
|
|
):
|
|
"""When a preset overlays (append) an extension-provided base command,
|
|
the initial composed non-skill-agent command file must have the
|
|
extension's own subdir references rewritten to their installed
|
|
location (#2101), matching the live repro: extension body
|
|
'Read agents/control/commander.md', preset appends to
|
|
speckit.fakeext.cmd, generated Gemini content retains the bare path."""
|
|
gemini_dir = project_dir / ".gemini" / "commands"
|
|
gemini_dir.mkdir(parents=True)
|
|
|
|
extension_dir = project_dir / ".specify" / "extensions" / "fakeext"
|
|
(extension_dir / "commands").mkdir(parents=True, exist_ok=True)
|
|
(extension_dir / "agents" / "control").mkdir(parents=True, exist_ok=True)
|
|
(extension_dir / "agents" / "control" / "commander.md").write_text("# Commander\n")
|
|
(extension_dir / "commands" / "cmd.md").write_text(
|
|
"---\ndescription: Extension fakeext cmd\n---\n\n"
|
|
"Read agents/control/commander.md for context.\n"
|
|
)
|
|
extension_manifest = {
|
|
"schema_version": "1.0",
|
|
"extension": {
|
|
"id": "fakeext",
|
|
"name": "Fake Extension",
|
|
"version": "1.0.0",
|
|
"description": "Test",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"commands": [
|
|
{
|
|
"name": "speckit.fakeext.cmd",
|
|
"file": "commands/cmd.md",
|
|
"description": "Fake extension command",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
with open(extension_dir / "extension.yml", "w") as f:
|
|
yaml.dump(extension_manifest, f)
|
|
|
|
preset_dir = temp_dir / "ext-cmd-append"
|
|
preset_dir.mkdir()
|
|
(preset_dir / "commands").mkdir()
|
|
(preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text(
|
|
"---\ndescription: Append fakeext cmd\n---\n\n## Extra\n"
|
|
)
|
|
preset_manifest = {
|
|
"schema_version": "1.0",
|
|
"preset": {
|
|
"id": "ext-cmd-append",
|
|
"name": "Ext Cmd Append",
|
|
"version": "1.0.0",
|
|
"description": "Test",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"templates": [
|
|
{
|
|
"type": "command",
|
|
"name": "speckit.fakeext.cmd",
|
|
"file": "commands/speckit.fakeext.cmd.md",
|
|
"strategy": "append",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
with open(preset_dir / "preset.yml", "w") as f:
|
|
yaml.dump(preset_manifest, f)
|
|
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(preset_dir, "0.1.5")
|
|
|
|
cmd_files = list(gemini_dir.glob("*fakeext*"))
|
|
assert cmd_files, "Command file should exist in gemini dir"
|
|
content = cmd_files[0].read_text()
|
|
assert ".specify/extensions/fakeext/agents/control/commander.md" in content
|
|
assert "Read agents/control" not in content
|
|
assert "## Extra" in content
|
|
|
|
def test_remove_restores_lower_priority_command(
|
|
self, project_dir, temp_dir, valid_pack_data
|
|
):
|
|
"""After removing the top-priority preset, the next preset's command
|
|
should be re-registered in agent directories."""
|
|
manager = PresetManager(project_dir)
|
|
|
|
# Create a gemini commands dir so reconciliation writes there
|
|
gemini_dir = project_dir / ".gemini" / "commands"
|
|
gemini_dir.mkdir(parents=True)
|
|
|
|
# Install a low-priority preset with a command
|
|
lo_data = {**valid_pack_data}
|
|
lo_data["preset"] = {
|
|
**valid_pack_data["preset"],
|
|
"id": "lo-preset",
|
|
"name": "Lo",
|
|
}
|
|
lo_data["provides"] = {
|
|
"templates": [{
|
|
"type": "command",
|
|
"name": "speckit.specify",
|
|
"file": "commands/speckit.specify.md",
|
|
}]
|
|
}
|
|
lo_dir = temp_dir / "lo-preset"
|
|
lo_dir.mkdir()
|
|
with open(lo_dir / "preset.yml", "w") as f:
|
|
yaml.dump(lo_data, f)
|
|
(lo_dir / "commands").mkdir()
|
|
(lo_dir / "commands" / "speckit.specify.md").write_text(
|
|
"---\ndescription: lo\n---\nLo content\n"
|
|
)
|
|
manager.install_from_directory(lo_dir, "0.1.5", priority=10)
|
|
|
|
# Install a high-priority preset overriding the same command
|
|
hi_data = {**valid_pack_data}
|
|
hi_data["preset"] = {
|
|
**valid_pack_data["preset"],
|
|
"id": "hi-preset",
|
|
"name": "Hi",
|
|
}
|
|
hi_data["provides"] = {
|
|
"templates": [{
|
|
"type": "command",
|
|
"name": "speckit.specify",
|
|
"file": "commands/speckit.specify.md",
|
|
}]
|
|
}
|
|
hi_dir = temp_dir / "hi-preset"
|
|
hi_dir.mkdir()
|
|
with open(hi_dir / "preset.yml", "w") as f:
|
|
yaml.dump(hi_data, f)
|
|
(hi_dir / "commands").mkdir()
|
|
(hi_dir / "commands" / "speckit.specify.md").write_text(
|
|
"---\ndescription: hi\n---\nHi content\n"
|
|
)
|
|
manager.install_from_directory(hi_dir, "0.1.5", priority=1)
|
|
|
|
# Verify the hi-preset's content is active in agent dir
|
|
cmd_files = list(gemini_dir.glob("*specify*"))
|
|
assert cmd_files, "Command file should exist in gemini dir"
|
|
assert "Hi content" in cmd_files[0].read_text()
|
|
|
|
# Remove the high-priority preset
|
|
manager.remove("hi-preset")
|
|
|
|
# The low-priority preset's command should now be in the resolution stack
|
|
resolver = PresetResolver(project_dir)
|
|
layers = resolver.collect_all_layers("speckit.specify", "command")
|
|
assert len(layers) >= 1
|
|
assert "lo-preset" in layers[0]["source"]
|
|
|
|
# Verify on-disk agent command file switched to lo-preset content
|
|
cmd_files = list(gemini_dir.glob("*specify*"))
|
|
assert cmd_files, "Command file should still exist after removal"
|
|
assert "Lo content" in cmd_files[0].read_text()
|
|
|
|
|
|
def _create_pack(temp_dir, valid_pack_data, pack_id, content,
|
|
strategy="replace", template_type="template",
|
|
template_name="spec-template"):
|
|
"""Helper to create a preset pack directory."""
|
|
pack_data = {**valid_pack_data}
|
|
pack_data["preset"] = {**valid_pack_data["preset"], "id": pack_id, "name": pack_id}
|
|
|
|
tmpl_entry = {
|
|
"type": template_type,
|
|
"name": template_name,
|
|
}
|
|
if template_type == "script":
|
|
tmpl_entry["file"] = f"scripts/{template_name}.sh"
|
|
elif template_type == "command":
|
|
tmpl_entry["file"] = f"commands/{template_name}.md"
|
|
else:
|
|
tmpl_entry["file"] = f"templates/{template_name}.md"
|
|
if strategy != "replace":
|
|
tmpl_entry["strategy"] = strategy
|
|
pack_data["provides"] = {"templates": [tmpl_entry]}
|
|
|
|
pack_dir = temp_dir / pack_id
|
|
pack_dir.mkdir(exist_ok=True)
|
|
with open(pack_dir / "preset.yml", 'w') as f:
|
|
yaml.dump(pack_data, f)
|
|
|
|
if template_type == "script":
|
|
subdir = pack_dir / "scripts"
|
|
subdir.mkdir(exist_ok=True)
|
|
(subdir / f"{template_name}.sh").write_text(content)
|
|
elif template_type == "command":
|
|
subdir = pack_dir / "commands"
|
|
subdir.mkdir(exist_ok=True)
|
|
(subdir / f"{template_name}.md").write_text(content)
|
|
else:
|
|
subdir = pack_dir / "templates"
|
|
subdir.mkdir(exist_ok=True)
|
|
(subdir / f"{template_name}.md").write_text(content)
|
|
|
|
return pack_dir
|
|
|
|
|
|
def test_preset_wrapper_resolves_ghes_asset_when_host_configured(tmp_path, monkeypatch):
|
|
"""End-to-end wiring for presets: auth.json github host → GHES asset resolution."""
|
|
from specify_cli.authentication import http as _auth_http
|
|
from specify_cli.authentication.config import AuthConfigEntry
|
|
from specify_cli.presets import PresetCatalog
|
|
|
|
monkeypatch.setattr(_auth_http, "_config_override", [
|
|
AuthConfigEntry(hosts=("ghes.example",), provider="github",
|
|
auth="bearer", token="t"),
|
|
])
|
|
catalog = PresetCatalog(tmp_path)
|
|
|
|
captured = []
|
|
|
|
@contextmanager
|
|
def fake_open(url, timeout=None, extra_headers=None):
|
|
captured.append(url)
|
|
resp = MagicMock()
|
|
resp.read.side_effect = io.BytesIO(json.dumps({
|
|
"assets": [{"name": "pack.zip",
|
|
"url": "https://ghes.example/api/v3/repos/o/r/releases/assets/9"}]
|
|
}).encode()).read
|
|
yield resp
|
|
|
|
monkeypatch.setattr(catalog, "_open_url", fake_open)
|
|
|
|
resolved = catalog._resolve_github_release_asset_api_url(
|
|
"https://ghes.example/o/r/releases/download/v2/pack.zip"
|
|
)
|
|
assert resolved == "https://ghes.example/api/v3/repos/o/r/releases/assets/9"
|
|
assert captured == ["https://ghes.example/api/v3/repos/o/r/releases/tags/v2"]
|
|
|
|
|
|
# ===== ensure_constitution_from_template resolver-awareness =====
|
|
|
|
|
|
class TestEnsureConstitutionResolverAware:
|
|
"""`ensure_constitution_from_template` must resolve through PresetResolver.
|
|
|
|
The constitution is the only template materialized to a live file rather
|
|
than resolved on demand. These tests pin the regression from issue #3272:
|
|
a preset-provided ``constitution-template`` must seed memory, while the
|
|
core template is used when no preset overrides it.
|
|
"""
|
|
|
|
def _core_constitution(self, project_dir):
|
|
templates_dir = project_dir / ".specify" / "templates"
|
|
templates_dir.mkdir(parents=True, exist_ok=True)
|
|
(templates_dir / "constitution-template.md").write_text(
|
|
"# [PROJECT_NAME] Constitution\n\n### [PRINCIPLE_1_NAME]\n"
|
|
)
|
|
|
|
def _wrap_constitution_preset(self, temp_dir):
|
|
preset_dir = temp_dir / "ensure-wrap-preset"
|
|
(preset_dir / "templates").mkdir(parents=True)
|
|
(preset_dir / "templates" / "constitution-template.md").write_text(
|
|
"# Ensure Wrapper\n\n{CORE_TEMPLATE}\n\n## Tail\n"
|
|
)
|
|
(preset_dir / "preset.yml").write_text(
|
|
yaml.dump(
|
|
{
|
|
"schema_version": "1.0",
|
|
"preset": {
|
|
"id": "ensure-wrap",
|
|
"name": "Ensure Wrap",
|
|
"version": "1.0.0",
|
|
"description": "Wrap strategy for ensure() coverage",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"templates": [
|
|
{
|
|
"type": "template",
|
|
"name": "constitution-template",
|
|
"file": "templates/constitution-template.md",
|
|
"strategy": "wrap",
|
|
"description": "Wrapped constitution",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
)
|
|
)
|
|
return preset_dir
|
|
|
|
def test_seeds_from_core_when_no_preset(self, project_dir):
|
|
from specify_cli.commands.init import ensure_constitution_from_template
|
|
|
|
self._core_constitution(project_dir)
|
|
ensure_constitution_from_template(project_dir)
|
|
|
|
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
|
assert memory.exists()
|
|
assert "[PROJECT_NAME]" in memory.read_text()
|
|
assert (memory.parent / ".constitution-template.json").exists()
|
|
|
|
def test_seeds_from_preset_when_installed(self, project_dir):
|
|
from specify_cli.commands.init import ensure_constitution_from_template
|
|
|
|
self._core_constitution(project_dir)
|
|
manager = PresetManager(project_dir)
|
|
install_self_test_preset(manager)
|
|
|
|
# Remove the memory file seeded during install to test ensure() in
|
|
# isolation; it must re-seed from the preset, not the core template.
|
|
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
|
memory.unlink()
|
|
|
|
ensure_constitution_from_template(project_dir)
|
|
|
|
assert memory.exists()
|
|
content = memory.read_text()
|
|
assert "preset:self-test" in content
|
|
assert "[PROJECT_NAME]" not in content
|
|
|
|
def test_preserves_existing_memory(self, project_dir):
|
|
from specify_cli.commands.init import ensure_constitution_from_template
|
|
|
|
self._core_constitution(project_dir)
|
|
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
|
memory.parent.mkdir(parents=True, exist_ok=True)
|
|
authored = "# Acme Constitution\nAuthored.\n"
|
|
memory.write_text(authored)
|
|
|
|
ensure_constitution_from_template(project_dir)
|
|
|
|
assert memory.read_text() == authored
|
|
|
|
def test_preserves_edited_generated_memory(self, project_dir):
|
|
from specify_cli.commands.init import ensure_constitution_from_template
|
|
|
|
self._core_constitution(project_dir)
|
|
ensure_constitution_from_template(project_dir)
|
|
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
|
authored = memory.read_text() + "\nAuthored amendment.\n"
|
|
memory.write_text(authored)
|
|
|
|
manager = PresetManager(project_dir)
|
|
install_self_test_preset(manager)
|
|
|
|
assert memory.read_text() == authored
|
|
|
|
def test_composes_wrap_strategy_when_ensuring(self, project_dir, temp_dir):
|
|
from specify_cli.commands.init import ensure_constitution_from_template
|
|
|
|
self._core_constitution(project_dir)
|
|
manager = PresetManager(project_dir)
|
|
manager.install_from_directory(self._wrap_constitution_preset(temp_dir), "0.1.5")
|
|
|
|
# Ensure we validate ensure() behavior directly.
|
|
memory = project_dir / ".specify" / "memory" / "constitution.md"
|
|
memory.unlink()
|
|
ensure_constitution_from_template(project_dir)
|
|
|
|
content = memory.read_text()
|
|
assert "{CORE_TEMPLATE}" not in content
|
|
assert "# Ensure Wrapper" in content
|
|
assert "[PROJECT_NAME]" in content
|