Skip to content

Commit 4f9d966

Browse files
authored
Stage 5: Skills, Generic & Option-Driven Integrations (#1924) (#2052)
* Stage 5: Skills, Generic & Option-Driven Integrations (#1924) Add SkillsIntegration base class and migrate codex, kimi, agy, and generic to the integration system. Integrations: - SkillsIntegration(IntegrationBase) in base.py — creates speckit-<name>/SKILL.md layout matching release ZIP output byte-for-byte - CodexIntegration — .agents/skills/, --skills default=True - KimiIntegration — .kimi/skills/, --skills + --migrate-legacy options, dotted→hyphenated skill directory migration - AgyIntegration — .agent/skills/, skills-only (commands deprecated v1.20.5) - GenericIntegration — user-specified --commands-dir, MarkdownIntegration - All four have update-context.sh/.ps1 scripts - All four registered in INTEGRATION_REGISTRY CLI changes: - --ai <agent> auto-promotes to integration path for all registered agents - Interactive agent selection also auto-promotes (bug fix) - --ai-skills and --ai-commands-dir show deprecation notices on integration path - Next-steps display shows correct skill invocation syntax for skills integrations - agy added to CommandRegistrar.AGENT_CONFIGS Tests: - test_integration_base_skills.py — reusable mixin with setup, frontmatter, directory structure, scripts, CLI auto-promote, and complete file inventory (sh+ps) tests - Per-agent test files: test_integration_{codex,kimi,agy,generic}.py - Kimi legacy migration tests, generic --commands-dir validation - Registry updated with Stage 5 keys - Removed 9 dead-mock tests, moved 4 integration tests to proper locations - Fixed all bare project-name tests to use tmp_path - Fixed 6 pre-existing ANSI escape code test failures in test_extensions.py and test_presets.py 1524 tests pass, 0 failures. * fix: remove unused variable flagged by ruff (F841) * fix: address PR review — integration-type-aware deprecation messages and early generic validation - --ai-skills deprecation message now distinguishes SkillsIntegration ("skills are the default") from command-based integrations ("has no effect") - --ai-commands-dir validation for generic runs even when auto-promoted, giving clear CLI error instead of late ValueError from setup() - Resolves review comments from #2052 * fix: address PR review round 2 - Remove unused SKILL_DESCRIPTIONS dict from base.py (dead code after switching to template descriptions for ZIP parity) - Narrow YAML parse catch from Exception to yaml.YAMLError - Remove unused shutil import from test_integration_kimi.py - Remove unused _REGISTRAR_EXEMPT class attr from test_registry.py - Reword --ai-commands-dir deprecation to be actionable - Update generic validation error to mention both --ai and --integration * fix: address PR review round 3 - Clarify parsed_options forwarding is intentional (all options passed, integrations decide what to use) - Extract _strip_ansi() helper in test_extensions.py and test_presets.py - Remove unused pytest import (test_cli.py), unused locals (test_integration_base_skills.py) - Reword --ai-commands-dir deprecation to be actionable without referencing the not-yet-implemented --integration-options * fix: address PR review round 4 - Reorder kimi migration: run super().setup() first so hyphenated targets exist, then migrate dotted dirs (prevents user content loss) - Move _strip_ansi() to shared tests/conftest.py, import from there in test_extensions.py, test_presets.py, test_ai_skills.py - Remove now-unused re imports from all three test files * fix: address PR review round 5 - Use write_bytes() for LF-only newlines (no CRLF on Windows) - Add --integration-options CLI parameter — raw string passed through to the integration via opts['raw_options']; the integration owns parsing of its own options - GenericIntegration.setup() reads --commands-dir from raw_options when not in parsed_options (supports --integration-options="...") - Skip early --ai-commands-dir validation when --integration-options is provided (integration validates in its own setup()) - Remove parse_integration_options from core — integrations parse their own options * fix: address PR review round 6 - GenericIntegration is now stateless: removed self._commands_dir instance state, overrides setup() directly to compute destination from parsed_options/raw_options on the stack - commands_dest() raises by design (stateless singleton) - _quote() in SkillsIntegration now escapes backslashes and double quotes to produce valid YAML even with special characters * fix: address PR review round 7 - Support --commands-dir=value form in raw_options parsing (not just --commands-dir value with space separator) - Normalize CRLF to LF in write_file_and_record() before encoding - Persist ai_skills=True in init-options.json when using a SkillsIntegration, so extensions/presets emit SKILL.md overrides correctly even without explicit --ai-skills flag
1 parent b44ffc0 commit 4f9d966

28 files changed

+1777
-414
lines changed

src/specify_cli/__init__.py

Lines changed: 81 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1907,6 +1907,7 @@ def init(
19071907
preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"),
19081908
branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, ...) or 'timestamp' (YYYYMMDD-HHMMSS)"),
19091909
integration: str = typer.Option(None, "--integration", help="Use the new integration system (e.g. --integration copilot). Mutually exclusive with --ai."),
1910+
integration_options: str = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'),
19101911
):
19111912
"""
19121913
Initialize a new Specify project.
@@ -1997,6 +1998,26 @@ def init(
19971998
f"--ai {ai_assistant}. The --ai flag will be deprecated in a future release.[/dim]"
19981999
)
19992000

2001+
# Deprecation warnings for --ai-skills and --ai-commands-dir when using integration path
2002+
if use_integration:
2003+
if ai_skills:
2004+
from .integrations.base import SkillsIntegration as _SkillsCheck
2005+
if isinstance(resolved_integration, _SkillsCheck):
2006+
console.print(
2007+
"[dim]Note: --ai-skills is not needed with --integration; "
2008+
"skills are the default for this integration.[/dim]"
2009+
)
2010+
else:
2011+
console.print(
2012+
"[dim]Note: --ai-skills has no effect with --integration "
2013+
f"{resolved_integration.key}; this integration uses commands, not skills.[/dim]"
2014+
)
2015+
if ai_commands_dir and resolved_integration.key != "generic":
2016+
console.print(
2017+
"[dim]Note: --ai-commands-dir is deprecated; "
2018+
'use [bold]--integration generic --integration-options="--commands-dir <dir>"[/bold] instead.[/dim]'
2019+
)
2020+
20002021
if project_name == ".":
20012022
here = True
20022023
project_name = None # Clear project_name to use existing validation logic
@@ -2062,8 +2083,18 @@ def init(
20622083
"copilot"
20632084
)
20642085

2086+
# Auto-promote interactively selected agents to the integration path
2087+
# when a matching integration is registered (same behavior as --ai).
2088+
if not use_integration:
2089+
from .integrations import get_integration as _get_int
2090+
_resolved = _get_int(selected_ai)
2091+
if _resolved:
2092+
use_integration = True
2093+
resolved_integration = _resolved
2094+
20652095
# Agents that have moved from explicit commands/prompts to agent skills.
2066-
if selected_ai in AGENT_SKILLS_MIGRATIONS and not ai_skills:
2096+
# Skip this check when using the integration path — skills are the default.
2097+
if not use_integration and selected_ai in AGENT_SKILLS_MIGRATIONS and not ai_skills:
20672098
# If selected interactively (no --ai provided), automatically enable
20682099
# ai_skills so the agent remains usable without requiring an extra flag.
20692100
# Preserve fail-fast behavior only for explicit '--ai <agent>' without skills.
@@ -2073,14 +2104,20 @@ def init(
20732104
ai_skills = True
20742105
console.print(f"\n[yellow]Note:[/yellow] {AGENT_SKILLS_MIGRATIONS[selected_ai]['interactive_note']}")
20752106

2076-
# Validate --ai-commands-dir usage
2077-
if selected_ai == "generic":
2107+
# Validate --ai-commands-dir usage.
2108+
# Skip validation when --integration-options is provided — the integration
2109+
# will validate its own options in setup().
2110+
if selected_ai == "generic" and not integration_options:
20782111
if not ai_commands_dir:
2079-
console.print("[red]Error:[/red] --ai-commands-dir is required when using --ai generic")
2080-
console.print("[dim]Example: specify init my-project --ai generic --ai-commands-dir .myagent/commands/[/dim]")
2112+
console.print("[red]Error:[/red] --ai-commands-dir is required when using --ai generic or --integration generic")
2113+
console.print("[dim]Example: specify init my-project --integration generic --integration-options=\"--commands-dir .myagent/commands/\"[/dim]")
20812114
raise typer.Exit(1)
2082-
elif ai_commands_dir:
2083-
console.print(f"[red]Error:[/red] --ai-commands-dir can only be used with --ai generic (not '{selected_ai}')")
2115+
elif ai_commands_dir and not use_integration:
2116+
console.print(
2117+
f"[red]Error:[/red] --ai-commands-dir can only be used with the "
2118+
f"'generic' integration via --ai generic or --integration generic "
2119+
f"(not '{selected_ai}')"
2120+
)
20842121
raise typer.Exit(1)
20852122

20862123
current_dir = Path.cwd()
@@ -2210,9 +2247,21 @@ def init(
22102247
manifest = IntegrationManifest(
22112248
resolved_integration.key, project_path, version=get_speckit_version()
22122249
)
2250+
2251+
# Forward all legacy CLI flags to the integration as parsed_options.
2252+
# Integrations receive every option and decide what to use;
2253+
# irrelevant keys are simply ignored by the integration's setup().
2254+
integration_parsed_options: dict[str, Any] = {}
2255+
if ai_commands_dir:
2256+
integration_parsed_options["commands_dir"] = ai_commands_dir
2257+
if ai_skills:
2258+
integration_parsed_options["skills"] = True
2259+
22132260
resolved_integration.setup(
22142261
project_path, manifest,
2262+
parsed_options=integration_parsed_options or None,
22152263
script_type=selected_script,
2264+
raw_options=integration_options,
22162265
)
22172266
manifest.save()
22182267

@@ -2268,7 +2317,7 @@ def init(
22682317
shutil.rmtree(project_path)
22692318
raise typer.Exit(1)
22702319
# For generic agent, rename placeholder directory to user-specified path
2271-
if selected_ai == "generic" and ai_commands_dir:
2320+
if not use_integration and selected_ai == "generic" and ai_commands_dir:
22722321
placeholder_dir = project_path / ".speckit" / "commands"
22732322
target_dir = project_path / ai_commands_dir
22742323
if placeholder_dir.is_dir():
@@ -2284,18 +2333,19 @@ def init(
22842333
ensure_constitution_from_template(project_path, tracker=tracker)
22852334

22862335
# Determine skills directory and migrate any legacy Kimi dotted skills.
2336+
# (Legacy path only — integration path handles skills in setup().)
22872337
migrated_legacy_kimi_skills = 0
22882338
removed_legacy_kimi_skills = 0
22892339
skills_dir: Optional[Path] = None
2290-
if selected_ai in NATIVE_SKILLS_AGENTS:
2340+
if not use_integration and selected_ai in NATIVE_SKILLS_AGENTS:
22912341
skills_dir = _get_skills_dir(project_path, selected_ai)
22922342
if selected_ai == "kimi" and skills_dir.is_dir():
22932343
(
22942344
migrated_legacy_kimi_skills,
22952345
removed_legacy_kimi_skills,
22962346
) = _migrate_legacy_kimi_dotted_skills(skills_dir)
22972347

2298-
if ai_skills:
2348+
if not use_integration and ai_skills:
22992349
if selected_ai in NATIVE_SKILLS_AGENTS:
23002350
bundled_found = _has_bundled_skills(project_path, selected_ai)
23012351
if bundled_found:
@@ -2383,6 +2433,11 @@ def init(
23832433
}
23842434
if use_integration:
23852435
init_opts["integration"] = resolved_integration.key
2436+
# Ensure ai_skills is set for SkillsIntegration so downstream
2437+
# tools (extensions, presets) emit SKILL.md overrides correctly.
2438+
from .integrations.base import SkillsIntegration as _SkillsPersist
2439+
if isinstance(resolved_integration, _SkillsPersist):
2440+
init_opts["ai_skills"] = True
23862441
save_init_options(project_path, init_opts)
23872442

23882443
# Install preset if specified
@@ -2484,17 +2539,27 @@ def init(
24842539
steps_lines.append("1. You're already in the project directory!")
24852540
step_num = 2
24862541

2487-
if selected_ai == "codex" and ai_skills:
2488-
steps_lines.append(f"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]")
2489-
step_num += 1
2542+
# Determine skill display mode for the next-steps panel.
2543+
# Skills integrations (codex, kimi, agy) should show skill invocation syntax
2544+
# regardless of whether --ai-skills was explicitly passed.
2545+
_is_skills_integration = False
2546+
if use_integration:
2547+
from .integrations.base import SkillsIntegration as _SkillsInt
2548+
_is_skills_integration = isinstance(resolved_integration, _SkillsInt)
24902549

2491-
codex_skill_mode = selected_ai == "codex" and ai_skills
2550+
codex_skill_mode = selected_ai == "codex" and (ai_skills or _is_skills_integration)
24922551
kimi_skill_mode = selected_ai == "kimi"
2493-
native_skill_mode = codex_skill_mode or kimi_skill_mode
2552+
agy_skill_mode = selected_ai == "agy" and _is_skills_integration
2553+
native_skill_mode = codex_skill_mode or kimi_skill_mode or agy_skill_mode
2554+
2555+
if codex_skill_mode and not ai_skills:
2556+
# Integration path installed skills; show the helpful notice
2557+
steps_lines.append(f"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]")
2558+
step_num += 1
24942559
usage_label = "skills" if native_skill_mode else "slash commands"
24952560

24962561
def _display_cmd(name: str) -> str:
2497-
if codex_skill_mode:
2562+
if codex_skill_mode or agy_skill_mode:
24982563
return f"$speckit-{name}"
24992564
if kimi_skill_mode:
25002565
return f"/skill:speckit-{name}"

src/specify_cli/agents.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,12 @@ class CommandRegistrar:
168168
"format": "markdown",
169169
"args": "$ARGUMENTS",
170170
"extension": ".md"
171+
},
172+
"agy": {
173+
"dir": ".agent/skills",
174+
"format": "markdown",
175+
"args": "$ARGUMENTS",
176+
"extension": "/SKILL.md",
171177
}
172178
}
173179

src/specify_cli/integrations/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,17 +46,21 @@ def _register_builtins() -> None:
4646
users install and invoke.
4747
"""
4848
# -- Imports (alphabetical) -------------------------------------------
49+
from .agy import AgyIntegration
4950
from .amp import AmpIntegration
5051
from .auggie import AuggieIntegration
5152
from .bob import BobIntegration
5253
from .claude import ClaudeIntegration
54+
from .codex import CodexIntegration
5355
from .codebuddy import CodebuddyIntegration
5456
from .copilot import CopilotIntegration
5557
from .cursor_agent import CursorAgentIntegration
5658
from .gemini import GeminiIntegration
59+
from .generic import GenericIntegration
5760
from .iflow import IflowIntegration
5861
from .junie import JunieIntegration
5962
from .kilocode import KilocodeIntegration
63+
from .kimi import KimiIntegration
6064
from .kiro_cli import KiroCliIntegration
6165
from .opencode import OpencodeIntegration
6266
from .pi import PiIntegration
@@ -70,17 +74,21 @@ def _register_builtins() -> None:
7074
from .windsurf import WindsurfIntegration
7175

7276
# -- Registration (alphabetical) --------------------------------------
77+
_register(AgyIntegration())
7378
_register(AmpIntegration())
7479
_register(AuggieIntegration())
7580
_register(BobIntegration())
7681
_register(ClaudeIntegration())
82+
_register(CodexIntegration())
7783
_register(CodebuddyIntegration())
7884
_register(CopilotIntegration())
7985
_register(CursorAgentIntegration())
8086
_register(GeminiIntegration())
87+
_register(GenericIntegration())
8188
_register(IflowIntegration())
8289
_register(JunieIntegration())
8390
_register(KilocodeIntegration())
91+
_register(KimiIntegration())
8492
_register(KiroCliIntegration())
8593
_register(OpencodeIntegration())
8694
_register(PiIntegration())
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""Antigravity (agy) integration — skills-based agent.
2+
3+
Antigravity uses ``.agent/skills/speckit-<name>/SKILL.md`` layout.
4+
Explicit command support was deprecated in version 1.20.5;
5+
``--skills`` defaults to ``True``.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from ..base import IntegrationOption, SkillsIntegration
11+
12+
13+
class AgyIntegration(SkillsIntegration):
14+
"""Integration for Antigravity IDE."""
15+
16+
key = "agy"
17+
config = {
18+
"name": "Antigravity",
19+
"folder": ".agent/",
20+
"commands_subdir": "skills",
21+
"install_url": None,
22+
"requires_cli": False,
23+
}
24+
registrar_config = {
25+
"dir": ".agent/skills",
26+
"format": "markdown",
27+
"args": "$ARGUMENTS",
28+
"extension": "/SKILL.md",
29+
}
30+
context_file = "AGENTS.md"
31+
32+
@classmethod
33+
def options(cls) -> list[IntegrationOption]:
34+
return [
35+
IntegrationOption(
36+
"--skills",
37+
is_flag=True,
38+
default=True,
39+
help="Install as agent skills (default for Antigravity since v1.20.5)",
40+
),
41+
]
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# update-context.ps1 — Antigravity (agy) integration: create/update AGENTS.md
2+
#
3+
# Thin wrapper that delegates to the shared update-agent-context script.
4+
5+
$ErrorActionPreference = 'Stop'
6+
7+
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
8+
$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
9+
if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
10+
$repoRoot = $scriptDir
11+
$fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
12+
while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
13+
$repoRoot = Split-Path -Parent $repoRoot
14+
}
15+
}
16+
17+
& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType agy
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
#!/usr/bin/env bash
2+
# update-context.sh — Antigravity (agy) integration: create/update AGENTS.md
3+
#
4+
# Thin wrapper that delegates to the shared update-agent-context script.
5+
6+
set -euo pipefail
7+
8+
_script_dir="$(cd "$(dirname "$0")" && pwd)"
9+
_root="$_script_dir"
10+
while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
11+
if [ -z "${REPO_ROOT:-}" ]; then
12+
if [ -d "$_root/.specify" ]; then
13+
REPO_ROOT="$_root"
14+
else
15+
git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
16+
if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
17+
REPO_ROOT="$git_root"
18+
else
19+
REPO_ROOT="$_root"
20+
fi
21+
fi
22+
fi
23+
24+
exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" agy

0 commit comments

Comments
 (0)