mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
feat(dev): add integration scaffolder (#2685)
* feat(dev): add integration scaffolder * fix(dev): address integration scaffold review feedback * fix(dev): address scaffold follow-up review * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix(dev): default scaffolded integrations to multi_install_safe = False The scaffold template emitted `multi_install_safe = True` alongside a placeholder `context_file = "AGENTS.md"`. Registered as-is, that violates the registry contract (test_safe_integrations_have_distinct_context_files): codex already pairs AGENTS.md with multi_install_safe = True, so the generated boilerplate would collide on first registration. Default the scaffold to False (matching IntegrationBase) so generated code is registry-test-friendly out of the box; contributors opt in once they pick a unique context_file. Aligns the generated test skeleton and both scaffold tests, which previously contradicted each other (one expected True, one False). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(dev): harden scaffold writes and accept case-insensitive --type - Guard scaffold_integration() against symlinked target directories: walk each path component under the repo root and refuse symlinked dirs, then confirm the write destination resolves inside the repo (mirrors the manifest directory guard). Prevents scaffolding outside the repo when a contributor's integrations/tests path is symlinked. - Make the `--type` click.Choice case-insensitive so `--type YAML` is accepted, matching scaffold_integration()'s strip()/lower() normalization instead of rejecting at the CLI layer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(dev): report scaffold filesystem failures as a clean CLI error The `dev integration scaffold` command only caught FileExistsError/ValueError, so an OSError raised during mkdir()/write_text() (permission denied, read-only checkout, a path component that is a file, ...) bubbled up as a traceback instead of a clean error + exit code. Broaden the handler to OSError (which also covers FileExistsError) and add coverage for the filesystem-error path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(dev): move scaffold command under integration * fix(dev): roll back partial scaffold writes * fix(dev): correct lint docs and generated test docstring - local-development.md: ruff check src/ is enforced in CI, not absent - scaffolded test docstring: drop misleading 'scaffold' wording * fix(scaffold): create only leaf integration directory --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -98,15 +98,41 @@ ls -l scripts | grep .sh
|
|||||||
|
|
||||||
On Windows you will instead use the `.ps1` scripts (no chmod needed).
|
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
|
||||||
|
|
||||||
Currently no enforced lint config is bundled, but you can quickly sanity check importability:
|
Use the integration scaffold command to create the initial Python package and
|
||||||
|
test skeleton for a new built-in integration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
specify integration scaffold my-agent --type markdown
|
||||||
|
specify integration scaffold my-agent --type toml
|
||||||
|
specify integration scaffold my-agent --type yaml
|
||||||
|
specify 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
|
||||||
|
|
||||||
|
CI enforces `ruff check src/` (see `.github/workflows/test.yml`), so run it locally before pushing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvx ruff check src/
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also quickly sanity check importability:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -c "import specify_cli; print('Import OK')"
|
python -c "import specify_cli; print('Import OK')"
|
||||||
```
|
```
|
||||||
|
|
||||||
## 7. Build a Wheel Locally (Optional)
|
## 8. Build a Wheel Locally (Optional)
|
||||||
|
|
||||||
Validate packaging before publishing:
|
Validate packaging before publishing:
|
||||||
|
|
||||||
@@ -117,7 +143,7 @@ ls dist/
|
|||||||
|
|
||||||
Install the built artifact into a fresh throwaway environment if needed.
|
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:
|
When testing `init --here` in a dirty directory, create a temp workspace:
|
||||||
|
|
||||||
@@ -128,7 +154,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.
|
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.
|
> **Deprecated:** The `--skip-tls` flag is a no-op and has no effect.
|
||||||
> It was previously used to bypass TLS validation during local testing.
|
> It was previously used to bypass TLS validation during local testing.
|
||||||
@@ -137,7 +163,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`.
|
> For example, set `SSL_CERT_FILE` or configure `HTTPS_PROXY` / `HTTP_PROXY`.
|
||||||
|
|
||||||
## 10. Rapid Edit Loop Summary
|
## 11. Rapid Edit Loop Summary
|
||||||
|
|
||||||
| Action | Command |
|
| Action | Command |
|
||||||
|--------|---------|
|
|--------|---------|
|
||||||
@@ -148,7 +174,7 @@ Or copy only the modified CLI portion if you want a lighter sandbox.
|
|||||||
| Git branch uvx | `uvx --from git+URL@branch specify ...` |
|
| Git branch uvx | `uvx --from git+URL@branch specify ...` |
|
||||||
| Build wheel | `uv build` |
|
| Build wheel | `uv build` |
|
||||||
|
|
||||||
## 11. Cleaning Up
|
## 12. Cleaning Up
|
||||||
|
|
||||||
Remove build artifacts / virtual env quickly:
|
Remove build artifacts / virtual env quickly:
|
||||||
|
|
||||||
@@ -156,7 +182,7 @@ Remove build artifacts / virtual env quickly:
|
|||||||
rm -rf .venv dist build *.egg-info
|
rm -rf .venv dist build *.egg-info
|
||||||
```
|
```
|
||||||
|
|
||||||
## 12. Common Issues
|
## 13. Common Issues
|
||||||
|
|
||||||
| Symptom | Fix |
|
| Symptom | Fix |
|
||||||
|---------|-----|
|
|---------|-----|
|
||||||
@@ -166,7 +192,7 @@ rm -rf .venv dist build *.egg-info
|
|||||||
| Wrong script type downloaded | Pass `--script sh` or `--script ps` explicitly |
|
| 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. |
|
| 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
|
- Update docs and run through Quick Start using your modified CLI
|
||||||
- Open a PR when satisfied
|
- Open a PR when satisfied
|
||||||
|
|||||||
287
src/specify_cli/integration_scaffold.py
Normal file
287
src/specify_cli/integration_scaffold.py
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
"""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,
|
||||||
|
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"
|
||||||
|
# Default to False so the generated boilerplate passes the registry
|
||||||
|
# contract out of the box: multi-install-safe integrations must each have a
|
||||||
|
# distinct context_file, and the placeholder above ("AGENTS.md") collides
|
||||||
|
# with the existing codex integration. Opt in once you pick a unique one.
|
||||||
|
multi_install_safe = False
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
||||||
|
|
||||||
|
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"
|
||||||
|
assert integration.multi_install_safe is False
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def _is_spec_kit_repo_root(project_root: Path) -> bool:
|
||||||
|
"""Return True when `project_root` looks like the Spec Kit repository root."""
|
||||||
|
return all(
|
||||||
|
(
|
||||||
|
(project_root / "pyproject.toml").is_file(),
|
||||||
|
(project_root / "src" / "specify_cli" / "__init__.py").is_file(),
|
||||||
|
(project_root / "src" / "specify_cli" / "integrations").is_dir(),
|
||||||
|
(
|
||||||
|
project_root / "src" / "specify_cli" / "integrations" / "__init__.py"
|
||||||
|
).is_file(),
|
||||||
|
(project_root / "tests" / "integrations").is_dir(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_safe_scaffold_target(project_root: Path, target: Path) -> None:
|
||||||
|
"""Refuse to scaffold through a symlinked path that could escape the repo.
|
||||||
|
|
||||||
|
Walks each component of *target* under *project_root* and rejects any
|
||||||
|
existing symlinked directory (or symlinked target), then confirms the
|
||||||
|
write destination still resolves inside the repository root. Mirrors the
|
||||||
|
symlink-aware guarding used for integration manifests.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
rel = target.relative_to(project_root)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(
|
||||||
|
f"Refusing to scaffold outside the repository root: {target}"
|
||||||
|
) from None
|
||||||
|
|
||||||
|
current = project_root
|
||||||
|
for part in rel.parts:
|
||||||
|
current = current / part
|
||||||
|
if current.is_symlink():
|
||||||
|
label = current.relative_to(project_root).as_posix()
|
||||||
|
raise ValueError(f"Refusing to scaffold through symlinked path: {label}")
|
||||||
|
|
||||||
|
root_resolved = project_root.resolve()
|
||||||
|
try:
|
||||||
|
target.parent.resolve().relative_to(root_resolved)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
raise ValueError(
|
||||||
|
f"Refusing to scaffold outside the repository root: {target}"
|
||||||
|
) from None
|
||||||
|
|
||||||
|
|
||||||
|
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 '{normalized_type}'. Use one of: {supported}."
|
||||||
|
)
|
||||||
|
|
||||||
|
integrations_root = project_root / "src" / "specify_cli" / "integrations"
|
||||||
|
tests_root = project_root / "tests" / "integrations"
|
||||||
|
if not _is_spec_kit_repo_root(project_root):
|
||||||
|
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"
|
||||||
|
|
||||||
|
for target in (integration_file, test_file):
|
||||||
|
_assert_safe_scaffold_target(project_root, target)
|
||||||
|
|
||||||
|
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}")
|
||||||
|
|
||||||
|
created_integration_dir = not integration_dir.exists()
|
||||||
|
try:
|
||||||
|
integration_dir.mkdir(exist_ok=True)
|
||||||
|
integration_file.write_text(
|
||||||
|
_integration_content(
|
||||||
|
key=clean_key,
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
for path in (test_file, integration_file):
|
||||||
|
try:
|
||||||
|
if path.is_file() or path.is_symlink():
|
||||||
|
path.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if created_integration_dir:
|
||||||
|
try:
|
||||||
|
integration_dir.rmdir()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
@@ -31,4 +31,5 @@ def register(app: typer.Typer) -> None:
|
|||||||
from . import _install_commands # noqa: F401 — registers handlers via decorators
|
from . import _install_commands # noqa: F401 — registers handlers via decorators
|
||||||
from . import _migrate_commands # noqa: F401
|
from . import _migrate_commands # noqa: F401
|
||||||
from . import _query_commands # noqa: F401
|
from . import _query_commands # noqa: F401
|
||||||
|
from . import _scaffold_commands # noqa: F401
|
||||||
app.add_typer(integration_app, name="integration")
|
app.add_typer(integration_app, name="integration")
|
||||||
|
|||||||
52
src/specify_cli/integrations/_scaffold_commands.py
Normal file
52
src/specify_cli/integrations/_scaffold_commands.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""specify integration scaffold command handler."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from .._console import console
|
||||||
|
from ..integration_scaffold import supported_integration_scaffold_types
|
||||||
|
from ._commands import integration_app
|
||||||
|
|
||||||
|
|
||||||
|
INTEGRATION_SCAFFOLD_TYPES = supported_integration_scaffold_types()
|
||||||
|
_IntegrationScaffoldType = Enum(
|
||||||
|
"_IntegrationScaffoldType",
|
||||||
|
{name: name for name in INTEGRATION_SCAFFOLD_TYPES},
|
||||||
|
type=str,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@integration_app.command("scaffold")
|
||||||
|
def integration_scaffold(
|
||||||
|
key: str = typer.Argument(help="Integration key in lowercase kebab-case, e.g. my-agent"),
|
||||||
|
integration_type: _IntegrationScaffoldType = typer.Option(
|
||||||
|
_IntegrationScaffoldType.markdown,
|
||||||
|
"--type",
|
||||||
|
case_sensitive=False,
|
||||||
|
help=f"Scaffold type: {', '.join(INTEGRATION_SCAFFOLD_TYPES)}",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
"""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.value)
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
# OSError covers filesystem failures during mkdir()/write_text()
|
||||||
|
# (permission denied, read-only checkout, a path component that is a
|
||||||
|
# file, ...) as well as FileExistsError; surface them as a clean CLI
|
||||||
|
# error instead of a traceback.
|
||||||
|
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}")
|
||||||
238
tests/integrations/test_integration_scaffold.py
Normal file
238
tests/integrations/test_integration_scaffold.py
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
"""Tests for 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)
|
||||||
|
(root / "pyproject.toml").write_text("[project]\nname = \"specify-cli\"\n", encoding="utf-8")
|
||||||
|
(root / "src" / "specify_cli" / "__init__.py").write_text("", encoding="utf-8")
|
||||||
|
(root / "src" / "specify_cli" / "integrations" / "__init__.py").write_text(
|
||||||
|
"",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def test_integration_scaffold_creates_markdown_files(tmp_path, monkeypatch):
|
||||||
|
root = _repo_root(tmp_path)
|
||||||
|
monkeypatch.chdir(root)
|
||||||
|
|
||||||
|
result = runner.invoke(app, [
|
||||||
|
"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
|
||||||
|
assert "multi_install_safe = False" 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
|
||||||
|
assert "assert integration.multi_install_safe is False" 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
|
||||||
|
assert "multi_install_safe = False" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_integration_scaffold_rejects_unknown_type_before_scaffolding(tmp_path, monkeypatch):
|
||||||
|
root = _repo_root(tmp_path)
|
||||||
|
monkeypatch.chdir(root)
|
||||||
|
|
||||||
|
result = runner.invoke(app, [
|
||||||
|
"integration", "scaffold", "my-agent",
|
||||||
|
"--type", "xml",
|
||||||
|
])
|
||||||
|
|
||||||
|
output = strip_ansi(result.output)
|
||||||
|
assert result.exit_code == 2
|
||||||
|
assert "Invalid value for '--type'" in output
|
||||||
|
assert not (root / "src" / "specify_cli" / "integrations" / "my_agent").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_integration_scaffold_reports_filesystem_errors_cleanly(tmp_path, monkeypatch):
|
||||||
|
root = _repo_root(tmp_path)
|
||||||
|
monkeypatch.chdir(root)
|
||||||
|
|
||||||
|
import specify_cli.integration_scaffold as scaffold_module
|
||||||
|
|
||||||
|
def boom(*args, **kwargs):
|
||||||
|
raise PermissionError("Permission denied: read-only checkout")
|
||||||
|
|
||||||
|
monkeypatch.setattr(scaffold_module, "scaffold_integration", boom)
|
||||||
|
|
||||||
|
result = runner.invoke(app, [
|
||||||
|
"integration", "scaffold", "my-agent",
|
||||||
|
"--type", "markdown",
|
||||||
|
], catch_exceptions=False)
|
||||||
|
|
||||||
|
output = strip_ansi(result.output)
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "Error:" in output
|
||||||
|
assert "Permission denied" in output
|
||||||
|
|
||||||
|
|
||||||
|
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 'xml'"):
|
||||||
|
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_rolls_back_partial_files_on_write_failure(tmp_path, monkeypatch):
|
||||||
|
root = _repo_root(tmp_path)
|
||||||
|
integration_dir = root / "src" / "specify_cli" / "integrations" / "my_agent"
|
||||||
|
integration_file = integration_dir / "__init__.py"
|
||||||
|
test_file = root / "tests" / "integrations" / "test_integration_my_agent.py"
|
||||||
|
original_write_text = Path.write_text
|
||||||
|
|
||||||
|
def fail_test_write(path, *args, **kwargs):
|
||||||
|
if path == test_file:
|
||||||
|
raise PermissionError("simulated test file write failure")
|
||||||
|
return original_write_text(path, *args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(Path, "write_text", fail_test_write)
|
||||||
|
|
||||||
|
with pytest.raises(PermissionError, match="simulated test file write failure"):
|
||||||
|
scaffold_integration(root, "my-agent", "markdown")
|
||||||
|
|
||||||
|
assert not integration_file.exists()
|
||||||
|
assert not integration_dir.exists()
|
||||||
|
assert not test_file.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_scaffold_creates_only_leaf_integration_directory(tmp_path, monkeypatch):
|
||||||
|
root = _repo_root(tmp_path)
|
||||||
|
original_mkdir = Path.mkdir
|
||||||
|
mkdir_calls = []
|
||||||
|
|
||||||
|
def record_mkdir(path, *args, **kwargs):
|
||||||
|
mkdir_calls.append((path, args, kwargs))
|
||||||
|
return original_mkdir(path, *args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(Path, "mkdir", record_mkdir)
|
||||||
|
|
||||||
|
scaffold_integration(root, "my-agent", "markdown")
|
||||||
|
|
||||||
|
assert any(
|
||||||
|
path == root / "src" / "specify_cli" / "integrations" / "my_agent"
|
||||||
|
for path, _args, _kwargs in mkdir_calls
|
||||||
|
)
|
||||||
|
assert all(not kwargs.get("parents", False) for _path, _args, kwargs in mkdir_calls)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scaffold_requires_repo_root(tmp_path):
|
||||||
|
with pytest.raises(ValueError, match="Spec Kit repository root"):
|
||||||
|
scaffold_integration(tmp_path, "my-agent", "markdown")
|
||||||
|
|
||||||
|
|
||||||
|
def test_scaffold_requires_integration_registry_file(tmp_path):
|
||||||
|
root = _repo_root(tmp_path)
|
||||||
|
(root / "src" / "specify_cli" / "integrations" / "__init__.py").unlink()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Spec Kit repository root"):
|
||||||
|
scaffold_integration(root, "my-agent", "markdown")
|
||||||
|
|
||||||
|
|
||||||
|
def test_scaffold_refuses_symlinked_target_directory(tmp_path):
|
||||||
|
root = _repo_root(tmp_path)
|
||||||
|
# `outside` carries its own __init__.py so the repo-root heuristic still
|
||||||
|
# passes through the symlink, isolating the symlink guard under test.
|
||||||
|
outside = tmp_path / "outside"
|
||||||
|
outside.mkdir()
|
||||||
|
(outside / "__init__.py").write_text("", encoding="utf-8")
|
||||||
|
integrations = root / "src" / "specify_cli" / "integrations"
|
||||||
|
(integrations / "__init__.py").unlink()
|
||||||
|
integrations.rmdir()
|
||||||
|
try:
|
||||||
|
integrations.symlink_to(outside, target_is_directory=True)
|
||||||
|
except OSError as exc:
|
||||||
|
pytest.skip(f"symlinks unavailable: {exc}")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="symlinked path"):
|
||||||
|
scaffold_integration(root, "my-agent", "markdown")
|
||||||
|
|
||||||
|
assert not (outside / "my_agent").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_integration_scaffold_accepts_uppercase_type(tmp_path, monkeypatch):
|
||||||
|
root = _repo_root(tmp_path)
|
||||||
|
monkeypatch.chdir(root)
|
||||||
|
|
||||||
|
result = runner.invoke(app, [
|
||||||
|
"integration", "scaffold", "my-agent",
|
||||||
|
"--type", "YAML",
|
||||||
|
], catch_exceptions=False)
|
||||||
|
|
||||||
|
assert result.exit_code == 0, strip_ansi(result.output)
|
||||||
|
content = (
|
||||||
|
root / "src" / "specify_cli" / "integrations" / "my_agent" / "__init__.py"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
assert "class MyAgentIntegration(YamlIntegration):" in content
|
||||||
Reference in New Issue
Block a user