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..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: @@ -492,6 +502,16 @@ def register_commands( 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 @@ 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( @@ -685,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/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." + ) 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." + )