Skip to content

Commit 1c00cf7

Browse files
iamaeroplaneclaude
andcommitted
fix(extensions): rewrite extension-relative paths in generated SKILL.md files
Extension command bodies reference files using paths relative to the extension root (e.g. `agents/control/commander.md`, `knowledge-base/scores.yaml`). After install these files live at `.specify/extensions/<id>/...`, but the generated SKILL.md files were emitting bare relative paths that AI agents could not resolve from the workspace root. Add `CommandRegistrar.rewrite_extension_paths()` which discovers the subdirectories that exist in the installed extension directory and rewrites matching body references to `.specify/extensions/<id>/<subdir>/...`. The rewrite runs before `resolve_skill_placeholders()` so that extension-local `scripts/` and `templates/` subdirectories are not incorrectly redirected to the project-level `.specify/scripts/` and `.specify/templates/` paths. The method is called from `render_skill_command()` when `source_dir` is provided, which `register_commands()` now passes through for all agents. Affected agents: any using the `/SKILL.md` extension format (currently kimi and codex). Aliases receive the same rewriting. Closes #2101 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 94ba857 commit 1c00cf7

3 files changed

Lines changed: 468 additions & 4 deletions

File tree

src/specify_cli/agents.py

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"""
88

99
from pathlib import Path
10-
from typing import Dict, List, Any
10+
from typing import Dict, List, Any, Optional
1111

1212
import platform
1313
import re
@@ -150,6 +150,50 @@ def rewrite_project_relative_paths(text: str) -> str:
150150

151151
return text.replace(".specify/.specify/", ".specify/").replace(".specify.specify/", ".specify/")
152152

153+
@staticmethod
154+
def rewrite_extension_paths(text: str, extension_id: str, extension_dir: Path) -> str:
155+
"""Rewrite extension-relative paths to their installed project locations.
156+
157+
Extension command bodies reference files using paths relative to the
158+
extension root (e.g. ``agents/control/commander.md``). After install,
159+
those files live at ``.specify/extensions/<id>/...``. This method
160+
rewrites such references so that AI agents can locate them after install.
161+
162+
Only directories that actually exist inside *extension_dir* are rewritten,
163+
keeping the behaviour conservative and avoiding false positives on prose.
164+
165+
Args:
166+
text: Body text of the command file.
167+
extension_id: The extension identifier (e.g. ``"echelon"``).
168+
extension_dir: Path to the installed extension directory.
169+
170+
Returns:
171+
Body text with extension-relative paths expanded.
172+
"""
173+
if not isinstance(text, str) or not text:
174+
return text
175+
176+
_SKIP = {"commands", ".git"}
177+
try:
178+
subdirs = [
179+
d.name
180+
for d in extension_dir.iterdir()
181+
if d.is_dir() and d.name not in _SKIP
182+
]
183+
except OSError:
184+
return text
185+
186+
base_prefix = f".specify/extensions/{extension_id}/"
187+
for subdir in subdirs:
188+
escaped = re.escape(subdir)
189+
text = re.sub(
190+
r"(^|[\s`\"'(])(?:\.?/)?" + escaped + r"/",
191+
r"\1" + base_prefix + subdir + "/",
192+
text,
193+
)
194+
195+
return text
196+
153197
def render_markdown_command(
154198
self,
155199
frontmatter: dict,
@@ -229,6 +273,7 @@ def render_skill_command(
229273
source_id: str,
230274
source_file: str,
231275
project_root: Path,
276+
source_dir: Optional[Path] = None,
232277
) -> str:
233278
"""Render a command override as a SKILL.md file.
234279
@@ -245,6 +290,9 @@ def render_skill_command(
245290
if not isinstance(frontmatter, dict):
246291
frontmatter = {}
247292

293+
if source_dir is not None:
294+
body = self.rewrite_extension_paths(body, source_id, source_dir)
295+
248296
if agent_name in {"codex", "kimi"}:
249297
body = self.resolve_skill_placeholders(agent_name, frontmatter, body, project_root)
250298

@@ -424,7 +472,8 @@ def register_commands(
424472

425473
if agent_config["extension"] == "/SKILL.md":
426474
output = self.render_skill_command(
427-
agent_name, output_name, frontmatter, body, source_id, cmd_file, project_root
475+
agent_name, output_name, frontmatter, body, source_id, cmd_file, project_root,
476+
source_dir=source_dir,
428477
)
429478
elif agent_config["format"] == "markdown":
430479
output = self.render_markdown_command(frontmatter, body, source_id, context_note)
@@ -452,7 +501,8 @@ def register_commands(
452501

453502
if agent_config["extension"] == "/SKILL.md":
454503
alias_output = self.render_skill_command(
455-
agent_name, alias_output_name, alias_frontmatter, body, source_id, cmd_file, project_root
504+
agent_name, alias_output_name, alias_frontmatter, body, source_id, cmd_file, project_root,
505+
source_dir=source_dir,
456506
)
457507
elif agent_config["format"] == "markdown":
458508
alias_output = self.render_markdown_command(alias_frontmatter, body, source_id, context_note)
@@ -465,7 +515,8 @@ def register_commands(
465515
alias_output = output
466516
if agent_config["extension"] == "/SKILL.md":
467517
alias_output = self.render_skill_command(
468-
agent_name, alias_output_name, frontmatter, body, source_id, cmd_file, project_root
518+
agent_name, alias_output_name, frontmatter, body, source_id, cmd_file, project_root,
519+
source_dir=source_dir,
469520
)
470521

471522
alias_file = commands_dir / f"{alias_output_name}{agent_config['extension']}"

tests/test_extensions.py

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1209,6 +1209,205 @@ def test_codex_skill_registration_resolves_script_placeholders(self, project_dir
12091209
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
12101210
assert ".specify/scripts/bash/update-agent-context.sh codex" in content
12111211

1212+
def test_skill_registration_rewrites_extension_relative_paths(self, project_dir, temp_dir):
1213+
"""Extension subdirectory paths in command bodies should be rewritten to
1214+
.specify/extensions/<id>/... in generated SKILL.md files."""
1215+
import yaml
1216+
1217+
ext_dir = temp_dir / "ext-multidir"
1218+
ext_dir.mkdir()
1219+
(ext_dir / "commands").mkdir()
1220+
(ext_dir / "agents").mkdir()
1221+
(ext_dir / "templates").mkdir()
1222+
(ext_dir / "scripts").mkdir()
1223+
(ext_dir / "knowledge-base").mkdir()
1224+
1225+
manifest_data = {
1226+
"schema_version": "1.0",
1227+
"extension": {
1228+
"id": "ext-multidir",
1229+
"name": "Multi-Dir Extension",
1230+
"version": "1.0.0",
1231+
"description": "Test",
1232+
},
1233+
"requires": {"speckit_version": ">=0.1.0"},
1234+
"provides": {
1235+
"commands": [
1236+
{
1237+
"name": "speckit.ext-multidir.run",
1238+
"file": "commands/run.md",
1239+
"description": "Run command",
1240+
}
1241+
]
1242+
},
1243+
}
1244+
with open(ext_dir / "extension.yml", "w") as f:
1245+
yaml.dump(manifest_data, f)
1246+
1247+
(ext_dir / "commands" / "run.md").write_text(
1248+
"---\n"
1249+
"description: Run command\n"
1250+
"---\n\n"
1251+
"Read agents/control/commander.md for instructions.\n"
1252+
"Use templates/report.md as output format.\n"
1253+
"Run scripts/bash/gate.sh to validate.\n"
1254+
"Load knowledge-base/scores.yaml for calibration.\n"
1255+
"Also check memory/constitution.md for project rules.\n"
1256+
)
1257+
1258+
skills_dir = project_dir / ".agents" / "skills"
1259+
skills_dir.mkdir(parents=True)
1260+
1261+
manifest = ExtensionManifest(ext_dir / "extension.yml")
1262+
registrar = CommandRegistrar()
1263+
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
1264+
1265+
content = (skills_dir / "speckit-ext-multidir-run" / "SKILL.md").read_text()
1266+
# Extension-owned directories → extension-local paths
1267+
assert ".specify/extensions/ext-multidir/agents/control/commander.md" in content
1268+
assert ".specify/extensions/ext-multidir/templates/report.md" in content
1269+
assert ".specify/extensions/ext-multidir/scripts/bash/gate.sh" in content
1270+
assert ".specify/extensions/ext-multidir/knowledge-base/scores.yaml" in content
1271+
# memory/ is not an extension directory, so stays project-level
1272+
assert "memory/constitution.md" in content
1273+
# No bare extension-relative path references remain
1274+
assert "Read agents/" not in content
1275+
assert "Load knowledge-base/" not in content
1276+
1277+
def test_skill_registration_rewrites_extension_relative_paths_for_kimi(self, project_dir, temp_dir):
1278+
"""Path rewriting should also apply to kimi, which uses the /SKILL.md extension."""
1279+
import yaml
1280+
1281+
ext_dir = temp_dir / "ext-kimi-paths"
1282+
ext_dir.mkdir()
1283+
(ext_dir / "commands").mkdir()
1284+
(ext_dir / "agents").mkdir()
1285+
(ext_dir / "knowledge-base").mkdir()
1286+
1287+
manifest_data = {
1288+
"schema_version": "1.0",
1289+
"extension": {
1290+
"id": "ext-kimi-paths",
1291+
"name": "Kimi Paths Extension",
1292+
"version": "1.0.0",
1293+
"description": "Test",
1294+
},
1295+
"requires": {"speckit_version": ">=0.1.0"},
1296+
"provides": {
1297+
"commands": [
1298+
{
1299+
"name": "speckit.ext-kimi-paths.run",
1300+
"file": "commands/run.md",
1301+
"description": "Run command",
1302+
}
1303+
]
1304+
},
1305+
}
1306+
with open(ext_dir / "extension.yml", "w") as f:
1307+
yaml.dump(manifest_data, f)
1308+
1309+
(ext_dir / "commands" / "run.md").write_text(
1310+
"---\n"
1311+
"description: Run command\n"
1312+
"---\n\n"
1313+
"Read agents/control/commander.md for instructions.\n"
1314+
"Load knowledge-base/scores.yaml for calibration.\n"
1315+
)
1316+
1317+
skills_dir = project_dir / ".kimi" / "skills"
1318+
skills_dir.mkdir(parents=True)
1319+
1320+
manifest = ExtensionManifest(ext_dir / "extension.yml")
1321+
registrar = CommandRegistrar()
1322+
registrar.register_commands_for_agent("kimi", manifest, ext_dir, project_dir)
1323+
1324+
content = (skills_dir / "speckit-ext-kimi-paths-run" / "SKILL.md").read_text()
1325+
assert ".specify/extensions/ext-kimi-paths/agents/control/commander.md" in content
1326+
assert ".specify/extensions/ext-kimi-paths/knowledge-base/scores.yaml" in content
1327+
assert "Read agents/" not in content
1328+
1329+
def test_skill_registration_rewrites_paths_in_aliases(self, project_dir, temp_dir):
1330+
"""Alias SKILL.md files should also have extension-relative paths rewritten."""
1331+
import yaml
1332+
1333+
ext_dir = temp_dir / "ext-alias-paths"
1334+
ext_dir.mkdir()
1335+
(ext_dir / "commands").mkdir()
1336+
(ext_dir / "agents").mkdir()
1337+
1338+
manifest_data = {
1339+
"schema_version": "1.0",
1340+
"extension": {
1341+
"id": "ext-alias-paths",
1342+
"name": "Alias Paths Extension",
1343+
"version": "1.0.0",
1344+
"description": "Test",
1345+
},
1346+
"requires": {"speckit_version": ">=0.1.0"},
1347+
"provides": {
1348+
"commands": [
1349+
{
1350+
"name": "speckit.ext-alias-paths.run",
1351+
"file": "commands/run.md",
1352+
"description": "Run command",
1353+
"aliases": ["speckit.ext-alias-paths.go"],
1354+
}
1355+
]
1356+
},
1357+
}
1358+
with open(ext_dir / "extension.yml", "w") as f:
1359+
yaml.dump(manifest_data, f)
1360+
1361+
(ext_dir / "commands" / "run.md").write_text(
1362+
"---\n"
1363+
"description: Run command\n"
1364+
"---\n\n"
1365+
"Read agents/control/commander.md for instructions.\n"
1366+
)
1367+
1368+
skills_dir = project_dir / ".agents" / "skills"
1369+
skills_dir.mkdir(parents=True)
1370+
1371+
manifest = ExtensionManifest(ext_dir / "extension.yml")
1372+
registrar = CommandRegistrar()
1373+
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
1374+
1375+
alias_content = (skills_dir / "speckit-ext-alias-paths-go" / "SKILL.md").read_text()
1376+
assert ".specify/extensions/ext-alias-paths/agents/control/commander.md" in alias_content
1377+
assert "Read agents/" not in alias_content
1378+
1379+
def test_rewrite_extension_paths_no_subdirs(self, project_dir, temp_dir):
1380+
"""Extension with no subdirectories should leave command body text unchanged."""
1381+
import yaml
1382+
1383+
ext_dir = temp_dir / "bare-ext"
1384+
ext_dir.mkdir()
1385+
(ext_dir / "commands").mkdir()
1386+
1387+
manifest_data = {
1388+
"schema_version": "1.0",
1389+
"extension": {"id": "bare-ext", "name": "Bare", "version": "1.0.0", "description": "Test"},
1390+
"requires": {"speckit_version": ">=0.1.0"},
1391+
"provides": {"commands": [{"name": "speckit.bare-ext.run", "file": "commands/run.md", "description": "Run"}]},
1392+
}
1393+
with open(ext_dir / "extension.yml", "w") as f:
1394+
yaml.dump(manifest_data, f)
1395+
1396+
(ext_dir / "commands" / "run.md").write_text(
1397+
"---\ndescription: Run\n---\n\nRead agents/control/commander.md and templates/report.md.\n"
1398+
)
1399+
1400+
skills_dir = project_dir / ".agents" / "skills"
1401+
skills_dir.mkdir(parents=True)
1402+
1403+
manifest = ExtensionManifest(ext_dir / "extension.yml")
1404+
CommandRegistrar().register_commands_for_agent("codex", manifest, ext_dir, project_dir)
1405+
1406+
content = (skills_dir / "speckit-bare-ext-run" / "SKILL.md").read_text()
1407+
# No subdirs to match — text unchanged
1408+
assert "agents/control/commander.md" in content
1409+
assert "templates/report.md" in content
1410+
12121411
def test_codex_skill_alias_frontmatter_matches_alias_name(self, project_dir, temp_dir):
12131412
"""Codex alias skills should render their own matching `name:` frontmatter."""
12141413
import yaml

0 commit comments

Comments
 (0)