mirror of
https://github.com/github/spec-kit.git
synced 2026-07-06 05:53:12 +08:00
* fix(forge): use hyphen notation for command refs in Forge integration - Add invoke_separator = "-" class attribute to ForgeIntegration so effective_invoke_separator() returns "-" for shared-template installs - Add "invoke_separator": "-" to ForgeIntegration.registrar_config so agents.py CommandRegistrar can resolve refs with the correct separator - Pass invoke_separator to process_template() in ForgeIntegration.setup() so all .forge/commands/*.md bodies use /speckit-foo notation - Replace literal /speckit.specify with __SPECKIT_COMMAND_SPECIFY__ in extensions/git/commands/speckit.git.feature.md so every agent resolves the reference through its own separator - Apply resolve_command_refs re.sub in agents.py register_commands() after argument-placeholder substitution so extension commands registered for Forge get /speckit-foo refs; all other agents continue to get /speckit.foo Fixes ZSH compatibility: dot-notation command invocations (/speckit.specify) are misinterpreted by ZSH as file-path operations; hyphen notation (/speckit-specify) works correctly in all shells. * fix(agents): propagate invoke_separator from integration class into AGENT_CONFIGS Skills-based agents (claude, codex, kimi, …) inherit invoke_separator="-" from SkillsIntegration but do not repeat it in their registrar_config dicts. _build_agent_configs() was copying registrar_config verbatim, so register_commands() fell back to "." when resolving __SPECKIT_COMMAND_*__ tokens for those agents — emitting /speckit.specify instead of the correct /speckit-specify for extension commands like speckit.git.feature. Fix: after copying registrar_config, inject invoke_separator from the integration's class attribute when it is not already declared explicitly. This makes the integration class the single source of truth for all agents, without requiring each SkillsIntegration subclass to duplicate the field. Also replace the inline re.sub in register_commands() with a call to IntegrationBase.resolve_command_refs() (deferred import to avoid the existing circular dependency) so token-resolution logic is not duplicated. Adds two tests in test_agent_config_consistency.py: - test_skills_agents_have_hyphen_invoke_separator_in_agent_configs: asserts every /SKILL.md agent has invoke_separator="-" in AGENT_CONFIGS. - test_skills_agent_command_token_resolves_with_hyphen: end-to-end check via CommandRegistrar that the git extension's speckit.git.feature command is installed for Claude with /speckit-specify (not /speckit.specify). Addresses review comment on PR #2462.
210 lines
7.4 KiB
Python
210 lines
7.4 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
|
|
"invoke_separator": "-",
|
|
}
|
|
context_file = "AGENTS.md"
|
|
invoke_separator = "-"
|
|
|
|
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,
|
|
context_file=self.context_file or "",
|
|
invoke_separator=self.invoke_separator,
|
|
)
|
|
|
|
# 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)
|
|
|
|
# Upsert managed context section into the agent context file
|
|
self.upsert_context_section(project_root)
|
|
|
|
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)
|