Skip to content
Merged
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
5 changes: 3 additions & 2 deletions design/integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,9 @@ integration behavior. If the agent supports non-interactive workflows, implement
`build_exec_args()` with the full base signature; `options()` declares
install-time `--integration-options`, not per-workflow runtime options.
Agent-specific native events can be declared on the integration. Set
`multi_install_safe = True` only for a static, non-overlapping agent root and
command directory; shared dynamic paths are not safe by default.
`multi_install_safe = True` only when the integration emits no files or uses a
static, non-overlapping agent root and command directory; shared dynamic paths
are not safe by default.

## Output flavors

Expand Down
6 changes: 6 additions & 0 deletions docs/reference/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

The Specify CLI supports a wide range of AI coding agents. When you run `specify init`, the CLI sets up the appropriate command files and directory structures for your chosen AI coding agent — so you can start using Spec-Driven Development immediately, regardless of which tool you prefer.

For shared scaffolding without agent-specific files, use
`specify init my-project --integration none`. Normal `.specify/` metadata remains.
Use `specify integration switch <key>` to replace it, or
`specify integration install <key>` followed by `specify integration use <key>`
to keep both installed. The other integration must also be multi-install safe.

## Supported AI Coding Agents

| Agent | Key | Notes |
Expand Down
9 changes: 9 additions & 0 deletions integrations/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@
"updated_at": "2026-09-24T23:51:45Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json",
"integrations": {
"none": {
"id": "none",
"name": "None (shared scaffolding only)",
"version": "1.0.0",
"description": "Shared scaffolding without generated agent artifacts",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["shared", "artifact-free"]
},
"alquimia": {
"id": "alquimia",
"name": "Alquimia AI",
Expand Down
2 changes: 2 additions & 0 deletions src/specify_cli/integrations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ def _register_builtins() -> None:
from .lingma import LingmaIntegration
from .mcode import McodeIntegration
from .muse import MuseIntegration
from .none import NoneIntegration
from .omp import OmpIntegration
from .opencode import OpencodeIntegration
from .pi import PiIntegration
Expand Down Expand Up @@ -140,6 +141,7 @@ def _register_builtins() -> None:
_register(LingmaIntegration())
_register(McodeIntegration())
_register(MuseIntegration())
_register(NoneIntegration())
_register(OmpIntegration())
_register(OpencodeIntegration())
_register(PiIntegration())
Expand Down
5 changes: 2 additions & 3 deletions src/specify_cli/integrations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,9 +148,8 @@ class IntegrationBase(ABC):
multi_install_safe: bool = False
"""Whether this integration is declared safe to install alongside others.
Safe integrations must use a static, unique agent root and command
directory. Registry tests enforce those invariants for every
integration that sets this flag.
Safe integrations must emit no files or use a static, unique agent root
and command directory. Registry tests enforce these invariants.
"""

legacy_flat_command_dir: str | None = None
Expand Down
29 changes: 29 additions & 0 deletions src/specify_cli/integrations/none/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""An artifact-free integration for teams using shared scaffolding only."""

from pathlib import Path

from ..base import IntegrationBase
from ..manifest import IntegrationManifest


class NoneIntegration(IntegrationBase):
key = "none"
multi_install_safe = True
config = {
"name": "None (shared scaffolding only)",
"folder": None,
"install_url": None,
"requires_cli": False,
}

def list_command_templates(self) -> list[Path]:
return []

def teardown(
self,
project_root: Path,
manifest: IntegrationManifest,
*,
force: bool = False,
) -> tuple[list[Path], list[Path]]:
return manifest.uninstall(project_root, force=force)
4 changes: 2 additions & 2 deletions tests/extensions/github/test_github_extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -364,11 +364,11 @@ def test_remove_deletes_registered_artifacts_and_keeps_core(


class TestScriptPathResolution:
def test_extension_registrar_scope_excludes_generic(self):
def test_extension_registrar_scope_excludes_generic_and_none(self):
from specify_cli.integrations import INTEGRATION_REGISTRY

assert set(EXTENSION_REGISTRAR_AGENTS) == set(INTEGRATION_REGISTRY) - {
"generic"
"generic", "none"
}

def test_frontmatter_uses_plain_extension_local_spelling(self):
Expand Down
106 changes: 106 additions & 0 deletions tests/integrations/test_integration_none.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""Artifact-free integration and its normal management lifecycle."""

import json

import pytest
from typer.testing import CliRunner

from specify_cli import app
from specify_cli.integrations import get_integration
from specify_cli.integrations.manifest import IntegrationManifest


def test_none_setup_produces_no_files(tmp_path):
integration = get_integration("none")
assert integration is not None
assert integration.multi_install_safe
assert integration.registrar_config is None
assert integration.list_command_templates() == []
manifest = IntegrationManifest("none", tmp_path)
before = list(tmp_path.iterdir())
assert integration.setup(tmp_path, manifest) == []
assert manifest.files == {}
assert list(tmp_path.iterdir()) == before


@pytest.mark.parametrize("integration_args", [[], ["--integration", "none"]])
def test_none_lifecycle(tmp_path, monkeypatch, integration_args):
runner = CliRunner()
monkeypatch.setenv("SPECKIT_INTEGRATION_DEFAULT", "none")
project = tmp_path / "project"

def run(*arguments):
result = runner.invoke(app, list(arguments))
assert result.exit_code == 0, result.output

def state():
return json.loads((project / ".specify/integration.json").read_text())

run("init", str(project), "--non-interactive", *integration_args)
monkeypatch.chdir(project)
assert state()["integration"] == "none"
assert state()["installed_integrations"] == ["none"]
options = json.loads((project / ".specify/init-options.json").read_text())
assert options["ai"] == options["integration"] == "none"
assert IntegrationManifest.load("none", project).files == {}
assert not (project / ".github").exists()
user_file = project / "AGENTS.md"
user_file.write_text("User-owned instructions\n")
run("integration", "install", "copilot", "--force")
assert state()["integration"] == "none"
run("integration", "use", "copilot")
run("integration", "uninstall", "copilot")
assert state()["integration"] == "none"
run("integration", "upgrade", "none")
run("integration", "uninstall", "none")
assert not (project / ".specify/integration.json").exists()
assert not (project / ".specify/integrations/none.manifest.json").exists()
assert (project / ".specify/templates/spec-template.md").is_file()
run("integration", "install", "none")
run("integration", "switch", "copilot")
assert state()["installed_integrations"] == ["copilot"]
run("integration", "switch", "none")
assert state()["installed_integrations"] == ["none"]
assert IntegrationManifest.load("none", project).files == {}
assert not (project / ".github").exists()
assert user_file.read_text() == "User-owned instructions\n"
result = runner.invoke(app, [
"integration", "upgrade", "none", "--integration-options=--skills",
])
assert result.exit_code == 1, result.output
assert "Unknown integration option" in result.output
assert IntegrationManifest.load("none", project).files == {}


@pytest.mark.parametrize("force", [False, True])
def test_none_teardown_preserves_unowned_dispatcher(tmp_path, force):
integration = get_integration("none")
manifest = IntegrationManifest("none", tmp_path)
manifest.save()
dispatcher = tmp_path / ".specify/events.py"
dispatcher.write_text("# User-owned dispatcher\n")

assert integration.teardown(tmp_path, manifest, force=force) == ([], [])
assert dispatcher.read_text() == "# User-owned dispatcher\n"
assert not (tmp_path / ".specify/integrations/none.manifest.json").exists()


def test_none_coexists_with_a_safe_agent_without_force(tmp_path, monkeypatch):
runner = CliRunner()
project = tmp_path / "project"
result = runner.invoke(app, [
"init", str(project), "--integration", "none", "--non-interactive",
])
assert result.exit_code == 0, result.output
monkeypatch.chdir(project)
result = runner.invoke(app, ["integration", "install", "kiro-cli"])
assert result.exit_code == 0, result.output
state = json.loads((project / ".specify/integration.json").read_text())
assert state["integration"] == "none"
assert state["installed_integrations"] == ["none", "kiro-cli"]
result = runner.invoke(app, ["integration", "status", "--json"])
assert result.exit_code == 0, result.output
assert json.loads(result.output)["status"] == "ok"
result = runner.invoke(app, ["integration", "install", "copilot"])
assert result.exit_code == 1, result.output
assert "multi-install safe" in result.output
24 changes: 18 additions & 6 deletions tests/integrations/test_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"gemini", "tabnine",
# Stage 5 — skills, generic & option-driven integrations
"codex", "kimi", "agy", "zed", "generic",
"droid", "command-code", "dsh", "muse", "mcode",
"droid", "command-code", "dsh", "muse", "mcode", "none",
]


Expand Down Expand Up @@ -141,15 +141,16 @@ def test_key_registered(self, key):


class TestRegistrarKeyAlignment:
"""Every integration key must have a matching AGENT_CONFIGS entry.
"""Every rendering integration key must have a matching AGENT_CONFIGS entry.

``generic`` is excluded because it has no fixed directory — its
output path comes from ``--commands-dir`` at runtime.
output path comes from ``--commands-dir`` at runtime. ``none`` does
not render command files.
"""

@pytest.mark.parametrize(
"key",
[k for k in ALL_INTEGRATION_KEYS if k != "generic"],
[k for k in ALL_INTEGRATION_KEYS if k not in {"generic", "none"}],
)
def test_integration_key_in_registrar(self, key):
from specify_cli.agents import CommandRegistrar
Expand All @@ -163,7 +164,7 @@ def test_no_stale_cursor_shorthand(self):
assert "cursor" not in CommandRegistrar.AGENT_CONFIGS

@pytest.mark.parametrize(
"key", sorted(k for k in INTEGRATION_REGISTRY if k != "generic")
"key", sorted(k for k in INTEGRATION_REGISTRY if k not in {"generic", "none"})
)
def test_command_mode_separator_matches_registered_names(self, key):
"""A ``-`` command-mode separator must mean hyphenated registration.
Expand Down Expand Up @@ -194,7 +195,18 @@ def test_safe_install_orders_rotate_each_integration_through_init(self):
assert all(sorted(order) == safe_keys for order in orders)

@pytest.mark.parametrize("key", _multi_install_safe_keys())
def test_safe_integrations_have_static_isolated_paths(self, key):
def test_safe_integrations_have_static_or_no_output_paths(self, key, tmp_path):
integration = INTEGRATION_REGISTRY[key]
if integration.registrar_config is None:
from specify_cli.integrations.manifest import IntegrationManifest

assert _integration_root_dir(key) is None
manifest = IntegrationManifest(key, tmp_path)
before = list(tmp_path.iterdir())
assert integration.setup(tmp_path, manifest) == []
assert manifest.files == {}
assert list(tmp_path.iterdir()) == before
return
assert _integration_root_dir(key), (
f"{key} is declared multi-install safe but has no static root directory"
)
Expand Down
2 changes: 1 addition & 1 deletion tests/test_agent_config_consistency.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ def test_register_commands_resolved_dir_annotation_accepts_none(self):

def test_issue_template_agent_lists_match_runtime_integrations(self):
"""GitHub issue templates should list all concrete built-in agents."""
concrete_agent_keys = set(AGENT_CONFIG) - {"generic"}
concrete_agent_keys = set(AGENT_CONFIG) - {"generic", "none"}
issue_template_agent_keys = set(ISSUE_TEMPLATE_AGENT_KEYS)

missing_agent_keys = sorted(concrete_agent_keys - issue_template_agent_keys)
Expand Down
Loading