diff --git a/AGENTS.md b/AGENTS.md index 7adfd1d12e..d711b4214d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,23 +20,17 @@ src/specify_cli/integrations/ ├── base.py # IntegrationBase, MarkdownIntegration, TomlIntegration, YamlIntegration, SkillsIntegration ├── manifest.py # IntegrationManifest (file tracking) ├── claude/ # Example: SkillsIntegration subclass -│ ├── __init__.py # ClaudeIntegration class -│ └── scripts/ # Thin wrapper scripts -│ ├── update-context.sh -│ └── update-context.ps1 +│ └── __init__.py # ClaudeIntegration class ├── gemini/ # Example: TomlIntegration subclass -│ ├── __init__.py -│ └── scripts/ +│ └── __init__.py ├── windsurf/ # Example: MarkdownIntegration subclass -│ ├── __init__.py -│ └── scripts/ +│ └── __init__.py ├── copilot/ # Example: IntegrationBase subclass (custom setup) -│ ├── __init__.py -│ └── scripts/ +│ └── __init__.py └── ... # One subpackage per supported agent ``` -The registry is the **single source of truth for Python integration metadata**. Supported agents, their directories, formats, and capabilities are derived from the integration classes for the Python integration layer. However, context-update behavior still requires explicit cases in the shared dispatcher scripts (`scripts/bash/update-agent-context.sh` and `scripts/powershell/update-agent-context.ps1`), which currently maintain their own supported-agent lists and agent-key→context-file mappings until they are migrated to registry-based dispatch. +The registry is the **single source of truth for Python integration metadata**. Supported agents, their directories, formats, capabilities, and context files are derived from the integration classes for the Python integration layer. --- @@ -179,63 +173,11 @@ def _register_builtins() -> None: # ... ``` -### 4. Add scripts +### 4. Context file behavior -Create two thin wrapper scripts in `src/specify_cli/integrations//scripts/` that delegate to the shared context-update scripts. Each is ~25 lines of boilerplate. +Set `context_file` on the integration class. The base integration setup creates or updates the managed Spec Kit section in that file, and uninstall removes the managed section when appropriate. -> **Note on `` vs ``:** `` is the Python-safe directory name for your integration — it matches `` exactly when the key contains no hyphens (e.g., key `"gemini"` → `gemini/`), but uses underscores when it does (e.g., key `"kiro-cli"` → `kiro_cli/`). The `IntegrationBase.key` class attribute always retains the original hyphenated value (e.g., `key = "kiro-cli"`), since that is what the CLI and registry use. - -**`update-context.sh`:** - -```bash -#!/usr/bin/env bash -# update-context.sh — integration: create/update -set -euo pipefail - -_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" -``` - -**`update-context.ps1`:** - -```powershell -# update-context.ps1 — integration: create/update -$ErrorActionPreference = 'Stop' - -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -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 -``` - -Replace `` with your integration key and `` / `` with the appropriate values. - -You must also add the agent to the shared context-update scripts so the shared dispatcher recognises the new key: - -- **`scripts/bash/update-agent-context.sh`** — add a file-path variable and a case in `update_specific_agent()`. -- **`scripts/powershell/update-agent-context.ps1`** — add a file-path variable, add the new key to the `AgentType` parameter's `[ValidateSet(...)]`, add a switch case in `Update-SpecificAgent`, and add an entry in `Update-AllExistingAgents`. +Only add custom setup logic when the agent needs non-standard behavior. Most integrations do not need wrapper scripts or separate context-update dispatch code. ### 5. Test it @@ -422,7 +364,6 @@ Implementation: Extends `MarkdownIntegration` with custom `setup()` method that: 3. Applies Forge-specific transformations via `_apply_forge_transformations()` 4. Strips `handoffs` frontmatter key 5. Injects missing `name` fields -6. Ensures the shared `update-agent-context.*` scripts include a `forge` case that maps context updates to `AGENTS.md` and lists `forge` in their usage/help text ### Goose Integration @@ -436,7 +377,7 @@ Implementation: Extends `YamlIntegration` (parallel to `TomlIntegration`): 2. Extracts title and description from frontmatter 3. Renders output as Goose recipe YAML (version, title, description, author, extensions, activities, prompt) 4. Uses `yaml.safe_dump()` for header fields to ensure proper escaping -5. Context updates map to `AGENTS.md` (shared with opencode/codex/pi/forge) +5. Sets `context_file = "AGENTS.md"` so the base setup manages the Spec Kit context section there ## Common Pitfalls diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 8039f79983..a43f3e7a75 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1968,6 +1968,16 @@ def _resolve_script_type(project_root: Path, script_type: str | None) -> str: return "ps" if os.name == "nt" else "sh" +def _require_specify_project() -> Path: + """Return the current project root if it is a spec-kit project, else exit.""" + project_root = Path.cwd() + if (project_root / ".specify").is_dir(): + return project_root + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + @integration_app.command("list") def integration_list( catalog: bool = typer.Option(False, "--catalog", help="Browse full catalog (built-in + community)"), @@ -1975,14 +1985,7 @@ def integration_list( """List available integrations and installed status.""" from .integrations import INTEGRATION_REGISTRY - project_root = Path.cwd() - - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - + project_root = _require_specify_project() current = _read_integration_json(project_root) installed_key = current.get("integration") @@ -2069,14 +2072,7 @@ def integration_install( from .integrations import INTEGRATION_REGISTRY, get_integration from .integrations.manifest import IntegrationManifest - project_root = Path.cwd() - - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - + project_root = _require_specify_project() integration = get_integration(key) if integration is None: console.print(f"[red]Error:[/red] Unknown integration '{key}'") @@ -2220,14 +2216,7 @@ def integration_uninstall( from .integrations import get_integration from .integrations.manifest import IntegrationManifest - project_root = Path.cwd() - - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - + project_root = _require_specify_project() current = _read_integration_json(project_root) installed_key = current.get("integration") @@ -2309,14 +2298,7 @@ def integration_switch( from .integrations import INTEGRATION_REGISTRY, get_integration from .integrations.manifest import IntegrationManifest - project_root = Path.cwd() - - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - + project_root = _require_specify_project() target_integration = get_integration(target) if target_integration is None: console.print(f"[red]Error:[/red] Unknown integration '{target}'") @@ -2445,14 +2427,7 @@ def integration_upgrade( from .integrations import get_integration from .integrations.manifest import IntegrationManifest - project_root = Path.cwd() - - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - + project_root = _require_specify_project() current = _read_integration_json(project_root) installed_key = current.get("integration") @@ -2557,16 +2532,6 @@ def integration_upgrade( # not additive like extensions and presets. -def _require_specify_project() -> Path: - """Return the current project root if it is a spec-kit project, else exit.""" - project_root = Path.cwd() - if not (project_root / ".specify").exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - return project_root - - @integration_app.command("search") def integration_search( query: Optional[str] = typer.Argument(None, help="Search query (optional)"), diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index 60e51a5fb9..95dcf206e8 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -706,6 +706,35 @@ def test_catalog_list_requires_specify_project(self, tmp_path): assert result.exit_code == 1 assert "Not a spec-kit project" in result.output + def test_primary_integration_commands_require_specify_project(self, tmp_path): + project = tmp_path / "bare" + project.mkdir() + commands = [ + ["integration", "list"], + ["integration", "install", "codex"], + ["integration", "uninstall"], + ["integration", "switch", "codex"], + ["integration", "upgrade"], + ] + + for command in commands: + result = self._invoke(command, project) + failure_context = ( + f"command={command!r}, exit_code={result.exit_code}, output={result.output!r}" + ) + assert result.exit_code == 1, failure_context + assert "Not a spec-kit project" in result.output, failure_context + + def test_integration_commands_require_specify_directory(self, tmp_path): + project = tmp_path / "bad" + project.mkdir() + (project / ".specify").write_text("not a directory") + + result = self._invoke(["integration", "list"], project) + + assert result.exit_code == 1, result.output + assert "Not a spec-kit project" in result.output + # -- search ------------------------------------------------------------ def test_search_lists_all(self, tmp_path, monkeypatch):