Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 55 additions & 4 deletions src/specify_cli/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"""

from pathlib import Path
from typing import Dict, List, Any
from typing import Dict, List, Any, Optional

import platform
import re
Expand Down Expand Up @@ -150,6 +150,50 @@ def rewrite_project_relative_paths(text: str) -> str:

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

@staticmethod
def rewrite_extension_paths(text: str, extension_id: str, extension_dir: Path) -> str:
"""Rewrite extension-relative paths to their installed project locations.

Extension command bodies reference files using paths relative to the
extension root (e.g. ``agents/control/commander.md``). After install,
those files live at ``.specify/extensions/<id>/...``. This method
rewrites such references so that AI agents can locate them after install.

Only directories that actually exist inside *extension_dir* are rewritten,
keeping the behaviour conservative and avoiding false positives on prose.

Args:
text: Body text of the command file.
extension_id: The extension identifier (e.g. ``"echelon"``).
extension_dir: Path to the installed extension directory.

Returns:
Body text with extension-relative paths expanded.
"""
if not isinstance(text, str) or not text:
return text

_SKIP = {"commands", ".git"}
try:
subdirs = [
d.name
for d in extension_dir.iterdir()
if d.is_dir() and d.name not in _SKIP
]
except OSError:
return text

base_prefix = f".specify/extensions/{extension_id}/"
for subdir in subdirs:
escaped = re.escape(subdir)
text = re.sub(
r"(^|[\s`\"'(])(?:\.?/)?" + escaped + r"/",
r"\1" + base_prefix + subdir + "/",
text,
)

return text

def render_markdown_command(
self,
frontmatter: dict,
Expand Down Expand Up @@ -229,6 +273,7 @@ def render_skill_command(
source_id: str,
source_file: str,
project_root: Path,
source_dir: Optional[Path] = None,
) -> str:
"""Render a command override as a SKILL.md file.

Expand All @@ -245,6 +290,9 @@ def render_skill_command(
if not isinstance(frontmatter, dict):
frontmatter = {}

if source_dir is not None:
body = self.rewrite_extension_paths(body, source_id, source_dir)

Comment on lines +293 to +295
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

render_skill_command() rewrites paths whenever source_dir is provided, but register_commands() always passes source_dir for all sources (extensions and presets). For presets, source_dir typically contains a templates/ directory, so this logic will rewrite any templates/... references in preset command bodies to .specify/extensions/<preset-id>/templates/..., which is an incorrect location and can break preset-generated SKILL.md files.

Consider gating this rewrite so it only runs for extensions (e.g., only when an extension.yml is present in source_dir, or by threading an explicit is_extension/installed_prefix into render_skill_command()), rather than applying it to every SKILL.md source directory.

Copilot uses AI. Check for mistakes.
if agent_name in {"codex", "kimi"}:
body = self.resolve_skill_placeholders(agent_name, frontmatter, body, project_root)

Expand Down Expand Up @@ -424,7 +472,8 @@ def register_commands(

if agent_config["extension"] == "/SKILL.md":
output = self.render_skill_command(
agent_name, output_name, frontmatter, body, source_id, cmd_file, project_root
agent_name, output_name, frontmatter, body, source_id, cmd_file, project_root,
source_dir=source_dir,
)
elif agent_config["format"] == "markdown":
output = self.render_markdown_command(frontmatter, body, source_id, context_note)
Expand Down Expand Up @@ -452,7 +501,8 @@ def register_commands(

if agent_config["extension"] == "/SKILL.md":
alias_output = self.render_skill_command(
agent_name, alias_output_name, alias_frontmatter, body, source_id, cmd_file, project_root
agent_name, alias_output_name, alias_frontmatter, body, source_id, cmd_file, project_root,
source_dir=source_dir,
)
elif agent_config["format"] == "markdown":
alias_output = self.render_markdown_command(alias_frontmatter, body, source_id, context_note)
Expand All @@ -465,7 +515,8 @@ def register_commands(
alias_output = output
if agent_config["extension"] == "/SKILL.md":
alias_output = self.render_skill_command(
agent_name, alias_output_name, frontmatter, body, source_id, cmd_file, project_root
agent_name, alias_output_name, frontmatter, body, source_id, cmd_file, project_root,
source_dir=source_dir,
)

alias_file = commands_dir / f"{alias_output_name}{agent_config['extension']}"
Expand Down
199 changes: 199 additions & 0 deletions tests/test_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1209,6 +1209,205 @@ def test_codex_skill_registration_resolves_script_placeholders(self, project_dir
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
assert ".specify/scripts/bash/update-agent-context.sh codex" in content

def test_skill_registration_rewrites_extension_relative_paths(self, project_dir, temp_dir):
"""Extension subdirectory paths in command bodies should be rewritten to
.specify/extensions/<id>/... in generated SKILL.md files."""
import yaml

ext_dir = temp_dir / "ext-multidir"
ext_dir.mkdir()
(ext_dir / "commands").mkdir()
(ext_dir / "agents").mkdir()
(ext_dir / "templates").mkdir()
(ext_dir / "scripts").mkdir()
(ext_dir / "knowledge-base").mkdir()

manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "ext-multidir",
"name": "Multi-Dir Extension",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.ext-multidir.run",
"file": "commands/run.md",
"description": "Run command",
}
]
},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.dump(manifest_data, f)

(ext_dir / "commands" / "run.md").write_text(
"---\n"
"description: Run command\n"
"---\n\n"
"Read agents/control/commander.md for instructions.\n"
"Use templates/report.md as output format.\n"
"Run scripts/bash/gate.sh to validate.\n"
"Load knowledge-base/scores.yaml for calibration.\n"
"Also check memory/constitution.md for project rules.\n"
)

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-ext-multidir-run" / "SKILL.md").read_text()
# Extension-owned directories → extension-local paths
assert ".specify/extensions/ext-multidir/agents/control/commander.md" in content
assert ".specify/extensions/ext-multidir/templates/report.md" in content
assert ".specify/extensions/ext-multidir/scripts/bash/gate.sh" in content
assert ".specify/extensions/ext-multidir/knowledge-base/scores.yaml" in content
# memory/ is not an extension directory, so stays project-level
assert "memory/constitution.md" in content
# No bare extension-relative path references remain
assert "Read agents/" not in content
assert "Load knowledge-base/" not in content

def test_skill_registration_rewrites_extension_relative_paths_for_kimi(self, project_dir, temp_dir):
"""Path rewriting should also apply to kimi, which uses the /SKILL.md extension."""
import yaml

ext_dir = temp_dir / "ext-kimi-paths"
ext_dir.mkdir()
(ext_dir / "commands").mkdir()
(ext_dir / "agents").mkdir()
(ext_dir / "knowledge-base").mkdir()

manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "ext-kimi-paths",
"name": "Kimi Paths Extension",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.ext-kimi-paths.run",
"file": "commands/run.md",
"description": "Run command",
}
]
},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.dump(manifest_data, f)

(ext_dir / "commands" / "run.md").write_text(
"---\n"
"description: Run command\n"
"---\n\n"
"Read agents/control/commander.md for instructions.\n"
"Load knowledge-base/scores.yaml for calibration.\n"
)

skills_dir = project_dir / ".kimi" / "skills"
skills_dir.mkdir(parents=True)

manifest = ExtensionManifest(ext_dir / "extension.yml")
registrar = CommandRegistrar()
registrar.register_commands_for_agent("kimi", manifest, ext_dir, project_dir)

content = (skills_dir / "speckit-ext-kimi-paths-run" / "SKILL.md").read_text()
assert ".specify/extensions/ext-kimi-paths/agents/control/commander.md" in content
assert ".specify/extensions/ext-kimi-paths/knowledge-base/scores.yaml" in content
assert "Read agents/" not in content

def test_skill_registration_rewrites_paths_in_aliases(self, project_dir, temp_dir):
"""Alias SKILL.md files should also have extension-relative paths rewritten."""
import yaml

ext_dir = temp_dir / "ext-alias-paths"
ext_dir.mkdir()
(ext_dir / "commands").mkdir()
(ext_dir / "agents").mkdir()

manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "ext-alias-paths",
"name": "Alias Paths Extension",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.ext-alias-paths.run",
"file": "commands/run.md",
"description": "Run command",
"aliases": ["speckit.ext-alias-paths.go"],
}
]
},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.dump(manifest_data, f)

(ext_dir / "commands" / "run.md").write_text(
"---\n"
"description: Run command\n"
"---\n\n"
"Read agents/control/commander.md for instructions.\n"
)

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_rewrite_extension_paths_no_subdirs(self, project_dir, temp_dir):
"""Extension with no subdirectories should leave command body text unchanged."""
import yaml

ext_dir = temp_dir / "bare-ext"
ext_dir.mkdir()
(ext_dir / "commands").mkdir()

manifest_data = {
"schema_version": "1.0",
"extension": {"id": "bare-ext", "name": "Bare", "version": "1.0.0", "description": "Test"},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {"commands": [{"name": "speckit.bare-ext.run", "file": "commands/run.md", "description": "Run"}]},
}
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\nRead agents/control/commander.md and templates/report.md.\n"
)

skills_dir = project_dir / ".agents" / "skills"
skills_dir.mkdir(parents=True)

manifest = ExtensionManifest(ext_dir / "extension.yml")
CommandRegistrar().register_commands_for_agent("codex", manifest, ext_dir, project_dir)

content = (skills_dir / "speckit-bare-ext-run" / "SKILL.md").read_text()
# No subdirs to match — text unchanged
assert "agents/control/commander.md" in content
assert "templates/report.md" in content

def test_codex_skill_alias_frontmatter_matches_alias_name(self, project_dir, temp_dir):
"""Codex alias skills should render their own matching `name:` frontmatter."""
import yaml
Expand Down
Loading
Loading