mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
* 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
134 lines
4.4 KiB
Python
134 lines
4.4 KiB
Python
"""Generic integration — bring your own agent.
|
|
|
|
Requires ``--commands-dir`` to specify the output directory for command
|
|
files. No longer special-cased in the core CLI — just another
|
|
integration with its own required option.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from ..base import IntegrationOption, MarkdownIntegration
|
|
from ..manifest import IntegrationManifest
|
|
|
|
|
|
class GenericIntegration(MarkdownIntegration):
|
|
"""Integration for user-specified (generic) agents."""
|
|
|
|
key = "generic"
|
|
config = {
|
|
"name": "Generic (bring your own agent)",
|
|
"folder": None, # Set dynamically from --commands-dir
|
|
"commands_subdir": "commands",
|
|
"install_url": None,
|
|
"requires_cli": False,
|
|
}
|
|
registrar_config = {
|
|
"dir": "", # Set dynamically from --commands-dir
|
|
"format": "markdown",
|
|
"args": "$ARGUMENTS",
|
|
"extension": ".md",
|
|
}
|
|
context_file = None
|
|
|
|
@classmethod
|
|
def options(cls) -> list[IntegrationOption]:
|
|
return [
|
|
IntegrationOption(
|
|
"--commands-dir",
|
|
required=True,
|
|
help="Directory for command files (e.g. .myagent/commands/)",
|
|
),
|
|
]
|
|
|
|
@staticmethod
|
|
def _resolve_commands_dir(
|
|
parsed_options: dict[str, Any] | None,
|
|
opts: dict[str, Any],
|
|
) -> str:
|
|
"""Extract ``--commands-dir`` from parsed options or raw_options.
|
|
|
|
Returns the directory string or raises ``ValueError``.
|
|
"""
|
|
parsed_options = parsed_options or {}
|
|
|
|
commands_dir = parsed_options.get("commands_dir")
|
|
if commands_dir:
|
|
return commands_dir
|
|
|
|
# Fall back to raw_options (--integration-options="--commands-dir ...")
|
|
raw = opts.get("raw_options")
|
|
if raw:
|
|
import shlex
|
|
tokens = shlex.split(raw)
|
|
for i, token in enumerate(tokens):
|
|
if token == "--commands-dir" and i + 1 < len(tokens):
|
|
return tokens[i + 1]
|
|
if token.startswith("--commands-dir="):
|
|
return token.split("=", 1)[1]
|
|
|
|
raise ValueError(
|
|
"--commands-dir is required for the generic integration"
|
|
)
|
|
|
|
def commands_dest(self, project_root: Path) -> Path:
|
|
"""Not supported for GenericIntegration — use setup() directly.
|
|
|
|
GenericIntegration is stateless; the output directory comes from
|
|
``parsed_options`` or ``raw_options`` at call time, not from
|
|
instance state.
|
|
"""
|
|
raise ValueError(
|
|
"GenericIntegration.commands_dest() cannot be called directly; "
|
|
"the output directory is resolved from parsed_options in setup()"
|
|
)
|
|
|
|
def setup(
|
|
self,
|
|
project_root: Path,
|
|
manifest: IntegrationManifest,
|
|
parsed_options: dict[str, Any] | None = None,
|
|
**opts: Any,
|
|
) -> list[Path]:
|
|
"""Install commands to the user-provided commands directory."""
|
|
commands_dir = self._resolve_commands_dir(parsed_options, opts)
|
|
|
|
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 = (project_root / commands_dir).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 = "$ARGUMENTS"
|
|
created: list[Path] = []
|
|
|
|
for src_file in templates:
|
|
raw = src_file.read_text(encoding="utf-8")
|
|
processed = self.process_template(raw, self.key, script_type, arg_placeholder)
|
|
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)
|
|
|
|
created.extend(self.install_scripts(project_root, manifest))
|
|
return created
|