Skip to content
8 changes: 6 additions & 2 deletions src/specify_cli/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,9 @@ def register_commands(
frontmatter.pop(key, None)

if agent_config.get("inject_name") and not frontmatter.get("name"):
frontmatter["name"] = cmd_name
# Use custom name formatter if provided (e.g., Forge's hyphenated format)
format_name = agent_config.get("format_name")
frontmatter["name"] = format_name(cmd_name) if format_name else cmd_name

body = self._convert_argument_placeholder(
body, "$ARGUMENTS", agent_config["args"]
Expand Down Expand Up @@ -446,7 +448,9 @@ def register_commands(
# For agents with inject_name, render with alias-specific frontmatter
if agent_config.get("inject_name"):
alias_frontmatter = deepcopy(frontmatter)
alias_frontmatter["name"] = alias
# Use custom name formatter if provided (e.g., Forge's hyphenated format)
format_name = agent_config.get("format_name")
alias_frontmatter["name"] = format_name(alias) if format_name else alias

if agent_config["extension"] == "/SKILL.md":
alias_output = self.render_skill_command(
Expand Down
56 changes: 52 additions & 4 deletions src/specify_cli/integrations/forge/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
- Uses `{{parameters}}` instead of `$ARGUMENTS` for argument passing
- Strips `handoffs` frontmatter key (Claude Code feature that causes Forge to hang)
- Injects `name` field into frontmatter when missing
- Requires hyphenated command names (speckit-foo-bar) instead of dot notation (speckit.foo.bar)
"""

from __future__ import annotations
Expand All @@ -15,6 +16,52 @@
from ..manifest import IntegrationManifest


def format_forge_command_name(cmd_name: str) -> str:
"""Convert command name to Forge-compatible hyphenated format.

Forge requires command names to use hyphens instead of dots for
compatibility with ZSH and other shells. This function converts
dot-notation command names to hyphenated format.

The function is idempotent: already-formatted names are returned unchanged.

Examples:
>>> format_forge_command_name("plan")
'speckit-plan'
>>> format_forge_command_name("speckit.plan")
'speckit-plan'
>>> format_forge_command_name("speckit-plan")
'speckit-plan'
>>> format_forge_command_name("speckit.my-extension.example")
'speckit-my-extension-example'
>>> format_forge_command_name("speckit-my-extension-example")
'speckit-my-extension-example'
>>> format_forge_command_name("speckit.jira.sync-status")
'speckit-jira-sync-status'

Args:
cmd_name: Command name in dot notation (speckit.foo.bar),
hyphenated format (speckit-foo-bar), or plain name (foo)

Returns:
Hyphenated command name with 'speckit-' prefix
"""
# Already in hyphenated format - return as-is (idempotent)
if cmd_name.startswith("speckit-"):
return cmd_name

# Strip 'speckit.' prefix if present
short_name = cmd_name
if short_name.startswith("speckit."):
short_name = short_name[len("speckit."):]

# Replace all dots with hyphens
short_name = short_name.replace(".", "-")

# Return with 'speckit-' prefix
return f"speckit-{short_name}"


class ForgeIntegration(MarkdownIntegration):
"""Integration for Forge (forgecode.dev).

Expand All @@ -39,6 +86,7 @@ class ForgeIntegration(MarkdownIntegration):
"extension": ".md",
"strip_frontmatter_keys": ["handoffs"],
"inject_name": True,
"format_name": format_forge_command_name, # Custom name formatter
}
context_file = "AGENTS.md"

Expand Down Expand Up @@ -106,7 +154,7 @@ def _apply_forge_transformations(self, content: str, template_name: str) -> str:
"""Apply Forge-specific transformations to processed content.

1. Strip 'handoffs' frontmatter key (from Claude Code templates; incompatible with Forge)
2. Inject 'name' field if missing
2. Inject 'name' field if missing (using hyphenated format)
"""
# Parse frontmatter
lines = content.split('\n')
Expand Down Expand Up @@ -143,11 +191,11 @@ def _apply_forge_transformations(self, content: str, template_name: str) -> str:

filtered_frontmatter.append(line)

# 2. Inject 'name' field if missing
# 2. Inject 'name' field if missing (using centralized formatter)
has_name = any(line.strip().startswith('name:') for line in filtered_frontmatter)
if not has_name:
# Use the template name as the command name (e.g., "plan" -> "speckit.plan")
cmd_name = f"speckit.{template_name}"
# Use centralized formatter to ensure consistent hyphenated format
cmd_name = format_forge_command_name(template_name)
filtered_frontmatter.insert(0, f'name: {cmd_name}')

# Reconstruct content
Expand Down
214 changes: 214 additions & 0 deletions tests/integrations/test_integration_forge.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,47 @@

from specify_cli.integrations import get_integration
from specify_cli.integrations.manifest import IntegrationManifest
from specify_cli.integrations.forge import format_forge_command_name


class TestForgeCommandNameFormatter:
"""Test the centralized Forge command name formatter."""

def test_simple_name_without_prefix(self):
"""Test formatting a simple name without 'speckit.' prefix."""
assert format_forge_command_name("plan") == "speckit-plan"
assert format_forge_command_name("tasks") == "speckit-tasks"
assert format_forge_command_name("specify") == "speckit-specify"

def test_name_with_speckit_prefix(self):
"""Test formatting a name that already has 'speckit.' prefix."""
assert format_forge_command_name("speckit.plan") == "speckit-plan"
assert format_forge_command_name("speckit.tasks") == "speckit-tasks"

def test_extension_command_name(self):
"""Test formatting extension command names with dots."""
assert format_forge_command_name("speckit.my-extension.example") == "speckit-my-extension-example"
assert format_forge_command_name("my-extension.example") == "speckit-my-extension-example"

def test_complex_nested_name(self):
"""Test formatting deeply nested command names."""
assert format_forge_command_name("speckit.jira.sync-status") == "speckit-jira-sync-status"
assert format_forge_command_name("speckit.foo.bar.baz") == "speckit-foo-bar-baz"

def test_name_with_hyphens_preserved(self):
"""Test that existing hyphens are preserved."""
assert format_forge_command_name("my-extension") == "speckit-my-extension"
assert format_forge_command_name("speckit.my-ext.test-cmd") == "speckit-my-ext-test-cmd"

def test_alias_formatting(self):
"""Test formatting alias names."""
assert format_forge_command_name("speckit.my-extension.example-short") == "speckit-my-extension-example-short"

def test_idempotent_already_hyphenated(self):
"""Test that already-hyphenated names are returned unchanged (idempotent)."""
assert format_forge_command_name("speckit-plan") == "speckit-plan"
assert format_forge_command_name("speckit-my-extension-example") == "speckit-my-extension-example"
assert format_forge_command_name("speckit-jira-sync-status") == "speckit-jira-sync-status"


class TestForgeIntegration:
Expand Down Expand Up @@ -168,3 +209,176 @@ def test_uses_parameters_placeholder(self, tmp_path):
assert "{{parameters}}" in content, (
"checklist should contain {{parameters}} in User Input section"
)

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
forge = ForgeIntegration()
m = IntegrationManifest("forge", tmp_path)
forge.setup(tmp_path, m)
commands_dir = tmp_path / ".forge" / "commands"

# Check that name fields use hyphenated format
for cmd_file in commands_dir.glob("speckit.*.md"):
content = cmd_file.read_text(encoding="utf-8")
# Extract the name field from frontmatter
import re
name_match = re.search(r'^name:\s*(.+)$', content, re.MULTILINE)
assert name_match is not None, (
f"{cmd_file.name} missing injected 'name' field in frontmatter"
)
name_value = name_match.group(1).strip()
# Name should use hyphens, not dots
assert "." not in name_value, (
f"{cmd_file.name} has name field with dots: {name_value} "
f"(should use hyphens for Forge/ZSH compatibility)"
)
assert name_value.startswith("speckit-"), (
f"{cmd_file.name} name field should start with 'speckit-': {name_value}"
)


class TestForgeCommandRegistrar:
"""Test CommandRegistrar's Forge-specific name formatting."""

def test_registrar_formats_extension_command_names_for_forge(self, tmp_path):
"""Verify CommandRegistrar converts dot notation to hyphens for Forge."""
from specify_cli.agents import CommandRegistrar

# Create a mock extension command file
ext_dir = tmp_path / "extension"
ext_dir.mkdir()
cmd_dir = ext_dir / "commands"
cmd_dir.mkdir()

# Create a test command with dot notation name
cmd_file = cmd_dir / "example.md"
cmd_file.write_text(
"---\n"
"description: Test extension command\n"
"---\n\n"
"Test content with $ARGUMENTS\n",
encoding="utf-8"
)

# Register with Forge
registrar = CommandRegistrar()
commands = [
{
"name": "speckit.my-extension.example",
"file": "commands/example.md"
}
]

registered = registrar.register_commands(
"forge",
commands,
"test-extension",
ext_dir,
tmp_path
)

# Verify registration succeeded
assert "speckit.my-extension.example" in registered

# Check the generated file has hyphenated name in frontmatter
forge_cmd = tmp_path / ".forge" / "commands" / "speckit.my-extension.example.md"
assert forge_cmd.exists()

content = forge_cmd.read_text(encoding="utf-8")
# Name field should use hyphens, not dots
assert "name: speckit-my-extension-example" in content
assert "name: speckit.my-extension.example" not in content

def test_registrar_formats_alias_names_for_forge(self, tmp_path):
"""Verify CommandRegistrar converts alias names to hyphens for Forge."""
from specify_cli.agents import CommandRegistrar

# Create a mock extension command file
ext_dir = tmp_path / "extension"
ext_dir.mkdir()
cmd_dir = ext_dir / "commands"
cmd_dir.mkdir()

cmd_file = cmd_dir / "example.md"
cmd_file.write_text(
"---\n"
"description: Test command with alias\n"
"---\n\n"
"Test content\n",
encoding="utf-8"
)

# Register with Forge including an alias
registrar = CommandRegistrar()
commands = [
{
"name": "speckit.my-extension.example",
"file": "commands/example.md",
"aliases": ["speckit.my-extension.ex"]
}
]

registrar.register_commands(
"forge",
commands,
"test-extension",
ext_dir,
tmp_path
)

# Check the alias file has hyphenated name in frontmatter
alias_file = tmp_path / ".forge" / "commands" / "speckit.my-extension.ex.md"
assert alias_file.exists()

content = alias_file.read_text(encoding="utf-8")
# Alias name field should also use hyphens
assert "name: speckit-my-extension-ex" in content
assert "name: speckit.my-extension.ex" not in content

def test_registrar_does_not_affect_other_agents(self, tmp_path):
"""Verify other agents still use dot notation (not affected by Forge formatter)."""
from specify_cli.agents import CommandRegistrar

# Create a mock extension command file
ext_dir = tmp_path / "extension"
ext_dir.mkdir()
cmd_dir = ext_dir / "commands"
cmd_dir.mkdir()

cmd_file = cmd_dir / "example.md"
cmd_file.write_text(
"---\n"
"description: Test command\n"
"---\n\n"
"Test content with $ARGUMENTS\n",
encoding="utf-8"
)

# Register with Claude (uses dot notation)
registrar = CommandRegistrar()
commands = [
{
"name": "speckit.my-extension.example",
"file": "commands/example.md"
}
]

registrar.register_commands(
"claude",
commands,
"test-extension",
ext_dir,
tmp_path
)

# Claude uses skills format with hyphenated directory names.
# It doesn't use the inject_name path, but generated SKILL.md
# frontmatter still includes the hyphenated name.
skill_dir = tmp_path / ".claude" / "skills" / "speckit-my-extension-example"
assert skill_dir.exists()

skill_file = skill_dir / "SKILL.md"
content = skill_file.read_text(encoding="utf-8")
# Claude skills should have hyphenated name in metadata
assert "name: speckit-my-extension-example" in content
Loading