diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py index 4468bcdb6b..1a0130f062 100644 --- a/src/specify_cli/presets.py +++ b/src/specify_cli/presets.py @@ -618,13 +618,20 @@ def _register_commands( # Filter out extension command overrides if the extension isn't installed. # Command names follow the pattern: speckit.. # Core commands (e.g. speckit.specify) have only one dot — always register. + # Only skip a 3-part command when it uses a composition strategy + # (append/prepend/wrap) and the target extension isn't installed — those + # commands require a base command to compose against. Commands with the + # default "replace" strategy are self-contained and must always be + # registered, even when the segment doesn't match an installed + # extension (e.g. new preset commands in a fresh namespace). extensions_dir = self.project_root / ".specify" / "extensions" filtered = [] for cmd in command_templates: parts = cmd["name"].split(".") if len(parts) >= 3 and parts[0] == "speckit": ext_id = parts[1] - if not (extensions_dir / ext_id).is_dir(): + strategy = cmd.get("strategy", "replace") + if strategy != "replace" and not (extensions_dir / ext_id).is_dir(): continue filtered.append(cmd) @@ -1236,13 +1243,17 @@ def _register_skills( # Filter out extension command overrides if the extension isn't installed, # matching the same logic used by _register_commands(). + # Only skip a 3-part command when it uses a composition strategy + # (append/prepend/wrap) and the target extension isn't installed; "replace" + # commands are self-contained and always registered. extensions_dir = self.project_root / ".specify" / "extensions" filtered = [] for cmd in command_templates: parts = cmd["name"].split(".") if len(parts) >= 3 and parts[0] == "speckit": ext_id = parts[1] - if not (extensions_dir / ext_id).is_dir(): + strategy = cmd.get("strategy", "replace") + if strategy != "replace" and not (extensions_dir / ext_id).is_dir(): continue filtered.append(cmd) @@ -1588,6 +1599,8 @@ def install_from_directory( # install order doesn't determine the winning command file. # Apply the same extension-installed filter as _register_commands to # avoid reconciling extension commands when the extension isn't installed. + # Only skip composition-strategy (append/prepend/wrap) commands whose + # target extension isn't installed; "replace" commands are always included. extensions_dir = self.project_root / ".specify" / "extensions" cmd_names = [] for t in manifest.templates: @@ -1597,7 +1610,8 @@ def install_from_directory( parts = name.split(".") if len(parts) >= 3 and parts[0] == "speckit": ext_id = parts[1] - if not (extensions_dir / ext_id).is_dir(): + strategy = t.get("strategy", "replace") + if strategy != "replace" and not (extensions_dir / ext_id).is_dir(): continue cmd_names.append(name) if cmd_names: diff --git a/tests/test_presets.py b/tests/test_presets.py index 380b128619..a4de1f1497 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -2155,7 +2155,7 @@ def test_self_test_no_commands_without_agent_dirs(self, project_dir): assert metadata["registered_commands"] == {} 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.""" + """Test that composition-strategy extension overrides are skipped if the extension isn't installed.""" claude_dir = project_dir / ".claude" / "skills" claude_dir.mkdir(parents=True) @@ -2181,6 +2181,7 @@ def test_extension_command_skipped_when_extension_missing(self, project_dir, tem "name": "speckit.fakeext.cmd", "file": "commands/speckit.fakeext.cmd.md", "description": "Override fakeext cmd", + "strategy": "append", } ] }, @@ -2191,12 +2192,54 @@ def test_extension_command_skipped_when_extension_missing(self, project_dir, tem manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") - # Extension not installed — command should NOT be registered + # Extension not installed — composition override should NOT be registered cmd_file = claude_dir / "speckit.fakeext.cmd.md" - assert not cmd_file.exists(), "Command registered for missing extension" + assert not cmd_file.exists(), "Composition override registered for missing extension" metadata = manager.registry.get("ext-override") assert metadata["registered_commands"] == {} + def test_preset_command_new_namespace_always_registered(self, project_dir, temp_dir): + """Test that preset commands in a new 3-part namespace are registered even without a matching extension.""" + claude_dir = project_dir / ".claude" / "skills" + claude_dir.mkdir(parents=True) + + preset_dir = temp_dir / "new-namespace-preset" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + (preset_dir / "commands" / "speckit.myplugin.reviewer.md").write_text( + "---\ndescription: My plugin reviewer command\n---\nReviewer content" + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": "new-namespace", + "name": "New Namespace", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.myplugin.reviewer", + "file": "commands/speckit.myplugin.reviewer.md", + "description": "My plugin reviewer", + } + ] + }, + } + 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") + + # No extension named "myplugin" exists, but the command uses "replace" (default) + # strategy and is self-contained — it must be registered. + cmd_file = claude_dir / "speckit-myplugin-reviewer" / "SKILL.md" + assert cmd_file.exists(), "New preset command in novel namespace was not registered" + 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"