mirror of
https://github.com/github/spec-kit.git
synced 2026-07-05 13:34:06 +08:00
feat: migrate Forge agent to Python integration system
- Create ForgeIntegration class with custom processing for {{parameters}}, handoffs stripping, and name injection
- Add update-context scripts (bash and PowerShell) for Forge
- Register Forge in integration registry
- Update AGENTS.md with Forge documentation and special processing requirements section
- Add comprehensive test suite (11 tests, all passing)
Closes migration from release packaging to Python-based scaffolding for Forge agent.
This commit is contained in:
42
AGENTS.md
42
AGENTS.md
@@ -48,6 +48,7 @@ Specify supports multiple AI agents by generating agent-specific command files a
|
||||
| **Kimi Code** | `.kimi/skills/` | Markdown | `kimi` | Kimi Code CLI (Moonshot AI) |
|
||||
| **Pi Coding Agent** | `.pi/prompts/` | Markdown | `pi` | Pi terminal coding agent |
|
||||
| **iFlow CLI** | `.iflow/commands/` | Markdown | `iflow` | iFlow CLI (iflow-ai) |
|
||||
| **Forge** | `.forge/commands/` | Markdown | `forge` | Forge CLI (forgecode.dev) |
|
||||
| **IBM Bob** | `.bob/commands/` | Markdown | N/A (IDE-based) | IBM Bob IDE |
|
||||
| **Trae** | `.trae/rules/` | Markdown | N/A (IDE-based) | Trae IDE |
|
||||
| **Antigravity** | `.agent/commands/` | Markdown | N/A (IDE-based) | Antigravity IDE (`--ai agy --ai-skills`) |
|
||||
@@ -333,6 +334,7 @@ Require a command-line tool to be installed:
|
||||
- **Mistral Vibe**: `vibe` CLI
|
||||
- **Pi Coding Agent**: `pi` CLI
|
||||
- **iFlow CLI**: `iflow` CLI
|
||||
- **Forge**: `forge` CLI
|
||||
|
||||
### IDE-Based Agents
|
||||
|
||||
@@ -351,7 +353,7 @@ Work within integrated development environments:
|
||||
|
||||
### Markdown Format
|
||||
|
||||
Used by: Claude, Cursor, GitHub Copilot, opencode, Windsurf, Junie, Kiro CLI, Amp, SHAI, IBM Bob, Kimi Code, Qwen, Pi, Codex, Auggie, CodeBuddy, Qoder, Roo Code, Kilo Code, Trae, Antigravity, Mistral Vibe, iFlow
|
||||
Used by: Claude, Cursor, GitHub Copilot, opencode, Windsurf, Junie, Kiro CLI, Amp, SHAI, IBM Bob, Kimi Code, Qwen, Pi, Codex, Auggie, CodeBuddy, Qoder, Roo Code, Kilo Code, Trae, Antigravity, Mistral Vibe, iFlow, Forge
|
||||
|
||||
**Standard format:**
|
||||
|
||||
@@ -419,9 +421,47 @@ Different agents use different argument placeholders:
|
||||
|
||||
- **Markdown/prompt-based**: `$ARGUMENTS`
|
||||
- **TOML-based**: `{{args}}`
|
||||
- **Forge-specific**: `{{parameters}}` (uses custom parameter syntax)
|
||||
- **Script placeholders**: `{SCRIPT}` (replaced with actual script path)
|
||||
- **Agent placeholders**: `__AGENT__` (replaced with agent name)
|
||||
|
||||
## Special Processing Requirements
|
||||
|
||||
Some agents require custom processing beyond the standard template transformations:
|
||||
|
||||
### Copilot Integration
|
||||
|
||||
GitHub Copilot has unique requirements:
|
||||
- Commands use `.agent.md` extension (not `.md`)
|
||||
- Each command gets a companion `.prompt.md` file in `.github/prompts/`
|
||||
- Installs `.vscode/settings.json` with prompt file recommendations
|
||||
- Context file lives at `.github/copilot-instructions.md`
|
||||
|
||||
Implementation: Extends `IntegrationBase` with custom `setup()` method that:
|
||||
1. Processes templates with `process_template()`
|
||||
2. Generates companion `.prompt.md` files
|
||||
3. Merges VS Code settings
|
||||
|
||||
### Forge Integration
|
||||
|
||||
Forge has special frontmatter and argument requirements:
|
||||
- Uses `{{parameters}}` instead of `$ARGUMENTS`
|
||||
- Strips `handoffs` frontmatter key (Forge-specific collaboration feature)
|
||||
- Injects `name` field into frontmatter when missing
|
||||
|
||||
Implementation: Extends `IntegrationBase` with custom `setup()` method that:
|
||||
1. Processes templates with `process_template()` using `{{parameters}}`
|
||||
2. Applies Forge-specific transformations via `_apply_forge_transformations()`
|
||||
3. Strips unwanted frontmatter keys
|
||||
4. Injects missing `name` fields
|
||||
|
||||
### Standard Markdown Agents
|
||||
|
||||
Most agents (Bob, Claude, Windsurf, etc.) use `MarkdownIntegration`:
|
||||
- Simple subclass with just `key`, `config`, `registrar_config` set
|
||||
- Inherits standard processing from `MarkdownIntegration.setup()`
|
||||
- No custom processing needed
|
||||
|
||||
## Testing New Agent Integration
|
||||
|
||||
1. **Build test**: Run package creation script locally
|
||||
|
||||
@@ -53,6 +53,7 @@ def _register_builtins() -> None:
|
||||
from .codebuddy import CodebuddyIntegration
|
||||
from .copilot import CopilotIntegration
|
||||
from .cursor_agent import CursorAgentIntegration
|
||||
from .forge import ForgeIntegration
|
||||
from .iflow import IflowIntegration
|
||||
from .junie import JunieIntegration
|
||||
from .kilocode import KilocodeIntegration
|
||||
@@ -75,6 +76,7 @@ def _register_builtins() -> None:
|
||||
_register(CodebuddyIntegration())
|
||||
_register(CopilotIntegration())
|
||||
_register(CursorAgentIntegration())
|
||||
_register(ForgeIntegration())
|
||||
_register(IflowIntegration())
|
||||
_register(JunieIntegration())
|
||||
_register(KilocodeIntegration())
|
||||
|
||||
151
src/specify_cli/integrations/forge/__init__.py
Normal file
151
src/specify_cli/integrations/forge/__init__.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""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 (Forge-specific collaboration feature)
|
||||
- Injects `name` field into frontmatter when missing
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..base import IntegrationBase
|
||||
from ..manifest import IntegrationManifest
|
||||
|
||||
|
||||
class ForgeIntegration(IntegrationBase):
|
||||
"""Integration for Forge (forgecode.dev)."""
|
||||
|
||||
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,
|
||||
}
|
||||
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.
|
||||
|
||||
Processes command templates similarly to MarkdownIntegration but with
|
||||
Forge-specific transformations:
|
||||
1. Replaces {SCRIPT} and {ARGS} placeholders
|
||||
2. Strips 'handoffs' frontmatter key
|
||||
3. Injects 'name' field into frontmatter
|
||||
4. Uses {{parameters}} instead of $ARGUMENTS
|
||||
"""
|
||||
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 Forge-specific argument placeholder
|
||||
processed = self.process_template(raw, self.key, script_type, arg_placeholder)
|
||||
|
||||
# Apply Forge-specific 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
|
||||
2. Inject 'name' field if missing
|
||||
"""
|
||||
import re
|
||||
|
||||
# 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
|
||||
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}"
|
||||
filtered_frontmatter.insert(0, f'name: {cmd_name}')
|
||||
|
||||
# Reconstruct content
|
||||
result = ['---'] + filtered_frontmatter + ['---'] + body_lines
|
||||
return '\n'.join(result)
|
||||
@@ -0,0 +1,23 @@
|
||||
# update-context.ps1 — Forge integration: create/update AGENTS.md
|
||||
#
|
||||
# Thin wrapper that delegates to the shared update-agent-context script.
|
||||
# Activated in Stage 7 when the shared script uses integration.json dispatch.
|
||||
#
|
||||
# Until then, this delegates to the shared script as a subprocess.
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Derive repo root from script location (walks up to find .specify/)
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
|
||||
# If git did not return a repo root, or the git root does not contain .specify,
|
||||
# fall back to walking up from the script directory to find the initialized project root.
|
||||
if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
|
||||
$repoRoot = $scriptDir
|
||||
$fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
|
||||
while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
|
||||
$repoRoot = Split-Path -Parent $repoRoot
|
||||
}
|
||||
}
|
||||
|
||||
& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType forge
|
||||
28
src/specify_cli/integrations/forge/scripts/update-context.sh
Executable file
28
src/specify_cli/integrations/forge/scripts/update-context.sh
Executable file
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# update-context.sh — Forge integration: create/update AGENTS.md
|
||||
#
|
||||
# Thin wrapper that delegates to the shared update-agent-context script.
|
||||
# Activated in Stage 7 when the shared script uses integration.json dispatch.
|
||||
#
|
||||
# Until then, this delegates to the shared script as a subprocess.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Derive repo root from script location (walks up to find .specify/)
|
||||
_script_dir="$(cd "$(dirname "$0")" && pwd)"
|
||||
_root="$_script_dir"
|
||||
while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
|
||||
if [ -z "${REPO_ROOT:-}" ]; then
|
||||
if [ -d "$_root/.specify" ]; then
|
||||
REPO_ROOT="$_root"
|
||||
else
|
||||
git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
||||
if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
|
||||
REPO_ROOT="$git_root"
|
||||
else
|
||||
REPO_ROOT="$_root"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" forge
|
||||
144
tests/integrations/test_integration_forge.py
Normal file
144
tests/integrations/test_integration_forge.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""Tests for ForgeIntegration."""
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
|
||||
class TestForgeIntegration:
|
||||
def test_forge_key_and_config(self):
|
||||
forge = get_integration("forge")
|
||||
assert forge is not None
|
||||
assert forge.key == "forge"
|
||||
assert forge.config["folder"] == ".forge/"
|
||||
assert forge.config["commands_subdir"] == "commands"
|
||||
assert forge.config["requires_cli"] is True
|
||||
assert forge.registrar_config["args"] == "{{parameters}}"
|
||||
assert forge.registrar_config["extension"] == ".md"
|
||||
assert forge.context_file == "AGENTS.md"
|
||||
|
||||
def test_command_filename_md(self):
|
||||
forge = get_integration("forge")
|
||||
assert forge.command_filename("plan") == "speckit.plan.md"
|
||||
|
||||
def test_setup_creates_md_files(self, tmp_path):
|
||||
from specify_cli.integrations.forge import ForgeIntegration
|
||||
forge = ForgeIntegration()
|
||||
m = IntegrationManifest("forge", tmp_path)
|
||||
created = forge.setup(tmp_path, m)
|
||||
assert len(created) > 0
|
||||
# Separate command files from scripts
|
||||
command_files = [f for f in created if f.parent == tmp_path / ".forge" / "commands"]
|
||||
assert len(command_files) > 0
|
||||
for f in command_files:
|
||||
assert f.name.endswith(".md")
|
||||
|
||||
def test_setup_installs_update_scripts(self, tmp_path):
|
||||
from specify_cli.integrations.forge import ForgeIntegration
|
||||
forge = ForgeIntegration()
|
||||
m = IntegrationManifest("forge", tmp_path)
|
||||
created = forge.setup(tmp_path, m)
|
||||
script_files = [f for f in created if "scripts" in f.parts]
|
||||
assert len(script_files) > 0
|
||||
sh_script = tmp_path / ".specify" / "integrations" / "forge" / "scripts" / "update-context.sh"
|
||||
ps_script = tmp_path / ".specify" / "integrations" / "forge" / "scripts" / "update-context.ps1"
|
||||
assert sh_script in created
|
||||
assert ps_script in created
|
||||
assert sh_script.exists()
|
||||
assert ps_script.exists()
|
||||
|
||||
def test_all_created_files_tracked_in_manifest(self, tmp_path):
|
||||
from specify_cli.integrations.forge import ForgeIntegration
|
||||
forge = ForgeIntegration()
|
||||
m = IntegrationManifest("forge", tmp_path)
|
||||
created = forge.setup(tmp_path, m)
|
||||
for f in created:
|
||||
rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
|
||||
assert rel in m.files, f"Created file {rel} not tracked in manifest"
|
||||
|
||||
def test_install_uninstall_roundtrip(self, tmp_path):
|
||||
from specify_cli.integrations.forge import ForgeIntegration
|
||||
forge = ForgeIntegration()
|
||||
m = IntegrationManifest("forge", tmp_path)
|
||||
created = forge.install(tmp_path, m)
|
||||
assert len(created) > 0
|
||||
m.save()
|
||||
for f in created:
|
||||
assert f.exists()
|
||||
removed, skipped = forge.uninstall(tmp_path, m)
|
||||
assert len(removed) == len(created)
|
||||
assert skipped == []
|
||||
|
||||
def test_modified_file_survives_uninstall(self, tmp_path):
|
||||
from specify_cli.integrations.forge import ForgeIntegration
|
||||
forge = ForgeIntegration()
|
||||
m = IntegrationManifest("forge", tmp_path)
|
||||
created = forge.install(tmp_path, m)
|
||||
m.save()
|
||||
# Modify a command file (not a script)
|
||||
command_files = [f for f in created if f.parent == tmp_path / ".forge" / "commands"]
|
||||
modified_file = command_files[0]
|
||||
modified_file.write_text("user modified this", encoding="utf-8")
|
||||
removed, skipped = forge.uninstall(tmp_path, m)
|
||||
assert modified_file.exists()
|
||||
assert modified_file in skipped
|
||||
|
||||
def test_directory_structure(self, tmp_path):
|
||||
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"
|
||||
assert commands_dir.is_dir()
|
||||
command_files = sorted(commands_dir.glob("speckit.*.md"))
|
||||
assert len(command_files) == 9
|
||||
expected_commands = {
|
||||
"analyze", "checklist", "clarify", "constitution",
|
||||
"implement", "plan", "specify", "tasks", "taskstoissues",
|
||||
}
|
||||
actual_commands = {f.name.removeprefix("speckit.").removesuffix(".md") for f in command_files}
|
||||
assert actual_commands == expected_commands
|
||||
|
||||
def test_templates_are_processed(self, tmp_path):
|
||||
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"
|
||||
for cmd_file in commands_dir.glob("speckit.*.md"):
|
||||
content = cmd_file.read_text(encoding="utf-8")
|
||||
# Check standard replacements
|
||||
assert "{SCRIPT}" not in content, f"{cmd_file.name} has unprocessed {{SCRIPT}}"
|
||||
assert "__AGENT__" not in content, f"{cmd_file.name} has unprocessed __AGENT__"
|
||||
assert "{ARGS}" not in content, f"{cmd_file.name} has unprocessed {{ARGS}}"
|
||||
# Note: $ARGUMENTS may appear in template content (examples, instructions)
|
||||
# The placeholder that gets replaced is {ARGS}, not $ARGUMENTS
|
||||
# Frontmatter sections should be stripped
|
||||
assert "\nscripts:\n" not in content
|
||||
assert "\nagent_scripts:\n" not in content
|
||||
|
||||
def test_forge_specific_transformations(self, tmp_path):
|
||||
"""Test Forge-specific processing: name injection and handoffs stripping."""
|
||||
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"
|
||||
|
||||
for cmd_file in commands_dir.glob("speckit.*.md"):
|
||||
content = cmd_file.read_text(encoding="utf-8")
|
||||
|
||||
# Check that name field is injected in frontmatter
|
||||
assert "\nname: " in content, f"{cmd_file.name} missing injected 'name' field"
|
||||
|
||||
# Check that handoffs frontmatter key is stripped
|
||||
assert "\nhandoffs:" not in content, f"{cmd_file.name} has unstripped 'handoffs' key"
|
||||
|
||||
def test_uses_parameters_placeholder(self, tmp_path):
|
||||
"""Verify Forge config specifies {{parameters}} as the args placeholder."""
|
||||
from specify_cli.integrations.forge import ForgeIntegration
|
||||
forge = ForgeIntegration()
|
||||
# The registrar_config should specify {{parameters}}
|
||||
assert forge.registrar_config["args"] == "{{parameters}}"
|
||||
|
||||
# When process_template is called, it should replace {ARGS} with {{parameters}}
|
||||
# Note: $ARGUMENTS in template content is intentional (examples/instructions)
|
||||
Reference in New Issue
Block a user