Skip to content

Commit 2b60086

Browse files
fix: resolve extension commands via manifest file mapping
PresetResolver.resolve_extension_command_via_manifest() consults each installed extension.yml to find the actual file declared for a command name, rather than assuming the file is named <cmd_name>.md. This fixes _substitute_core_template for extensions like selftest where the manifest maps speckit.selftest.extension → commands/selftest.md. Resolution order in _substitute_core_template is now: 1. resolve_core(cmd_name) — project overrides win, then name-based lookup 2. resolve_extension_command_via_manifest(cmd_name) — manifest fallback 3. resolve_core(short_name) — core template short-name fallback Path traversal guard mirrors the containment check already present in ExtensionManager to reject absolute paths or paths escaping the extension root.
1 parent 7c643f9 commit 2b60086

File tree

2 files changed

+103
-7
lines changed

2 files changed

+103
-7
lines changed

src/specify_cli/presets.py

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,22 @@ def _substitute_core_template(
6060
short_name = short_name[len("speckit."):]
6161

6262
resolver = PresetResolver(project_root)
63-
# Try the full command name first so extension commands
64-
# (e.g. speckit.git.feature -> extensions/git/commands/speckit.git.feature.md)
65-
# are found before falling back to the short name used by core commands
66-
# (e.g. specify -> templates/commands/specify.md).
67-
# Use resolve_core() to skip installed presets (tier 2), preventing accidental
68-
# nesting where another preset's wrap output is mistaken for the real core.
69-
core_file = resolver.resolve_core(cmd_name, "command") or resolver.resolve_core(short_name, "command")
63+
# Resolution order for the core template:
64+
# 1. resolve_core(cmd_name) — covers tier-1 project overrides and tier-3/4
65+
# name-based lookup (file named <cmd_name>.md). Checked first so that a
66+
# local override always wins, even for extension commands.
67+
# 2. resolve_extension_command_via_manifest(cmd_name) — manifest-based tier-3
68+
# fallback for extension commands whose file is named differently from the
69+
# command name (e.g. speckit.selftest.extension → commands/selftest.md).
70+
# 3. resolve_core(short_name) — core template fallback using the unprefixed
71+
# name (e.g. specify → templates/commands/specify.md).
72+
# resolve_core() skips installed presets (tier 2) to prevent accidental nesting
73+
# where another preset's wrap output is mistaken for the real core.
74+
core_file = (
75+
resolver.resolve_core(cmd_name, "command")
76+
or resolver.resolve_extension_command_via_manifest(cmd_name)
77+
or resolver.resolve_core(short_name, "command")
78+
)
7079
if core_file is None:
7180
return body, {}
7281

@@ -2134,6 +2143,54 @@ def resolve_core(
21342143
"""
21352144
return self.resolve(template_name, template_type, skip_presets=True)
21362145

2146+
def resolve_extension_command_via_manifest(self, cmd_name: str) -> Optional[Path]:
2147+
"""Resolve an extension command by consulting installed extension manifests.
2148+
2149+
Walks installed extension directories in priority order, loads each
2150+
extension.yml via ExtensionManifest, and looks up the command by its
2151+
declared name to find the actual file path. This is necessary because
2152+
the manifest's ``provides.commands[].file`` field is authoritative and
2153+
may differ from the command name
2154+
(e.g. ``speckit.selftest.extension`` → ``commands/selftest.md``).
2155+
2156+
Returns None if no manifest maps the given command name, so the caller
2157+
can fall back to the name-based lookup.
2158+
"""
2159+
if not self.extensions_dir.exists():
2160+
return None
2161+
2162+
from .extensions import ExtensionManifest, ValidationError
2163+
2164+
for _priority, ext_id, _metadata in self._get_all_extensions_by_priority():
2165+
ext_dir = self.extensions_dir / ext_id
2166+
manifest_path = ext_dir / "extension.yml"
2167+
if not manifest_path.is_file():
2168+
continue
2169+
try:
2170+
manifest = ExtensionManifest(manifest_path)
2171+
except (ValidationError, Exception):
2172+
continue
2173+
for cmd_info in manifest.commands:
2174+
if cmd_info.get("name") != cmd_name:
2175+
continue
2176+
file_rel = cmd_info.get("file")
2177+
if not file_rel:
2178+
continue
2179+
# Mirror the containment check in ExtensionManager to guard against
2180+
# path traversal via a malformed manifest (e.g. file: ../../AGENTS.md).
2181+
cmd_path = Path(file_rel)
2182+
if cmd_path.is_absolute():
2183+
continue
2184+
try:
2185+
ext_root = ext_dir.resolve()
2186+
candidate = (ext_root / cmd_path).resolve()
2187+
candidate.relative_to(ext_root) # raises ValueError if outside
2188+
except (OSError, ValueError):
2189+
continue
2190+
if candidate.is_file():
2191+
return candidate
2192+
return None
2193+
21372194
def resolve_with_source(
21382195
self,
21392196
template_name: str,

tests/test_presets.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3545,6 +3545,45 @@ def test_extension_command_resolves_via_extension_directory(self, project_dir):
35453545
assert "# Git Feature Core" in result
35463546
assert "{CORE_TEMPLATE}" not in result
35473547

3548+
def test_extension_command_resolves_via_manifest_when_filename_differs(self, project_dir):
3549+
"""Extension commands whose filename differs from the command name resolve via extension.yml.
3550+
3551+
The selftest extension maps speckit.selftest.extension → commands/selftest.md.
3552+
Name-based lookup would look for commands/speckit.selftest.extension.md and fail;
3553+
manifest-based lookup must find the actual file declared in the manifest.
3554+
"""
3555+
from specify_cli.presets import _substitute_core_template
3556+
from specify_cli.agents import CommandRegistrar
3557+
3558+
ext_dir = project_dir / ".specify" / "extensions" / "selftest"
3559+
cmd_dir = ext_dir / "commands"
3560+
cmd_dir.mkdir(parents=True, exist_ok=True)
3561+
3562+
# File is named selftest.md, NOT speckit.selftest.extension.md
3563+
(cmd_dir / "selftest.md").write_text(
3564+
"---\ndescription: selftest core\n---\n\n# Selftest Core\n"
3565+
)
3566+
# Manifest maps the command name to the actual file
3567+
(ext_dir / "extension.yml").write_text(
3568+
"schema_version: '1.0'\n"
3569+
"extension:\n id: selftest\n name: Self-Test\n version: 1.0.0\n"
3570+
" description: test\n author: test\n repository: https://example.com\n"
3571+
" license: MIT\n"
3572+
"requires:\n speckit_version: '>=0.2.0'\n"
3573+
"provides:\n"
3574+
" commands:\n"
3575+
" - name: speckit.selftest.extension\n"
3576+
" file: commands/selftest.md\n"
3577+
" description: Selftest command\n"
3578+
)
3579+
3580+
registrar = CommandRegistrar()
3581+
body = "## Wrapper\n\n{CORE_TEMPLATE}\n"
3582+
result, _ = _substitute_core_template(body, "speckit.selftest.extension", project_dir, registrar)
3583+
3584+
assert "# Selftest Core" in result
3585+
assert "{CORE_TEMPLATE}" not in result
3586+
35483587

35493588
# ===== _replay_wraps_for_command Tests =====
35503589

0 commit comments

Comments
 (0)