From 16c15f061ddf04ba33b9239d48222ee3c4345230 Mon Sep 17 00:00:00 2001 From: ericnoam Date: Wed, 6 May 2026 02:08:29 +0200 Subject: [PATCH 1/2] 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. --- .../git/commands/speckit.git.feature.md | 2 +- src/specify_cli/agents.py | 10 +++ .../integrations/forge/__init__.py | 3 + tests/integrations/test_integration_forge.py | 78 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) diff --git a/extensions/git/commands/speckit.git.feature.md b/extensions/git/commands/speckit.git.feature.md index 1a9c5e35da..5bed9e5e57 100644 --- a/extensions/git/commands/speckit.git.feature.md +++ b/extensions/git/commands/speckit.git.feature.md @@ -4,7 +4,7 @@ description: "Create a feature branch with sequential or timestamp numbering" # Create Feature Branch -Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `/speckit.specify` workflow. +Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `__SPECKIT_COMMAND_SPECIFY__` workflow. ## User Input diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 726b0fd2a6..9bc08c36bd 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -492,6 +492,16 @@ def register_commands( body, "$ARGUMENTS", agent_config["args"] ) + # Resolve __SPECKIT_COMMAND_*__ tokens using the agent's invoke separator. + # Defaults to "." (dot notation) for agents that don't override it; Forge + # sets "invoke_separator": "-" in its registrar_config to get hyphen notation. + _sep = agent_config.get("invoke_separator", ".") + body = re.sub( + r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__", + lambda m: "/speckit" + _sep + m.group(1).lower().replace("_", _sep), + body, + ) + output_name = self._compute_output_name(agent_name, cmd_name, agent_config) if agent_config["extension"] == "/SKILL.md": diff --git a/src/specify_cli/integrations/forge/__init__.py b/src/specify_cli/integrations/forge/__init__.py index a941d4c331..47a90687dc 100644 --- a/src/specify_cli/integrations/forge/__init__.py +++ b/src/specify_cli/integrations/forge/__init__.py @@ -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 @@ def setup( 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 diff --git a/tests/integrations/test_integration_forge.py b/tests/integrations/test_integration_forge.py index 8cd8b17c95..62fee73210 100644 --- a/tests/integrations/test_integration_forge.py +++ b/tests/integrations/test_integration_forge.py @@ -141,6 +141,7 @@ def test_directory_structure(self, tmp_path): assert actual_commands == expected_commands def test_templates_are_processed(self, tmp_path): + import re from specify_cli.integrations.forge import ForgeIntegration forge = ForgeIntegration() m = IntegrationManifest("forge", tmp_path) @@ -157,6 +158,11 @@ def test_templates_are_processed(self, tmp_path): assert "$ARGUMENTS" not in content, f"{cmd_file.name} has unprocessed $ARGUMENTS" # Frontmatter sections should be stripped assert "\nscripts:\n" not in content + # Check Forge-specific: command references use hyphen notation, not dot notation + assert not re.search(r"/speckit\.[a-z]", content), ( + f"{cmd_file.name} contains dot-notation command reference (/speckit.); " + "Forge requires hyphen notation (/speckit-) for ZSH compatibility" + ) def test_plan_references_correct_context_file(self, tmp_path): """The generated plan command must reference forge's context file.""" @@ -224,6 +230,33 @@ def test_uses_parameters_placeholder(self, tmp_path): "checklist should contain {{parameters}} in User Input section" ) + def test_command_refs_use_hyphen_notation(self, tmp_path): + """Verify all generated Forge command files use /speckit-foo, not /speckit.foo.""" + import re + from specify_cli.integrations.forge import ForgeIntegration + forge = ForgeIntegration() + m = IntegrationManifest("forge", tmp_path) + forge.setup(tmp_path, m) + commands_dir = tmp_path / ".forge" / "commands" + + files_with_refs = [] + files_with_dot_refs = [] + for cmd_file in commands_dir.glob("speckit.*.md"): + content = cmd_file.read_text(encoding="utf-8") + if re.search(r"/speckit-[a-z]", content): + files_with_refs.append(cmd_file.name) + if re.search(r"/speckit\.[a-z]", content): + files_with_dot_refs.append(cmd_file.name) + + assert files_with_dot_refs == [], ( + f"Files contain dot-notation command references: {files_with_dot_refs}. " + "Forge requires hyphen notation (/speckit-) for ZSH compatibility." + ) + assert len(files_with_refs) > 0, ( + "Expected at least one generated Forge command to contain /speckit- reference, " + "but none were found. Check that __SPECKIT_COMMAND_*__ tokens are being resolved." + ) + def test_name_field_uses_hyphenated_format(self, tmp_path): """Verify that injected name fields use hyphenated format (speckit-plan, not speckit.plan).""" from specify_cli.integrations.forge import ForgeIntegration @@ -401,3 +434,48 @@ def test_registrar_does_not_affect_other_agents(self, tmp_path): assert "name:" not in content, ( "Windsurf should not inject name field - format_name callback should be Forge-only" ) + + def test_git_extension_command_uses_hyphen_notation(self, tmp_path): + """Verify the git extension's feature command uses /speckit-specify (not /speckit.specify) for Forge.""" + from pathlib import Path + from specify_cli.agents import CommandRegistrar + + # Locate the real git extension command source file + repo_root = Path(__file__).resolve().parent.parent.parent + ext_dir = repo_root / "extensions" / "git" + cmd_source = ext_dir / "commands" / "speckit.git.feature.md" + assert cmd_source.exists(), ( + f"Git extension command source not found at {cmd_source}. " + "Ensure extensions/git/commands/speckit.git.feature.md exists." + ) + + registrar = CommandRegistrar() + commands = [ + { + "name": "speckit.git.feature", + "file": "commands/speckit.git.feature.md", + } + ] + + registered = registrar.register_commands( + "forge", + commands, + "git", + ext_dir, + tmp_path, + ) + + assert "speckit.git.feature" in registered + + forge_cmd = tmp_path / ".forge" / "commands" / "speckit.git.feature.md" + assert forge_cmd.exists(), "Expected Forge command file was not created" + + content = forge_cmd.read_text(encoding="utf-8") + assert "/speckit-specify" in content, ( + "Expected '/speckit-specify' (hyphen) in generated Forge git.feature command body, " + "but it was not found. Check that __SPECKIT_COMMAND_SPECIFY__ is resolved correctly." + ) + assert "/speckit.specify" not in content, ( + "Found '/speckit.specify' (dot notation) in generated Forge git.feature command body. " + "Forge requires hyphen notation for ZSH compatibility." + ) From cd6f67777055d03d77f6aa311b69572b7c0fbb9b Mon Sep 17 00:00:00 2001 From: ericnoam Date: Wed, 6 May 2026 09:43:02 +0200 Subject: [PATCH 2/2] fix(agents): propagate invoke_separator from integration class into AGENT_CONFIGS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/specify_cli/agents.py | 67 +++++++++++++------- tests/test_agent_config_consistency.py | 86 +++++++++++++++++++++++++- 2 files changed, 130 insertions(+), 23 deletions(-) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 9bc08c36bd..4d78d5ac41 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -7,12 +7,12 @@ """ 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 @@ def _ensure_inside(candidate: Path, base: Path) -> None: 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 @@ def register_commands( 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: @@ -493,14 +503,14 @@ def register_commands( ) # Resolve __SPECKIT_COMMAND_*__ tokens using the agent's invoke separator. - # Defaults to "." (dot notation) for agents that don't override it; Forge - # sets "invoke_separator": "-" in its registrar_config to get hyphen notation. + # 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 = re.sub( - r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__", - lambda m: "/speckit" + _sep + m.group(1).lower().replace("_", _sep), - body, - ) + body = IntegrationBase.resolve_command_refs(body, _sep) output_name = self._compute_output_name(agent_name, cmd_name, agent_config) @@ -515,12 +525,22 @@ def register_commands( 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( @@ -695,8 +715,11 @@ def register_commands_for_non_skill_agents( 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: diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index 75e80fdf33..2f0fe15127 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -5,7 +5,6 @@ from specify_cli import AGENT_CONFIG, AI_ASSISTANT_ALIASES, AI_ASSISTANT_HELP from specify_cli.extensions import CommandRegistrar - REPO_ROOT = Path(__file__).resolve().parent.parent @@ -199,3 +198,88 @@ def test_goose_in_extension_registrar(self): def test_ai_help_includes_goose(self): """CLI help text for --ai should include goose.""" assert "goose" in AI_ASSISTANT_HELP + + # --- invoke_separator propagation checks --- + + def test_skills_agents_have_hyphen_invoke_separator_in_agent_configs(self): + """Skills-based agents must expose invoke_separator='-' in AGENT_CONFIGS. + + SkillsIntegration sets ``invoke_separator = "-"`` as a class attribute, + but individual skills integrations (claude, codex, …) do not repeat it in + their ``registrar_config`` dicts. ``_build_agent_configs()`` must + propagate the class attribute so that ``register_commands()`` resolves + ``__SPECKIT_COMMAND_*__`` tokens with the correct hyphen separator. + """ + cfg = CommandRegistrar.AGENT_CONFIGS + skills_agents = [ + key for key, c in cfg.items() if c.get("extension") == "/SKILL.md" + ] + assert skills_agents, ( + "Expected at least one skills-based agent in AGENT_CONFIGS" + ) + for agent in skills_agents: + assert cfg[agent].get("invoke_separator") == "-", ( + f"Skills agent '{agent}' has invoke_separator=" + f"{cfg[agent].get('invoke_separator')!r} in AGENT_CONFIGS; " + "expected '-' (propagated from SkillsIntegration.invoke_separator)" + ) + + def test_skills_agent_command_token_resolves_with_hyphen(self, tmp_path): + """__SPECKIT_COMMAND_*__ tokens in extension commands resolve to /speckit- + when registered for a skills-based agent (e.g. claude). + + Regression guard: before the fix, _build_agent_configs() did not + propagate invoke_separator from the integration class, so + register_commands() fell back to '.' and emitted /speckit.specify instead + of /speckit-specify for skills agents. + """ + import re + from pathlib import Path + + from specify_cli.agents import CommandRegistrar + + repo_root = Path(__file__).resolve().parent.parent + ext_dir = repo_root / "extensions" / "git" + cmd_source = ext_dir / "commands" / "speckit.git.feature.md" + assert cmd_source.exists(), ( + f"Git extension command source not found at {cmd_source}" + ) + assert "__SPECKIT_COMMAND_SPECIFY__" in cmd_source.read_text( + encoding="utf-8" + ), ( + "Expected __SPECKIT_COMMAND_SPECIFY__ token in speckit.git.feature.md; " + "check that the file uses the token rather than a hard-coded ref." + ) + + registrar = CommandRegistrar() + commands = [ + {"name": "speckit.git.feature", "file": "commands/speckit.git.feature.md"} + ] + + registered = registrar.register_commands( + "claude", + commands, + "git", + ext_dir, + tmp_path, + ) + + assert "speckit.git.feature" in registered + skill_file = ( + tmp_path / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md" + ) + assert skill_file.exists(), ( + f"Expected Claude skill file not found at {skill_file}" + ) + content = skill_file.read_text(encoding="utf-8") + assert "/speckit-specify" in content, ( + "Expected '/speckit-specify' (hyphen) in generated Claude skill for git.feature; " + "__SPECKIT_COMMAND_SPECIFY__ was not resolved with the correct separator." + ) + # Negative lookbehind (?) in generated Claude skill. " + "Skills agents must use hyphen notation." + )