Skip to content

Commit e3f269b

Browse files
committed
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.
1 parent d1360ef commit e3f269b

File tree

6 files changed

+389
-1
lines changed

6 files changed

+389
-1
lines changed

AGENTS.md

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ Specify supports multiple AI agents by generating agent-specific command files a
4848
| **Kimi Code** | `.kimi/skills/` | Markdown | `kimi` | Kimi Code CLI (Moonshot AI) |
4949
| **Pi Coding Agent** | `.pi/prompts/` | Markdown | `pi` | Pi terminal coding agent |
5050
| **iFlow CLI** | `.iflow/commands/` | Markdown | `iflow` | iFlow CLI (iflow-ai) |
51+
| **Forge** | `.forge/commands/` | Markdown | `forge` | Forge CLI (forgecode.dev) |
5152
| **IBM Bob** | `.bob/commands/` | Markdown | N/A (IDE-based) | IBM Bob IDE |
5253
| **Trae** | `.trae/rules/` | Markdown | N/A (IDE-based) | Trae IDE |
5354
| **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:
333334
- **Mistral Vibe**: `vibe` CLI
334335
- **Pi Coding Agent**: `pi` CLI
335336
- **iFlow CLI**: `iflow` CLI
337+
- **Forge**: `forge` CLI
336338

337339
### IDE-Based Agents
338340

@@ -351,7 +353,7 @@ Work within integrated development environments:
351353

352354
### Markdown Format
353355

354-
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
356+
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
355357

356358
**Standard format:**
357359

@@ -419,9 +421,47 @@ Different agents use different argument placeholders:
419421

420422
- **Markdown/prompt-based**: `$ARGUMENTS`
421423
- **TOML-based**: `{{args}}`
424+
- **Forge-specific**: `{{parameters}}` (uses custom parameter syntax)
422425
- **Script placeholders**: `{SCRIPT}` (replaced with actual script path)
423426
- **Agent placeholders**: `__AGENT__` (replaced with agent name)
424427

428+
## Special Processing Requirements
429+
430+
Some agents require custom processing beyond the standard template transformations:
431+
432+
### Copilot Integration
433+
434+
GitHub Copilot has unique requirements:
435+
- Commands use `.agent.md` extension (not `.md`)
436+
- Each command gets a companion `.prompt.md` file in `.github/prompts/`
437+
- Installs `.vscode/settings.json` with prompt file recommendations
438+
- Context file lives at `.github/copilot-instructions.md`
439+
440+
Implementation: Extends `IntegrationBase` with custom `setup()` method that:
441+
1. Processes templates with `process_template()`
442+
2. Generates companion `.prompt.md` files
443+
3. Merges VS Code settings
444+
445+
### Forge Integration
446+
447+
Forge has special frontmatter and argument requirements:
448+
- Uses `{{parameters}}` instead of `$ARGUMENTS`
449+
- Strips `handoffs` frontmatter key (Forge-specific collaboration feature)
450+
- Injects `name` field into frontmatter when missing
451+
452+
Implementation: Extends `IntegrationBase` with custom `setup()` method that:
453+
1. Processes templates with `process_template()` using `{{parameters}}`
454+
2. Applies Forge-specific transformations via `_apply_forge_transformations()`
455+
3. Strips unwanted frontmatter keys
456+
4. Injects missing `name` fields
457+
458+
### Standard Markdown Agents
459+
460+
Most agents (Bob, Claude, Windsurf, etc.) use `MarkdownIntegration`:
461+
- Simple subclass with just `key`, `config`, `registrar_config` set
462+
- Inherits standard processing from `MarkdownIntegration.setup()`
463+
- No custom processing needed
464+
425465
## Testing New Agent Integration
426466

427467
1. **Build test**: Run package creation script locally

src/specify_cli/integrations/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def _register_builtins() -> None:
5353
from .codebuddy import CodebuddyIntegration
5454
from .copilot import CopilotIntegration
5555
from .cursor_agent import CursorAgentIntegration
56+
from .forge import ForgeIntegration
5657
from .iflow import IflowIntegration
5758
from .junie import JunieIntegration
5859
from .kilocode import KilocodeIntegration
@@ -75,6 +76,7 @@ def _register_builtins() -> None:
7576
_register(CodebuddyIntegration())
7677
_register(CopilotIntegration())
7778
_register(CursorAgentIntegration())
79+
_register(ForgeIntegration())
7880
_register(IflowIntegration())
7981
_register(JunieIntegration())
8082
_register(KilocodeIntegration())
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
"""Forge integration — forgecode.dev AI coding agent.
2+
3+
Forge has several unique behaviors compared to standard markdown agents:
4+
- Uses `{{parameters}}` instead of `$ARGUMENTS` for argument passing
5+
- Strips `handoffs` frontmatter key (Forge-specific collaboration feature)
6+
- Injects `name` field into frontmatter when missing
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from pathlib import Path
12+
from typing import Any
13+
14+
from ..base import IntegrationBase
15+
from ..manifest import IntegrationManifest
16+
17+
18+
class ForgeIntegration(IntegrationBase):
19+
"""Integration for Forge (forgecode.dev)."""
20+
21+
key = "forge"
22+
config = {
23+
"name": "Forge",
24+
"folder": ".forge/",
25+
"commands_subdir": "commands",
26+
"install_url": "https://forgecode.dev/docs/",
27+
"requires_cli": True,
28+
}
29+
registrar_config = {
30+
"dir": ".forge/commands",
31+
"format": "markdown",
32+
"args": "{{parameters}}",
33+
"extension": ".md",
34+
"strip_frontmatter_keys": ["handoffs"],
35+
"inject_name": True,
36+
}
37+
context_file = "AGENTS.md"
38+
39+
def setup(
40+
self,
41+
project_root: Path,
42+
manifest: IntegrationManifest,
43+
parsed_options: dict[str, Any] | None = None,
44+
**opts: Any,
45+
) -> list[Path]:
46+
"""Install Forge commands with custom processing.
47+
48+
Processes command templates similarly to MarkdownIntegration but with
49+
Forge-specific transformations:
50+
1. Replaces {SCRIPT} and {ARGS} placeholders
51+
2. Strips 'handoffs' frontmatter key
52+
3. Injects 'name' field into frontmatter
53+
4. Uses {{parameters}} instead of $ARGUMENTS
54+
"""
55+
templates = self.list_command_templates()
56+
if not templates:
57+
return []
58+
59+
project_root_resolved = project_root.resolve()
60+
if manifest.project_root != project_root_resolved:
61+
raise ValueError(
62+
f"manifest.project_root ({manifest.project_root}) does not match "
63+
f"project_root ({project_root_resolved})"
64+
)
65+
66+
dest = self.commands_dest(project_root).resolve()
67+
try:
68+
dest.relative_to(project_root_resolved)
69+
except ValueError as exc:
70+
raise ValueError(
71+
f"Integration destination {dest} escapes "
72+
f"project root {project_root_resolved}"
73+
) from exc
74+
dest.mkdir(parents=True, exist_ok=True)
75+
76+
script_type = opts.get("script_type", "sh")
77+
arg_placeholder = self.registrar_config.get("args", "{{parameters}}")
78+
created: list[Path] = []
79+
80+
for src_file in templates:
81+
raw = src_file.read_text(encoding="utf-8")
82+
# Process template with Forge-specific argument placeholder
83+
processed = self.process_template(raw, self.key, script_type, arg_placeholder)
84+
85+
# Apply Forge-specific transformations
86+
processed = self._apply_forge_transformations(processed, src_file.stem)
87+
88+
dst_name = self.command_filename(src_file.stem)
89+
dst_file = self.write_file_and_record(
90+
processed, dest / dst_name, project_root, manifest
91+
)
92+
created.append(dst_file)
93+
94+
# Install integration-specific update-context scripts
95+
created.extend(self.install_scripts(project_root, manifest))
96+
97+
return created
98+
99+
def _apply_forge_transformations(self, content: str, template_name: str) -> str:
100+
"""Apply Forge-specific transformations to processed content.
101+
102+
1. Strip 'handoffs' frontmatter key
103+
2. Inject 'name' field if missing
104+
"""
105+
import re
106+
107+
# Parse frontmatter
108+
lines = content.split('\n')
109+
if not lines or lines[0].strip() != '---':
110+
return content
111+
112+
# Find end of frontmatter
113+
frontmatter_end = -1
114+
for i in range(1, len(lines)):
115+
if lines[i].strip() == '---':
116+
frontmatter_end = i
117+
break
118+
119+
if frontmatter_end == -1:
120+
return content
121+
122+
frontmatter_lines = lines[1:frontmatter_end]
123+
body_lines = lines[frontmatter_end + 1:]
124+
125+
# 1. Strip 'handoffs' key
126+
filtered_frontmatter = []
127+
skip_until_outdent = False
128+
for line in frontmatter_lines:
129+
if skip_until_outdent:
130+
# Skip indented lines under handoffs:
131+
if line and (line[0] == ' ' or line[0] == '\t'):
132+
continue
133+
else:
134+
skip_until_outdent = False
135+
136+
if line.strip().startswith('handoffs:'):
137+
skip_until_outdent = True
138+
continue
139+
140+
filtered_frontmatter.append(line)
141+
142+
# 2. Inject 'name' field if missing
143+
has_name = any(line.strip().startswith('name:') for line in filtered_frontmatter)
144+
if not has_name:
145+
# Use the template name as the command name (e.g., "plan" -> "speckit.plan")
146+
cmd_name = f"speckit.{template_name}"
147+
filtered_frontmatter.insert(0, f'name: {cmd_name}')
148+
149+
# Reconstruct content
150+
result = ['---'] + filtered_frontmatter + ['---'] + body_lines
151+
return '\n'.join(result)
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# update-context.ps1 — Forge integration: create/update AGENTS.md
2+
#
3+
# Thin wrapper that delegates to the shared update-agent-context script.
4+
# Activated in Stage 7 when the shared script uses integration.json dispatch.
5+
#
6+
# Until then, this delegates to the shared script as a subprocess.
7+
8+
$ErrorActionPreference = 'Stop'
9+
10+
# Derive repo root from script location (walks up to find .specify/)
11+
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
12+
$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
13+
# If git did not return a repo root, or the git root does not contain .specify,
14+
# fall back to walking up from the script directory to find the initialized project root.
15+
if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
16+
$repoRoot = $scriptDir
17+
$fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
18+
while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
19+
$repoRoot = Split-Path -Parent $repoRoot
20+
}
21+
}
22+
23+
& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType forge
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
#!/usr/bin/env bash
2+
# update-context.sh — Forge integration: create/update AGENTS.md
3+
#
4+
# Thin wrapper that delegates to the shared update-agent-context script.
5+
# Activated in Stage 7 when the shared script uses integration.json dispatch.
6+
#
7+
# Until then, this delegates to the shared script as a subprocess.
8+
9+
set -euo pipefail
10+
11+
# Derive repo root from script location (walks up to find .specify/)
12+
_script_dir="$(cd "$(dirname "$0")" && pwd)"
13+
_root="$_script_dir"
14+
while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
15+
if [ -z "${REPO_ROOT:-}" ]; then
16+
if [ -d "$_root/.specify" ]; then
17+
REPO_ROOT="$_root"
18+
else
19+
git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
20+
if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
21+
REPO_ROOT="$git_root"
22+
else
23+
REPO_ROOT="$_root"
24+
fi
25+
fi
26+
fi
27+
28+
exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" forge

0 commit comments

Comments
 (0)