mirror of
https://github.com/github/spec-kit.git
synced 2026-07-06 22:32:13 +08:00
* 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>
204 lines
7.3 KiB
Python
204 lines
7.3 KiB
Python
"""Forge integration — forgecode.dev AI coding agent.
|
|
|
|
Forge has several unique behaviors compared to standard markdown agents:
|
|
- 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
|
|
- Uses a hyphenated frontmatter `name` value (e.g., `speckit-foo-bar`) for shell compatibility, especially with ZSH
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from ..base import MarkdownIntegration
|
|
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).
|
|
|
|
Extends MarkdownIntegration to add Forge-specific processing:
|
|
- Replaces $ARGUMENTS with {{parameters}}
|
|
- Strips 'handoffs' frontmatter key (incompatible with Forge)
|
|
- Injects 'name' field into frontmatter when missing
|
|
"""
|
|
|
|
key = "forge"
|
|
config = {
|
|
"name": "Forge",
|
|
"folder": ".forge/",
|
|
"commands_subdir": "commands",
|
|
"install_url": "https://forgecode.dev/docs/",
|
|
"requires_cli": True,
|
|
}
|
|
registrar_config = {
|
|
"dir": ".forge/commands",
|
|
"format": "markdown",
|
|
"args": "{{parameters}}",
|
|
"extension": ".md",
|
|
"strip_frontmatter_keys": ["handoffs"],
|
|
"inject_name": True,
|
|
"format_name": format_forge_command_name, # Custom name formatter
|
|
}
|
|
context_file = "AGENTS.md"
|
|
|
|
def setup(
|
|
self,
|
|
project_root: Path,
|
|
manifest: IntegrationManifest,
|
|
parsed_options: dict[str, Any] | None = None,
|
|
**opts: Any,
|
|
) -> list[Path]:
|
|
"""Install Forge commands with custom processing.
|
|
|
|
Extends MarkdownIntegration.setup() to inject Forge-specific transformations
|
|
after standard template processing.
|
|
"""
|
|
templates = self.list_command_templates()
|
|
if not templates:
|
|
return []
|
|
|
|
project_root_resolved = project_root.resolve()
|
|
if manifest.project_root != project_root_resolved:
|
|
raise ValueError(
|
|
f"manifest.project_root ({manifest.project_root}) does not match "
|
|
f"project_root ({project_root_resolved})"
|
|
)
|
|
|
|
dest = self.commands_dest(project_root).resolve()
|
|
try:
|
|
dest.relative_to(project_root_resolved)
|
|
except ValueError as exc:
|
|
raise ValueError(
|
|
f"Integration destination {dest} escapes "
|
|
f"project root {project_root_resolved}"
|
|
) from exc
|
|
dest.mkdir(parents=True, exist_ok=True)
|
|
|
|
script_type = opts.get("script_type", "sh")
|
|
arg_placeholder = self.registrar_config.get("args", "{{parameters}}")
|
|
created: list[Path] = []
|
|
|
|
for src_file in templates:
|
|
raw = src_file.read_text(encoding="utf-8")
|
|
# Process template with standard MarkdownIntegration logic
|
|
processed = self.process_template(raw, self.key, script_type, arg_placeholder)
|
|
|
|
# FORGE-SPECIFIC: Ensure any remaining $ARGUMENTS placeholders are
|
|
# converted to {{parameters}}
|
|
processed = processed.replace("$ARGUMENTS", arg_placeholder)
|
|
|
|
# FORGE-SPECIFIC: Apply frontmatter transformations
|
|
processed = self._apply_forge_transformations(processed, src_file.stem)
|
|
|
|
dst_name = self.command_filename(src_file.stem)
|
|
dst_file = self.write_file_and_record(
|
|
processed, dest / dst_name, project_root, manifest
|
|
)
|
|
created.append(dst_file)
|
|
|
|
# Install integration-specific update-context scripts
|
|
created.extend(self.install_scripts(project_root, manifest))
|
|
|
|
return created
|
|
|
|
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 (using hyphenated format)
|
|
"""
|
|
# Parse frontmatter
|
|
lines = content.split('\n')
|
|
if not lines or lines[0].strip() != '---':
|
|
return content
|
|
|
|
# Find end of frontmatter
|
|
frontmatter_end = -1
|
|
for i in range(1, len(lines)):
|
|
if lines[i].strip() == '---':
|
|
frontmatter_end = i
|
|
break
|
|
|
|
if frontmatter_end == -1:
|
|
return content
|
|
|
|
frontmatter_lines = lines[1:frontmatter_end]
|
|
body_lines = lines[frontmatter_end + 1:]
|
|
|
|
# 1. Strip 'handoffs' key
|
|
filtered_frontmatter = []
|
|
skip_until_outdent = False
|
|
for line in frontmatter_lines:
|
|
if skip_until_outdent:
|
|
# Skip indented lines under handoffs:
|
|
if line and (line[0] == ' ' or line[0] == '\t'):
|
|
continue
|
|
else:
|
|
skip_until_outdent = False
|
|
|
|
if line.strip().startswith('handoffs:'):
|
|
skip_until_outdent = True
|
|
continue
|
|
|
|
filtered_frontmatter.append(line)
|
|
|
|
# 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 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
|
|
result = ['---'] + filtered_frontmatter + ['---'] + body_lines
|
|
return '\n'.join(result)
|