Skip to content

Commit 63cad6a

Browse files
authored
chore(integrations): clean up docs and project guard (#2428)
1 parent fcd6a80 commit 63cad6a

3 files changed

Lines changed: 53 additions & 118 deletions

File tree

AGENTS.md

Lines changed: 9 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -20,23 +20,17 @@ src/specify_cli/integrations/
2020
├── base.py # IntegrationBase, MarkdownIntegration, TomlIntegration, YamlIntegration, SkillsIntegration
2121
├── manifest.py # IntegrationManifest (file tracking)
2222
├── claude/ # Example: SkillsIntegration subclass
23-
│ ├── __init__.py # ClaudeIntegration class
24-
│ └── scripts/ # Thin wrapper scripts
25-
│ ├── update-context.sh
26-
│ └── update-context.ps1
23+
│ └── __init__.py # ClaudeIntegration class
2724
├── gemini/ # Example: TomlIntegration subclass
28-
│ ├── __init__.py
29-
│ └── scripts/
25+
│ └── __init__.py
3026
├── windsurf/ # Example: MarkdownIntegration subclass
31-
│ ├── __init__.py
32-
│ └── scripts/
27+
│ └── __init__.py
3328
├── copilot/ # Example: IntegrationBase subclass (custom setup)
34-
│ ├── __init__.py
35-
│ └── scripts/
29+
│ └── __init__.py
3630
└── ... # One subpackage per supported agent
3731
```
3832

39-
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.
33+
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.
4034

4135
---
4236

@@ -179,63 +173,11 @@ def _register_builtins() -> None:
179173
# ...
180174
```
181175

182-
### 4. Add scripts
176+
### 4. Context file behavior
183177

184-
Create two thin wrapper scripts in `src/specify_cli/integrations/<package_dir>/scripts/` that delegate to the shared context-update scripts. Each is ~25 lines of boilerplate.
178+
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.
185179

186-
> **Note on `<package_dir>` vs `<key>`:** `<package_dir>` is the Python-safe directory name for your integration — it matches `<key>` 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.
187-
188-
**`update-context.sh`:**
189-
190-
```bash
191-
#!/usr/bin/env bash
192-
# update-context.sh — <Agent Name> integration: create/update <context_file>
193-
set -euo pipefail
194-
195-
_script_dir="$(cd "$(dirname "$0")" && pwd)"
196-
_root="$_script_dir"
197-
while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
198-
if [ -z "${REPO_ROOT:-}" ]; then
199-
if [ -d "$_root/.specify" ]; then
200-
REPO_ROOT="$_root"
201-
else
202-
git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
203-
if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
204-
REPO_ROOT="$git_root"
205-
else
206-
REPO_ROOT="$_root"
207-
fi
208-
fi
209-
fi
210-
211-
exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" <key>
212-
```
213-
214-
**`update-context.ps1`:**
215-
216-
```powershell
217-
# update-context.ps1 — <Agent Name> integration: create/update <context_file>
218-
$ErrorActionPreference = 'Stop'
219-
220-
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
221-
$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
222-
if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
223-
$repoRoot = $scriptDir
224-
$fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
225-
while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
226-
$repoRoot = Split-Path -Parent $repoRoot
227-
}
228-
}
229-
230-
& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType <key>
231-
```
232-
233-
Replace `<key>` with your integration key and `<Agent Name>` / `<context_file>` with the appropriate values.
234-
235-
You must also add the agent to the shared context-update scripts so the shared dispatcher recognises the new key:
236-
237-
- **`scripts/bash/update-agent-context.sh`** — add a file-path variable and a case in `update_specific_agent()`.
238-
- **`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`.
180+
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.
239181

240182
### 5. Test it
241183

@@ -422,7 +364,6 @@ Implementation: Extends `MarkdownIntegration` with custom `setup()` method that:
422364
3. Applies Forge-specific transformations via `_apply_forge_transformations()`
423365
4. Strips `handoffs` frontmatter key
424366
5. Injects missing `name` fields
425-
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
426367

427368
### Goose Integration
428369

@@ -436,7 +377,7 @@ Implementation: Extends `YamlIntegration` (parallel to `TomlIntegration`):
436377
2. Extracts title and description from frontmatter
437378
3. Renders output as Goose recipe YAML (version, title, description, author, extensions, activities, prompt)
438379
4. Uses `yaml.safe_dump()` for header fields to ensure proper escaping
439-
5. Context updates map to `AGENTS.md` (shared with opencode/codex/pi/forge)
380+
5. Sets `context_file = "AGENTS.md"` so the base setup manages the Spec Kit context section there
440381

441382
## Common Pitfalls
442383

src/specify_cli/__init__.py

Lines changed: 15 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1968,21 +1968,24 @@ def _resolve_script_type(project_root: Path, script_type: str | None) -> str:
19681968
return "ps" if os.name == "nt" else "sh"
19691969

19701970

1971+
def _require_specify_project() -> Path:
1972+
"""Return the current project root if it is a spec-kit project, else exit."""
1973+
project_root = Path.cwd()
1974+
if (project_root / ".specify").is_dir():
1975+
return project_root
1976+
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
1977+
console.print("Run this command from a spec-kit project root")
1978+
raise typer.Exit(1)
1979+
1980+
19711981
@integration_app.command("list")
19721982
def integration_list(
19731983
catalog: bool = typer.Option(False, "--catalog", help="Browse full catalog (built-in + community)"),
19741984
):
19751985
"""List available integrations and installed status."""
19761986
from .integrations import INTEGRATION_REGISTRY
19771987

1978-
project_root = Path.cwd()
1979-
1980-
specify_dir = project_root / ".specify"
1981-
if not specify_dir.exists():
1982-
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
1983-
console.print("Run this command from a spec-kit project root")
1984-
raise typer.Exit(1)
1985-
1988+
project_root = _require_specify_project()
19861989
current = _read_integration_json(project_root)
19871990
installed_key = current.get("integration")
19881991

@@ -2069,14 +2072,7 @@ def integration_install(
20692072
from .integrations import INTEGRATION_REGISTRY, get_integration
20702073
from .integrations.manifest import IntegrationManifest
20712074

2072-
project_root = Path.cwd()
2073-
2074-
specify_dir = project_root / ".specify"
2075-
if not specify_dir.exists():
2076-
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
2077-
console.print("Run this command from a spec-kit project root")
2078-
raise typer.Exit(1)
2079-
2075+
project_root = _require_specify_project()
20802076
integration = get_integration(key)
20812077
if integration is None:
20822078
console.print(f"[red]Error:[/red] Unknown integration '{key}'")
@@ -2220,14 +2216,7 @@ def integration_uninstall(
22202216
from .integrations import get_integration
22212217
from .integrations.manifest import IntegrationManifest
22222218

2223-
project_root = Path.cwd()
2224-
2225-
specify_dir = project_root / ".specify"
2226-
if not specify_dir.exists():
2227-
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
2228-
console.print("Run this command from a spec-kit project root")
2229-
raise typer.Exit(1)
2230-
2219+
project_root = _require_specify_project()
22312220
current = _read_integration_json(project_root)
22322221
installed_key = current.get("integration")
22332222

@@ -2309,14 +2298,7 @@ def integration_switch(
23092298
from .integrations import INTEGRATION_REGISTRY, get_integration
23102299
from .integrations.manifest import IntegrationManifest
23112300

2312-
project_root = Path.cwd()
2313-
2314-
specify_dir = project_root / ".specify"
2315-
if not specify_dir.exists():
2316-
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
2317-
console.print("Run this command from a spec-kit project root")
2318-
raise typer.Exit(1)
2319-
2301+
project_root = _require_specify_project()
23202302
target_integration = get_integration(target)
23212303
if target_integration is None:
23222304
console.print(f"[red]Error:[/red] Unknown integration '{target}'")
@@ -2471,14 +2453,7 @@ def integration_upgrade(
24712453
from .integrations import get_integration
24722454
from .integrations.manifest import IntegrationManifest
24732455

2474-
project_root = Path.cwd()
2475-
2476-
specify_dir = project_root / ".specify"
2477-
if not specify_dir.exists():
2478-
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
2479-
console.print("Run this command from a spec-kit project root")
2480-
raise typer.Exit(1)
2481-
2456+
project_root = _require_specify_project()
24822457
current = _read_integration_json(project_root)
24832458
installed_key = current.get("integration")
24842459

@@ -2583,16 +2558,6 @@ def integration_upgrade(
25832558
# not additive like extensions and presets.
25842559

25852560

2586-
def _require_specify_project() -> Path:
2587-
"""Return the current project root if it is a spec-kit project, else exit."""
2588-
project_root = Path.cwd()
2589-
if not (project_root / ".specify").exists():
2590-
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
2591-
console.print("Run this command from a spec-kit project root")
2592-
raise typer.Exit(1)
2593-
return project_root
2594-
2595-
25962561
@integration_app.command("search")
25972562
def integration_search(
25982563
query: Optional[str] = typer.Argument(None, help="Search query (optional)"),

tests/integrations/test_cli.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,35 @@ def test_catalog_list_requires_specify_project(self, tmp_path):
706706
assert result.exit_code == 1
707707
assert "Not a spec-kit project" in result.output
708708

709+
def test_primary_integration_commands_require_specify_project(self, tmp_path):
710+
project = tmp_path / "bare"
711+
project.mkdir()
712+
commands = [
713+
["integration", "list"],
714+
["integration", "install", "codex"],
715+
["integration", "uninstall"],
716+
["integration", "switch", "codex"],
717+
["integration", "upgrade"],
718+
]
719+
720+
for command in commands:
721+
result = self._invoke(command, project)
722+
failure_context = (
723+
f"command={command!r}, exit_code={result.exit_code}, output={result.output!r}"
724+
)
725+
assert result.exit_code == 1, failure_context
726+
assert "Not a spec-kit project" in result.output, failure_context
727+
728+
def test_integration_commands_require_specify_directory(self, tmp_path):
729+
project = tmp_path / "bad"
730+
project.mkdir()
731+
(project / ".specify").write_text("not a directory")
732+
733+
result = self._invoke(["integration", "list"], project)
734+
735+
assert result.exit_code == 1, result.output
736+
assert "Not a spec-kit project" in result.output
737+
709738
# -- search ------------------------------------------------------------
710739

711740
def test_search_lists_all(self, tmp_path, monkeypatch):

0 commit comments

Comments
 (0)