From 57e4477af997d5c6d3c419d08c9902dfc14d79d8 Mon Sep 17 00:00:00 2001 From: MahinurRahman <36816132+MahinurRahman@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:14:16 +0200 Subject: [PATCH] fix(extensions): normalize literal command calls in generated skills Fixes #3451 Assisted-by: Codex (autonomous) --- extensions/EXTENSION-USER-GUIDE.md | 7 ++++ src/specify_cli/agents.py | 26 ++++++++++++ src/specify_cli/extensions/__init__.py | 1 + tests/test_extensions.py | 57 ++++++++++++++++++++++++++ 4 files changed, 91 insertions(+) diff --git a/extensions/EXTENSION-USER-GUIDE.md b/extensions/EXTENSION-USER-GUIDE.md index c3391dbc75..2db592a2ce 100644 --- a/extensions/EXTENSION-USER-GUIDE.md +++ b/extensions/EXTENSION-USER-GUIDE.md @@ -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 diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index b5f018b89a..f07b2e80da 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -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}" @@ -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"(? str: diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index c20160c167..5d5821614c 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -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}" diff --git a/tests/test_extensions.py b/tests/test_extensions.py index e7ded45608..ae88c3705b 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -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."""