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
22 changes: 11 additions & 11 deletions extensions/EXTENSION-DEVELOPMENT-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,15 +296,16 @@ A command body is a *template* that Spec Kit renders once per agent. Different a

Instead use the agent-neutral token `__SPECKIT_COMMAND_<NAME>__`. Spec Kit resolves it to a `/speckit<separator>...` invocation using the active integration's `invoke_separator` (and integrations may post-process that further in skills output).

Encode the command name in upper case, dropping the `speckit.` prefix and turning each dotted segment separator into an underscore:
Encode the command name in upper case, dropping the `speckit.` prefix and turning each dotted segment separator into an underscore. A hyphen within a segment is written verbatim — command names are constrained to `[a-z0-9-]+` per segment and never contain an underscore, so there's no ambiguity between a dot and a hyphen:

| Command file | Token |
| --- | --- |
| `speckit.plan.md` | `__SPECKIT_COMMAND_PLAN__` |
| `speckit.bug.fix.md` | `__SPECKIT_COMMAND_BUG_FIX__` |
| `speckit.git.commit.md` | `__SPECKIT_COMMAND_GIT_COMMIT__` |
| `speckit.agent-context.update.md` | `__SPECKIT_COMMAND_AGENT-CONTEXT_UPDATE__` |

The resolver maps each underscore back to the active agent's separator, so use tokens to reference commands whose name segments are single words. (Command names are dotted segments like `git.commit`; the token scheme rebuilds those dots and does not carry hyphens within a segment.)
The resolver maps each underscore back to the active agent's separator and leaves hyphens untouched, so both single-word and hyphenated segments round-trip correctly.

**Example** — a command body that points the user at the next step:

Expand All @@ -314,15 +315,14 @@ Once the assessment exists, the next step is `__SPECKIT_COMMAND_BUG_FIX__ slug=<

This renders as `/speckit.bug.fix slug=<slug>` for a slash-based agent, `/speckit-bug-fix slug=<slug>` for a skills-based agent, and so on — the author writes it once and it stays portable. The first-party `bug` and `git` extensions use this token exclusively; see `extensions/bug/commands/` for working examples.

> **Current limitation — skills mode.** Token resolution runs in the
> command-rendering path (`CommandRegistrar`), so it applies when an extension
> installs *command files*. It does **not** yet run when an extension is
> registered as *skills* for a skills-based agent: `_register_extension_skills`
> resolves placeholders and post-processes content but never calls
> `resolve_command_refs`, so a `__SPECKIT_COMMAND_<NAME>__` token reaches
> agents such as Codex, ZCode, and Kimi verbatim in that mode. Until that
> rendering step lands, prefer the token for command-file extensions and avoid
> relying on it inside skill bodies destined for skills-based agents.
> **Note — skills mode.** When an extension is registered as *skills* for a
> skills-based agent, tokens are resolved by a separate resolver in
> `ExtensionManager._register_extension_skills`, not by the shared
> `resolve_command_refs()` used for command-file extensions. Both resolvers
> share the same token grammar, so `__SPECKIT_COMMAND_<NAME>__` (including
> hyphenated segments) resolves correctly in either mode — but a fix to one
> resolver does not automatically apply to the other, so keep both in sync
> when touching this token's grammar or resolution logic.

### Script Path Rewriting

Expand Down
2 changes: 1 addition & 1 deletion src/specify_cli/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1596,7 +1596,7 @@ def _replacement(match: re.Match[str]) -> str:
)

return re.sub(
r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__", _replacement, body
r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_-]*)__", _replacement, body
)

for cmd_info in manifest.commands:
Expand Down
12 changes: 10 additions & 2 deletions src/specify_cli/integrations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -630,17 +630,25 @@ def resolve_command_refs(

Each placeholder encodes a command name in upper-case with
underscores (e.g. ``__SPECKIT_COMMAND_PLAN__``,
``__SPECKIT_COMMAND_GIT_COMMIT__``). The replacement uses
``__SPECKIT_COMMAND_GIT_COMMIT__``). Each underscore is replaced by
*separator* to join the segments:

* ``separator="."`` → ``/speckit.plan``, ``/speckit.git.commit``
* ``separator="-"`` → ``/speckit-plan``, ``/speckit-git-commit``

A segment may itself contain a literal hyphen (command names are
constrained to ``[a-z0-9-]+`` per segment and never contain an
underscore, see ``EXTENSION_COMMAND_NAME_PATTERN``), so hyphens are
written verbatim in the placeholder and pass through unchanged:

* ``__SPECKIT_COMMAND_AGENT-CONTEXT_UPDATE__`` with
``separator="."`` → ``/speckit.agent-context.update``

*prefix* defaults to ``"/"`` but may be ``"$"`` for agents whose
native skills invocation uses dollar-prefixed chat commands.
"""
return re.sub(
r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__",
r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_-]*)__",
lambda m: prefix
+ "speckit"
+ separator
Expand Down
43 changes: 43 additions & 0 deletions tests/integrations/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,49 @@ def test_placeholder_with_digits(self):
result = IntegrationBase.resolve_command_refs(text, ".")
assert result == "/speckit.v2.plan"

# -- Hyphenated segment tests ---------------------------------------------

def test_hyphen_in_segment_dot_separator(self):
"""A literal hyphen in a segment survives with the dot separator."""
text = "__SPECKIT_COMMAND_AGENT-CONTEXT_UPDATE__"
result = IntegrationBase.resolve_command_refs(text, ".")
assert result == "/speckit.agent-context.update"

def test_hyphen_in_segment_hyphen_separator(self):
"""A literal hyphen in a segment survives with the hyphen separator."""
text = "__SPECKIT_COMMAND_AGENT-CONTEXT_UPDATE__"
result = IntegrationBase.resolve_command_refs(text, "-")
assert result == "/speckit-agent-context-update"

def test_hyphen_in_segment_dollar_prefix(self):
text = "__SPECKIT_COMMAND_AGENT-CONTEXT_UPDATE__"
result = IntegrationBase.resolve_command_refs(text, "-", "$")
assert result == "$speckit-agent-context-update"

def test_hyphen_in_multiple_segments(self):
"""Every segment may independently contain a hyphen."""
text = "__SPECKIT_COMMAND_CODE-REVIEW_REQUEST-CHANGES__"
result = IntegrationBase.resolve_command_refs(text, ".")
assert result == "/speckit.code-review.request-changes"

def test_hyphen_adjacent_to_digit(self):
"""A hyphen directly next to a digit round-trips."""
text = "__SPECKIT_COMMAND_STEP-2_RUN__"
result = IntegrationBase.resolve_command_refs(text, ".")
assert result == "/speckit.step-2.run"

def test_multiple_hyphens_within_one_segment(self):
"""A segment with more than one hyphen round-trips."""
text = "__SPECKIT_COMMAND_MULTI-WORD-SEGMENT__"
result = IntegrationBase.resolve_command_refs(text, ".")
assert result == "/speckit.multi-word-segment"

def test_no_limit_on_segment_count(self):
"""The token isn't limited to two dotted segments; any number works."""
text = "__SPECKIT_COMMAND_A_B-C_D-E-F__"
result = IntegrationBase.resolve_command_refs(text, ".")
assert result == "/speckit.a.b-c.d-e-f"


class TestResolvePythonInterpreter:
def test_returns_python_on_path(self, monkeypatch):
Expand Down
48 changes: 48 additions & 0 deletions tests/test_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3714,6 +3714,54 @@ def test_codex_skill_registration_uses_dollar_command_refs(
assert "$speckit-plan" in content
assert "/speckit-plan" not in content

def test_codex_skill_registration_resolves_hyphenated_command_ref(
self, extension_dir, project_dir
):
"""A hyphenated segment in a command-ref token resolves for dollar-skills agents."""
skills_dir = project_dir / ".agents" / "skills"
skills_dir.mkdir(parents=True)
command = extension_dir / "commands" / "hello.md"
command.write_text(
"---\ndescription: Test hello command\n---\n\n"
"Run __SPECKIT_COMMAND_AGENT-CONTEXT_UPDATE__.",
encoding="utf-8",
)

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

skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
content = skill_file.read_text(encoding="utf-8")
assert "$speckit-agent-context-update" in content
assert "__SPECKIT_COMMAND_" not in content

def test_claude_skill_registration_resolves_hyphenated_command_ref(
self, extension_dir, project_dir
):
"""A hyphenated segment in a command-ref token resolves for slash-skills agents."""
skills_dir = project_dir / ".claude" / "skills"
skills_dir.mkdir(parents=True)
command = extension_dir / "commands" / "hello.md"
command.write_text(
"---\ndescription: Test hello command\n---\n\n"
"Run __SPECKIT_COMMAND_AGENT-CONTEXT_UPDATE__.",
encoding="utf-8",
)

manifest = ExtensionManifest(extension_dir / "extension.yml")
registrar = CommandRegistrar()
registrar.register_commands_for_agent(
"claude", manifest, extension_dir, project_dir
)

skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
content = skill_file.read_text(encoding="utf-8")
assert "/speckit-agent-context-update" in content
assert "__SPECKIT_COMMAND_" not in content

def test_codex_skill_registration_resolves_script_placeholders(self, project_dir, temp_dir):
"""Codex SKILL.md overrides should resolve script placeholders."""
import yaml
Expand Down