Skip to content

Commit 7114359

Browse files
toxicafunkericnoam
andauthored
fix(forge): use hyphen notation in frontmatter name field (#2075)
* fix(forge): use hyphen notation in frontmatter name field - Changed injected name field from 'speckit.{command}' to 'speckit-{command}' - Keeps standard filename format 'speckit.{command}.md' - Aligns with Forge's command naming convention requirements - All tests pass * feat(forge): centralize name formatting to fix extension/preset command names Address PR feedback by centralizing Forge command name formatting to ensure consistent hyphenated names across both core template setup and extension/preset command registration. Changes: - Add format_forge_command_name() utility function in forge integration - Update ForgeIntegration._apply_forge_transformations() to use centralized formatter - Add _format_name_for_agent() helper in CommandRegistrar to apply agent-specific formatting - Update CommandRegistrar.register_commands() to format names for Forge (both primary commands and aliases) - Add comprehensive test coverage for the formatter and registrar behavior Impact: - Extension commands installed for Forge now use 'name: speckit-my-extension-example' instead of 'name: speckit.my-extension.example' - Fixes ZSH/shell compatibility issues with dot notation in command names - Maintains backward compatibility for all other agents (they continue using dot notation) - Eliminates duplication between integration setup and registrar paths Example transformation: Before: name: speckit.jira.sync-status (breaks in ZSH/Forge) After: name: speckit-jira-sync-status (works everywhere) Fixes inconsistency where core templates used hyphens but extension/preset commands preserved dots, breaking Forge's naming requirements. * refactor(forge): move name formatting logic to integration module Move _format_name_for_agent function logic into Forge integration's registrar_config as a 'format_name' callback, improving separation of concerns and keeping Forge-specific logic within its integration module. Changes: - Remove _format_name_for_agent() from agents.py (shared module) - Add 'format_name' callback to Forge's registrar_config pointing to format_forge_command_name - Update CommandRegistrar to use format_name callback when available - Maintains same behavior: Forge commands use hyphenated names, others use dot notation Benefits: - Better encapsulation: Forge-specific logic lives in forge integration - More extensible: Other integrations can provide custom formatters via registrar_config - Cleaner separation: agents.py doesn't need to know about specific agent requirements * fix(forge): make format_forge_command_name idempotent Handle already-hyphenated names (speckit-foo) to prevent double-prefixing (speckit-speckit-foo). The function now returns already-formatted names unchanged, making it safe to call multiple times. Changes: - Add early return for names starting with 'speckit-' - Update docstring to clarify accepted input formats - Add examples showing idempotent behavior - Add test coverage for idempotent behavior Examples: format_forge_command_name('speckit-plan') -> 'speckit-plan' (unchanged) format_forge_command_name('speckit.plan') -> 'speckit-plan' (converted) format_forge_command_name('plan') -> 'speckit-plan' (prefixed) * test(forge): strengthen name field assertions and clarify comments Improve test_name_field_uses_hyphenated_format to fail loudly when the name field is missing instead of silently passing. Changes: - Add explicit assertion that name_match is not None before validating value - Ensures test fails if regex doesn't match (e.g., frontmatter rendering changes) - Clarify Claude comment: it doesn't use inject_name path but SKILL.md frontmatter still includes hyphenated name via build_skill_frontmatter() Before: Test would silently pass if 'name:' field was missing from frontmatter After: Test explicitly asserts field presence before validating format * docs(forge): clarify frontmatter name requirement and improve test isolation Fix misleading docstring and improve test to properly validate that the format_name callback is Forge-specific. Changes to src/specify_cli/integrations/forge/__init__.py: - Reword module docstring to clarify the requirement is specifically for the frontmatter 'name' field value, not command files or invocation - Before: 'Requires hyphenated command names ... instead of dot notation' (implied dot notation unsupported overall) - After: 'Uses a hyphenated frontmatter name value ... for shell compatibility' (clarifies it's the frontmatter field, and Forge still supports dot filenames) Changes to tests/integrations/test_integration_forge.py: - Replace Claude with Windsurf in test_registrar_does_not_affect_other_agents - Claude uses build_skill_frontmatter() which always includes hyphenated names, so testing it didn't validate that format_name callback is Forge-only - Windsurf is a standard markdown agent without inject_name - Now asserts NO 'name:' field is present, proving format_name isn't invoked - This properly validates the callback mechanism is isolated to Forge * test(forge): use parse_frontmatter for precise YAML validation Replace regex and string searches with CommandRegistrar.parse_frontmatter() to validate only YAML frontmatter, not entire file content. Prevents false positives if command body contains 'name:' lines. Changes: - test_forge_specific_transformations: Parse frontmatter dict instead of string search - test_name_field_uses_hyphenated_format: Replace regex with frontmatter parsing - test_registrar_formats_extension_command_names_for_forge: Use dict validation - test_registrar_formats_alias_names_for_forge: Use dict validation Benefits: More precise, robust against body content, better error messages, consistent with existing codebase utilities. --------- Co-authored-by: ericnoam <eric.rodriguez@leovegas.com>
1 parent 9c73e68 commit 7114359

File tree

3 files changed

+282
-8
lines changed

3 files changed

+282
-8
lines changed

src/specify_cli/agents.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -420,7 +420,9 @@ def register_commands(
420420
frontmatter.pop(key, None)
421421

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

425427
body = self._convert_argument_placeholder(
426428
body, "$ARGUMENTS", agent_config["args"]
@@ -454,7 +456,9 @@ def register_commands(
454456
# For agents with inject_name, render with alias-specific frontmatter
455457
if agent_config.get("inject_name"):
456458
alias_frontmatter = deepcopy(frontmatter)
457-
alias_frontmatter["name"] = alias
459+
# Use custom name formatter if provided (e.g., Forge's hyphenated format)
460+
format_name = agent_config.get("format_name")
461+
alias_frontmatter["name"] = format_name(alias) if format_name else alias
458462

459463
if agent_config["extension"] == "/SKILL.md":
460464
alias_output = self.render_skill_command(

src/specify_cli/integrations/forge/__init__.py

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
- Uses `{{parameters}}` instead of `$ARGUMENTS` for argument passing
55
- Strips `handoffs` frontmatter key (Claude Code feature that causes Forge to hang)
66
- Injects `name` field into frontmatter when missing
7+
- Uses a hyphenated frontmatter `name` value (e.g., `speckit-foo-bar`) for shell compatibility, especially with ZSH
78
"""
89

910
from __future__ import annotations
@@ -15,6 +16,52 @@
1516
from ..manifest import IntegrationManifest
1617

1718

19+
def format_forge_command_name(cmd_name: str) -> str:
20+
"""Convert command name to Forge-compatible hyphenated format.
21+
22+
Forge requires command names to use hyphens instead of dots for
23+
compatibility with ZSH and other shells. This function converts
24+
dot-notation command names to hyphenated format.
25+
26+
The function is idempotent: already-formatted names are returned unchanged.
27+
28+
Examples:
29+
>>> format_forge_command_name("plan")
30+
'speckit-plan'
31+
>>> format_forge_command_name("speckit.plan")
32+
'speckit-plan'
33+
>>> format_forge_command_name("speckit-plan")
34+
'speckit-plan'
35+
>>> format_forge_command_name("speckit.my-extension.example")
36+
'speckit-my-extension-example'
37+
>>> format_forge_command_name("speckit-my-extension-example")
38+
'speckit-my-extension-example'
39+
>>> format_forge_command_name("speckit.jira.sync-status")
40+
'speckit-jira-sync-status'
41+
42+
Args:
43+
cmd_name: Command name in dot notation (speckit.foo.bar),
44+
hyphenated format (speckit-foo-bar), or plain name (foo)
45+
46+
Returns:
47+
Hyphenated command name with 'speckit-' prefix
48+
"""
49+
# Already in hyphenated format - return as-is (idempotent)
50+
if cmd_name.startswith("speckit-"):
51+
return cmd_name
52+
53+
# Strip 'speckit.' prefix if present
54+
short_name = cmd_name
55+
if short_name.startswith("speckit."):
56+
short_name = short_name[len("speckit."):]
57+
58+
# Replace all dots with hyphens
59+
short_name = short_name.replace(".", "-")
60+
61+
# Return with 'speckit-' prefix
62+
return f"speckit-{short_name}"
63+
64+
1865
class ForgeIntegration(MarkdownIntegration):
1966
"""Integration for Forge (forgecode.dev).
2067
@@ -39,6 +86,7 @@ class ForgeIntegration(MarkdownIntegration):
3986
"extension": ".md",
4087
"strip_frontmatter_keys": ["handoffs"],
4188
"inject_name": True,
89+
"format_name": format_forge_command_name, # Custom name formatter
4290
}
4391
context_file = "AGENTS.md"
4492

@@ -106,7 +154,7 @@ def _apply_forge_transformations(self, content: str, template_name: str) -> str:
106154
"""Apply Forge-specific transformations to processed content.
107155
108156
1. Strip 'handoffs' frontmatter key (from Claude Code templates; incompatible with Forge)
109-
2. Inject 'name' field if missing
157+
2. Inject 'name' field if missing (using hyphenated format)
110158
"""
111159
# Parse frontmatter
112160
lines = content.split('\n')
@@ -143,11 +191,11 @@ def _apply_forge_transformations(self, content: str, template_name: str) -> str:
143191

144192
filtered_frontmatter.append(line)
145193

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

153201
# Reconstruct content

tests/integrations/test_integration_forge.py

Lines changed: 224 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,47 @@
22

33
from specify_cli.integrations import get_integration
44
from specify_cli.integrations.manifest import IntegrationManifest
5+
from specify_cli.integrations.forge import format_forge_command_name
6+
7+
8+
class TestForgeCommandNameFormatter:
9+
"""Test the centralized Forge command name formatter."""
10+
11+
def test_simple_name_without_prefix(self):
12+
"""Test formatting a simple name without 'speckit.' prefix."""
13+
assert format_forge_command_name("plan") == "speckit-plan"
14+
assert format_forge_command_name("tasks") == "speckit-tasks"
15+
assert format_forge_command_name("specify") == "speckit-specify"
16+
17+
def test_name_with_speckit_prefix(self):
18+
"""Test formatting a name that already has 'speckit.' prefix."""
19+
assert format_forge_command_name("speckit.plan") == "speckit-plan"
20+
assert format_forge_command_name("speckit.tasks") == "speckit-tasks"
21+
22+
def test_extension_command_name(self):
23+
"""Test formatting extension command names with dots."""
24+
assert format_forge_command_name("speckit.my-extension.example") == "speckit-my-extension-example"
25+
assert format_forge_command_name("my-extension.example") == "speckit-my-extension-example"
26+
27+
def test_complex_nested_name(self):
28+
"""Test formatting deeply nested command names."""
29+
assert format_forge_command_name("speckit.jira.sync-status") == "speckit-jira-sync-status"
30+
assert format_forge_command_name("speckit.foo.bar.baz") == "speckit-foo-bar-baz"
31+
32+
def test_name_with_hyphens_preserved(self):
33+
"""Test that existing hyphens are preserved."""
34+
assert format_forge_command_name("my-extension") == "speckit-my-extension"
35+
assert format_forge_command_name("speckit.my-ext.test-cmd") == "speckit-my-ext-test-cmd"
36+
37+
def test_alias_formatting(self):
38+
"""Test formatting alias names."""
39+
assert format_forge_command_name("speckit.my-extension.example-short") == "speckit-my-extension-example-short"
40+
41+
def test_idempotent_already_hyphenated(self):
42+
"""Test that already-hyphenated names are returned unchanged (idempotent)."""
43+
assert format_forge_command_name("speckit-plan") == "speckit-plan"
44+
assert format_forge_command_name("speckit-my-extension-example") == "speckit-my-extension-example"
45+
assert format_forge_command_name("speckit-jira-sync-status") == "speckit-jira-sync-status"
546

647

748
class TestForgeIntegration:
@@ -123,19 +164,22 @@ def test_templates_are_processed(self, tmp_path):
123164
def test_forge_specific_transformations(self, tmp_path):
124165
"""Test Forge-specific processing: name injection and handoffs stripping."""
125166
from specify_cli.integrations.forge import ForgeIntegration
167+
from specify_cli.agents import CommandRegistrar
126168
forge = ForgeIntegration()
127169
m = IntegrationManifest("forge", tmp_path)
128170
forge.setup(tmp_path, m)
129171
commands_dir = tmp_path / ".forge" / "commands"
130172

173+
registrar = CommandRegistrar()
131174
for cmd_file in commands_dir.glob("speckit.*.md"):
132175
content = cmd_file.read_text(encoding="utf-8")
176+
frontmatter, _ = registrar.parse_frontmatter(content)
133177

134178
# Check that name field is injected in frontmatter
135-
assert "\nname: " in content, f"{cmd_file.name} missing injected 'name' field"
179+
assert "name" in frontmatter, f"{cmd_file.name} missing injected 'name' field in frontmatter"
136180

137181
# Check that handoffs frontmatter key is stripped
138-
assert "\nhandoffs:" not in content, f"{cmd_file.name} has unstripped 'handoffs' key"
182+
assert "handoffs" not in frontmatter, f"{cmd_file.name} has unstripped 'handoffs' key in frontmatter"
139183

140184
def test_uses_parameters_placeholder(self, tmp_path):
141185
"""Verify Forge replaces $ARGUMENTS with {{parameters}} in generated files."""
@@ -168,3 +212,181 @@ def test_uses_parameters_placeholder(self, tmp_path):
168212
assert "{{parameters}}" in content, (
169213
"checklist should contain {{parameters}} in User Input section"
170214
)
215+
216+
def test_name_field_uses_hyphenated_format(self, tmp_path):
217+
"""Verify that injected name fields use hyphenated format (speckit-plan, not speckit.plan)."""
218+
from specify_cli.integrations.forge import ForgeIntegration
219+
from specify_cli.agents import CommandRegistrar
220+
forge = ForgeIntegration()
221+
m = IntegrationManifest("forge", tmp_path)
222+
forge.setup(tmp_path, m)
223+
commands_dir = tmp_path / ".forge" / "commands"
224+
225+
# Check that name fields use hyphenated format
226+
registrar = CommandRegistrar()
227+
for cmd_file in commands_dir.glob("speckit.*.md"):
228+
content = cmd_file.read_text(encoding="utf-8")
229+
# Extract the name field from frontmatter using the parser
230+
frontmatter, _ = registrar.parse_frontmatter(content)
231+
assert "name" in frontmatter, (
232+
f"{cmd_file.name} missing injected 'name' field in frontmatter"
233+
)
234+
name_value = frontmatter["name"]
235+
# Name should use hyphens, not dots
236+
assert "." not in name_value, (
237+
f"{cmd_file.name} has name field with dots: {name_value} "
238+
f"(should use hyphens for Forge/ZSH compatibility)"
239+
)
240+
assert name_value.startswith("speckit-"), (
241+
f"{cmd_file.name} name field should start with 'speckit-': {name_value}"
242+
)
243+
244+
245+
class TestForgeCommandRegistrar:
246+
"""Test CommandRegistrar's Forge-specific name formatting."""
247+
248+
def test_registrar_formats_extension_command_names_for_forge(self, tmp_path):
249+
"""Verify CommandRegistrar converts dot notation to hyphens for Forge."""
250+
from specify_cli.agents import CommandRegistrar
251+
252+
# Create a mock extension command file
253+
ext_dir = tmp_path / "extension"
254+
ext_dir.mkdir()
255+
cmd_dir = ext_dir / "commands"
256+
cmd_dir.mkdir()
257+
258+
# Create a test command with dot notation name
259+
cmd_file = cmd_dir / "example.md"
260+
cmd_file.write_text(
261+
"---\n"
262+
"description: Test extension command\n"
263+
"---\n\n"
264+
"Test content with $ARGUMENTS\n",
265+
encoding="utf-8"
266+
)
267+
268+
# Register with Forge
269+
registrar = CommandRegistrar()
270+
commands = [
271+
{
272+
"name": "speckit.my-extension.example",
273+
"file": "commands/example.md"
274+
}
275+
]
276+
277+
registered = registrar.register_commands(
278+
"forge",
279+
commands,
280+
"test-extension",
281+
ext_dir,
282+
tmp_path
283+
)
284+
285+
# Verify registration succeeded
286+
assert "speckit.my-extension.example" in registered
287+
288+
# Check the generated file has hyphenated name in frontmatter
289+
forge_cmd = tmp_path / ".forge" / "commands" / "speckit.my-extension.example.md"
290+
assert forge_cmd.exists()
291+
292+
content = forge_cmd.read_text(encoding="utf-8")
293+
# Parse frontmatter to validate name field precisely
294+
frontmatter, _ = registrar.parse_frontmatter(content)
295+
assert "name" in frontmatter, "name field should be injected in frontmatter"
296+
# Name field should use hyphens, not dots
297+
assert frontmatter["name"] == "speckit-my-extension-example"
298+
299+
def test_registrar_formats_alias_names_for_forge(self, tmp_path):
300+
"""Verify CommandRegistrar converts alias names to hyphens for Forge."""
301+
from specify_cli.agents import CommandRegistrar
302+
303+
# Create a mock extension command file
304+
ext_dir = tmp_path / "extension"
305+
ext_dir.mkdir()
306+
cmd_dir = ext_dir / "commands"
307+
cmd_dir.mkdir()
308+
309+
cmd_file = cmd_dir / "example.md"
310+
cmd_file.write_text(
311+
"---\n"
312+
"description: Test command with alias\n"
313+
"---\n\n"
314+
"Test content\n",
315+
encoding="utf-8"
316+
)
317+
318+
# Register with Forge including an alias
319+
registrar = CommandRegistrar()
320+
commands = [
321+
{
322+
"name": "speckit.my-extension.example",
323+
"file": "commands/example.md",
324+
"aliases": ["speckit.my-extension.ex"]
325+
}
326+
]
327+
328+
registrar.register_commands(
329+
"forge",
330+
commands,
331+
"test-extension",
332+
ext_dir,
333+
tmp_path
334+
)
335+
336+
# Check the alias file has hyphenated name in frontmatter
337+
alias_file = tmp_path / ".forge" / "commands" / "speckit.my-extension.ex.md"
338+
assert alias_file.exists()
339+
340+
content = alias_file.read_text(encoding="utf-8")
341+
# Parse frontmatter to validate alias name field precisely
342+
frontmatter, _ = registrar.parse_frontmatter(content)
343+
assert "name" in frontmatter, "name field should be injected in alias frontmatter"
344+
# Alias name field should also use hyphens
345+
assert frontmatter["name"] == "speckit-my-extension-ex"
346+
347+
def test_registrar_does_not_affect_other_agents(self, tmp_path):
348+
"""Verify format_name callback is Forge-specific and doesn't affect other agents."""
349+
from specify_cli.agents import CommandRegistrar
350+
351+
# Create a mock extension command file
352+
ext_dir = tmp_path / "extension"
353+
ext_dir.mkdir()
354+
cmd_dir = ext_dir / "commands"
355+
cmd_dir.mkdir()
356+
357+
cmd_file = cmd_dir / "example.md"
358+
cmd_file.write_text(
359+
"---\n"
360+
"description: Test command\n"
361+
"---\n\n"
362+
"Test content with $ARGUMENTS\n",
363+
encoding="utf-8"
364+
)
365+
366+
# Register with Windsurf (standard markdown agent without inject_name)
367+
registrar = CommandRegistrar()
368+
commands = [
369+
{
370+
"name": "speckit.my-extension.example",
371+
"file": "commands/example.md"
372+
}
373+
]
374+
375+
registrar.register_commands(
376+
"windsurf",
377+
commands,
378+
"test-extension",
379+
ext_dir,
380+
tmp_path
381+
)
382+
383+
# Windsurf uses standard markdown format without name injection.
384+
# The format_name callback should not be invoked for non-Forge agents.
385+
windsurf_cmd = tmp_path / ".windsurf" / "workflows" / "speckit.my-extension.example.md"
386+
assert windsurf_cmd.exists()
387+
388+
content = windsurf_cmd.read_text(encoding="utf-8")
389+
# Windsurf should NOT have a name field injected
390+
assert "name:" not in content, (
391+
"Windsurf should not inject name field - format_name callback should be Forge-only"
392+
)

0 commit comments

Comments
 (0)