From 8ff7b7ce598132dce79f421ab5957f8af84a6f5a Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:30:32 -0500 Subject: [PATCH 1/3] feat(integration): add none for shared scaffolding only Register a conventional no-op adapter and catalog entry. Use the existing init and integration lifecycle unchanged, with focused lifecycle coverage, registry consistency expectations, and integration reference documentation. Assisted-by: GitHub Copilot (model: GPT-6.1 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/integrations.md | 10 ++ integrations/catalog.json | 9 + src/specify_cli/integrations/__init__.py | 2 + src/specify_cli/integrations/none/__init__.py | 26 +++ .../github/test_github_extension.py | 4 +- tests/integrations/test_integration_none.py | 167 ++++++++++++++++++ tests/integrations/test_registry.py | 11 +- tests/test_agent_config_consistency.py | 2 +- 8 files changed, 223 insertions(+), 8 deletions(-) create mode 100644 src/specify_cli/integrations/none/__init__.py create mode 100644 tests/integrations/test_integration_none.py diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index 2480695630..d6c7a263dc 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -2,6 +2,16 @@ 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`. You can add an integration later +with `specify integration install --force`, then select it with +`specify integration use ` from the project directory. `none` is a regular +built-in integration with an empty file manifest and supports all integration +management commands. Normal `.specify/` project metadata and shared scaffolding +remain. To replace `none` instead of keeping both integrations installed, use +`specify integration switch `. Existing multi-install safety rules apply +unchanged. + ## 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/none/__init__.py b/src/specify_cli/integrations/none/__init__.py new file mode 100644 index 0000000000..103a26edbd --- /dev/null +++ b/src/specify_cli/integrations/none/__init__.py @@ -0,0 +1,26 @@ +"""An artifact-free integration for teams using shared scaffolding only.""" + +from pathlib import Path +from typing import Any + +from ..base import IntegrationBase +from ..manifest import IntegrationManifest + + +class NoneIntegration(IntegrationBase): + key = "none" + config = { + "name": "None (shared scaffolding only)", + "folder": None, + "install_url": None, + "requires_cli": False, + } + + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + return [] 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..3b4a97eba8 --- /dev/null +++ b/tests/integrations/test_integration_none.py @@ -0,0 +1,167 @@ +"""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.registrar_config is None + before = list(tmp_path.iterdir()) + manifest = IntegrationManifest("none", tmp_path) + assert integration.setup(tmp_path, manifest) == [] + assert manifest.files == {} + assert list(tmp_path.iterdir()) == before + + +@pytest.mark.parametrize("script_type", ["sh", "ps", "py"]) +def test_none_init_uses_standard_project_metadata(tmp_path, script_type): + project = tmp_path / "project" + result = CliRunner().invoke(app, [ + "init", str(project), "--integration", "none", "--script", script_type, + "--non-interactive", + ]) + 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"] + options = json.loads((project / ".specify/init-options.json").read_text()) + assert options["ai"] == options["integration"] == "none" + assert options["script"] == script_type + assert IntegrationManifest.load("none", project).files == {} + assert (project / ".specify/templates/spec-template.md").is_file() + assert not (project / ".github").exists() + + +def test_none_can_be_the_configured_init_default(tmp_path, monkeypatch): + monkeypatch.setenv("SPECKIT_INTEGRATION_DEFAULT", "none") + project = tmp_path / "project" + result = CliRunner().invoke(app, ["init", str(project), "--non-interactive"]) + assert result.exit_code == 0, result.output + state = json.loads((project / ".specify/integration.json").read_text()) + assert state["integration"] == "none" + assert IntegrationManifest.load("none", project).files == {} + assert not (project / ".github").exists() + + +def test_none_uses_normal_install_selection_and_uninstall_lifecycle(tmp_path, monkeypatch): + runner = CliRunner() + project = tmp_path / "project" + result = runner.invoke(app, [ + "init", str(project), "--integration", "none", "--non-interactive", + "--extension", "git", + ]) + assert result.exit_code == 0, result.output + monkeypatch.chdir(project) + + def run(*arguments): + result = runner.invoke(app, list(arguments)) + assert result.exit_code == 0, result.output + return result + + def state(): + return json.loads((project / ".specify/integration.json").read_text()) + + assert state()["integration"] == "none" + assert state()["installed_integrations"] == ["none"] + assert IntegrationManifest.load("none", project).files == {} + assert not (project / ".github").exists() + run("integration", "upgrade", "none") + run("integration", "install", "copilot", "--force") + assert state()["integration"] == "none" + assert state()["installed_integrations"] == ["none", "copilot"] + assert not (project / ".github/skills/speckit-git-feature/SKILL.md").exists() + result = runner.invoke(app, ["integration", "install", "claude"]) + assert result.exit_code == 1, result.output + assert "multi-install safe" in result.output + assert state()["installed_integrations"] == ["none", "copilot"] + assert not (project / ".claude").exists() + run("integration", "use", "copilot") + assert state()["integration"] == "copilot" + assert (project / ".github/skills/speckit-git-feature/SKILL.md").is_file() + run("integration", "use", "none") + assert state()["integration"] == "none" + assert IntegrationManifest.load("none", project).files == {} + run("integration", "uninstall", "copilot") + assert state()["installed_integrations"] == ["none"] + assert json.loads(run("integration", "status", "--json").output)["status"] == "ok" + 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") + assert state()["integration"] == "none" + assert IntegrationManifest.load("none", project).files == {} + run("integration", "switch", "copilot") + assert state()["installed_integrations"] == ["copilot"] + assert not (project / ".specify/integrations/none.manifest.json").exists() + run("integration", "switch", "none") + assert state()["installed_integrations"] == ["none"] + assert not (project / ".github/skills/speckit-plan/SKILL.md").exists() + assert IntegrationManifest.load("none", project).files == {} + + +def test_uninstalling_default_agent_reselects_installed_none(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) + for arguments in [ + ["integration", "install", "copilot", "--force"], + ["integration", "use", "copilot"], + ["integration", "uninstall", "copilot"], + ]: + result = runner.invoke(app, arguments) + assert result.exit_code == 0, result.output + options = json.loads((project / ".specify/init-options.json").read_text()) + assert options["ai"] == options["integration"] == "none" + + +def test_none_operations_do_not_adopt_or_modify_agent_files(tmp_path, monkeypatch): + runner = CliRunner() + project = tmp_path / "project" + result = runner.invoke(app, [ + "init", str(project), "--integration", "copilot", "--non-interactive", + "--extension", "git", + ]) + assert result.exit_code == 0, result.output + user_file = project / "AGENTS.md" + user_file.write_text("User-owned instructions\n") + before = { + path.relative_to(project): path.read_bytes() + for path in project.rglob("*") + if path.is_file() and ".specify" not in path.relative_to(project).parts + } + monkeypatch.chdir(project) + for arguments in [ + ["integration", "install", "none", "--force"], + ["integration", "upgrade", "none"], + ["integration", "use", "none"], + ["integration", "upgrade", "none"], + ]: + result = runner.invoke(app, arguments) + assert result.exit_code == 0, result.output + assert IntegrationManifest.load("none", project).files == {} + result = runner.invoke(app, ["integration", "uninstall", "none"]) + assert result.exit_code == 0, result.output + options = json.loads((project / ".specify/init-options.json").read_text()) + assert options["ai"] == options["integration"] == "copilot" + assert { + path.relative_to(project): path.read_bytes() + for path in project.rglob("*") + if path.is_file() and ".specify" not in path.relative_to(project).parts + } == before + result = runner.invoke(app, [ + "integration", "install", "none", "--force", "--integration-options=--skills", + ]) + assert result.exit_code == 1, result.output + assert "Unknown integration option" in result.output + assert not (project / ".specify/integrations/none.manifest.json").exists() diff --git a/tests/integrations/test_registry.py b/tests/integrations/test_registry.py index d52a644d0c..cec739e803 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. 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) From 0fde37712da6e8a0930368a7eb8327dd5fac4523 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:47:53 -0500 Subject: [PATCH 2/3] refactor(integration): minimize none implementation and coverage Reuse the base setup implementation with an empty template list. Keep focused positive and negative integration coverage without duplicating shared lifecycle tests, and shorten the reference documentation. Assisted-by: GitHub Copilot (model: GPT-6.1 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/integrations.md | 12 +- src/specify_cli/integrations/none/__init__.py | 10 +- tests/integrations/test_integration_none.py | 130 +++--------------- 3 files changed, 22 insertions(+), 130 deletions(-) diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index d6c7a263dc..e221be0044 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -3,14 +3,10 @@ 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`. You can add an integration later -with `specify integration install --force`, then select it with -`specify integration use ` from the project directory. `none` is a regular -built-in integration with an empty file manifest and supports all integration -management commands. Normal `.specify/` project metadata and shared scaffolding -remain. To replace `none` instead of keeping both integrations installed, use -`specify integration switch `. Existing multi-install safety rules apply -unchanged. +`specify init my-project --integration none`. Normal `.specify/` metadata remains. +Use `specify integration switch ` to replace it, or +`specify integration install --force` followed by +`specify integration use ` to keep both installed. ## Supported AI Coding Agents diff --git a/src/specify_cli/integrations/none/__init__.py b/src/specify_cli/integrations/none/__init__.py index 103a26edbd..b00daad002 100644 --- a/src/specify_cli/integrations/none/__init__.py +++ b/src/specify_cli/integrations/none/__init__.py @@ -1,10 +1,8 @@ """An artifact-free integration for teams using shared scaffolding only.""" from pathlib import Path -from typing import Any from ..base import IntegrationBase -from ..manifest import IntegrationManifest class NoneIntegration(IntegrationBase): @@ -16,11 +14,5 @@ class NoneIntegration(IntegrationBase): "requires_cli": False, } - def setup( - self, - project_root: Path, - manifest: IntegrationManifest, - parsed_options: dict[str, Any] | None = None, - **opts: Any, - ) -> list[Path]: + def list_command_templates(self) -> list[Path]: return [] diff --git a/tests/integrations/test_integration_none.py b/tests/integrations/test_integration_none.py index 3b4a97eba8..6a7ed00fc1 100644 --- a/tests/integrations/test_integration_none.py +++ b/tests/integrations/test_integration_none.py @@ -14,154 +14,58 @@ def test_none_setup_produces_no_files(tmp_path): integration = get_integration("none") assert integration is not None assert integration.registrar_config is None - before = list(tmp_path.iterdir()) + 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("script_type", ["sh", "ps", "py"]) -def test_none_init_uses_standard_project_metadata(tmp_path, script_type): - project = tmp_path / "project" - result = CliRunner().invoke(app, [ - "init", str(project), "--integration", "none", "--script", script_type, - "--non-interactive", - ]) - 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"] - options = json.loads((project / ".specify/init-options.json").read_text()) - assert options["ai"] == options["integration"] == "none" - assert options["script"] == script_type - assert IntegrationManifest.load("none", project).files == {} - assert (project / ".specify/templates/spec-template.md").is_file() - assert not (project / ".github").exists() - - -def test_none_can_be_the_configured_init_default(tmp_path, monkeypatch): - monkeypatch.setenv("SPECKIT_INTEGRATION_DEFAULT", "none") - project = tmp_path / "project" - result = CliRunner().invoke(app, ["init", str(project), "--non-interactive"]) - assert result.exit_code == 0, result.output - state = json.loads((project / ".specify/integration.json").read_text()) - assert state["integration"] == "none" - assert IntegrationManifest.load("none", project).files == {} - assert not (project / ".github").exists() - - -def test_none_uses_normal_install_selection_and_uninstall_lifecycle(tmp_path, monkeypatch): +@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" - result = runner.invoke(app, [ - "init", str(project), "--integration", "none", "--non-interactive", - "--extension", "git", - ]) - assert result.exit_code == 0, result.output - monkeypatch.chdir(project) def run(*arguments): result = runner.invoke(app, list(arguments)) assert result.exit_code == 0, result.output - return result 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() - run("integration", "upgrade", "none") + user_file = project / "AGENTS.md" + user_file.write_text("User-owned instructions\n") run("integration", "install", "copilot", "--force") assert state()["integration"] == "none" - assert state()["installed_integrations"] == ["none", "copilot"] - assert not (project / ".github/skills/speckit-git-feature/SKILL.md").exists() - result = runner.invoke(app, ["integration", "install", "claude"]) - assert result.exit_code == 1, result.output - assert "multi-install safe" in result.output - assert state()["installed_integrations"] == ["none", "copilot"] - assert not (project / ".claude").exists() run("integration", "use", "copilot") - assert state()["integration"] == "copilot" - assert (project / ".github/skills/speckit-git-feature/SKILL.md").is_file() - run("integration", "use", "none") - assert state()["integration"] == "none" - assert IntegrationManifest.load("none", project).files == {} run("integration", "uninstall", "copilot") - assert state()["installed_integrations"] == ["none"] - assert json.loads(run("integration", "status", "--json").output)["status"] == "ok" + 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") - assert state()["integration"] == "none" - assert IntegrationManifest.load("none", project).files == {} run("integration", "switch", "copilot") assert state()["installed_integrations"] == ["copilot"] - assert not (project / ".specify/integrations/none.manifest.json").exists() run("integration", "switch", "none") assert state()["installed_integrations"] == ["none"] - assert not (project / ".github/skills/speckit-plan/SKILL.md").exists() assert IntegrationManifest.load("none", project).files == {} - - -def test_uninstalling_default_agent_reselects_installed_none(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) - for arguments in [ - ["integration", "install", "copilot", "--force"], - ["integration", "use", "copilot"], - ["integration", "uninstall", "copilot"], - ]: - result = runner.invoke(app, arguments) - assert result.exit_code == 0, result.output - options = json.loads((project / ".specify/init-options.json").read_text()) - assert options["ai"] == options["integration"] == "none" - - -def test_none_operations_do_not_adopt_or_modify_agent_files(tmp_path, monkeypatch): - runner = CliRunner() - project = tmp_path / "project" - result = runner.invoke(app, [ - "init", str(project), "--integration", "copilot", "--non-interactive", - "--extension", "git", - ]) - assert result.exit_code == 0, result.output - user_file = project / "AGENTS.md" - user_file.write_text("User-owned instructions\n") - before = { - path.relative_to(project): path.read_bytes() - for path in project.rglob("*") - if path.is_file() and ".specify" not in path.relative_to(project).parts - } - monkeypatch.chdir(project) - for arguments in [ - ["integration", "install", "none", "--force"], - ["integration", "upgrade", "none"], - ["integration", "use", "none"], - ["integration", "upgrade", "none"], - ]: - result = runner.invoke(app, arguments) - assert result.exit_code == 0, result.output - assert IntegrationManifest.load("none", project).files == {} - result = runner.invoke(app, ["integration", "uninstall", "none"]) - assert result.exit_code == 0, result.output - options = json.loads((project / ".specify/init-options.json").read_text()) - assert options["ai"] == options["integration"] == "copilot" - assert { - path.relative_to(project): path.read_bytes() - for path in project.rglob("*") - if path.is_file() and ".specify" not in path.relative_to(project).parts - } == before + assert not (project / ".github").exists() + assert user_file.read_text() == "User-owned instructions\n" result = runner.invoke(app, [ - "integration", "install", "none", "--force", "--integration-options=--skills", + "integration", "upgrade", "none", "--integration-options=--skills", ]) assert result.exit_code == 1, result.output assert "Unknown integration option" in result.output - assert not (project / ".specify/integrations/none.manifest.json").exists() + assert IntegrationManifest.load("none", project).files == {} From a0b4b88250ca4668328af55006c3d5b503f86ff1 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:22:27 -0500 Subject: [PATCH 3/3] fix(integration): make none safe for coinstallation and teardown Declare none multi-install safe and limit teardown to its own manifest. Verify empty-output integrations through the general safety contract while retaining static-root and collision checks. Cover healthy unforced coexistence, unsafe-partner rejection, and preservation of unowned event dispatchers. Assisted-by: GitHub Copilot (model: GPT-6.1 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- design/integration.md | 5 +-- docs/reference/integrations.md | 4 +-- src/specify_cli/integrations/base.py | 5 ++- src/specify_cli/integrations/none/__init__.py | 11 ++++++ tests/integrations/test_integration_none.py | 35 +++++++++++++++++++ tests/integrations/test_registry.py | 13 ++++++- 6 files changed, 65 insertions(+), 8 deletions(-) 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 e221be0044..af0dbccdcf 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -5,8 +5,8 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify 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 --force` followed by -`specify integration use ` to keep both installed. +`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 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 index b00daad002..bb342e3eda 100644 --- a/src/specify_cli/integrations/none/__init__.py +++ b/src/specify_cli/integrations/none/__init__.py @@ -3,10 +3,12 @@ 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, @@ -16,3 +18,12 @@ class NoneIntegration(IntegrationBase): 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/integrations/test_integration_none.py b/tests/integrations/test_integration_none.py index 6a7ed00fc1..9837382f39 100644 --- a/tests/integrations/test_integration_none.py +++ b/tests/integrations/test_integration_none.py @@ -13,6 +13,7 @@ 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) @@ -69,3 +70,37 @@ def state(): 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 cec739e803..d64e1af8f2 100644 --- a/tests/integrations/test_registry.py +++ b/tests/integrations/test_registry.py @@ -195,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" )