diff --git a/design/integration.md b/design/integration.md index 04509705d3..02d83b9428 100644 --- a/design/integration.md +++ b/design/integration.md @@ -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 diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index 2480695630..af0dbccdcf 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -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 ` to replace it, or +`specify integration install ` followed by `specify integration use ` +to keep both installed. The other integration must also be multi-install safe. + ## Supported AI Coding Agents | Agent | Key | Notes | diff --git a/integrations/catalog.json b/integrations/catalog.json index e42fd162ad..f1b5dcb3f8 100644 --- a/integrations/catalog.json +++ b/integrations/catalog.json @@ -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", diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py index de32902832..bc7595332d 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -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 @@ -140,6 +141,7 @@ def _register_builtins() -> None: _register(LingmaIntegration()) _register(McodeIntegration()) _register(MuseIntegration()) + _register(NoneIntegration()) _register(OmpIntegration()) _register(OpencodeIntegration()) _register(PiIntegration()) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index b3dd0abc34..259ded3191 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -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 diff --git a/src/specify_cli/integrations/none/__init__.py b/src/specify_cli/integrations/none/__init__.py new file mode 100644 index 0000000000..bb342e3eda --- /dev/null +++ b/src/specify_cli/integrations/none/__init__.py @@ -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) diff --git a/tests/extensions/github/test_github_extension.py b/tests/extensions/github/test_github_extension.py index 718c1c8421..7d348d20f2 100644 --- a/tests/extensions/github/test_github_extension.py +++ b/tests/extensions/github/test_github_extension.py @@ -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): diff --git a/tests/integrations/test_integration_none.py b/tests/integrations/test_integration_none.py new file mode 100644 index 0000000000..9837382f39 --- /dev/null +++ b/tests/integrations/test_integration_none.py @@ -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 diff --git a/tests/integrations/test_registry.py b/tests/integrations/test_registry.py index d52a644d0c..d64e1af8f2 100644 --- a/tests/integrations/test_registry.py +++ b/tests/integrations/test_registry.py @@ -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", ] @@ -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 @@ -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. @@ -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" ) diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index 294f4eed27..c18783f2a9 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -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)