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
2 changes: 1 addition & 1 deletion extensions/git/commands/speckit.git.feature.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
63 changes: 48 additions & 15 deletions src/specify_cli/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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


Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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":
Expand All @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions src/specify_cli/integrations/forge/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions tests/integrations/test_integration_forge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.<cmd>); "
"Forge requires hyphen notation (/speckit-<cmd>) for ZSH compatibility"
)

def test_plan_references_correct_context_file(self, tmp_path):
"""The generated plan command must reference forge's context file."""
Expand Down Expand Up @@ -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-<cmd>) for ZSH compatibility."
)
assert len(files_with_refs) > 0, (
"Expected at least one generated Forge command to contain /speckit-<cmd> 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
Expand Down Expand Up @@ -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."
)
86 changes: 85 additions & 1 deletion tests/test_agent_config_consistency.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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-<cmd>
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 (?<![a-zA-Z0-9_]) excludes file-path occurrences
# such as 'source: git:commands/speckit.git.feature.md' in frontmatter,
# where the '/' is a path separator preceded by a word character.
assert not re.search(r"(?<![a-zA-Z0-9_])/speckit\.[a-z]", content), (
"Found dot-notation command ref (/speckit.<cmd>) in generated Claude skill. "
"Skills agents must use hyphen notation."
)
Loading