Skip to content

Commit a055f62

Browse files
mnriemCopilot
andauthored
feat(integration): add none for shared scaffolding only (#5030)
* 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> * 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> * 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> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 6ad2082 commit a055f62

10 files changed

Lines changed: 178 additions & 14 deletions

File tree

‎design/integration.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,9 @@ integration behavior. If the agent supports non-interactive workflows, implement
4444
`build_exec_args()` with the full base signature; `options()` declares
4545
install-time `--integration-options`, not per-workflow runtime options.
4646
Agent-specific native events can be declared on the integration. Set
47-
`multi_install_safe = True` only for a static, non-overlapping agent root and
48-
command directory; shared dynamic paths are not safe by default.
47+
`multi_install_safe = True` only when the integration emits no files or uses a
48+
static, non-overlapping agent root and command directory; shared dynamic paths
49+
are not safe by default.
4950

5051
## Output flavors
5152

‎docs/reference/integrations.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
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.
44

5+
For shared scaffolding without agent-specific files, use
6+
`specify init my-project --integration none`. Normal `.specify/` metadata remains.
7+
Use `specify integration switch <key>` to replace it, or
8+
`specify integration install <key>` followed by `specify integration use <key>`
9+
to keep both installed. The other integration must also be multi-install safe.
10+
511
## Supported AI Coding Agents
612

713
| Agent | Key | Notes |

‎integrations/catalog.json‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@
33
"updated_at": "2026-09-24T23:51:45Z",
44
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json",
55
"integrations": {
6+
"none": {
7+
"id": "none",
8+
"name": "None (shared scaffolding only)",
9+
"version": "1.0.0",
10+
"description": "Shared scaffolding without generated agent artifacts",
11+
"author": "spec-kit-core",
12+
"repository": "https://github.com/github/spec-kit",
13+
"tags": ["shared", "artifact-free"]
14+
},
615
"alquimia": {
716
"id": "alquimia",
817
"name": "Alquimia AI",

‎src/specify_cli/integrations/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ def _register_builtins() -> None:
9696
from .lingma import LingmaIntegration
9797
from .mcode import McodeIntegration
9898
from .muse import MuseIntegration
99+
from .none import NoneIntegration
99100
from .omp import OmpIntegration
100101
from .opencode import OpencodeIntegration
101102
from .pi import PiIntegration
@@ -140,6 +141,7 @@ def _register_builtins() -> None:
140141
_register(LingmaIntegration())
141142
_register(McodeIntegration())
142143
_register(MuseIntegration())
144+
_register(NoneIntegration())
143145
_register(OmpIntegration())
144146
_register(OpencodeIntegration())
145147
_register(PiIntegration())

‎src/specify_cli/integrations/base.py‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -148,9 +148,8 @@ class IntegrationBase(ABC):
148148
multi_install_safe: bool = False
149149
"""Whether this integration is declared safe to install alongside others.
150150
151-
Safe integrations must use a static, unique agent root and command
152-
directory. Registry tests enforce those invariants for every
153-
integration that sets this flag.
151+
Safe integrations must emit no files or use a static, unique agent root
152+
and command directory. Registry tests enforce these invariants.
154153
"""
155154

156155
legacy_flat_command_dir: str | None = None
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""An artifact-free integration for teams using shared scaffolding only."""
2+
3+
from pathlib import Path
4+
5+
from ..base import IntegrationBase
6+
from ..manifest import IntegrationManifest
7+
8+
9+
class NoneIntegration(IntegrationBase):
10+
key = "none"
11+
multi_install_safe = True
12+
config = {
13+
"name": "None (shared scaffolding only)",
14+
"folder": None,
15+
"install_url": None,
16+
"requires_cli": False,
17+
}
18+
19+
def list_command_templates(self) -> list[Path]:
20+
return []
21+
22+
def teardown(
23+
self,
24+
project_root: Path,
25+
manifest: IntegrationManifest,
26+
*,
27+
force: bool = False,
28+
) -> tuple[list[Path], list[Path]]:
29+
return manifest.uninstall(project_root, force=force)

‎tests/extensions/github/test_github_extension.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -364,11 +364,11 @@ def test_remove_deletes_registered_artifacts_and_keeps_core(
364364

365365

366366
class TestScriptPathResolution:
367-
def test_extension_registrar_scope_excludes_generic(self):
367+
def test_extension_registrar_scope_excludes_generic_and_none(self):
368368
from specify_cli.integrations import INTEGRATION_REGISTRY
369369

370370
assert set(EXTENSION_REGISTRAR_AGENTS) == set(INTEGRATION_REGISTRY) - {
371-
"generic"
371+
"generic", "none"
372372
}
373373

374374
def test_frontmatter_uses_plain_extension_local_spelling(self):
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""Artifact-free integration and its normal management lifecycle."""
2+
3+
import json
4+
5+
import pytest
6+
from typer.testing import CliRunner
7+
8+
from specify_cli import app
9+
from specify_cli.integrations import get_integration
10+
from specify_cli.integrations.manifest import IntegrationManifest
11+
12+
13+
def test_none_setup_produces_no_files(tmp_path):
14+
integration = get_integration("none")
15+
assert integration is not None
16+
assert integration.multi_install_safe
17+
assert integration.registrar_config is None
18+
assert integration.list_command_templates() == []
19+
manifest = IntegrationManifest("none", tmp_path)
20+
before = list(tmp_path.iterdir())
21+
assert integration.setup(tmp_path, manifest) == []
22+
assert manifest.files == {}
23+
assert list(tmp_path.iterdir()) == before
24+
25+
26+
@pytest.mark.parametrize("integration_args", [[], ["--integration", "none"]])
27+
def test_none_lifecycle(tmp_path, monkeypatch, integration_args):
28+
runner = CliRunner()
29+
monkeypatch.setenv("SPECKIT_INTEGRATION_DEFAULT", "none")
30+
project = tmp_path / "project"
31+
32+
def run(*arguments):
33+
result = runner.invoke(app, list(arguments))
34+
assert result.exit_code == 0, result.output
35+
36+
def state():
37+
return json.loads((project / ".specify/integration.json").read_text())
38+
39+
run("init", str(project), "--non-interactive", *integration_args)
40+
monkeypatch.chdir(project)
41+
assert state()["integration"] == "none"
42+
assert state()["installed_integrations"] == ["none"]
43+
options = json.loads((project / ".specify/init-options.json").read_text())
44+
assert options["ai"] == options["integration"] == "none"
45+
assert IntegrationManifest.load("none", project).files == {}
46+
assert not (project / ".github").exists()
47+
user_file = project / "AGENTS.md"
48+
user_file.write_text("User-owned instructions\n")
49+
run("integration", "install", "copilot", "--force")
50+
assert state()["integration"] == "none"
51+
run("integration", "use", "copilot")
52+
run("integration", "uninstall", "copilot")
53+
assert state()["integration"] == "none"
54+
run("integration", "upgrade", "none")
55+
run("integration", "uninstall", "none")
56+
assert not (project / ".specify/integration.json").exists()
57+
assert not (project / ".specify/integrations/none.manifest.json").exists()
58+
assert (project / ".specify/templates/spec-template.md").is_file()
59+
run("integration", "install", "none")
60+
run("integration", "switch", "copilot")
61+
assert state()["installed_integrations"] == ["copilot"]
62+
run("integration", "switch", "none")
63+
assert state()["installed_integrations"] == ["none"]
64+
assert IntegrationManifest.load("none", project).files == {}
65+
assert not (project / ".github").exists()
66+
assert user_file.read_text() == "User-owned instructions\n"
67+
result = runner.invoke(app, [
68+
"integration", "upgrade", "none", "--integration-options=--skills",
69+
])
70+
assert result.exit_code == 1, result.output
71+
assert "Unknown integration option" in result.output
72+
assert IntegrationManifest.load("none", project).files == {}
73+
74+
75+
@pytest.mark.parametrize("force", [False, True])
76+
def test_none_teardown_preserves_unowned_dispatcher(tmp_path, force):
77+
integration = get_integration("none")
78+
manifest = IntegrationManifest("none", tmp_path)
79+
manifest.save()
80+
dispatcher = tmp_path / ".specify/events.py"
81+
dispatcher.write_text("# User-owned dispatcher\n")
82+
83+
assert integration.teardown(tmp_path, manifest, force=force) == ([], [])
84+
assert dispatcher.read_text() == "# User-owned dispatcher\n"
85+
assert not (tmp_path / ".specify/integrations/none.manifest.json").exists()
86+
87+
88+
def test_none_coexists_with_a_safe_agent_without_force(tmp_path, monkeypatch):
89+
runner = CliRunner()
90+
project = tmp_path / "project"
91+
result = runner.invoke(app, [
92+
"init", str(project), "--integration", "none", "--non-interactive",
93+
])
94+
assert result.exit_code == 0, result.output
95+
monkeypatch.chdir(project)
96+
result = runner.invoke(app, ["integration", "install", "kiro-cli"])
97+
assert result.exit_code == 0, result.output
98+
state = json.loads((project / ".specify/integration.json").read_text())
99+
assert state["integration"] == "none"
100+
assert state["installed_integrations"] == ["none", "kiro-cli"]
101+
result = runner.invoke(app, ["integration", "status", "--json"])
102+
assert result.exit_code == 0, result.output
103+
assert json.loads(result.output)["status"] == "ok"
104+
result = runner.invoke(app, ["integration", "install", "copilot"])
105+
assert result.exit_code == 1, result.output
106+
assert "multi-install safe" in result.output

‎tests/integrations/test_registry.py‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
"gemini", "tabnine",
2929
# Stage 5 — skills, generic & option-driven integrations
3030
"codex", "kimi", "agy", "zed", "generic",
31-
"droid", "command-code", "dsh", "muse", "mcode",
31+
"droid", "command-code", "dsh", "muse", "mcode", "none",
3232
]
3333

3434

@@ -141,15 +141,16 @@ def test_key_registered(self, key):
141141

142142

143143
class TestRegistrarKeyAlignment:
144-
"""Every integration key must have a matching AGENT_CONFIGS entry.
144+
"""Every rendering integration key must have a matching AGENT_CONFIGS entry.
145145
146146
``generic`` is excluded because it has no fixed directory — its
147-
output path comes from ``--commands-dir`` at runtime.
147+
output path comes from ``--commands-dir`` at runtime. ``none`` does
148+
not render command files.
148149
"""
149150

150151
@pytest.mark.parametrize(
151152
"key",
152-
[k for k in ALL_INTEGRATION_KEYS if k != "generic"],
153+
[k for k in ALL_INTEGRATION_KEYS if k not in {"generic", "none"}],
153154
)
154155
def test_integration_key_in_registrar(self, key):
155156
from specify_cli.agents import CommandRegistrar
@@ -163,7 +164,7 @@ def test_no_stale_cursor_shorthand(self):
163164
assert "cursor" not in CommandRegistrar.AGENT_CONFIGS
164165

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

196197
@pytest.mark.parametrize("key", _multi_install_safe_keys())
197-
def test_safe_integrations_have_static_isolated_paths(self, key):
198+
def test_safe_integrations_have_static_or_no_output_paths(self, key, tmp_path):
199+
integration = INTEGRATION_REGISTRY[key]
200+
if integration.registrar_config is None:
201+
from specify_cli.integrations.manifest import IntegrationManifest
202+
203+
assert _integration_root_dir(key) is None
204+
manifest = IntegrationManifest(key, tmp_path)
205+
before = list(tmp_path.iterdir())
206+
assert integration.setup(tmp_path, manifest) == []
207+
assert manifest.files == {}
208+
assert list(tmp_path.iterdir()) == before
209+
return
198210
assert _integration_root_dir(key), (
199211
f"{key} is declared multi-install safe but has no static root directory"
200212
)

‎tests/test_agent_config_consistency.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ def test_register_commands_resolved_dir_annotation_accepts_none(self):
121121

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

127127
missing_agent_keys = sorted(concrete_agent_keys - issue_template_agent_keys)

0 commit comments

Comments
 (0)