diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index c535869121..e4d09ffe99 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -213,6 +213,52 @@ def rewrite_project_relative_paths( ".specify.specify/", ".specify/" ) + @staticmethod + def rewrite_extension_paths( + text: str, extension_id: str, extension_dir: Path + ) -> str: + """Rewrite extension-relative paths to their installed locations. + + Extension command bodies reference bundled files relative to the + extension root (e.g. ``agents/control/commander.md``). After install + those files live under ``.specify/extensions//``, so bare + references would resolve against the workspace root and never be + found (#2101). + + Only directories that actually exist inside *extension_dir* are + rewritten, keeping the behaviour conservative and avoiding false + positives on prose. ``commands`` (slash-command sources), ``specs`` + (user project artifacts) and dot-directories are never rewritten. + """ + if not isinstance(text, str) or not text: + return text + + skip = {"commands", ".git", "specs"} + try: + subdirs = [ + entry.name + for entry in extension_dir.iterdir() + if entry.is_dir() + and entry.name not in skip + and not entry.name.startswith(".") + ] + except OSError: + return text + + for subdir in subdirs: + # Only rewrite relative references (subdir/... or ./subdir/...); + # absolute paths like /subdir/... keep their meaning. Use a + # callable replacement: subdir/extension_id come from the + # filesystem and could contain backslashes or "\1"-like + # sequences, which would corrupt a string replacement template. + replacement = f".specify/extensions/{extension_id}/{subdir}/" + text = re.sub( + r'(^|[\s`"\'(])(?:\./)?' + re.escape(subdir) + "/", + lambda m: m.group(1) + replacement, + text, + ) + return text + def render_markdown_command( self, frontmatter: dict, body: str, source_id: str, context_note: str = None ) -> str: @@ -639,6 +685,9 @@ def register_commands( frontmatter[key] = core_frontmatter[key] frontmatter.pop("strategy", None) + if extension_id: + body = self.rewrite_extension_paths(body, extension_id, source_root) + frontmatter = self._adjust_script_paths( frontmatter, extension_id=extension_id ) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index b84b836545..6b3ba0cc84 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1078,6 +1078,11 @@ def _register_extension_skills( frontmatter = registrar._adjust_script_paths( frontmatter, extension_id=manifest.id ) + # Mirror the register_commands() rewrite (#2101): resolve + # extension-relative subdir references (agents/, knowledge-base/, + # etc.) to their installed .specify/extensions// location + # before the generic placeholder/path resolution below. + body = registrar.rewrite_extension_paths(body, manifest.id, extension_dir) body = registrar.resolve_skill_placeholders( selected_ai, frontmatter, body, self.project_root, extension_id=manifest.id ) diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 863b6ef7dc..81e8eefc62 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -778,6 +778,7 @@ def _reconcile_composed_commands(self, command_names: List[str]) -> None: matching_cmds, ext_id, ext_dir, self.project_root, context_note=f"\n\n\n", + extension_id=ext_id, ) registered = True except Exception: @@ -1199,6 +1200,8 @@ def _build_extension_skill_restore_index(self) -> Dict[str, Dict[str, Any]]: "command_name": cmd_name, "source_file": source_file, "source": f"extension:{manifest.id}", + "extension_id": manifest.id, + "extension_dir": ext_root, } modern_skill_name, legacy_skill_name = self._skill_names_for_command(cmd_name) restore_index.setdefault(modern_skill_name, restore_info) @@ -1463,6 +1466,17 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None: if extension_restore: content = extension_restore["source_file"].read_text(encoding="utf-8") frontmatter, body = registrar.parse_frontmatter(content) + # Mirror the register-time rewrite (#2101): resolve + # extension-relative subdir references (agents/, + # knowledge-base/, etc.) to their installed location before + # the generic placeholder resolution below, otherwise + # restoring after a preset override removal would leave + # bare, unresolvable paths in the skill body. + body = registrar.rewrite_extension_paths( + body, + extension_restore["extension_id"], + extension_restore["extension_dir"], + ) if isinstance(selected_ai, str): body = registrar.resolve_skill_placeholders( selected_ai, frontmatter, body, self.project_root @@ -3038,6 +3052,8 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: "path": candidate, "source": source, "strategy": "replace", + "extension_id": ext_id, + "extension_dir": ext_dir, }) # Priority 4: Core templates (always "replace") @@ -3157,10 +3173,32 @@ def resolve_content( if not layers: return None + def _read_layer_content(layer: Dict[str, Any]) -> str: + """Read a layer's raw text, rewriting extension-relative subdir + references (agents/, knowledge-base/, etc.) to their installed + location when the layer is extension-provided (#2101). + + Extension layers are always inserted with strategy "replace" + (see collect_all_layers), so a layer only ever needs this + rewrite when it wins outright above or serves as the + composition base below — never as a mid-stack composing + (append/prepend/wrap) layer. + """ + text = layer["path"].read_text(encoding="utf-8") + extension_id = layer.get("extension_id") + extension_dir = layer.get("extension_dir") + if extension_id and extension_dir: + from ..agents import CommandRegistrar + + text = CommandRegistrar.rewrite_extension_paths( + text, extension_id, extension_dir + ) + return text + # If the top (highest-priority) layer is replace, it wins entirely — # lower layers are irrelevant regardless of their strategies. if layers[0]["strategy"] == "replace": - return layers[0]["path"].read_text(encoding="utf-8") + return _read_layer_content(layers[0]) # Composition: build content bottom-up from the effective base. # The base is the nearest replace layer scanning from highest priority @@ -3183,7 +3221,7 @@ def resolve_content( # Convert to reversed_layers index base_reversed_idx = len(layers) - 1 - base_layer_idx - content = layers[base_layer_idx]["path"].read_text(encoding="utf-8") + content = _read_layer_content(layers[base_layer_idx]) # Compose only the layers above the base (higher priority = lower index in layers, # higher index in reversed_layers). Process bottom-up from base+1. start_idx = base_reversed_idx + 1 diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index c2d9bb439e..2ebd3f5e6b 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -911,6 +911,65 @@ def test_skill_registration_uses_extension_local_script_paths(self, project_dir, assert ".specify/scripts/bash/resolve-skill.sh" not in content assert ".specify/scripts/bash/ensure-skills.sh" not in content + def test_skill_registration_rewrites_extension_subdir_paths(self, project_dir, temp_dir): + """Auto-registered skills should resolve extension-relative subdir + references (agents/, knowledge-base/) to their installed location, + matching the rewrite already applied by register_commands() (#2101).""" + _create_init_options(project_dir, ai="claude", ai_skills=True) + skills_dir = _create_skills_dir(project_dir, ai="claude") + + ext_dir = temp_dir / "path-ext" + ext_dir.mkdir() + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "path-ext", + "name": "Path Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.path-ext.run", + "file": "commands/run.md", + "description": "Run command", + } + ] + }, + } + with open(ext_dir / "extension.yml", "w") as f: + yaml.safe_dump(manifest_data, f) + + (ext_dir / "commands").mkdir() + (ext_dir / "agents" / "control").mkdir(parents=True) + (ext_dir / "agents" / "control" / "commander.md").write_text("# Commander\n") + (ext_dir / "knowledge-base").mkdir() + (ext_dir / "knowledge-base" / "agent-scores.yaml").write_text("scores: {}\n") + (ext_dir / "templates").mkdir() + (ext_dir / "templates" / "kill-report.md").write_text("# Kill Report\n") + + (ext_dir / "commands" / "run.md").write_text( + "---\n" + "description: Run command\n" + "---\n\n" + "Read agents/control/commander.md and knowledge-base/agent-scores.yaml.\n" + "Use templates/kill-report.md as the report template.\n" + ) + + manager = ExtensionManager(project_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + content = (skills_dir / "speckit-path-ext-run" / "SKILL.md").read_text() + assert ".specify/extensions/path-ext/agents/control/commander.md" in content + assert ".specify/extensions/path-ext/knowledge-base/agent-scores.yaml" in content + # extension's own templates/ dir must resolve under the extension, + # not the project-level .specify/templates/ + assert ".specify/extensions/path-ext/templates/kill-report.md" in content + assert "Read agents/control" not in content + assert "and knowledge-base/" not in content + def test_missing_command_file_skipped(self, skills_project, temp_dir): """Commands with missing source files should be skipped gracefully.""" project_dir, skills_dir = skills_project diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 2885180360..6f79f96595 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -2376,6 +2376,185 @@ def test_codex_skill_registration_fallback_prefers_powershell_on_windows( assert ".specify/scripts/powershell/setup-plan.ps1 -Json" in content assert ".specify/scripts/bash/setup-plan.sh" not in content + @staticmethod + def _make_subdir_extension(temp_dir, ext_id="echelon", aliases=None): + """Create an extension whose command body references bundled subdirs.""" + import yaml + + ext_dir = temp_dir / ext_id + ext_dir.mkdir() + (ext_dir / "commands").mkdir() + (ext_dir / "agents" / "control").mkdir(parents=True) + (ext_dir / "knowledge-base").mkdir() + (ext_dir / "templates").mkdir() + (ext_dir / "specs" / "001-internal").mkdir(parents=True) + + command = { + "name": f"speckit.{ext_id}.run", + "file": "commands/run.md", + "description": "Run", + } + if aliases: + command["aliases"] = aliases + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": ext_id, + "name": "Echelon", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": {"commands": [command]}, + } + with open(ext_dir / "extension.yml", "w") as f: + yaml.dump(manifest_data, f) + + (ext_dir / "commands" / "run.md").write_text( + "---\ndescription: Run\n---\n\n" + "Read agents/control/commander.md for instructions.\n" + "Load knowledge-base/agent-scores.yaml for calibration.\n" + "Use templates/kill-report.md as output format.\n" + "Artifacts go to specs/001-internal/plan.md.\n" + "See commands/run.md for the source.\n" + ) + return ext_dir + + def test_codex_skill_registration_rewrites_extension_subdir_paths( + self, project_dir, temp_dir + ): + """Extension-relative subdir refs must point at the installed location.""" + ext_dir = self._make_subdir_extension(temp_dir) + + skills_dir = project_dir / ".agents" / "skills" + skills_dir.mkdir(parents=True) + + manifest = ExtensionManifest(ext_dir / "extension.yml") + registrar = CommandRegistrar() + registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir) + + content = (skills_dir / "speckit-echelon-run" / "SKILL.md").read_text() + assert ".specify/extensions/echelon/agents/control/commander.md" in content + assert ".specify/extensions/echelon/knowledge-base/agent-scores.yaml" in content + assert ".specify/extensions/echelon/templates/kill-report.md" in content + assert "Read agents/" not in content + # specs/ refs point at the user's project artifacts, never the extension + assert "to specs/001-internal/plan.md" in content + assert ".specify/extensions/echelon/specs/" not in content + # commands/ refs are slash-command sources, not runtime reads + assert "See commands/run.md" in content + + def test_skill_registration_rewrites_extension_subdir_paths_in_aliases( + self, project_dir, temp_dir + ): + """Alias skills reuse the rewritten body.""" + ext_dir = self._make_subdir_extension( + temp_dir, ext_id="ext-alias-paths", aliases=["speckit.ext-alias-paths.go"] + ) + + skills_dir = project_dir / ".agents" / "skills" + skills_dir.mkdir(parents=True) + + manifest = ExtensionManifest(ext_dir / "extension.yml") + registrar = CommandRegistrar() + registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir) + + alias_content = ( + skills_dir / "speckit-ext-alias-paths-go" / "SKILL.md" + ).read_text() + assert ( + ".specify/extensions/ext-alias-paths/agents/control/commander.md" + in alias_content + ) + assert "Read agents/" not in alias_content + + def test_markdown_registration_rewrites_extension_subdir_paths( + self, project_dir, temp_dir + ): + """Markdown-format agents get the same rewrite via the shared path.""" + ext_dir = self._make_subdir_extension(temp_dir, ext_id="ext-md-paths") + + amp_dir = project_dir / ".agents" / "commands" + amp_dir.mkdir(parents=True) + + manifest = ExtensionManifest(ext_dir / "extension.yml") + registrar = CommandRegistrar() + registrar.register_commands_for_agent("amp", manifest, ext_dir, project_dir) + + content = (amp_dir / "speckit.ext-md-paths.run.md").read_text() + assert ".specify/extensions/ext-md-paths/agents/control/commander.md" in content + assert "Read agents/" not in content + + def test_rewrite_extension_paths_only_rewrites_existing_subdirs(self, temp_dir): + """Only directories present in the extension are rewritten.""" + from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar + + ext_dir = temp_dir / "ext-existing" + (ext_dir / "agents").mkdir(parents=True) + (ext_dir / ".hidden").mkdir() + + text = ( + "Read agents/one.md then knowledge-base/two.md.\n" + "Also ./agents/three.md but not /agents/abs.md.\n" + "Keep .hidden/secret.md alone.\n" + ) + rewritten = AgentCommandRegistrar.rewrite_extension_paths( + text, "ext-existing", ext_dir + ) + + assert ".specify/extensions/ext-existing/agents/one.md" in rewritten + assert "Also .specify/extensions/ext-existing/agents/three.md" in rewritten + # absolute paths keep their meaning + assert "not /agents/abs.md" in rewritten + # knowledge-base/ does not exist in this extension: left untouched + assert "then knowledge-base/two.md" in rewritten + assert ".hidden/secret.md" in rewritten + assert ".specify/extensions/ext-existing/.hidden/" not in rewritten + + def test_rewrite_extension_paths_handles_regex_special_replacement_text( + self, temp_dir + ): + """subdir/extension_id containing regex-replacement-special characters + (e.g. backslash / group references) must not raise or be misinterpreted + by re.sub's replacement template (#2101). + + The subdir name uses brackets rather than a backslash: on Windows, + "\\" is a path separator, so a subdir literally named "assets\\q" + would create nested directories "assets/q" instead of a single + directory, and iterdir() would then only discover "assets" - never + exercising the intended replacement text. extension_id isn't used to + create a directory, so it's free to contain a real backslash/"\\1" + to verify the callable replacement treats it literally. + """ + from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar + + ext_dir = temp_dir / "ext-backslash" + weird_subdir = "assets[q]" + (ext_dir / weird_subdir).mkdir(parents=True) + # sanity-check the cross-platform assumption above + assert [p.name for p in ext_dir.iterdir()] == [weird_subdir] + + text = f"Read {weird_subdir}/file.md but not /{weird_subdir}/abs.md.\n" + rewritten = AgentCommandRegistrar.rewrite_extension_paths( + text, "ext\\1", ext_dir + ) + + assert f".specify/extensions/ext\\1/{weird_subdir}/file.md" in rewritten + # absolute paths are still left untouched + assert f"/{weird_subdir}/abs.md" in rewritten + + def test_rewrite_extension_paths_missing_dir_returns_text(self, temp_dir): + """A missing extension directory leaves the text unchanged.""" + from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar + + text = "Read agents/one.md." + assert ( + AgentCommandRegistrar.rewrite_extension_paths( + text, "gone", temp_dir / "does-not-exist" + ) + == text + ) + def test_register_commands_for_copilot(self, extension_dir, project_dir): """Test registering commands for Copilot agent with .agent.md extension.""" # Create .github/agents directory (Copilot project) diff --git a/tests/test_presets.py b/tests/test_presets.py index 54cb9dd5b3..26c53541e6 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -3663,6 +3663,8 @@ def test_extension_skill_restored_on_preset_remove(self, project_dir, temp_dir): 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" @@ -3671,6 +3673,7 @@ def test_extension_skill_restored_on_preset_remove(self, project_dir, temp_dir): "---\n\n" "extension:fakeext\n" "Run {SCRIPT}\n" + "Read agents/control/commander.md for context.\n" ) extension_manifest = { "schema_version": "1.0", @@ -3736,8 +3739,92 @@ def test_extension_skill_restored_on_preset_remove(self, project_dir, temp_dir): 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") @@ -5938,6 +6025,86 @@ def test_resolve_content_replace_over_wrap(self, project_dir, temp_dir, valid_pa 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.""" @@ -6018,6 +6185,172 @@ def test_layers_read_strategy_from_manifest(self, project_dir, temp_dir, valid_p 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 ):