diff --git a/AGENTS.md b/AGENTS.md index eb3d27065..0cadf3a44 100644 --- a/AGENTS.md +++ b/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 diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py index 0d7a71242..e9ae6e07e 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -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()) diff --git a/src/specify_cli/integrations/forge/__init__.py b/src/specify_cli/integrations/forge/__init__.py new file mode 100644 index 000000000..da09da13b --- /dev/null +++ b/src/specify_cli/integrations/forge/__init__.py @@ -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) diff --git a/src/specify_cli/integrations/forge/scripts/update-context.ps1 b/src/specify_cli/integrations/forge/scripts/update-context.ps1 new file mode 100644 index 000000000..c6071ff3a --- /dev/null +++ b/src/specify_cli/integrations/forge/scripts/update-context.ps1 @@ -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 diff --git a/src/specify_cli/integrations/forge/scripts/update-context.sh b/src/specify_cli/integrations/forge/scripts/update-context.sh new file mode 100755 index 000000000..126fe1cfa --- /dev/null +++ b/src/specify_cli/integrations/forge/scripts/update-context.sh @@ -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 diff --git a/tests/integrations/test_integration_forge.py b/tests/integrations/test_integration_forge.py new file mode 100644 index 000000000..9b8d9df1e --- /dev/null +++ b/tests/integrations/test_integration_forge.py @@ -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)