Compare commits

...
Author SHA1 Message Date
github-actions[bot] b85aaeda4a chore: bump version to 0.16.3 2026-08-13 15:37:04 +00:00
Quratulain-bilalandGitHub 16f45774a5 fix: narrow bare except Exception in VS Code settings merge (#3844)
* fix: narrow bare except Exception in VS Code settings merge

Replace overly broad except Exception with (OSError, ValueError, KeyError)
to let programming errors like TypeError or AttributeError propagate while
still handling expected I/O and parse errors gracefully.

* test: verify programming errors propagate through handle_vscode_settings

The narrow exception change from 'except Exception' to
'except (OSError, ValueError, KeyError)' was not covered by a
regression test. Add a test that monkeypatches merge_json_files to
raise TypeError and verifies it propagates rather than being swallowed.
2026-08-13 10:36:00 -05:00
229022943c feat(presets): list presets in resolution/precedence order (#4086) (#4104)
`specify preset list` now sorts installed presets by (priority, id) so the
printed order matches the actual resolution/composition order used by
PresetRegistry.list_by_priority(). Lower priority number = higher precedence;
ties are broken alphabetically by preset id. Adds a header and footer note
clarifying the ordering, updates the presets reference docs, and adds tests.

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

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-08-13 10:00:16 -05:00
e79fa25f3f Fix: scaffold self-contained namespaced preset commands (#4076) (#4082)
* Fix: scaffold self-contained namespaced preset commands (#4076)

Preset command templates named `speckit.<ns>.<cmd>` were silently dropped
whenever `.specify/extensions/<ns>/` was absent, while `speckit.<cmd>`
always scaffolded. The `_extension_installed_for_command` guard filtered
purely on name shape, conflating "override of an installed extension's
command" with "a preset shipping its own namespaced command." Because a
`type: command` template always ships its own body, such a command is
self-contained and must scaffold like any short-named command.

Remove the name-shape guard at all four call sites (registration, both
reconciliation passes, and skills). The reconciliation loop already skips
names that resolve to no layers (`if not layers: continue`), and the
composed-None branch still cleans up commands whose base layer disappeared.
Convert the command-mode "no base layer to compose onto" hard error into a
warn + skip, matching the existing behavior in _reconcile_composed_commands
so command-mode install and reconciliation stay consistent.

Update the two tests that encoded the old drop behavior to assert the new
consistent-scaffold contract, and add coverage proving 2-part and 3-part
preset commands scaffold identically with no extension installed.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: cfc4f1ce-6acb-465a-aa7b-999f2e4197fb

* Skip uncomposable commands in skills mode too (PR #4082 review)

When _register_commands skips an uncomposable composition command (a
wrap/prepend/append with no base layer to compose onto — e.g. the command
it wraps comes from an uninstalled extension), install still passed the
full manifest to _register_skills. For a command-backed integration in
skills mode, _register_skills created the missing skill and fell back to
the raw preset body because no `.composed` file existed, materializing a
broken SKILL.md — a literal `{CORE_TEMPLATE}` for wrap, or just the
preset's own fragment for prepend/append. Previously the raise in
_register_commands aborted before skills ran, so this never surfaced.

Make _register_skills apply the same skip: for a composition-strategy
command with no `.composed` file, resolve the stack and skip when no base
exists (resolve_content is None). The skip is silent because
_register_commands already warned for the same command in the same pass.

Add a regression test proving an uncomposable wrap command renders no skill
and never leaks a literal {CORE_TEMPLATE} in skills mode.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: cfc4f1ce-6acb-465a-aa7b-999f2e4197fb

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: cfc4f1ce-6acb-465a-aa7b-999f2e4197fb
2026-08-13 09:04:39 -05:00
github-actions[bot]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>Copilot
750ce47cb8 Update Cross-Platform Governance preset to v0.2.2 (#4080)
Update cross-platform-governance preset submitted by @hindermath to:
- presets/catalog.community.json (version, download_url, documentation, description, provides, tags, updated_at)
- docs/community/presets.md community presets table

Closes #4078

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

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-13 08:36:36 -05:00
10 changed files with 343 additions and 126 deletions
+25
View File
@@ -2,6 +2,31 @@
<!-- insert new changelog below this comment -->
## [0.16.3] - 2026-08-13
### Changed
- fix: narrow bare except Exception in VS Code settings merge (#3844)
- feat(presets): list presets in resolution/precedence order (#4086) (#4104)
- Fix: scaffold self-contained namespaced preset commands (#4076) (#4082)
- Update Cross-Platform Governance preset to v0.2.2 (#4080)
- fix(bundler): treat a blank active integration as indeterminate in FR-019 (#3886)
- Integrate Junie with dot-to-hyphen behavior and command formatting (#4073)
- Update A11Y Governance preset to v0.4.3 (#4074)
- Fix Alquimia argument hints after folded descriptions (#4063)
- fix: use bounded read for bundle download HTTP responses (#3764)
- Update iSAQB Architecture Governance preset to v0.2.2 (#4056)
- fix(claude): make argument-hint injection fold-aware for long descriptions (#4045)
- Add SpecKit Grill Me extension to community catalog (#4052)
- Update Architecture Governance preset to v0.5.2 (#4050)
- Remove auto-assign from catalog submission workflow (#4054)
- docs: clarify example spec guidance (#4048)
- Clarify custom checklist ownership and lifecycle (#4028)
- Update Archive Extension to v1.2.2 (#4053)
- Update Security Governance preset to v0.6.2 (#4040)
- docs: clarify maintainer applies submission label during triage (#4041)
- chore: release 0.16.2, begin 0.16.3.dev0 development (#4038)
## [0.16.2] - 2026-08-10
### Changed
+1 -1
View File
@@ -15,7 +15,7 @@ The following community-contributed presets customize how Spec Kit behaves — o
| Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) |
| Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) |
| Command Density | Compacts the nine core Spec Kit command prompts while preserving scripts, handoffs, placeholders, hook output blocks, and rule structure | 9 commands | — | [spec-kit-preset-command-density](https://github.com/Xopoko/spec-kit-preset-command-density) |
| Cross-Platform Governance | Adds Bash/PowerShell and read-only check parity, root-path and native-override review, Unix man pages, bilingual help, Verb-Noun discipline, and audit-ready evidence. | 8 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) |
| Cross-Platform Governance | Adds Bash/PowerShell parity, read-only checks, path and native-override review, Unix man pages, bilingual PowerShell help, and provider-neutral model routing. | 9 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) |
| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) |
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose principles. Supports interactive elements like brainstorming, interview, roleplay, and extras like statistics, cover builder, illustration builder, and bio command. Export with templates for KDP, D2D, etc. | 26 templates, 34 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
| Game Narrative Writing | Preset for game narrative design and interactive storytelling. It adapts the Spec-Driven Development workflow for game narratives: features become story mechanics, specs become narrative briefs, plans become story maps, and tasks become dialogue and scene-writing tasks. Supports branching narratives, player agency systems, state machines, and interactive dialogue trees. | 37 templates, 34 commands, 5 scripts | — | [speckit-preset-game-narrative-writing](https://github.com/adaumann/speckit-preset-game-narrative-writing) |
+2
View File
@@ -47,6 +47,8 @@ specify preset list
Lists installed presets with their versions, descriptions, template counts, and current status.
Presets are printed in **resolution/precedence order**: the highest-precedence preset (lowest priority number) is listed first, and ties on priority are broken alphabetically by preset id. This matches the order used when composing commands and resolving templates, so the top entry is the one that wins for overlapping files.
## Preset Info
```bash
+7 -12
View File
@@ -228,19 +228,19 @@
"cross-platform-governance": {
"name": "Cross-Platform Governance",
"id": "cross-platform-governance",
"version": "0.2.1",
"description": "Adds Bash/PowerShell and read-only check parity, root-path and native-override review, Unix man pages, bilingual help, Verb-Noun discipline, and audit-ready evidence.",
"version": "0.2.2",
"description": "Adds Bash/PowerShell parity, read-only checks, path and native-override review, Unix man pages, bilingual PowerShell help, and provider-neutral model routing.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/archive/refs/tags/v0.2.1.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/archive/refs/tags/v0.2.2.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/blob/v0.2.1/README.md",
"documentation": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/blob/v0.2.2/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.0"
},
"provides": {
"templates": 8,
"templates": 9,
"commands": 3
},
"tags": [
@@ -248,15 +248,10 @@
"governance",
"bash",
"powershell",
"man-page",
"cmdlet",
"verb-noun",
"windows",
"macos",
"linux"
"model-routing"
],
"created_at": "2026-04-27T00:00:00Z",
"updated_at": "2026-07-23T00:00:00Z"
"updated_at": "2026-08-12T00:00:00Z"
},
"explicit-task-dependencies": {
"name": "Explicit Task Dependencies",
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.16.3.dev0"
version = "0.16.3"
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
readme = "README.md"
requires-python = ">=3.11"
+1 -1
View File
@@ -213,7 +213,7 @@ def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker
shutil.copy2(sub_item, dest_file)
log("Copied (no existing settings.json):", "blue")
except Exception as e:
except (OSError, ValueError, KeyError) as e:
log(f"Warning: Could not merge settings: {e}", "yellow")
if not dest_file.exists():
shutil.copy2(sub_item, dest_file)
+69 -71
View File
@@ -823,25 +823,6 @@ class PresetManager:
return True
def _extension_installed_for_command(self, command_name: str) -> bool:
"""Whether *command_name* may be materialized in this project.
Extension command overrides follow ``speckit.<ext-id>.<cmd-name>``;
they must be skipped everywhere preset artifacts are written —
registration *and* reconciliation — when the extension isn't
installed, or reconciliation would materialize files that
registration refused to track. Core commands (single-dot names,
e.g. ``speckit.specify``) always pass.
"""
parts = command_name.split(".")
if len(parts) >= 3 and parts[0] == "speckit":
ext_id = parts[1]
if not (
self.project_root / ".specify" / "extensions" / ext_id
).is_dir():
return False
return True
def _register_commands(
self,
manifest: PresetManifest,
@@ -870,21 +851,20 @@ class PresetManager:
if not command_templates:
return {}
# Filter out extension command overrides if the extension isn't installed.
filtered = [
cmd
for cmd in command_templates
if self._extension_installed_for_command(cmd["name"])
]
if not filtered:
return {}
# A preset command template always ships its own body, so it is
# self-contained and scaffolds regardless of whether any similarly
# named extension is installed. Namespaced names (speckit.<ns>.<cmd>)
# are treated exactly like short names (speckit.<cmd>) — they are NOT
# filtered out just because ``.specify/extensions/<ns>/`` is absent.
# The only command that cannot be materialized is a composition
# (prepend/append/wrap) with no base layer to compose onto; that case
# is handled per-command below (warn + skip), not by dropping names up
# front.
# Handle composition strategies: resolve composed content for non-replace commands
resolver = PresetResolver(self.project_root)
composed_dir = None
commands_to_register = []
for cmd in filtered:
for cmd in command_templates:
strategy = cmd.get("strategy", "replace")
if strategy != "replace":
# Only pre-compose if this preset is the top composing layer.
@@ -907,13 +887,23 @@ class PresetManager:
"file": f".composed/{cmd['name']}.md",
})
else:
raise PresetValidationError(
f"Command '{cmd['name']}' uses '{strategy}' strategy "
f"but no base command layer exists to compose onto. "
f"Ensure a lower-priority preset, extension, or core "
f"command provides this command before using "
f"composition strategies."
# No base layer to compose onto (e.g. the command it
# would wrap comes from an extension that isn't
# installed). Warn and skip this single command rather
# than aborting the whole install — mirrors the
# "composed is None" branch in
# _reconcile_composed_commands so command-mode and
# reconciliation behave identically.
import warnings
warnings.warn(
f"Command '{cmd['name']}' uses '{strategy}' "
f"strategy but no base command layer exists to "
f"compose onto; skipping. Provide a lower-priority "
f"preset, extension, or core command for it before "
f"using composition strategies.",
stacklevel=2,
)
continue
else:
# Not the top layer — register raw file; reconciliation
# will overwrite with the correct composed/winning content.
@@ -1681,21 +1671,13 @@ class PresetManager:
if not command_names:
return set()
# Never materialize extension-scoped commands whose extension isn't
# installed. Registration (_register_commands / _register_skills)
# already refuses them, so a reconciliation pass writing them would
# create files no registry entry tracks. Filtering here — the single
# chokepoint every install/remove/rescaffold reconciliation funnels
# through — keeps all callers consistent without each one re-applying
# the filter when seeding names from manifest templates.
command_names = [
name
for name in command_names
if self._extension_installed_for_command(name)
]
if not command_names:
return set()
# Every preset-owned command name flows through unchanged. Names are
# NOT filtered by the ``speckit.<ns>.<cmd>`` shape: a self-contained
# preset command scaffolds whether or not a like-named extension is
# installed (parity with _register_commands), and a name whose base
# layer has disappeared must still reach the loop below so its now
# uncomposable stale file gets unregistered. The loop already skips
# names that resolve to no layers at all (``if not layers: continue``).
try:
from ..agents import CommandRegistrar
except ImportError:
@@ -2136,14 +2118,11 @@ class PresetManager:
if not command_names:
return set()
command_names = [
name
for name in command_names
if self._extension_installed_for_command(name)
]
if not command_names:
return set()
# Preset-owned command names are not filtered by the
# ``speckit.<ns>.<cmd>`` shape here either: a self-contained preset
# command renders its skill whether or not a like-named extension is
# installed. The per-name loop below skips anything that doesn't
# resolve to a managed skill directory.
resolver = PresetResolver(self.project_root)
active_skills_dir = self._get_skills_dir()
@@ -2673,21 +2652,17 @@ class PresetManager:
if not command_templates:
return {}
# Filter out extension command overrides if the extension isn't installed,
# matching the same logic used by _register_commands().
filtered = [
cmd
for cmd in command_templates
if self._extension_installed_for_command(cmd["name"])
]
if not filtered:
return {}
# Preset command templates are self-contained and render as skills
# regardless of whether a like-named extension is installed — the same
# rule _register_commands() uses. No ``speckit.<ns>.<cmd>`` name-shape
# filtering; the per-command loop below skips anything without a target
# skill directory.
skills_dir = target_dir if target_dir is not None else self._get_skills_dir()
if not skills_dir:
return {}
resolver = PresetResolver(self.project_root)
from .. import SKILL_DESCRIPTIONS, load_init_options
from ..agents import CommandRegistrar
from ..integrations import get_integration
@@ -2717,7 +2692,7 @@ class PresetManager:
written: List[str] = []
for cmd_tmpl in filtered:
for cmd_tmpl in command_templates:
cmd_name = cmd_tmpl["name"]
cmd_file_rel = cmd_tmpl["file"]
source_file = preset_dir / cmd_file_rel
@@ -2757,6 +2732,29 @@ class PresetManager:
content = source_file.read_text(encoding="utf-8")
frontmatter, body = registrar.parse_frontmatter(content)
# A composition-strategy command (wrap/prepend/append) needs a
# base layer to compose onto. When _register_commands produced no
# composed file for it and the stack still has no base
# (resolve_content is None) — e.g. the command it wraps comes from
# an extension that isn't installed — rendering the raw preset
# fragment as a skill would emit broken output: a literal
# {CORE_TEMPLATE} for wrap, or only the preset's own fragment for
# prepend/append. Skip it here too so command mode and skills mode
# agree (mirrors _register_commands, which skips the same command).
# _register_commands already warned for this command in the same
# pass, so the skip is silent here to avoid a duplicate warning.
effective_strategy = (
cmd_tmpl.get("strategy")
or frontmatter.get("strategy")
or "replace"
)
if (
effective_strategy != "replace"
and not composed_file.exists()
and resolver.resolve_content(cmd_name, "command") is None
):
continue
if frontmatter.get("strategy") == "wrap":
body, core_frontmatter = _substitute_core_template(body, cmd_name, self.project_root, registrar)
frontmatter = dict(frontmatter)
+11 -1
View File
@@ -59,7 +59,15 @@ def preset_list():
console.print(" [cyan]specify preset add <pack-name>[/cyan]")
return
console.print("\n[bold cyan]Installed Presets:[/bold cyan]\n")
# Sort by actual resolution precedence: lower priority number wins, ties
# broken by preset id (matching PresetRegistry.list_by_priority()). This
# keeps the printed order aligned with how presets are composed/resolved.
installed = sorted(
installed,
key=lambda pack: (pack.get("priority", 10), str(pack.get("id", ""))),
)
console.print("\n[bold cyan]Installed Presets[/bold cyan] [dim](in resolution order — highest precedence first)[/dim]\n")
for pack in installed:
status = "[green]enabled[/green]" if pack.get("enabled", True) else "[red]disabled[/red]"
pri = pack.get('priority', 10)
@@ -75,6 +83,8 @@ def preset_list():
console.print(f" [dim]Templates: {pack['template_count']}[/dim]")
console.print()
console.print("[dim]Lower priority number = higher precedence. Ties are broken by preset id (alphabetical).[/dim]")
@preset_app.command("add")
def preset_add(
+24
View File
@@ -1,5 +1,7 @@
import stat
import pytest
from specify_cli import merge_json_files
from specify_cli import handle_vscode_settings
@@ -188,3 +190,25 @@ def test_handle_vscode_settings_preserves_mode_on_atomic_write(tmp_path):
after_mode = stat.S_IMODE(dest_file.stat().st_mode)
assert after_mode == before_mode
def test_handle_vscode_settings_propagates_programming_errors(tmp_path):
"""Unexpected programming errors (TypeError) must propagate, not be silently swallowed."""
vscode_dir = tmp_path / ".vscode"
vscode_dir.mkdir()
dest_file = vscode_dir / "settings.json"
dest_file.write_text('{"a": 1}\n', encoding="utf-8")
template_file = tmp_path / "template_settings.json"
template_file.write_text('{"b": 2}\n', encoding="utf-8")
import specify_cli._utils as utils_mod
original_merge = utils_mod.merge_json_files
utils_mod.merge_json_files = lambda *a, **kw: (_ for _ in ()).throw(TypeError("boom"))
try:
with pytest.raises(TypeError):
handle_vscode_settings(
template_file, dest_file, "settings.json",
verbose=False, tracker=None,
)
finally:
utils_mod.merge_json_files = original_merge
+202 -39
View File
@@ -4260,8 +4260,14 @@ class TestSelfTestPreset:
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."""
def test_selfcontained_namespaced_command_scaffolds_without_extension(self, project_dir, temp_dir):
"""A preset shipping a self-contained ``speckit.<ns>.<cmd>`` command
scaffolds even when no matching extension is installed.
The command template ships its own body, so it is self-contained and
must render just like a short ``speckit.<cmd>`` command. It is not
dropped merely because ``.specify/extensions/fakeext/`` is absent.
"""
claude_dir = project_dir / ".claude" / "skills"
claude_dir.mkdir(parents=True)
@@ -4297,11 +4303,13 @@ class TestSelfTestPreset:
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"
# Extension not installed, but the preset ships its own command body —
# it must scaffold (as a native-skill SKILL.md for claude) and be
# tracked in the preset's registered_commands.
skill_file = claude_dir / "speckit-fakeext-cmd" / "SKILL.md"
assert skill_file.exists(), "Self-contained namespaced command was dropped"
metadata = manager.registry.get("ext-override")
assert metadata["registered_commands"] == {}
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."""
@@ -6528,17 +6536,16 @@ class TestPresetSkills:
"sanity: the new skills-mode artifact should still be written"
)
def test_rescaffold_skips_extension_commands_when_extension_not_installed(
def test_rescaffold_scaffolds_selfcontained_namespaced_commands(
self, project_dir, temp_dir
):
"""Rescaffold must not materialize extension-scoped commands
(``speckit.<ext>.<cmd>``) when the extension isn't installed.
"""A self-contained ``speckit.<ns>.<cmd>`` preset command scaffolds and
survives rescaffold, even when no matching extension is 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).
The preset ships the command body itself, so it is materialized just
like a short ``speckit.<cmd>`` command — both at install and through a
later reconciliation/rescaffold pass. It is not dropped by the
``speckit.<ns>.<cmd>`` name shape (#4076).
"""
self._write_init_options(project_dir, ai="copilot", ai_skills=False)
commands_dir = project_dir / ".github" / "agents"
@@ -6552,24 +6559,24 @@ class TestPresetSkills:
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"
assert ext_cmd.exists(), (
"sanity: install must scaffold a self-contained namespaced command "
"even when its like-named 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"
assert ext_cmd.exists(), (
"rescaffold must keep the self-contained namespaced command"
)
metadata = manager.registry.get("ext-scoped-preset")
assert not (metadata.get("registered_commands") or {}).get("copilot")
assert (metadata.get("registered_commands") or {}).get("copilot")
def test_rescaffold_skips_extension_skills_when_extension_not_installed(
def test_rescaffold_scaffolds_selfcontained_namespaced_skills(
self, project_dir, temp_dir
):
"""Historical tracking must not recreate a missing extension's skill."""
"""A self-contained ``speckit.<ns>.<cmd>`` preset command renders its
skill even when no matching extension is installed."""
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
skills_dir = project_dir / ".github" / "skills"
skills_dir.mkdir(parents=True)
@@ -6586,28 +6593,80 @@ class TestPresetSkills:
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",
assert skill_file.exists(), (
"install must render a self-contained namespaced command's skill "
"even when its like-named extension isn't installed"
)
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"
assert skill_file.exists(), (
"rescaffold must keep the self-contained namespaced command's skill"
)
def test_uncomposable_wrap_command_skips_skill_in_skills_mode(
self, project_dir, temp_dir
):
"""A wrap command with no base layer must not materialize a broken
skill in skills mode.
When ``_register_commands`` skips an uncomposable wrap command (no
base to compose onto — e.g. the command it wraps comes from an
uninstalled extension), ``_register_skills`` must skip it too. Before
this fix, skills mode fell back to the raw preset body and wrote a
SKILL.md containing a literal ``{CORE_TEMPLATE}`` placeholder.
"""
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
skills_dir = project_dir / ".github" / "skills"
skills_dir.mkdir(parents=True)
preset_dir = temp_dir / "uncomposable-wrap"
preset_dir.mkdir()
(preset_dir / "commands").mkdir()
# speckit.git.feature has no core command template and no installed
# extension, so there is no base layer to wrap.
(preset_dir / "commands" / "speckit.git.feature.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": "uncomposable-wrap",
"name": "uncomposable-wrap",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "command",
"name": "speckit.git.feature",
"file": "commands/speckit.git.feature.md",
"strategy": "wrap",
}
]
},
}
with open(preset_dir / "preset.yml", "w") as f:
yaml.dump(manifest_data, f)
manager = PresetManager(project_dir)
with pytest.warns(UserWarning, match="no base command layer"):
manager.install_from_directory(preset_dir, "0.1.5")
skill_file = skills_dir / "speckit-git-feature" / "SKILL.md"
assert not skill_file.exists(), (
"an uncomposable wrap command must not be rendered as a skill"
)
# Belt-and-suspenders: no artifact anywhere may leak the raw placeholder.
leaked = [
p for p in skills_dir.rglob("*")
if p.is_file() and "{CORE_TEMPLATE}" in p.read_text(encoding="utf-8")
]
assert not leaked, f"literal {{CORE_TEMPLATE}} leaked into {leaked}"
def test_same_mode_partial_command_rescaffold_keeps_skipped_tracking(
self, project_dir, temp_dir
):
@@ -10017,6 +10076,43 @@ class TestPresetSkills:
"claude's real ownership must be preserved in the migrated tracking"
)
def test_short_and_namespaced_commands_scaffold_consistently(
self, project_dir, temp_dir
):
"""A preset's ``speckit.<cmd>`` and ``speckit.<ns>.<cmd>`` commands must
scaffold identically in command mode, with no installed extension.
Regression: the 3-part (``speckit.<ns>.<cmd>``) form was silently
dropped by a name-shape guard whenever ``.specify/extensions/<ns>/``
was absent, even though the preset ships the command body itself. The
2-part form always scaffolded. Both are self-contained and must behave
the same (#4076).
"""
self._write_init_options(project_dir, ai="gemini", ai_skills=False)
gemini_commands_dir = project_dir / ".gemini" / "commands"
gemini_commands_dir.mkdir(parents=True)
short_preset = self._create_command_preset(
temp_dir, "short-cmd", "speckit.newcmd", "Short", "short body",
)
ns_preset = self._create_command_preset(
temp_dir, "ns-cmd", "speckit.fakeext.newcmd", "Namespaced", "ns body",
)
manager = PresetManager(project_dir)
manager.install_from_directory(short_preset, "0.1.5")
manager.install_from_directory(ns_preset, "0.1.5")
short_file = gemini_commands_dir / "speckit.newcmd.toml"
ns_file = gemini_commands_dir / "speckit.fakeext.newcmd.toml"
assert short_file.exists(), "2-part command should scaffold"
assert ns_file.exists(), (
"3-part namespaced command must scaffold too, even without the "
"matching extension installed"
)
assert manager.registry.get("short-cmd")["registered_commands"] != {}
assert manager.registry.get("ns-cmd")["registered_commands"] != {}
class TestPresetSetPriority:
"""Test preset set-priority CLI command."""
@@ -13680,6 +13776,73 @@ class TestInstalledPresetRichMarkup:
assert "[base]" in output, output
assert "[append]" in output, output
class TestPresetListOrdering:
"""``preset list`` must print presets in actual resolution/precedence order.
Regression coverage for #4086: the printed order was registry/insertion
order, so a preset with a *higher* priority number (lower precedence) could
appear before one with a lower number, misleading users about which preset
wins. Output must be sorted by (priority, id) to match
``PresetRegistry.list_by_priority()``.
"""
def _install(self, temp_dir, project_dir, pack_id, priority):
from specify_cli.presets import PresetManager
src = temp_dir / f"src-{pack_id}"
(src / "templates").mkdir(parents=True)
(src / "templates" / "spec-template.md").write_text("# tmpl\n")
(src / "preset.yml").write_text(yaml.dump({
"schema_version": "1.0",
"preset": {
"id": pack_id,
"name": pack_id,
"version": "1.0.0",
"description": "plain description",
},
"requires": {"speckit_version": ">=0.0.1"},
"provides": {"templates": [{
"type": "template",
"name": "spec-template",
"file": "templates/spec-template.md",
}]},
}))
PresetManager(project_dir).install_from_directory(src, "9.9.9", priority)
def _invoke(self, project_dir, args):
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
with patch.object(Path, "cwd", return_value=project_dir):
return CliRunner().invoke(app, args)
def test_list_sorted_by_priority(self, temp_dir, project_dir):
"""Lower priority number is listed first regardless of install order."""
# Install in an order that does NOT match precedence.
self._install(temp_dir, project_dir, "copilot-sub-agents", priority=100)
self._install(temp_dir, project_dir, "lean", priority=10)
result = self._invoke(project_dir, ["preset", "list"])
assert result.exit_code == 0, result.output
output = strip_ansi(result.output)
# `lean` (priority 10) must appear before `copilot-sub-agents` (100).
assert output.index("(lean)") < output.index("(copilot-sub-agents)"), output
assert "resolution order" in output, output
assert "Ties are broken by preset id" in output, output
def test_list_ties_broken_by_id(self, temp_dir, project_dir):
"""Equal priority ties are broken alphabetically by preset id."""
self._install(temp_dir, project_dir, "zebra", priority=10)
self._install(temp_dir, project_dir, "alpha", priority=10)
result = self._invoke(project_dir, ["preset", "list"])
assert result.exit_code == 0, result.output
output = strip_ansi(result.output)
assert output.index("(alpha)") < output.index("(zebra)"), output
class TestConstitutionSyncPreset:
"""The bundled opt-in ``constitution-sync`` preset re-adds materialization.