Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 28 additions & 8 deletions docs/local-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,15 +98,35 @@ 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:

```bash
python -c "import specify_cli; print('Import OK')"
```

## 7. Build a Wheel Locally (Optional)
## 8. Build a Wheel Locally (Optional)

Validate packaging before publishing:

Expand All @@ -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:

Expand All @@ -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.
Expand All @@ -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 |
|--------|---------|
Expand All @@ -148,15 +168,15 @@ 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:

```bash
rm -rf .venv dist build *.egg-info
```

## 12. Common Issues
## 13. Common Issues

| Symptom | Fix |
|---------|-----|
Expand All @@ -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
Expand Down
45 changes: 45 additions & 0 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
),
Comment on lines +1302 to +1306
):
"""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(
Expand Down
218 changes: 218 additions & 0 deletions src/specify_cli/integration_scaffold.py
Original file line number Diff line number Diff line change
@@ -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:
Comment on lines +90 to +96
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.")
Comment on lines +170 to +173

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.",
)
Comment on lines +206 to +210
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,
)
Loading