Skip to content

Commit 7d414cf

Browse files
committed
feat(integration): add multi-install support and redesign integration state schema
- Introduce integration.json schema v1 with default_integration, installed_integrations, and integration_settings fields; migrate legacy v0 format on first read - Add 'integration install' multi-install support: allow multiple multi_install_safe integrations to coexist without --force - Add 'integration use' command to switch default integration without uninstall/reinstall - Update 'integration switch' to set default when target is already installed, or perform full uninstall/reinstall otherwise - Update 'integration uninstall' to handle multi-install state and refresh templates for the new default on default removal - Update 'integration upgrade' to skip template refresh when upgrading a non-default integration - Add 'Multi-install Safe' column to 'integration list' table - Enforce integration_state_schema version guard in list/install/etc. - Export _refresh_shared_templates, _parse_integration_options, select_with_arrows, and urllib from __init__ for test monkeypatching - Delegate _install_shared_infra to shared_infra.install_shared_infra to gain symlink-safe writes and atomic template updates - Write integration.json in schema v1 format during 'init' - Fix remove_catalog bool priority to fall back to yaml index - Fix extension/workflow catalog list to use is_dir() instead of exists() - Split long Rich console messages onto separate lines to prevent ANSI span wrapping in narrow terminal environments
1 parent 522e7f5 commit 7d414cf

9 files changed

Lines changed: 940 additions & 362 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@
2727
"""
2828

2929
import sys
30+
import urllib
31+
import urllib.request
32+
import urllib.error
3033
from pathlib import Path
3134

3235
from typing import Optional
@@ -41,14 +44,74 @@
4144
from ._assets import _asset_service as _svc
4245
from ._git import _git_service as _git_svc
4346
from ._version import _version_service as _ver_svc
44-
from ._fs import save_init_options, merge_json_files, handle_vscode_settings
47+
from ._fs import save_init_options as save_init_options, load_init_options as load_init_options, merge_json_files as merge_json_files, handle_vscode_settings as handle_vscode_settings
4548
from ._helpers import (
4649
check_tool,
4750
get_speckit_version,
4851
AGENT_CONFIG,
49-
AI_ASSISTANT_ALIASES,
50-
AI_ASSISTANT_HELP,
52+
AI_ASSISTANT_ALIASES as AI_ASSISTANT_ALIASES,
53+
AI_ASSISTANT_HELP as AI_ASSISTANT_HELP,
54+
_get_skills_dir as _get_skills_dir,
55+
_parse_integration_options as _parse_integration_options,
5156
)
57+
from ._ui import select_with_arrows as select_with_arrows
58+
59+
60+
def _install_shared_infra(
61+
project_path: Path,
62+
script_type: str,
63+
tracker=None,
64+
force: bool = False,
65+
invoke_separator: str = ".",
66+
) -> bool:
67+
"""Install shared infrastructure files into *project_path*."""
68+
from .shared_infra import install_shared_infra
69+
from ._assets import _asset_service as _svc
70+
71+
def _get_version() -> str:
72+
import importlib.metadata
73+
try:
74+
return importlib.metadata.version("specify-cli")
75+
except Exception:
76+
return "unknown"
77+
78+
return install_shared_infra(
79+
project_path,
80+
script_type,
81+
version=_get_version(),
82+
core_pack=_svc.locate_core_pack(),
83+
repo_root=Path(__file__).parent.parent.parent,
84+
console=console,
85+
force=force,
86+
invoke_separator=invoke_separator,
87+
)
88+
89+
90+
def _refresh_shared_templates(
91+
project_path: Path,
92+
invoke_separator: str,
93+
force: bool = False,
94+
) -> None:
95+
"""Refresh default-sensitive shared templates without touching scripts."""
96+
from .shared_infra import refresh_shared_templates
97+
from ._assets import _asset_service as _svc
98+
99+
def _get_version() -> str:
100+
import importlib.metadata
101+
try:
102+
return importlib.metadata.version("specify-cli")
103+
except Exception:
104+
return "unknown"
105+
106+
refresh_shared_templates(
107+
project_path,
108+
version=_get_version(),
109+
core_pack=_svc.locate_core_pack(),
110+
repo_root=Path(__file__).parent.parent.parent,
111+
console=console,
112+
invoke_separator=invoke_separator,
113+
force=force,
114+
)
52115
from .commands import init as _init_cmd
53116
from .commands.extension import extension_app
54117
from .commands.integration import integration_app

src/specify_cli/_helpers.py

Lines changed: 15 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -202,108 +202,37 @@ def check_tool(tool: str, tracker: StepTracker = None) -> bool:
202202
def _install_shared_infra(
203203
project_path: Path,
204204
script_type: str,
205-
tracker: StepTracker | None = None,
205+
tracker=None,
206206
force: bool = False,
207207
invoke_separator: str = ".",
208208
) -> bool:
209209
"""Install shared infrastructure files into *project_path*.
210210
211-
Copies ``.specify/scripts/`` and ``.specify/templates/`` from the
212-
bundled core_pack or source checkout. Tracks all installed files
213-
in ``speckit.manifest.json``.
214-
215-
Page templates are processed to resolve ``__SPECKIT_COMMAND_<NAME>__``
216-
placeholders using *invoke_separator* (``"."`` for markdown agents,
217-
``"-"`` for skills agents).
218-
219-
When *force* is ``True``, existing files are overwritten with the
220-
latest bundled versions. When ``False`` (default), only missing
221-
files are added and existing ones are skipped.
211+
Delegates to ``shared_infra.install_shared_infra`` which provides
212+
symlink-safe writes and atomic template updates.
222213
223214
Returns ``True`` on success.
224215
"""
225216
import importlib.metadata
217+
from .shared_infra import install_shared_infra
218+
from ._assets import _asset_service as _svc
226219

227220
def _get_version() -> str:
228221
try:
229222
return importlib.metadata.version("specify-cli")
230223
except Exception:
231224
return "unknown"
232225

233-
from .integrations.base import IntegrationBase
234-
from .integrations.manifest import IntegrationManifest
235-
from ._assets import _asset_service as _svc
236-
237-
core = _svc.locate_core_pack()
238-
manifest = IntegrationManifest("speckit", project_path, version=_get_version())
239-
240-
# Scripts
241-
if core and (core / "scripts").is_dir():
242-
scripts_src = core / "scripts"
243-
else:
244-
repo_root = Path(__file__).parent.parent.parent
245-
scripts_src = repo_root / "scripts"
246-
247-
skipped_files: list[str] = []
248-
249-
if scripts_src.is_dir():
250-
dest_scripts = project_path / ".specify" / "scripts"
251-
dest_scripts.mkdir(parents=True, exist_ok=True)
252-
variant_dir = "bash" if script_type == "sh" else "powershell"
253-
variant_src = scripts_src / variant_dir
254-
if variant_src.is_dir():
255-
dest_variant = dest_scripts / variant_dir
256-
dest_variant.mkdir(parents=True, exist_ok=True)
257-
for src_path in variant_src.rglob("*"):
258-
if src_path.is_file():
259-
rel_path = src_path.relative_to(variant_src)
260-
dst_path = dest_variant / rel_path
261-
if dst_path.exists() and not force:
262-
skipped_files.append(str(dst_path.relative_to(project_path)))
263-
else:
264-
dst_path.parent.mkdir(parents=True, exist_ok=True)
265-
shutil.copy2(src_path, dst_path)
266-
rel = dst_path.relative_to(project_path).as_posix()
267-
manifest.record_existing(rel)
268-
269-
# Page templates (not command templates, not vscode-settings.json)
270-
if core and (core / "templates").is_dir():
271-
templates_src = core / "templates"
272-
else:
273-
repo_root = Path(__file__).parent.parent.parent
274-
templates_src = repo_root / "templates"
275-
276-
if templates_src.is_dir():
277-
dest_templates = project_path / ".specify" / "templates"
278-
dest_templates.mkdir(parents=True, exist_ok=True)
279-
for f in templates_src.iterdir():
280-
if f.is_file() and f.name != "vscode-settings.json" and not f.name.startswith("."):
281-
dst = dest_templates / f.name
282-
if dst.exists() and not force:
283-
skipped_files.append(str(dst.relative_to(project_path)))
284-
else:
285-
content = f.read_text(encoding="utf-8")
286-
content = IntegrationBase.resolve_command_refs(
287-
content, invoke_separator
288-
)
289-
dst.write_text(content, encoding="utf-8")
290-
rel = dst.relative_to(project_path).as_posix()
291-
manifest.record_existing(rel)
292-
293-
if skipped_files:
294-
console.print(
295-
f"[yellow]⚠[/yellow] {len(skipped_files)} shared infrastructure file(s) already exist and were not updated:"
296-
)
297-
for f in skipped_files:
298-
console.print(f" {f}")
299-
console.print(
300-
"To refresh shared infrastructure, run "
301-
"[cyan]specify init --here --force[/cyan] or "
302-
"[cyan]specify integration upgrade --force[/cyan]."
303-
)
304-
305-
manifest.save()
306-
return True
226+
return install_shared_infra(
227+
project_path,
228+
script_type,
229+
version=_get_version(),
230+
core_pack=_svc.locate_core_pack(),
231+
repo_root=Path(__file__).parent.parent.parent,
232+
console=console,
233+
force=force,
234+
invoke_separator=invoke_separator,
235+
)
307236

308237

309238
def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = None) -> None:

src/specify_cli/commands/extension.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ def extension_list(
260260

261261
# Check if we're in a spec-kit project
262262
specify_dir = project_root / _SPECIFY_DIR
263-
if not specify_dir.exists():
263+
if not specify_dir.is_dir():
264264
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
265265
console.print("Run this command from a spec-kit project root")
266266
raise typer.Exit(1)
@@ -300,7 +300,7 @@ def catalog_list() -> None:
300300
project_root = Path.cwd()
301301

302302
specify_dir = project_root / _SPECIFY_DIR
303-
if not specify_dir.exists():
303+
if not specify_dir.is_dir():
304304
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
305305
console.print("Run this command from a spec-kit project root")
306306
raise typer.Exit(1)
@@ -369,7 +369,7 @@ def catalog_add(
369369
project_root = Path.cwd()
370370

371371
specify_dir = project_root / _SPECIFY_DIR
372-
if not specify_dir.exists():
372+
if not specify_dir.is_dir():
373373
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
374374
console.print("Run this command from a spec-kit project root")
375375
raise typer.Exit(1)
@@ -432,7 +432,7 @@ def catalog_remove(
432432
project_root = Path.cwd()
433433

434434
specify_dir = project_root / _SPECIFY_DIR
435-
if not specify_dir.exists():
435+
if not specify_dir.is_dir():
436436
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
437437
console.print("Run this command from a spec-kit project root")
438438
raise typer.Exit(1)
@@ -481,7 +481,7 @@ def extension_add(
481481

482482
# Check if we're in a spec-kit project
483483
specify_dir = project_root / _SPECIFY_DIR
484-
if not specify_dir.exists():
484+
if not specify_dir.is_dir():
485485
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
486486
console.print("Run this command from a spec-kit project root")
487487
raise typer.Exit(1)
@@ -663,7 +663,7 @@ def extension_remove(
663663

664664
# Check if we're in a spec-kit project
665665
specify_dir = project_root / _SPECIFY_DIR
666-
if not specify_dir.exists():
666+
if not specify_dir.is_dir():
667667
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
668668
console.print("Run this command from a spec-kit project root")
669669
raise typer.Exit(1)
@@ -739,7 +739,7 @@ def extension_search(
739739

740740
# Check if we're in a spec-kit project
741741
specify_dir = project_root / _SPECIFY_DIR
742-
if not specify_dir.exists():
742+
if not specify_dir.is_dir():
743743
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
744744
console.print("Run this command from a spec-kit project root")
745745
raise typer.Exit(1)
@@ -823,7 +823,7 @@ def extension_info(
823823

824824
# Check if we're in a spec-kit project
825825
specify_dir = project_root / _SPECIFY_DIR
826-
if not specify_dir.exists():
826+
if not specify_dir.is_dir():
827827
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
828828
console.print("Run this command from a spec-kit project root")
829829
raise typer.Exit(1)
@@ -925,7 +925,7 @@ def extension_update(
925925

926926
# Check if we're in a spec-kit project
927927
specify_dir = project_root / _SPECIFY_DIR
928-
if not specify_dir.exists():
928+
if not specify_dir.is_dir():
929929
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
930930
console.print("Run this command from a spec-kit project root")
931931
raise typer.Exit(1)
@@ -1321,7 +1321,7 @@ def extension_enable(
13211321

13221322
# Check if we're in a spec-kit project
13231323
specify_dir = project_root / _SPECIFY_DIR
1324-
if not specify_dir.exists():
1324+
if not specify_dir.is_dir():
13251325
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
13261326
console.print("Run this command from a spec-kit project root")
13271327
raise typer.Exit(1)
@@ -1368,7 +1368,7 @@ def extension_disable(
13681368

13691369
# Check if we're in a spec-kit project
13701370
specify_dir = project_root / _SPECIFY_DIR
1371-
if not specify_dir.exists():
1371+
if not specify_dir.is_dir():
13721372
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
13731373
console.print("Run this command from a spec-kit project root")
13741374
raise typer.Exit(1)
@@ -1418,7 +1418,7 @@ def extension_set_priority(
14181418

14191419
# Check if we're in a spec-kit project
14201420
specify_dir = project_root / _SPECIFY_DIR
1421-
if not specify_dir.exists():
1421+
if not specify_dir.is_dir():
14221422
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
14231423
console.print("Run this command from a spec-kit project root")
14241424
raise typer.Exit(1)

src/specify_cli/commands/init.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,13 @@ def init(
349349
console.print(f"[cyan]Selected coding agent integration:[/cyan] {selected_ai}")
350350
console.print(f"[cyan]Selected script type:[/cyan] {selected_script}")
351351

352+
if not no_git:
353+
console.print(
354+
"\n[dim]Note: The git extension is currently enabled by default during `specify init`.\n"
355+
"Starting with v0.10.0, this will require an explicit opt-in via\n"
356+
"[bold]specify extension add git[/bold].[/dim]"
357+
)
358+
352359
tracker = StepTracker("Initialize Specify Project")
353360

354361
sys._specify_tracker_active = True
@@ -405,11 +412,19 @@ def init(
405412
)
406413
manifest.save()
407414

408-
# Write .specify/integration.json
415+
# Write .specify/integration.json (v1 schema)
409416
integration_json = project_path / ".specify" / "integration.json"
410417
integration_json.parent.mkdir(parents=True, exist_ok=True)
418+
_int_key = resolved_integration.key
419+
_int_sep = resolved_integration.effective_invoke_separator(integration_parsed_options)
411420
integration_json.write_text(json.dumps({
412-
"integration": resolved_integration.key,
421+
"integration_state_schema": 1,
422+
"integration": _int_key,
423+
"default_integration": _int_key,
424+
"installed_integrations": [_int_key],
425+
"integration_settings": {
426+
_int_key: {"invoke_separator": _int_sep},
427+
},
413428
"version": get_speckit_version(),
414429
}, indent=2) + "\n", encoding="utf-8")
415430

0 commit comments

Comments
 (0)