Skip to content
Open
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
7 changes: 7 additions & 0 deletions extensions/EXTENSION-USER-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,13 @@ Jira Integration (v1.0.0)

When an extension is removed, its corresponding skills are also cleaned up automatically. Pre-existing skills that were manually customized are never overwritten.

For portable cross-command calls, extension authors should use tokens such as
`__SPECKIT_COMMAND_PLAN__` or `__SPECKIT_COMMAND_MEMORY-MD_PREPARE-CONTEXT__`.
These resolve to the selected integration's invocation syntax. When generating
skills, legacy literal calls such as `/speckit.memory-md.prepare-context` are
also converted (for example, to `$speckit-memory-md-prepare-context` for Codex).
Canonical command IDs and file paths retain their original spelling.

---

## Using Extensions
Expand Down
26 changes: 26 additions & 0 deletions src/specify_cli/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,7 @@ def render_skill_command(
body = self.resolve_skill_placeholders(
agent_name, frontmatter, body, project_root, extension_id=extension_id
)
body = self.normalize_skill_invocations(agent_name, body)

description = frontmatter.get(
"description", f"Spec-kit workflow command: {skill_name}"
Expand Down Expand Up @@ -530,6 +531,31 @@ def resolve_skill_placeholders(
body, extension_id=extension_id
)

@staticmethod
def normalize_skill_invocations(agent_name: str, body: str) -> str:
"""Convert literal slash/dot command calls to native skill invocations."""
# Older extension commands use literal slash/dot invocations instead
# of __SPECKIT_COMMAND_*__ tokens. Normalize those in every skill
# rendering path, including the manager and command overrides (#3451).
prefix = get_invocation_prefix(agent_name, True)

def replace_invocation(match: re.Match[str]) -> str:
command = match.group(0)
# Do not rewrite absolute filenames or directory references that
# happen to start with /speckit.; relative paths and URLs are
# excluded by the leading boundary in the pattern below.
if command.endswith((".md", ".json", ".yml", ".yaml", ".toml")):
return command
if body[match.end():match.end() + 1] in ("/", "\\"):
return command
return prefix + command[1:].replace(".", "-")

return re.sub(
r"(?<![\w/\\:.-])/speckit\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*",
replace_invocation,
body,
)

def _convert_argument_placeholder(
self, content: str, from_placeholder: str, to_placeholder: str
) -> str:
Expand Down
1 change: 1 addition & 0 deletions src/specify_cli/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1678,6 +1678,7 @@ def _replacement(match: re.Match[str]) -> str:
selected_ai, frontmatter, body, self.project_root, extension_id=manifest.id
)
body = _resolve_command_ref_tokens(body)
body = registrar.normalize_skill_invocations(selected_ai, body)

original_desc = frontmatter.get("description", "")
description = original_desc or f"Extension command: {cmd_name}"
Expand Down
57 changes: 57 additions & 0 deletions tests/test_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -10625,6 +10625,63 @@ def test_unregister_hooks_removes_all_extension_entries(self, project_dir):
assert [e["extension"] for e in entries] == ["ext-b"]


@pytest.mark.parametrize("agent,skills_path,prefix", [
("codex", ".agents/skills", "$"),
("claude", ".claude/skills", "/"),
("copilot", ".github/skills", "/"),
("kimi", ".kimi-code/skills", "/skill:"),
])
@pytest.mark.parametrize("register_commands", [True, False])
def test_extension_install_normalizes_literal_skill_invocations(
extension_dir, project_dir, agent, skills_path, prefix, register_commands
):
"""Issue #3451: installed skills must call commands in the agent's syntax."""
from specify_cli.agents import CommandRegistrar as SkillRegistrar

(project_dir / ".specify/init-options.json").write_text(
json.dumps({"ai": agent, "ai_skills": True, "script": "py"}),
encoding="utf-8",
)
source = extension_dir / "commands/hello.md"
source.write_text(
"---\ndescription: Cross-command references\n---\n\n"
"Run `/speckit.memory-md.prepare-context` before /speckit.plan.\n"
"Then run __SPECKIT_COMMAND_TASKS__.\n"
"Keep `speckit.memory-md.prepare-context` as the canonical ID.\n"
"Read `commands/speckit.memory-md.prepare-context.md`.\n"
"Read `/speckit.plan.md` and `/speckit.plan/assets`.\n"
"See https://example.com/speckit.plan and ../speckit.plan.\n",
encoding="utf-8",
)
ExtensionManager(project_dir).install_from_directory(
extension_dir, "1.0.6", register_commands=register_commands
)

installed = project_dir / skills_path / "speckit-test-ext-hello/SKILL.md"
content = installed.read_text(encoding="utf-8")
assert f"Run `{prefix}speckit-memory-md-prepare-context` before {prefix}speckit-plan." in content
assert f"Then run {prefix}speckit-tasks." in content
assert "`speckit.memory-md.prepare-context` as the canonical ID" in content
assert "commands/speckit.memory-md.prepare-context.md" in content
assert "`/speckit.plan.md` and `/speckit.plan/assets`" in content
assert "https://example.com/speckit.plan and ../speckit.plan." in content
assert SkillRegistrar.normalize_skill_invocations(agent, content) == content


def test_command_layout_preserves_literal_dotted_invocations(extension_dir, project_dir):
"""The skill conversion must not affect a non-skill command layout."""
(extension_dir / "commands/hello.md").write_text(
"---\ndescription: Command reference\n---\n\nRun `/speckit.plan`.\n",
encoding="utf-8",
)
manifest = ExtensionManifest(extension_dir / "extension.yml")
CommandRegistrar().register_commands_for_agent(
"amp", manifest, extension_dir, project_dir
)
installed = project_dir / ".agents/commands/speckit.test-ext.hello.md"
assert "Run `/speckit.plan`." in installed.read_text(encoding="utf-8")


class TestHookInvocationRendering:
"""Test hook invocation formatting for different agent modes."""

Expand Down