Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1c00cf7
fix(extensions): rewrite extension-relative paths in generated SKILL.…
Apr 6, 2026
64cd276
feat: add BehaviorTranslator for neutral extension behavior vocabulary
Apr 8, 2026
b8768b4
feat: passthrough agent-specific skill frontmatter keys from source c…
Apr 8, 2026
dbb4fcb
feat: translate behavior: vocabulary into agent-specific frontmatter …
Apr 8, 2026
8688503
feat: route behavior.execution:agent commands to .claude/agents/ inst…
Apr 8, 2026
9942c64
feat: inject mode and tools into Copilot .agent.md for execution:agen…
Apr 8, 2026
807b8a4
fix: remove dead copilot alias injection, add missing agent deploymen…
Apr 8, 2026
a3459c8
test: end-to-end agent deployment integration test + RFC addendum
Apr 8, 2026
8ddfb60
fix: clarify RFC doc tool key remapping, add missing table rows
Apr 8, 2026
ce12331
feat(behavior): add 'write' tools level — Read Write Edit Grep Glob w…
Apr 10, 2026
58e0c72
feat(behavior): support custom tool lists — tools accepts list or lit…
Apr 10, 2026
c804c5f
feat(behavior): add color and write preset to behavior vocabulary
Apr 10, 2026
b316272
docs(rfc): add RFC addenda section linking behavior+deployment addendum
Apr 10, 2026
aa8c9a1
fix(extensions): skip agent-deployment commands in skill registration…
Apr 10, 2026
9b86c1f
feat(templates): mark all built-in commands as invocation: automatic
Apr 10, 2026
5618fd1
chore(gitignore): ignore local dev dirs and specs
Apr 10, 2026
2e4a556
chore(docs): remove old extension-behavior-deployment.md
Apr 10, 2026
09365c4
fix(integrations): translate behavior in SkillsIntegration before Cla…
Apr 11, 2026
5356704
fix(review): address Copilot inline feedback
Apr 12, 2026
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)

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