fix(forge): use hyphen notation for command refs in Forge integration (#2462)

* fix(forge): use hyphen notation for command refs in Forge integration

- Add invoke_separator = "-" class attribute to ForgeIntegration so
  effective_invoke_separator() returns "-" for shared-template installs
- Add "invoke_separator": "-" to ForgeIntegration.registrar_config so
  agents.py CommandRegistrar can resolve refs with the correct separator
- Pass invoke_separator to process_template() in ForgeIntegration.setup()
  so all .forge/commands/*.md bodies use /speckit-foo notation
- Replace literal /speckit.specify with __SPECKIT_COMMAND_SPECIFY__ in
  extensions/git/commands/speckit.git.feature.md so every agent resolves
  the reference through its own separator
- Apply resolve_command_refs re.sub in agents.py register_commands() after
  argument-placeholder substitution so extension commands registered for
  Forge get /speckit-foo refs; all other agents continue to get /speckit.foo

Fixes ZSH compatibility: dot-notation command invocations (/speckit.specify)
are misinterpreted by ZSH as file-path operations; hyphen notation
(/speckit-specify) works correctly in all shells.

* fix(agents): propagate invoke_separator from integration class into AGENT_CONFIGS

Skills-based agents (claude, codex, kimi, …) inherit invoke_separator="-"
from SkillsIntegration but do not repeat it in their registrar_config dicts.
_build_agent_configs() was copying registrar_config verbatim, so
register_commands() fell back to "." when resolving __SPECKIT_COMMAND_*__
tokens for those agents — emitting /speckit.specify instead of the correct
/speckit-specify for extension commands like speckit.git.feature.

Fix: after copying registrar_config, inject invoke_separator from the
integration's class attribute when it is not already declared explicitly.
This makes the integration class the single source of truth for all agents,
without requiring each SkillsIntegration subclass to duplicate the field.

Also replace the inline re.sub in register_commands() with a call to
IntegrationBase.resolve_command_refs() (deferred import to avoid the
existing circular dependency) so token-resolution logic is not duplicated.

Adds two tests in test_agent_config_consistency.py:
- test_skills_agents_have_hyphen_invoke_separator_in_agent_configs: asserts
  every /SKILL.md agent has invoke_separator="-" in AGENT_CONFIGS.
- test_skills_agent_command_token_resolves_with_hyphen: end-to-end check via
  CommandRegistrar that the git extension's speckit.git.feature command is
  installed for Claude with /speckit-specify (not /speckit.specify).

Addresses review comment on PR #2462.
This commit is contained in:
Eric Rodriguez Suazo
2026-05-06 19:19:10 +02:00
committed by GitHub
parent c0bf5d0c64
commit 793632089a
5 changed files with 215 additions and 17 deletions

View File

@@ -7,12 +7,12 @@ command files into agent-specific directories in the correct format.
"""
import os
from pathlib import Path
from typing import Dict, List, Any, Optional
import platform
import re
from copy import deepcopy
from pathlib import Path
from typing import Any, Dict, List, Optional
import yaml
@@ -25,7 +25,16 @@ def _build_agent_configs() -> dict[str, Any]:
if key == "generic":
continue
if integration.registrar_config:
configs[key] = dict(integration.registrar_config)
config = dict(integration.registrar_config)
# Propagate invoke_separator from the integration class when the
# registrar_config dict doesn't already declare it explicitly.
# SkillsIntegration subclasses (claude, codex, …) set
# invoke_separator="-" as a class attribute but omit it from
# registrar_config, so without this they would fall back to "."
# when register_commands() resolves __SPECKIT_COMMAND_*__ tokens.
if "invoke_separator" not in config:
config["invoke_separator"] = integration.invoke_separator
configs[key] = config
return configs
@@ -419,9 +428,7 @@ class CommandRegistrar:
normalized = Path(os.path.normpath(candidate))
base_normalized = Path(os.path.normpath(base))
if not normalized.is_relative_to(base_normalized):
raise ValueError(
f"Output path {candidate!r} escapes directory {base!r}"
)
raise ValueError(f"Output path {candidate!r} escapes directory {base!r}")
def register_commands(
self,
@@ -471,7 +478,10 @@ class CommandRegistrar:
if frontmatter.get("strategy") == "wrap":
from .presets import _substitute_core_template
body, core_frontmatter = _substitute_core_template(body, cmd_name, project_root, self)
body, core_frontmatter = _substitute_core_template(
body, cmd_name, project_root, self
)
frontmatter = dict(frontmatter)
for key in ("scripts", "agent_scripts"):
if key not in frontmatter and key in core_frontmatter:
@@ -492,6 +502,16 @@ class CommandRegistrar:
body, "$ARGUMENTS", agent_config["args"]
)
# Resolve __SPECKIT_COMMAND_*__ tokens using the agent's invoke separator.
# The separator is sourced from agent_config (populated by _build_agent_configs,
# which propagates each integration's invoke_separator class attribute).
# Deferred import of IntegrationBase avoids a circular import at module load
# (base.py itself imports CommandRegistrar lazily).
from specify_cli.integrations.base import IntegrationBase # noqa: PLC0415
_sep = agent_config.get("invoke_separator", ".")
body = IntegrationBase.resolve_command_refs(body, _sep)
output_name = self._compute_output_name(agent_name, cmd_name, agent_config)
if agent_config["extension"] == "/SKILL.md":
@@ -505,12 +525,22 @@ class CommandRegistrar:
project_root,
)
elif agent_config["format"] == "markdown":
body = self.resolve_skill_placeholders(agent_name, frontmatter, body, project_root)
body = self._convert_argument_placeholder(body, "$ARGUMENTS", agent_config["args"])
output = self.render_markdown_command(frontmatter, body, source_id, context_note)
body = self.resolve_skill_placeholders(
agent_name, frontmatter, body, project_root
)
body = self._convert_argument_placeholder(
body, "$ARGUMENTS", agent_config["args"]
)
output = self.render_markdown_command(
frontmatter, body, source_id, context_note
)
elif agent_config["format"] == "toml":
body = self.resolve_skill_placeholders(agent_name, frontmatter, body, project_root)
body = self._convert_argument_placeholder(body, "$ARGUMENTS", agent_config["args"])
body = self.resolve_skill_placeholders(
agent_name, frontmatter, body, project_root
)
body = self._convert_argument_placeholder(
body, "$ARGUMENTS", agent_config["args"]
)
output = self.render_toml_command(frontmatter, body, source_id)
elif agent_config["format"] == "yaml":
output = self.render_yaml_command(
@@ -685,8 +715,11 @@ class CommandRegistrar:
if agent_dir.exists():
try:
registered = self.register_commands(
agent_name, commands, source_id,
source_dir, project_root,
agent_name,
commands,
source_id,
source_dir,
project_root,
context_note=context_note,
)
if registered:

View File

@@ -87,8 +87,10 @@ class ForgeIntegration(MarkdownIntegration):
"strip_frontmatter_keys": ["handoffs"],
"inject_name": True,
"format_name": format_forge_command_name, # Custom name formatter
"invoke_separator": "-",
}
context_file = "AGENTS.md"
invoke_separator = "-"
def setup(
self,
@@ -133,6 +135,7 @@ class ForgeIntegration(MarkdownIntegration):
processed = self.process_template(
raw, self.key, script_type, arg_placeholder,
context_file=self.context_file or "",
invoke_separator=self.invoke_separator,
)
# FORGE-SPECIFIC: Ensure any remaining $ARGUMENTS placeholders are