From 4a59848609bd63c6c7b69fa1fbd2bb58e9b06eae Mon Sep 17 00:00:00 2001 From: Pascal Date: Sat, 23 May 2026 11:35:44 +0200 Subject: [PATCH] feat(dev): add integration scaffolder --- docs/local-development.md | 36 ++- src/specify_cli/__init__.py | 45 ++++ src/specify_cli/integration_scaffold.py | 218 ++++++++++++++++++ .../integrations/test_integration_scaffold.py | 105 +++++++++ 4 files changed, 396 insertions(+), 8 deletions(-) create mode 100644 src/specify_cli/integration_scaffold.py create mode 100644 tests/integrations/test_integration_scaffold.py diff --git a/docs/local-development.md b/docs/local-development.md index 4776204d7d..57da9cbc24 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -98,7 +98,27 @@ ls -l scripts | grep .sh On Windows you will instead use the `.ps1` scripts (no chmod needed). -## 6. Run Lint / Basic Checks (Add Your Own) +## 6. Scaffold a Built-In Integration + +Use the developer scaffold command to create the initial Python package and +test skeleton for a new built-in integration: + +```bash +specify dev integration scaffold my-agent --type markdown +specify dev integration scaffold my-agent --type toml +specify dev integration scaffold my-agent --type yaml +specify dev integration scaffold my-agent --type skills +``` + +Hyphenated keys are converted to Python-safe package names, for example +`my-agent` creates `src/specify_cli/integrations/my_agent/` and +`tests/integrations/test_integration_my_agent.py`. + +The scaffold does not register the integration automatically. Review the +generated metadata, then add the import and `_register()` call in +`src/specify_cli/integrations/__init__.py`. + +## 7. Run Lint / Basic Checks (Add Your Own) Currently no enforced lint config is bundled, but you can quickly sanity check importability: @@ -106,7 +126,7 @@ Currently no enforced lint config is bundled, but you can quickly sanity check i python -c "import specify_cli; print('Import OK')" ``` -## 7. Build a Wheel Locally (Optional) +## 8. Build a Wheel Locally (Optional) Validate packaging before publishing: @@ -117,7 +137,7 @@ ls dist/ Install the built artifact into a fresh throwaway environment if needed. -## 8. Using a Temporary Workspace +## 9. Using a Temporary Workspace When testing `init --here` in a dirty directory, create a temp workspace: @@ -128,7 +148,7 @@ python -m src.specify_cli init --here --integration claude --ignore-agent-tools Or copy only the modified CLI portion if you want a lighter sandbox. -## 9. Debug Network / TLS Issues +## 10. Debug Network / TLS Issues > **Deprecated:** The `--skip-tls` flag is a no-op and has no effect. > It was previously used to bypass TLS validation during local testing. @@ -137,7 +157,7 @@ Or copy only the modified CLI portion if you want a lighter sandbox. > > For example, set `SSL_CERT_FILE` or configure `HTTPS_PROXY` / `HTTP_PROXY`. -## 10. Rapid Edit Loop Summary +## 11. Rapid Edit Loop Summary | Action | Command | |--------|---------| @@ -148,7 +168,7 @@ Or copy only the modified CLI portion if you want a lighter sandbox. | Git branch uvx | `uvx --from git+URL@branch specify ...` | | Build wheel | `uv build` | -## 11. Cleaning Up +## 12. Cleaning Up Remove build artifacts / virtual env quickly: @@ -156,7 +176,7 @@ Remove build artifacts / virtual env quickly: rm -rf .venv dist build *.egg-info ``` -## 12. Common Issues +## 13. Common Issues | Symptom | Fix | |---------|-----| @@ -166,7 +186,7 @@ rm -rf .venv dist build *.egg-info | Wrong script type downloaded | Pass `--script sh` or `--script ps` explicitly | | TLS errors on corporate network | Configure your environment's certificate store or proxy. The `--skip-tls` flag is deprecated and has no effect. | -## 13. Next Steps +## 14. Next Steps - Update docs and run through Quick Start using your modified CLI - Open a PR when satisfied diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index c0bdbaabe3..ad4e138b8e 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1279,6 +1279,51 @@ def version( app.add_typer(_self_app, name="self") +# ===== Developer Commands ===== + +dev_app = typer.Typer( + name="dev", + help="Developer utilities for contributing to Spec Kit", + add_completion=False, +) +app.add_typer(dev_app, name="dev") + +dev_integration_app = typer.Typer( + name="integration", + help="Developer helpers for built-in integrations", + add_completion=False, +) +dev_app.add_typer(dev_integration_app, name="integration") + + +@dev_integration_app.command("scaffold") +def dev_integration_scaffold( + key: str = typer.Argument(help="Integration key in lowercase kebab-case, e.g. my-agent"), + integration_type: str = typer.Option( + "markdown", + "--type", + help="Scaffold type: markdown, toml, yaml, or skills", + ), +): + """Create a minimal built-in integration package and test skeleton.""" + from .integration_scaffold import scaffold_integration + + project_root = Path.cwd() + try: + result = scaffold_integration(project_root, key, integration_type) + except (FileExistsError, ValueError) as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + console.print(f"[green]Created integration scaffold:[/green] {result.key}") + console.print(f" {result.integration_file.relative_to(project_root).as_posix()}") + console.print(f" {result.test_file.relative_to(project_root).as_posix()}") + console.print() + console.print("[bold]Next steps:[/bold]") + for index, step in enumerate(result.next_steps, start=1): + console.print(f"{index}. {step}") + + # ===== Extension Commands ===== extension_app = typer.Typer( diff --git a/src/specify_cli/integration_scaffold.py b/src/specify_cli/integration_scaffold.py new file mode 100644 index 0000000000..2c5784d9da --- /dev/null +++ b/src/specify_cli/integration_scaffold.py @@ -0,0 +1,218 @@ +"""Developer helpers for scaffolding built-in integrations.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class IntegrationScaffoldResult: + """Files and next steps produced by an integration scaffold run.""" + + key: str + package_name: str + class_name: str + integration_file: Path + test_file: Path + next_steps: tuple[str, ...] + + +@dataclass(frozen=True) +class _IntegrationTemplate: + base_class: str + commands_subdir: str + registrar_format: str + args: str + extension: str + + +_KEY_RE = re.compile(r"^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$") +_TEMPLATES = { + "markdown": _IntegrationTemplate( + base_class="MarkdownIntegration", + commands_subdir="commands", + registrar_format="markdown", + args="$ARGUMENTS", + extension=".md", + ), + "toml": _IntegrationTemplate( + base_class="TomlIntegration", + commands_subdir="commands", + registrar_format="toml", + args="{{args}}", + extension=".toml", + ), + "yaml": _IntegrationTemplate( + base_class="YamlIntegration", + commands_subdir="recipes", + registrar_format="yaml", + args="{{args}}", + extension=".yaml", + ), + "skills": _IntegrationTemplate( + base_class="SkillsIntegration", + commands_subdir="skills", + registrar_format="markdown", + args="$ARGUMENTS", + extension="/SKILL.md", + ), +} + + +def supported_integration_scaffold_types() -> tuple[str, ...]: + """Return supported scaffold template names.""" + return tuple(sorted(_TEMPLATES)) + + +def _clean_key(key: str) -> str: + clean = key.strip() + if not _KEY_RE.fullmatch(clean): + raise ValueError( + "Integration key must be lowercase kebab-case, for example 'my-agent'." + ) + return clean + + +def _package_name(key: str) -> str: + return key.replace("-", "_") + + +def _class_name(key: str) -> str: + return "".join(part.capitalize() for part in key.split("-")) + "Integration" + + +def _display_name(key: str) -> str: + return " ".join(part.capitalize() for part in key.split("-")) + + +def _integration_content( + *, + key: str, + package_name: str, + class_name: str, + integration_type: str, +) -> str: + template = _TEMPLATES[integration_type] + display_name = _display_name(key) + folder = f".{key}/" + commands_dir = f"{folder}{template.commands_subdir}" + return f'''"""{display_name} integration.""" + +from ..base import {template.base_class} + + +class {class_name}({template.base_class}): + key = "{key}" + config = {{ + "name": "{display_name}", + "folder": "{folder}", + "commands_subdir": "{template.commands_subdir}", + "install_url": None, + "requires_cli": False, + }} + registrar_config = {{ + "dir": "{commands_dir}", + "format": "{template.registrar_format}", + "args": "{template.args}", + "extension": "{template.extension}", + }} + context_file = "AGENTS.md" +''' + + +def _test_content( + *, + key: str, + class_name: str, + integration_type: str, +) -> str: + template = _TEMPLATES[integration_type] + display_name = _display_name(key) + package_name = _package_name(key) + commands_dir = f".{key}/{template.commands_subdir}" + return f'''"""Tests for the {key} integration scaffold.""" + +from specify_cli.integrations.{package_name} import {class_name} +from specify_cli.integrations.base import {template.base_class} + + +def test_metadata(): + integration = {class_name}() + + assert isinstance(integration, {template.base_class}) + assert integration.key == "{key}" + assert integration.config["name"] == "{display_name}" + assert integration.config["folder"] == ".{key}/" + assert integration.config["commands_subdir"] == "{template.commands_subdir}" + assert integration.config["requires_cli"] is False + assert integration.registrar_config["dir"] == "{commands_dir}" + assert integration.registrar_config["format"] == "{template.registrar_format}" + assert integration.registrar_config["args"] == "{template.args}" + assert integration.registrar_config["extension"] == "{template.extension}" + assert integration.context_file == "AGENTS.md" +''' + + +def scaffold_integration( + project_root: Path, + key: str, + integration_type: str, +) -> IntegrationScaffoldResult: + """Create a minimal built-in integration package and test skeleton.""" + clean_key = _clean_key(key) + normalized_type = integration_type.strip().lower() + if normalized_type not in _TEMPLATES: + supported = ", ".join(supported_integration_scaffold_types()) + raise ValueError(f"Unsupported integration type '{integration_type}'. Use one of: {supported}.") + + integrations_root = project_root / "src" / "specify_cli" / "integrations" + tests_root = project_root / "tests" / "integrations" + if not integrations_root.is_dir() or not tests_root.is_dir(): + raise ValueError("Run this command from the Spec Kit repository root.") + + package_name = _package_name(clean_key) + class_name = _class_name(clean_key) + integration_dir = integrations_root / package_name + integration_file = integration_dir / "__init__.py" + test_file = tests_root / f"test_integration_{package_name}.py" + + existing = [path for path in (integration_file, test_file) if path.exists()] + if existing: + labels = ", ".join(path.relative_to(project_root).as_posix() for path in existing) + raise FileExistsError(f"Refusing to overwrite existing scaffold file(s): {labels}") + + integration_dir.mkdir(parents=True, exist_ok=True) + test_file.parent.mkdir(parents=True, exist_ok=True) + integration_file.write_text( + _integration_content( + key=clean_key, + package_name=package_name, + class_name=class_name, + integration_type=normalized_type, + ), + encoding="utf-8", + ) + test_file.write_text( + _test_content( + key=clean_key, + class_name=class_name, + integration_type=normalized_type, + ), + encoding="utf-8", + ) + + next_steps = ( + f"Register {class_name} in src/specify_cli/integrations/__init__.py.", + "Review config metadata, install_url, requires_cli, context_file, and multi_install_safe.", + f"Run pytest tests/integrations/test_integration_{package_name}.py -v.", + ) + return IntegrationScaffoldResult( + key=clean_key, + package_name=package_name, + class_name=class_name, + integration_file=integration_file, + test_file=test_file, + next_steps=next_steps, + ) diff --git a/tests/integrations/test_integration_scaffold.py b/tests/integrations/test_integration_scaffold.py new file mode 100644 index 0000000000..3ed0b05faf --- /dev/null +++ b/tests/integrations/test_integration_scaffold.py @@ -0,0 +1,105 @@ +"""Tests for developer integration scaffolding commands.""" + +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.integration_scaffold import scaffold_integration +from tests.conftest import strip_ansi + + +runner = CliRunner() + + +def _repo_root(tmp_path: Path) -> Path: + root = tmp_path / "spec-kit" + (root / "src" / "specify_cli" / "integrations").mkdir(parents=True) + (root / "tests" / "integrations").mkdir(parents=True) + return root + + +def test_dev_integration_scaffold_creates_markdown_files(tmp_path, monkeypatch): + root = _repo_root(tmp_path) + monkeypatch.chdir(root) + + result = runner.invoke(app, [ + "dev", "integration", "scaffold", "my-agent", + "--type", "markdown", + ], catch_exceptions=False) + + output = strip_ansi(result.output) + integration_file = root / "src" / "specify_cli" / "integrations" / "my_agent" / "__init__.py" + test_file = root / "tests" / "integrations" / "test_integration_my_agent.py" + + assert result.exit_code == 0 + assert integration_file.exists() + assert test_file.exists() + assert "Created integration scaffold: my-agent" in output + assert "Register MyAgentIntegration" in output + + content = integration_file.read_text(encoding="utf-8") + assert "class MyAgentIntegration(MarkdownIntegration):" in content + assert 'key = "my-agent"' in content + assert '"folder": ".my-agent/"' in content + assert '"extension": ".md"' in content + + test_content = test_file.read_text(encoding="utf-8") + assert "from specify_cli.integrations.my_agent import MyAgentIntegration" in test_content + assert 'assert integration.registrar_config["dir"] == ".my-agent/commands"' in test_content + + +@pytest.mark.parametrize( + ("integration_type", "base_class", "commands_subdir", "args", "extension"), + [ + ("markdown", "MarkdownIntegration", "commands", "$ARGUMENTS", ".md"), + ("toml", "TomlIntegration", "commands", "{{args}}", ".toml"), + ("yaml", "YamlIntegration", "recipes", "{{args}}", ".yaml"), + ("skills", "SkillsIntegration", "skills", "$ARGUMENTS", "/SKILL.md"), + ], +) +def test_scaffold_type_templates( + tmp_path, + integration_type, + base_class, + commands_subdir, + args, + extension, +): + root = _repo_root(tmp_path) + + result = scaffold_integration(root, f"{integration_type}-agent", integration_type) + + content = result.integration_file.read_text(encoding="utf-8") + assert f"class {result.class_name}({base_class}):" in content + assert f'"commands_subdir": "{commands_subdir}"' in content + assert f'"args": "{args}"' in content + assert f'"extension": "{extension}"' in content + + +def test_scaffold_refuses_invalid_key(tmp_path): + root = _repo_root(tmp_path) + + with pytest.raises(ValueError, match="lowercase kebab-case"): + scaffold_integration(root, "Bad_Key", "markdown") + + +def test_scaffold_refuses_unknown_type(tmp_path): + root = _repo_root(tmp_path) + + with pytest.raises(ValueError, match="Unsupported integration type"): + scaffold_integration(root, "my-agent", "xml") + + +def test_scaffold_refuses_overwrite(tmp_path): + root = _repo_root(tmp_path) + scaffold_integration(root, "my-agent", "markdown") + + with pytest.raises(FileExistsError, match="Refusing to overwrite"): + scaffold_integration(root, "my-agent", "markdown") + + +def test_scaffold_requires_repo_root(tmp_path): + with pytest.raises(ValueError, match="Spec Kit repository root"): + scaffold_integration(tmp_path, "my-agent", "markdown")