From 56c30e948b3273c82eb47bd3bd1fcc66c3efe49c Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:06:55 +0200 Subject: [PATCH 1/7] fix: rewrite extension-relative subdir paths in generated command bodies Extension command bodies reference bundled files relative to the extension root (agents/, knowledge-base/, templates/, ...). Generated SKILL.md and command files emitted those paths verbatim, so agents resolved them against the workspace root where they do not exist. Add CommandRegistrar.rewrite_extension_paths, which rewrites references to subdirectories that actually exist in the installed extension to .specify/extensions//..., and call it once in register_commands so every output format and alias gets the fix. commands/, specs/ and dot-directories are never rewritten. Fixes #2101 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/agents.py | 43 ++++++++++++ tests/test_extensions.py | 143 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index c535869121..6156d4770e 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -213,6 +213,46 @@ 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: + text = re.sub( + r'(^|[\s`"\'(])(?:\.?/)?' + re.escape(subdir) + "/", + rf"\1.specify/extensions/{extension_id}/{subdir}/", + text, + ) + return text + def render_markdown_command( self, frontmatter: dict, body: str, source_id: str, context_note: str = None ) -> str: @@ -639,6 +679,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/tests/test_extensions.py b/tests/test_extensions.py index 2885180360..d3a07746c4 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -2376,6 +2376,149 @@ 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" + "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 + # 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_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) From 5c0746b6dac92426fbb8792de8926ac6eaf8fb72 Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:55:49 +0200 Subject: [PATCH 2/7] fix: only rewrite relative extension path references Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/agents.py | 4 +++- tests/test_extensions.py | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 6156d4770e..6bbfd31bb0 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -246,8 +246,10 @@ def rewrite_extension_paths( return text for subdir in subdirs: + # Only rewrite relative references (subdir/... or ./subdir/...); + # absolute paths like /subdir/... keep their meaning. text = re.sub( - r'(^|[\s`"\'(])(?:\.?/)?' + re.escape(subdir) + "/", + r'(^|[\s`"\'(])(?:\./)?' + re.escape(subdir) + "/", rf"\1.specify/extensions/{extension_id}/{subdir}/", text, ) diff --git a/tests/test_extensions.py b/tests/test_extensions.py index d3a07746c4..4c130408b6 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -2495,6 +2495,7 @@ def test_rewrite_extension_paths_only_rewrites_existing_subdirs(self, temp_dir): 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( @@ -2502,6 +2503,9 @@ def test_rewrite_extension_paths_only_rewrites_existing_subdirs(self, temp_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 From 1b38de237f40772dbdf9ab0e35703c8ecad7e249 Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:25:03 +0200 Subject: [PATCH 3/7] fix: use callable re.sub replacement for extension subdir rewrite subdir and extension_id come from filesystem directory names and were interpolated into a re.sub string replacement template. A directory name containing a backslash (e.g. assets\q) would raise re.error: bad escape, aborting command registration even when the body didn't reference it. Use a callable replacement so these values are treated literally. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/agents.py | 8 ++++++-- tests/test_extensions.py | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 6bbfd31bb0..e4d09ffe99 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -247,10 +247,14 @@ def rewrite_extension_paths( for subdir in subdirs: # Only rewrite relative references (subdir/... or ./subdir/...); - # absolute paths like /subdir/... keep their meaning. + # 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) + "/", - rf"\1.specify/extensions/{extension_id}/{subdir}/", + lambda m: m.group(1) + replacement, text, ) return text diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 4c130408b6..501172754f 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -2511,6 +2511,24 @@ def test_rewrite_extension_paths_only_rewrites_existing_subdirs(self, temp_dir): assert ".hidden/secret.md" in rewritten assert ".specify/extensions/ext-existing/.hidden/" not in rewritten + def test_rewrite_extension_paths_handles_backslash_in_subdir_name(self, temp_dir): + """A subdir/extension_id containing backslashes must not raise or be + interpreted as a regex group reference in the replacement (#2101).""" + 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) + + 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 From 44910b600aeebfe38d4f110bd5e039132e3272d5 Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:40:21 +0200 Subject: [PATCH 4/7] fix: make subdir rewrite regression test cross-platform Renamed the test's subdir fixture from "assets\\q" to "assets[q]": on Windows, backslash is a path separator, so mkdir would create nested "assets/q" dirs instead of one literally-named directory, and iterdir() would only discover "assets", never exercising the rewrite. extension_id keeps a real backslash/"\\1" since it isn't used to create a directory, still verifying the callable replacement handles it literally. Added a sanity assertion for this assumption. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/test_extensions.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 501172754f..6f79f96595 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -2511,14 +2511,28 @@ def test_rewrite_extension_paths_only_rewrites_existing_subdirs(self, temp_dir): assert ".hidden/secret.md" in rewritten assert ".specify/extensions/ext-existing/.hidden/" not in rewritten - def test_rewrite_extension_paths_handles_backslash_in_subdir_name(self, temp_dir): - """A subdir/extension_id containing backslashes must not raise or be - interpreted as a regex group reference in the replacement (#2101).""" + 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" + 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( From 0fcee25a61405f994460c6e60c744ee7223e3f5f Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:56:30 +0200 Subject: [PATCH 5/7] fix: apply extension subdir path rewrite in skills-mode renderer register_commands() rewrote extension-relative subdir references (agents/, knowledge-base/, etc.) via rewrite_extension_paths(), but _register_extension_skills() - the separate renderer used for active non-native skills agents (e.g. Claude with ai_skills: true) - never called it. Generated SKILL.md files left agents/... and knowledge-base/... unresolved, and mapped the extension's own templates/ through the generic project-level rewrite instead of its installed .specify/extensions//templates/ location. Reuse the existing rewrite_extension_paths() helper in _register_extension_skills() at the same point register_commands() applies it (before resolve_skill_placeholders' generic rewrite), and add a skills-mode regression test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/extensions/__init__.py | 5 +++ tests/test_extension_skills.py | 59 ++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) 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/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 From 3645a3cc67d4e925bd79157dc9fbe1a93ab716ee Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:17:10 +0200 Subject: [PATCH 6/7] fix: apply extension subdir path rewrite on preset restore/reconcile paths _unregister_skills() restored extension-backed SKILL.md content via resolve_skill_placeholders() without first calling rewrite_extension_paths(), so removing a preset override that shadowed an extension command restored the bare, unresolvable agents/... and knowledge-base/... references. Carried extension_id/extension_dir through _build_extension_skill_restore_index() and applied the same rewrite used at initial registration before restoring. Found the identical gap in _reconcile_composed_commands()'s non-skill agent path: when a removed preset's command reverts to an extension winner, register_commands_for_non_skill_agents() was called without extension_id, so the rewrite never ran for plain command-file agents either. Passed extension_id through there too. Added regression tests for both restore paths (skills-mode and non-skill-agent command files) in tests/test_presets.py. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/presets/__init__.py | 14 +++++ tests/test_presets.py | 93 +++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 863b6ef7dc..dbab804ee6 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 diff --git a/tests/test_presets.py b/tests/test_presets.py index 54cb9dd5b3..df124fba72 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,6 +3739,11 @@ 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_preset_remove_skips_skill_dir_without_skill_file(self, project_dir, temp_dir): @@ -6018,6 +6026,91 @@ 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_remove_restores_lower_priority_command( self, project_dir, temp_dir, valid_pack_data ): From 8c92eada58ae3d017b11cd3dc068947608bd373b Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:47:15 +0200 Subject: [PATCH 7/7] fix: apply extension subdir path rewrite when composing over extension base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PresetResolver.resolve_content() read the effective base layer's raw content directly via path.read_text() before composing append/prepend/ wrap overlays on top of it, and its outright-replace shortcut did the same. When that base layer was extension-provided, neither read path applied rewrite_extension_paths(), so composing a preset over an extension command (or an extension winning outright through resolve_content) left bare, unresolvable agents/... and knowledge-base/... references in the composed output. All three call sites (PresetManager._register_commands()'s composed path, _reconcile_composed_commands()'s composed path, and skills-mode reading the .composed file written by either) consume resolve_content's return value, so fixing the read at its source covers command output, skill output, and both initial-install and reconcile flows without threading extension identity through each caller. Tagged extension layers in collect_all_layers() with extension_id/ extension_dir, and added a _read_layer_content() helper in resolve_content() that applies rewrite_extension_paths() whenever a layer carries that extension identity — used at both raw-read sites (outright-replace shortcut and composition base). Composing (append/prepend/wrap) layers are never extension-provided (extensions are always inserted with strategy "replace"), so no other read site needs the rewrite. Added regression tests: a parametrized resolve_content() test covering append/prepend/wrap composing over an extension base, a skills-mode test asserting the composed SKILL.md resolves the extension's subdir references, and a non-skill-agent (Gemini) install-time test matching the reported live repro. Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/presets/__init__.py | 28 +++- tests/test_presets.py | 240 ++++++++++++++++++++++++++++ 2 files changed, 266 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index dbab804ee6..81e8eefc62 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -3052,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") @@ -3171,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 @@ -3197,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_presets.py b/tests/test_presets.py index df124fba72..26c53541e6 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -3746,6 +3746,85 @@ def test_extension_skill_restored_on_preset_remove(self, project_dir, temp_dir): 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") @@ -5946,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.""" @@ -6111,6 +6270,87 @@ def test_remove_restores_extension_command_subdir_paths_for_non_skill_agent( 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 ):