From e1a3cdbb05366eadc7dc3e5326787b687ede50ae Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Sat, 25 Apr 2026 14:23:18 +0800 Subject: [PATCH 01/31] docs: add refactor design spec and implementation plan for __init__.py module split --- .../plans/2026-04-25-init-module-split.md | 1349 +++++++++++++++++ .../specs/2026-04-25-init-refactor-design.md | 186 +++ 2 files changed, 1535 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-25-init-module-split.md create mode 100644 docs/superpowers/specs/2026-04-25-init-refactor-design.md diff --git a/docs/superpowers/plans/2026-04-25-init-module-split.md b/docs/superpowers/plans/2026-04-25-init-module-split.md new file mode 100644 index 0000000000..f0a5f6ade7 --- /dev/null +++ b/docs/superpowers/plans/2026-04-25-init-module-split.md @@ -0,0 +1,1349 @@ +# `__init__.py` Module Split Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Split `src/specify_cli/__init__.py` (5329 lines) into focused modules and extract three service classes to improve testability, with zero change to public CLI behavior. + +**Architecture:** Three sequential PRs — PR-1 extracts UI/FS utilities into `_ui.py` and `_fs.py` with a shared `_console.py` singleton; PR-2 extracts `AssetService`, `GitService`, `VersionService` as pure classes; PR-3 moves CLI handlers into a `commands/` subpackage. All symbols remain importable from `specify_cli` via re-exports. + +**Tech Stack:** Python 3.11+, pytest, typer, rich + +--- + +## PR-1: Extract UI and File System Utilities + +### Task 1: Create `_console.py` — shared Rich console singleton + +**Files:** +- Create: `src/specify_cli/_console.py` +- Modify: `src/specify_cli/__init__.py` (line 329 — replace inline definition with import) + +- [ ] **Step 1: Write the test** + +```python +# tests/test_console_singleton.py +from specify_cli._console import console +from rich.console import Console + +def test_console_is_rich_console(): + assert isinstance(console, Console) + +def test_console_imported_in_init(): + import specify_cli + assert hasattr(specify_cli, 'console') +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd /Users/darion.yaphet/source/spec-kit +python -m pytest tests/test_console_singleton.py -v +``` + +Expected: `ModuleNotFoundError: No module named 'specify_cli._console'` + +- [ ] **Step 3: Create `_console.py`** + +```python +# src/specify_cli/_console.py +from rich.console import Console + +console = Console(highlight=False) +``` + +- [ ] **Step 4: Update `__init__.py` line 329** + +Replace: +```python +console = Console(highlight=False) +``` +With: +```python +from ._console import console +``` + +Also remove `from rich.console import Console` from `__init__.py` imports **only if** it is not used elsewhere in the file. Check first: + +```bash +grep -n "Console(" src/specify_cli/__init__.py | grep -v "from ._console" +``` + +If Console is still used directly (e.g. in BannerGroup), keep the import. Otherwise remove it. + +- [ ] **Step 5: Run tests** + +```bash +python -m pytest tests/test_console_singleton.py tests/test_merge.py tests/test_extensions.py -v +``` + +Expected: all PASS + +- [ ] **Step 6: Commit** + +```bash +git add src/specify_cli/_console.py src/specify_cli/__init__.py tests/test_console_singleton.py +git commit -m "refactor: extract shared console singleton to _console.py" +``` + +--- + +### Task 2: Create `_ui.py` — terminal interaction utilities + +**Files:** +- Create: `src/specify_cli/_ui.py` +- Modify: `src/specify_cli/__init__.py` (remove moved code, add imports) +- Test: `tests/test_ui.py` + +Symbols to move (from `__init__.py`): +- `StepTracker` (lines 149–232) +- `get_key` (lines 234–252) +- `select_with_arrows` (lines 254–329) +- `BannerGroup` (lines 331–346) +- `show_banner` (lines 348–360) + +- [ ] **Step 1: Write the test** + +```python +# tests/test_ui.py +from specify_cli._ui import StepTracker, BannerGroup, show_banner, select_with_arrows + +def test_step_tracker_add_and_complete(): + t = StepTracker("test") + t.add("step1", "Step One") + t.complete("step1", "done") + assert t.steps[0]["status"] == "done" + +def test_step_tracker_render_returns_tree(): + from rich.tree import Tree + t = StepTracker("test") + t.add("s", "S") + result = t.render() + assert isinstance(result, Tree) + +def test_step_tracker_error(): + t = StepTracker("test") + t.add("s", "S") + t.error("s", "failed") + assert t.steps[0]["status"] == "error" + +def test_banner_group_is_typer_group(): + from typer.core import TyperGroup + assert issubclass(BannerGroup, TyperGroup) + +def test_symbols_re_exported_from_package(): + import specify_cli + assert hasattr(specify_cli, 'StepTracker') + assert hasattr(specify_cli, 'BannerGroup') + assert hasattr(specify_cli, 'show_banner') +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +python -m pytest tests/test_ui.py -v +``` + +Expected: `ImportError: cannot import name 'StepTracker' from 'specify_cli._ui'` + +- [ ] **Step 3: Create `_ui.py`** + +Copy `StepTracker` (lines 149–232), `get_key` (lines 234–252), `select_with_arrows` (lines 254–329), `BannerGroup` (lines 331–346), `show_banner` (lines 348–360) verbatim into a new file: + +```python +# src/specify_cli/_ui.py +from pathlib import Path +from typing import Any + +from rich.console import Console +from rich.tree import Tree +from rich.panel import Panel +from rich.text import Text +from rich.live import Live +from rich.align import Align +from typer.core import TyperGroup +import readchar + +from ._console import console + +# Paste StepTracker class here (lines 149-232 from __init__.py) +# Paste get_key function here (lines 234-252) +# Paste select_with_arrows function here (lines 254-329) +# Paste BannerGroup class here (lines 331-346) +# Paste show_banner function here (lines 348-360) +``` + +> **Note:** `show_banner` references the `BANNER` and `TAGLINE` constants. Move those constants to `_ui.py` as well, or import them from `__init__.py`. Prefer moving them since they are display-only. + +- [ ] **Step 4: Update `__init__.py`** + +Replace the moved code blocks (lines 149–360) with: +```python +from ._ui import StepTracker, get_key, select_with_arrows, BannerGroup, show_banner, BANNER, TAGLINE +``` + +Remove from `__init__.py` the now-redundant rich imports that are only used in `_ui.py`: +- `from rich.tree import Tree` +- `from rich.panel import Panel` +- `from rich.text import Text` +- `from rich.live import Live` +- `from rich.align import Align` +- `import readchar` + +Before removing each import, verify it is no longer used in `__init__.py`: +```bash +grep -n "Tree\|Panel\|Text\|Live\|Align\|readchar" src/specify_cli/__init__.py +``` + +Only remove imports confirmed as unused. + +- [ ] **Step 5: Run full test suite** + +```bash +python -m pytest tests/test_ui.py tests/test_merge.py tests/test_extensions.py tests/test_presets.py -v +``` + +Expected: all PASS + +- [ ] **Step 6: Smoke test CLI** + +```bash +python -m specify_cli --help +python -m specify_cli init --help +``` + +Expected: help text renders normally with banner + +- [ ] **Step 7: Commit** + +```bash +git add src/specify_cli/_ui.py src/specify_cli/__init__.py tests/test_ui.py +git commit -m "refactor: extract UI utilities to _ui.py" +``` + +--- + +### Task 3: Create `_fs.py` — file system utilities + +**Files:** +- Create: `src/specify_cli/_fs.py` +- Modify: `src/specify_cli/__init__.py` +- Test: existing `tests/test_merge.py` covers `merge_json_files` + +Symbols to move: +- `handle_vscode_settings` (lines 481–548) +- `merge_json_files` (lines 550–627) +- `save_init_options` (lines 910–920) +- `load_init_options` (lines 922–933) + +- [ ] **Step 1: Verify existing merge tests pass before changes** + +```bash +python -m pytest tests/test_merge.py -v +``` + +Expected: all PASS (baseline) + +- [ ] **Step 2: Create `_fs.py`** + +```python +# src/specify_cli/_fs.py +import json +import json5 +import os +import shutil +import stat +import tempfile +from pathlib import Path +from typing import Any, Optional + +from ._console import console + + +# Paste handle_vscode_settings (lines 481-548) verbatim +# Paste merge_json_files (lines 550-627) verbatim +# Paste save_init_options (lines 910-920) verbatim +# Paste load_init_options (lines 922-933) verbatim +``` + +> **Note:** `handle_vscode_settings` calls `merge_json_files` — both are in the same file so no import needed. `save_init_options` and `load_init_options` use `json` and `Path` — already imported above. + +- [ ] **Step 3: Update `__init__.py`** + +Replace the moved code blocks with: +```python +from ._fs import handle_vscode_settings, merge_json_files, save_init_options, load_init_options +``` + +- [ ] **Step 4: Run tests** + +```bash +python -m pytest tests/test_merge.py tests/test_setup_plan_feature_json.py -v +``` + +Expected: all PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/specify_cli/_fs.py src/specify_cli/__init__.py +git commit -m "refactor: extract file system utilities to _fs.py" +``` + +--- + +## PR-2: Extract Service Classes + +### Task 4: Create `_assets.py` — AssetService + +**Files:** +- Create: `src/specify_cli/_assets.py` +- Modify: `src/specify_cli/__init__.py` +- Test: `tests/test_asset_service.py` + +- [ ] **Step 1: Write the test** + +```python +# tests/test_asset_service.py +from pathlib import Path +from specify_cli._assets import AssetService + +def test_locate_core_pack_returns_path_or_none(): + svc = AssetService() + result = svc.locate_core_pack() + assert result is None or isinstance(result, Path) + +def test_locate_bundled_extension_invalid_id_returns_none(): + svc = AssetService() + assert svc.locate_bundled_extension("../evil") is None + assert svc.locate_bundled_extension("UPPER") is None + +def test_locate_bundled_extension_valid_id(): + svc = AssetService() + result = svc.locate_bundled_extension("git") + # In source checkout, git extension should exist + assert result is None or isinstance(result, Path) + +def test_locate_bundled_workflow_invalid_id_returns_none(): + svc = AssetService() + assert svc.locate_bundled_workflow("") is None + assert svc.locate_bundled_workflow("BAD ID") is None + +def test_locate_bundled_preset_invalid_id_returns_none(): + svc = AssetService() + assert svc.locate_bundled_preset("../etc/passwd") is None + +def test_backward_compat_module_functions(): + # The old underscore functions must still work via __init__.py + from specify_cli import _locate_core_pack, _locate_bundled_extension + result = _locate_core_pack() + assert result is None or isinstance(result, Path) +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +python -m pytest tests/test_asset_service.py -v +``` + +Expected: `ImportError: cannot import name 'AssetService' from 'specify_cli._assets'` + +- [ ] **Step 3: Create `_assets.py`** + +```python +# src/specify_cli/_assets.py +import re +from pathlib import Path + + +class AssetService: + """Locates bundled assets (core_pack, extensions, workflows, presets).""" + + def locate_core_pack(self) -> Path | None: + candidate = Path(__file__).parent / "core_pack" + if candidate.is_dir(): + return candidate + return None + + def locate_bundled_extension(self, extension_id: str) -> Path | None: + if not re.match(r'^[a-z0-9-]+$', extension_id): + return None + core = self.locate_core_pack() + if core is not None: + candidate = core / "extensions" / extension_id + if (candidate / "extension.yml").is_file(): + return candidate + repo_root = Path(__file__).parent.parent.parent + candidate = repo_root / "extensions" / extension_id + if (candidate / "extension.yml").is_file(): + return candidate + return None + + def locate_bundled_workflow(self, workflow_id: str) -> Path | None: + if not re.match(r'^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$', workflow_id): + return None + core = self.locate_core_pack() + if core is not None: + candidate = core / "workflows" / workflow_id + if (candidate / "workflow.yml").is_file(): + return candidate + repo_root = Path(__file__).parent.parent.parent + candidate = repo_root / "workflows" / workflow_id + if (candidate / "workflow.yml").is_file(): + return candidate + return None + + def locate_bundled_preset(self, preset_id: str) -> Path | None: + if not re.match(r'^[a-z0-9-]+$', preset_id): + return None + core = self.locate_core_pack() + if core is not None: + candidate = core / "presets" / preset_id + if (candidate / "preset.yml").is_file(): + return candidate + repo_root = Path(__file__).parent.parent.parent + candidate = repo_root / "presets" / preset_id + if (candidate / "preset.yml").is_file(): + return candidate + return None + + +# Module-level singleton for backward compat +_asset_service = AssetService() +``` + +- [ ] **Step 4: Update `__init__.py` — replace `_locate_*` functions with wrappers** + +Replace the four `_locate_*` function bodies (lines 629–718) with: + +```python +from ._assets import AssetService as _AssetService, _asset_service as _svc + +def _locate_core_pack() -> Path | None: + return _svc.locate_core_pack() + +def _locate_bundled_extension(extension_id: str) -> Path | None: + return _svc.locate_bundled_extension(extension_id) + +def _locate_bundled_workflow(workflow_id: str) -> Path | None: + return _svc.locate_bundled_workflow(workflow_id) + +def _locate_bundled_preset(preset_id: str) -> Path | None: + return _svc.locate_bundled_preset(preset_id) +``` + +- [ ] **Step 5: Run tests** + +```bash +python -m pytest tests/test_asset_service.py tests/test_extensions.py tests/test_presets.py -v +``` + +Expected: all PASS + +- [ ] **Step 6: Commit** + +```bash +git add src/specify_cli/_assets.py src/specify_cli/__init__.py tests/test_asset_service.py +git commit -m "refactor: extract AssetService to _assets.py" +``` + +--- + +### Task 5: Create `_git.py` — GitService + +**Files:** +- Create: `src/specify_cli/_git.py` +- Modify: `src/specify_cli/__init__.py` +- Test: `tests/test_git_service.py` + +- [ ] **Step 1: Write the test** + +```python +# tests/test_git_service.py +import subprocess +from pathlib import Path +from unittest.mock import patch, MagicMock +from specify_cli._git import GitService + +def test_is_repo_true_in_real_git_repo(tmp_path): + subprocess.run(["git", "init"], cwd=tmp_path, check=True, capture_output=True) + svc = GitService() + assert svc.is_repo(tmp_path) is True + +def test_is_repo_false_in_plain_dir(tmp_path): + svc = GitService() + assert svc.is_repo(tmp_path) is False + +def test_init_repo_success(tmp_path): + svc = GitService() + ok, err = svc.init_repo(tmp_path) + assert ok is True + assert err is None + assert (tmp_path / ".git").is_dir() + +def test_init_repo_returns_error_on_failure(): + svc = GitService() + with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, ["git"])): + ok, err = svc.init_repo(Path("/nonexistent")) + assert ok is False + assert err is not None + +def test_init_repo_does_not_print(tmp_path, capsys): + svc = GitService() + svc.init_repo(tmp_path) + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err == "" + +def test_backward_compat_is_git_repo(tmp_path): + from specify_cli import is_git_repo + assert is_git_repo(tmp_path) is False + +def test_backward_compat_init_git_repo(tmp_path): + from specify_cli import init_git_repo + ok, err = init_git_repo(tmp_path, quiet=True) + assert ok is True +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +python -m pytest tests/test_git_service.py -v +``` + +Expected: `ImportError: cannot import name 'GitService' from 'specify_cli._git'` + +- [ ] **Step 3: Create `_git.py`** + +```python +# src/specify_cli/_git.py +import os +import subprocess +from pathlib import Path +from typing import Optional + + +class GitService: + """Pure git operations — no console output.""" + + def is_repo(self, path: Path = None) -> bool: + if path is None: + path = Path.cwd() + if not path.is_dir(): + return False + try: + subprocess.run( + ["git", "rev-parse", "--is-inside-work-tree"], + check=True, + capture_output=True, + cwd=path, + ) + return True + except (subprocess.CalledProcessError, FileNotFoundError): + return False + + def init_repo(self, project_path: Path) -> tuple[bool, Optional[str]]: + """Initialize a git repo. Returns (success, error_message_or_None).""" + try: + original_cwd = Path.cwd() + os.chdir(project_path) + subprocess.run(["git", "init"], check=True, capture_output=True, text=True) + subprocess.run(["git", "add", "."], check=True, capture_output=True, text=True) + subprocess.run( + ["git", "commit", "-m", "Initial commit from Specify template"], + check=True, capture_output=True, text=True, + ) + return True, None + except subprocess.CalledProcessError as e: + error_msg = f"Command: {' '.join(e.cmd)}\nExit code: {e.returncode}" + if e.stderr: + error_msg += f"\nError: {e.stderr.strip()}" + elif e.stdout: + error_msg += f"\nOutput: {e.stdout.strip()}" + return False, error_msg + finally: + try: + os.chdir(original_cwd) + except Exception: + pass + + +_git_service = GitService() +``` + +- [ ] **Step 4: Update `__init__.py` — replace `is_git_repo` and `init_git_repo` with wrappers** + +Replace `is_git_repo` (lines 435–452) and `init_git_repo` (lines 455–477) bodies with: + +```python +from ._git import GitService as _GitService, _git_service as _git_svc + +def is_git_repo(path: Path = None) -> bool: + return _git_svc.is_repo(path) + +def init_git_repo(project_path: Path, quiet: bool = False) -> tuple[bool, Optional[str]]: + ok, err = _git_svc.init_repo(project_path) + if not quiet: + if ok: + console.print("[green]✓[/green] Git repository initialized") + else: + console.print(f"[red]Error initializing git repository:[/red] {err}") + return ok, err +``` + +- [ ] **Step 5: Run tests** + +```bash +python -m pytest tests/test_git_service.py tests/test_check_tool.py -v +``` + +Expected: all PASS + +- [ ] **Step 6: Commit** + +```bash +git add src/specify_cli/_git.py src/specify_cli/__init__.py tests/test_git_service.py +git commit -m "refactor: extract GitService to _git.py" +``` + +--- + +### Task 6: Create `_version.py` — VersionService + +**Files:** +- Create: `src/specify_cli/_version.py` +- Modify: `src/specify_cli/__init__.py` +- Test: `tests/test_version_service.py` + +- [ ] **Step 1: Write the test** + +```python +# tests/test_version_service.py +from unittest.mock import patch, MagicMock +import json +from specify_cli._version import VersionService + +def test_is_newer_true(): + svc = VersionService() + assert svc.is_newer("0.9.0", "0.8.0") is True + +def test_is_newer_false_when_equal(): + svc = VersionService() + assert svc.is_newer("0.8.0", "0.8.0") is False + +def test_is_newer_false_when_older(): + svc = VersionService() + assert svc.is_newer("0.7.0", "0.8.0") is False + +def test_is_newer_false_with_unknown(): + svc = VersionService() + assert svc.is_newer("unknown", "0.8.0") is False + assert svc.is_newer("0.9.0", "unknown") is False + +def test_normalize_tag_strips_v(): + svc = VersionService() + assert svc._normalize_tag("v0.9.0") == "0.9.0" + assert svc._normalize_tag("0.9.0") == "0.9.0" + assert svc._normalize_tag("vv0.9.0") == "v0.9.0" + +def test_get_installed_version_returns_string(): + svc = VersionService() + result = svc.get_installed_version() + assert isinstance(result, str) + +def test_fetch_latest_tag_returns_tuple_on_network_error(): + svc = VersionService() + import urllib.error + with patch("urllib.request.urlopen", side_effect=urllib.error.URLError("timeout")): + tag, failure = svc.fetch_latest_tag() + assert tag is None + assert failure == "offline or timeout" + +def test_fetch_latest_tag_success(): + svc = VersionService() + mock_response = MagicMock() + mock_response.read.return_value = json.dumps({"tag_name": "v0.9.0"}).encode() + mock_response.__enter__ = lambda s: s + mock_response.__exit__ = MagicMock(return_value=False) + with patch("urllib.request.urlopen", return_value=mock_response): + tag, failure = svc.fetch_latest_tag() + assert tag == "v0.9.0" + assert failure is None + +def test_backward_compat_normalize_tag(): + from specify_cli import _normalize_tag + assert _normalize_tag("v1.0.0") == "1.0.0" + +def test_backward_compat_is_newer(): + from specify_cli import _is_newer + assert _is_newer("1.0.0", "0.9.0") is True +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +python -m pytest tests/test_version_service.py -v +``` + +Expected: `ImportError: cannot import name 'VersionService' from 'specify_cli._version'` + +- [ ] **Step 3: Create `_version.py`** + +```python +# src/specify_cli/_version.py +import json +import os +import urllib.error +import urllib.request +from typing import Optional + +from packaging.version import InvalidVersion, Version + +GITHUB_API_LATEST = "https://api.github.com/repos/github/spec-kit/releases/latest" + + +class VersionService: + """Version checking and comparison — no console output.""" + + def get_installed_version(self) -> str: + import importlib.metadata + metadata_errors = [importlib.metadata.PackageNotFoundError] + invalid_err = getattr(importlib.metadata, "InvalidMetadataError", None) + if invalid_err is not None: + metadata_errors.append(invalid_err) + try: + return importlib.metadata.version("specify-cli") + except tuple(metadata_errors): + return "unknown" + + def _normalize_tag(self, tag: str) -> str: + return tag[1:] if tag.startswith("v") else tag + + def is_newer(self, latest: str, current: str) -> bool: + if latest == "unknown" or current == "unknown": + return False + try: + return Version(latest) > Version(current) + except InvalidVersion: + return False + + def fetch_latest_tag(self) -> tuple[Optional[str], Optional[str]]: + """Returns (tag, failure_category). One of the two is always None.""" + req = urllib.request.Request( + GITHUB_API_LATEST, + headers={"Accept": "application/vnd.github+json"}, + ) + token = None + for env_var in ("GH_TOKEN", "GITHUB_TOKEN"): + candidate = os.environ.get(env_var) + if candidate is not None: + candidate = candidate.strip() + if candidate: + token = candidate + break + if token: + req.add_header("Authorization", f"Bearer {token}") + try: + with urllib.request.urlopen(req, timeout=5) as resp: + payload = json.loads(resp.read().decode("utf-8")) + tag = payload.get("tag_name") + if not isinstance(tag, str) or not tag: + raise ValueError("GitHub API response missing valid tag_name") + return tag, None + except urllib.error.HTTPError as e: + if e.code == 403: + return None, "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)" + return None, f"HTTP {e.code}" + except (urllib.error.URLError, OSError): + return None, "offline or timeout" + + +_version_service = VersionService() +``` + +- [ ] **Step 4: Update `__init__.py` — replace version functions with wrappers** + +Replace `_get_installed_version` (lines 1670–1690), `_normalize_tag` (lines 1692–1699), `_is_newer` (lines 1701–1713), `_fetch_latest_release_tag` (lines 1716–1751) with: + +```python +from ._version import VersionService as _VersionService, _version_service as _ver_svc + +def _get_installed_version() -> str: + return _ver_svc.get_installed_version() + +def _normalize_tag(tag: str) -> str: + return _ver_svc._normalize_tag(tag) + +def _is_newer(latest: str, current: str) -> bool: + return _ver_svc.is_newer(latest, current) + +def _fetch_latest_release_tag() -> tuple[str | None, str | None]: + return _ver_svc.fetch_latest_tag() +``` + +- [ ] **Step 5: Run tests** + +```bash +python -m pytest tests/test_version_service.py tests/test_cli_version.py tests/test_upgrade.py -v +``` + +Expected: all PASS + +- [ ] **Step 6: Commit** + +```bash +git add src/specify_cli/_version.py src/specify_cli/__init__.py tests/test_version_service.py +git commit -m "refactor: extract VersionService to _version.py" +``` + +--- + +## PR-3: Split CLI Command Handlers + +> **Circular import rule:** `commands/*.py` modules MUST NOT import from `specify_cli.__init__`. They import only from sibling utility modules (`_ui`, `_fs`, `_git`, `_assets`, `_version`, `_console`, `_helpers`). Each commands module defines its own sub-typer; `__init__.py` imports the sub-typer and wires it with `app.add_typer()`. + +### Task 7: Create `_helpers.py` — init-command utilities + +**Files:** +- Create: `src/specify_cli/_helpers.py` +- Modify: `src/specify_cli/__init__.py` (replace bodies with wrapper imports) +- Test: `tests/test_helpers.py` + +Functions to move: `run_command` (line 378), `check_tool` (line 396), `_install_shared_infra` (line 721), `ensure_executable_scripts` (line 819), `ensure_constitution_from_template` (line 871), `_get_skills_dir` (line 936). + +- [ ] **Step 1: Write the test** + +```python +# tests/test_helpers.py +import shutil +from pathlib import Path +from unittest.mock import patch +from specify_cli._helpers import check_tool, run_command + +def test_check_tool_git_found(): + if shutil.which("git"): + assert check_tool("git") is True + +def test_check_tool_nonexistent_returns_false(): + assert check_tool("__nonexistent_tool_xyz__") is False + +def test_run_command_capture(): + result = run_command(["echo", "hello"], capture=True) + assert result == "hello" + +def test_run_command_no_capture_returns_none(): + result = run_command(["true"]) + assert result is None +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +python -m pytest tests/test_helpers.py -v +``` + +Expected: `ImportError: cannot import name 'check_tool' from 'specify_cli._helpers'` + +- [ ] **Step 3: Create `_helpers.py`** + +```python +# src/specify_cli/_helpers.py +import os +import shlex +import shutil +import subprocess +from pathlib import Path +from typing import Any, Optional + +from ._console import console +from ._ui import StepTracker + +CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude" +CLAUDE_NPM_LOCAL_PATH = Path.home() / ".claude" / "local" / "node_modules" / ".bin" / "claude" + +# Paste run_command (lines 378-394) verbatim +# Paste check_tool (lines 396-432) verbatim +# Paste _install_shared_infra (lines 721-817) verbatim +# Paste ensure_executable_scripts (lines 819-868) verbatim +# Paste ensure_constitution_from_template (lines 871-908) verbatim +# Paste _get_skills_dir (lines 936-962) verbatim +``` + +- [ ] **Step 4: Update `__init__.py`** — replace the moved function bodies with imports + +```python +from ._helpers import ( + run_command, check_tool, + _install_shared_infra, ensure_executable_scripts, + ensure_constitution_from_template, _get_skills_dir, + CLAUDE_LOCAL_PATH, CLAUDE_NPM_LOCAL_PATH, +) +``` + +Remove the now-redundant `CLAUDE_LOCAL_PATH` and `CLAUDE_NPM_LOCAL_PATH` constant definitions from `__init__.py` (lines 136–137). + +- [ ] **Step 5: Run tests** + +```bash +python -m pytest tests/test_helpers.py tests/test_check_tool.py -v +``` + +Expected: all PASS + +- [ ] **Step 6: Commit** + +```bash +git add src/specify_cli/_helpers.py src/specify_cli/__init__.py tests/test_helpers.py +git commit -m "refactor: extract init helper utilities to _helpers.py" +``` + +--- + +### Task 8: Create `commands/` package skeleton + +**Files:** +- Create: `src/specify_cli/commands/__init__.py` +- Create: `src/specify_cli/commands/init.py` (empty stub) +- Create: `src/specify_cli/commands/integration.py` (empty stub) +- Create: `src/specify_cli/commands/preset.py` (empty stub) +- Create: `src/specify_cli/commands/extension.py` (empty stub) +- Create: `src/specify_cli/commands/workflow.py` (empty stub) + +- [ ] **Step 1: Write the structure test** + +```python +# tests/test_commands_package.py +def test_commands_package_importable(): + import specify_cli.commands + assert specify_cli.commands is not None + +def test_command_modules_importable(): + from specify_cli.commands import init, integration, preset, extension, workflow +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +python -m pytest tests/test_commands_package.py -v +``` + +Expected: `ModuleNotFoundError: No module named 'specify_cli.commands'` + +- [ ] **Step 3: Create the package** + +```python +# src/specify_cli/commands/__init__.py +# CLI command groups — each module registers its commands onto the shared app. +``` + +```python +# src/specify_cli/commands/init.py +# specify init command — placeholder, content moved in Task 8 +``` + +```python +# src/specify_cli/commands/integration.py +# specify integration * commands — placeholder, content moved in Task 9 +``` + +```python +# src/specify_cli/commands/preset.py +# specify preset * commands — placeholder, content moved in Task 10 +``` + +```python +# src/specify_cli/commands/extension.py +# specify extension * / catalog * commands — placeholder, content moved in Task 11 +``` + +```python +# src/specify_cli/commands/workflow.py +# specify workflow * commands — placeholder, content moved in Task 12 +``` + +- [ ] **Step 4: Run test** + +```bash +python -m pytest tests/test_commands_package.py -v +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/specify_cli/commands/ +git commit -m "refactor: create commands/ package skeleton" +``` + +--- + +### Task 9: Move `init` command handler + +**Files:** +- Modify: `src/specify_cli/commands/init.py` +- Modify: `src/specify_cli/__init__.py` + +The `init` command spans approximately lines 964–1596 in `__init__.py`. + +> **No circular imports:** `commands/init.py` imports ONLY from `_console`, `_ui`, `_fs`, `_git`, `_assets`, `_helpers`, and third-party libraries. Never from `specify_cli.__init__`. The `app` object is defined in `__init__.py`; pass it in via a `register(app)` pattern or keep `app` import as a lazy import at call-time. + +- [ ] **Step 1: Move handler to `commands/init.py`** + +Pattern: define a `register(app)` function that attaches the command, call it from `__init__.py`. + +```python +# src/specify_cli/commands/init.py +from pathlib import Path +from typing import Any, Optional +import shlex + +import typer + +from .._console import console +from .._ui import StepTracker, show_banner, select_with_arrows +from .._fs import handle_vscode_settings, merge_json_files, save_init_options, load_init_options +from .._git import _git_service +from .._assets import _asset_service +from .._helpers import ( + run_command, check_tool, _install_shared_infra, + ensure_constitution_from_template, ensure_executable_scripts, _get_skills_dir, + AGENT_CONFIG, AI_ASSISTANT_ALIASES, AI_ASSISTANT_HELP, SCRIPT_TYPE_CHOICES, +) + +def _build_ai_deprecation_warning(integration_key: str, ai_commands_dir: str | None = None) -> str: + replacement = f"--integration {integration_key}" + if integration_key == "generic" and ai_commands_dir: + replacement += f' --integration-options="--commands-dir {shlex.quote(ai_commands_dir)}"' + return ( + "[bold]--ai[/bold] is deprecated and will no longer be available in version 0.10.0 or later.\n\n" + f"Use [bold]{replacement}[/bold] instead." + ) + +def register(app: typer.Typer) -> None: + @app.command() + def init( + project_name: str = typer.Argument(None, ...), + # ... all parameters exactly as in __init__.py lines 966-983 + ): + # Paste full init() body verbatim from lines 1023-1596 + # Replace: is_git_repo(path) → _git_service.is_repo(path) + # Replace: init_git_repo(path, quiet=q) → + # ok, err = _git_service.init_repo(path) + # if not quiet: + # console.print("[green]✓[/green] Git repository initialized" if ok else f"[red]Error:[/red] {err}") + pass +``` + +- [ ] **Step 2: Update `__init__.py`** + +Replace the `init` command block (lines 964–1596) with: + +```python +from .commands import init as _init_cmd +_init_cmd.register(app) +``` + +Also move `_build_ai_deprecation_warning` and `_build_integration_equivalent` to `_helpers.py` since they are no longer needed in `__init__.py` after this task. + +- [ ] **Step 3: Run full test suite** + +```bash +python -m pytest -v +``` + +Expected: all PASS + +- [ ] **Step 4: Smoke test** + +```bash +python -m specify_cli init --help +``` + +Expected: init help renders correctly with all flags + +- [ ] **Step 5: Commit** + +```bash +git add src/specify_cli/commands/init.py src/specify_cli/__init__.py +git commit -m "refactor: move init command handler to commands/init.py" +``` + +--- + +### Task 10: Move `integration` command handlers + +**Files:** +- Modify: `src/specify_cli/commands/integration.py` +- Modify: `src/specify_cli/__init__.py` + +Functions to move: `integration_list`, `integration_install`, `integration_uninstall`, `integration_switch`, `integration_upgrade` and helpers `_parse_integration_options`, `_update_init_options_for_integration`, `_read_integration_json`, `_write_integration_json`, `_remove_integration_json`, `_normalize_script_type`, `_resolve_script_type`. + +> **Sub-typer pattern:** define `integration_app` inside `commands/integration.py`. `__init__.py` imports it and calls `app.add_typer(integration_app, name="integration")`. This avoids circular imports. + +- [ ] **Step 1: Move to `commands/integration.py`** + +```python +# src/specify_cli/commands/integration.py +from pathlib import Path +from typing import Any, Optional + +import typer + +from .._console import console +from .._ui import StepTracker +from .._fs import save_init_options, load_init_options +from .._helpers import run_command, check_tool, AGENT_CONFIG, SCRIPT_TYPE_CHOICES + +integration_app = typer.Typer( + name="integration", + help="Manage coding agent integrations", + add_completion=False, +) + +# Paste _read_integration_json, _write_integration_json, _remove_integration_json verbatim +# Paste _normalize_script_type, _resolve_script_type verbatim +# Paste _parse_integration_options, _update_init_options_for_integration verbatim +# Paste all @integration_app.command(...) decorated functions verbatim +``` + +- [ ] **Step 2: Update `__init__.py`** + +Replace the sub-typer definition (lines 1882–1887) and all integration handler code with: +```python +from .commands.integration import ( + integration_app, + _read_integration_json, _write_integration_json, _remove_integration_json, + _normalize_script_type, _resolve_script_type, +) +app.add_typer(integration_app, name="integration") +``` + +- [ ] **Step 3: Run tests** + +```bash +python -m pytest -v +python -m specify_cli integration --help +``` + +Expected: all PASS, help renders correctly + +- [ ] **Step 4: Commit** + +```bash +git add src/specify_cli/commands/integration.py src/specify_cli/__init__.py +git commit -m "refactor: move integration command handlers to commands/integration.py" +``` + +--- + +### Task 11: Move `preset` command handlers + +**Files:** +- Modify: `src/specify_cli/commands/preset.py` +- Modify: `src/specify_cli/__init__.py` + +Functions: `preset_list`, `preset_add`, `preset_remove`, `preset_search`, `preset_resolve`, `preset_info`, `preset_set_priority`, `preset_enable`, `preset_disable`, `preset_catalog_list`, `preset_catalog_add`, `preset_catalog_remove`. + +- [ ] **Step 1: Move to `commands/preset.py`** + +```python +# src/specify_cli/commands/preset.py +import typer +from .._console import console +from .._ui import StepTracker +from .._assets import _asset_service + +preset_app = typer.Typer(name="preset", help="Manage spec-kit presets", add_completion=False) +preset_catalog_app = typer.Typer(name="catalog", help="Manage preset catalogs", add_completion=False) +preset_app.add_typer(preset_catalog_app, name="catalog") + +# Paste all @preset_app.command(...) and @preset_catalog_app.command(...) decorated functions verbatim +``` + +- [ ] **Step 2: Update `__init__.py`** + +Replace lines 1844–1856 and all preset handler code with: +```python +from .commands.preset import preset_app, preset_catalog_app +app.add_typer(preset_app, name="preset") +``` + +- [ ] **Step 3: Run tests** + +```bash +python -m pytest tests/test_presets.py -v +python -m specify_cli preset --help +``` + +Expected: all PASS + +- [ ] **Step 4: Commit** + +```bash +git add src/specify_cli/commands/preset.py src/specify_cli/__init__.py +git commit -m "refactor: move preset command handlers to commands/preset.py" +``` + +--- + +### Task 12: Move `extension` and `catalog` command handlers + +**Files:** +- Modify: `src/specify_cli/commands/extension.py` +- Modify: `src/specify_cli/__init__.py` + +Functions: `extension_list`, `extension_add`, `extension_remove`, `extension_search`, `extension_info`, `extension_update`, `extension_enable`, `extension_disable`, `extension_set_priority`, `catalog_list`, `catalog_add`, `catalog_remove`, `_resolve_installed_extension`, `_resolve_catalog_extension`, `_print_extension_info`. + +- [ ] **Step 1: Move to `commands/extension.py`** + +```python +# src/specify_cli/commands/extension.py +import typer +from .._console import console +from .._ui import StepTracker +from .._assets import _asset_service + +extension_app = typer.Typer(name="extension", help="Manage spec-kit extensions", add_completion=False) +catalog_app = typer.Typer(name="catalog", help="Manage extension catalogs", add_completion=False) +extension_app.add_typer(catalog_app, name="catalog") + +# Paste _resolve_installed_extension, _resolve_catalog_extension, _print_extension_info verbatim +# Paste all @extension_app.command(...) and @catalog_app.command(...) decorated functions verbatim +``` + +- [ ] **Step 2: Update `__init__.py`** + +Replace lines 1830–1842 and all extension/catalog handler code with: +```python +from .commands.extension import extension_app, catalog_app +app.add_typer(extension_app, name="extension") +``` + +- [ ] **Step 3: Run tests** + +```bash +python -m pytest tests/test_extensions.py tests/integrations/ -v +python -m specify_cli extension --help +``` + +Expected: all PASS + +- [ ] **Step 4: Commit** + +```bash +git add src/specify_cli/commands/extension.py src/specify_cli/__init__.py +git commit -m "refactor: move extension command handlers to commands/extension.py" +``` + +--- + +### Task 13: Move `workflow` command handlers + +**Files:** +- Modify: `src/specify_cli/commands/workflow.py` +- Modify: `src/specify_cli/__init__.py` + +Functions: `workflow_run`, `workflow_resume`, `workflow_status`, `workflow_list`, `workflow_add`, `workflow_remove`, `workflow_search`, `workflow_info`, `workflow_catalog_list`, `workflow_catalog_add`, `workflow_catalog_remove`. + +- [ ] **Step 1: Move to `commands/workflow.py`** + +```python +# src/specify_cli/commands/workflow.py +import typer +from .._console import console +from .._ui import StepTracker +from .._assets import _asset_service + +workflow_app = typer.Typer(name="workflow", help="Run spec-driven workflows", add_completion=False) +workflow_catalog_app = typer.Typer(name="catalog", help="Manage workflow catalogs", add_completion=False) +workflow_app.add_typer(workflow_catalog_app, name="catalog") + +# Paste all @workflow_app.command(...) and @workflow_catalog_app.command(...) decorated functions verbatim +``` + +- [ ] **Step 2: Update `__init__.py`** + +```python +from .commands.workflow import workflow_app +app.add_typer(workflow_app, name="workflow") +``` + +- [ ] **Step 3: Run full test suite + smoke test** + +```bash +python -m pytest -v +python -m specify_cli --help +python -m specify_cli integration --help +python -m specify_cli preset --help +python -m specify_cli extension --help +python -m specify_cli workflow --help +python -m specify_cli self check +``` + +Expected: all PASS, all help renders correctly + +- [ ] **Step 4: Commit** + +```bash +git add src/specify_cli/commands/workflow.py src/specify_cli/__init__.py +git commit -m "refactor: move workflow command handlers to commands/workflow.py" +``` + +--- + +### Task 14: Final `__init__.py` cleanup + +**Files:** +- Modify: `src/specify_cli/__init__.py` + +After all moves, `__init__.py` should contain only: +- Module docstring +- `app = typer.Typer(cls=BannerGroup, ...)` definition +- `app.add_typer(...)` calls for each sub-typer (imported from commands modules) +- The `@app.callback()` function +- `main()` entry point +- Re-export imports for backward compatibility (all previously-public symbols) + +The `GITHUB_API_LATEST` constant moves to `_version.py`. Sub-typer objects (`integration_app`, `extension_app`, etc.) are no longer defined here — they live in their respective commands modules. + +- [ ] **Step 1: Count remaining lines** + +```bash +wc -l src/specify_cli/__init__.py +``` + +Expected: under 200 lines + +- [ ] **Step 2: Remove any unused imports from `__init__.py`** + +```bash +python -m py_compile src/specify_cli/__init__.py && echo "syntax OK" +``` + +Then run: +```bash +python -m pytest -v +``` + +Expected: all PASS + +- [ ] **Step 3: Final commit** + +```bash +git add src/specify_cli/__init__.py +git commit -m "refactor: clean up __init__.py after full module extraction" +``` + +--- + +## Verification Checklist + +After all tasks complete: + +- [ ] `wc -l src/specify_cli/__init__.py` shows < 200 lines +- [ ] `python -m pytest -v` — all existing tests pass +- [ ] `python -m specify_cli --help` — renders correctly +- [ ] `python -m specify_cli init --help` — renders correctly +- [ ] New service unit tests exist: `test_asset_service.py`, `test_git_service.py`, `test_version_service.py` +- [ ] No `console.print` in `_assets.py`, `_git.py`, `_version.py`, `_fs.py` +- [ ] All old symbol names importable from `specify_cli` (checked by backward-compat tests) diff --git a/docs/superpowers/specs/2026-04-25-init-refactor-design.md b/docs/superpowers/specs/2026-04-25-init-refactor-design.md new file mode 100644 index 0000000000..3909f175e3 --- /dev/null +++ b/docs/superpowers/specs/2026-04-25-init-refactor-design.md @@ -0,0 +1,186 @@ +# Refactor Design: `__init__.py` Code Quality Improvement + +**Date:** 2026-04-25 +**Goal:** Improve testability and maintainability of `src/specify_cli/__init__.py` (5329 lines) without breaking any public CLI interfaces. + +--- + +## Problem + +`__init__.py` contains 7 distinct responsibilities in a single 5329-line file: + +- Terminal UI utilities (StepTracker, BannerGroup, select_with_arrows) +- File system utilities (merge_json_files, handle_vscode_settings) +- Bundled asset location (_locate_core_pack, _locate_bundled_*) +- Git operations (is_git_repo, init_git_repo, ensure_executable_scripts) +- Version management (_get_installed_version, _fetch_latest_release_tag) +- Integration helpers (_read_integration_json, _write_integration_json) +- All CLI command handlers (init, integration_*, preset_*, extension_*, workflow_*) + +Business logic is deeply entangled with `console.print` calls, making it impossible to unit-test without capturing rich output or running a full CLI subprocess. + +--- + +## Constraints + +- **Strict backward compatibility**: all CLI commands and flags remain unchanged +- **No public API breakage**: external import paths preserved via `__init__.py` re-exports +- **Incremental**: each PR is independently reviewable and verifiable against the existing test suite + +--- + +## Approach: Progressive Targeted Extraction (Option C) + +Two phases: module decomposition first, then service extraction for the highest-value testability targets. + +--- + +## Module Structure (Target State) + +``` +src/specify_cli/ +│ +├── __init__.py # app definition, main(), global constants, re-exports +│ +├── _ui.py # Terminal interaction (console.print allowed here) +│ ├── StepTracker +│ ├── BannerGroup +│ ├── show_banner() +│ └── select_with_arrows() +│ +├── _fs.py # File system utilities (no console.print) +│ ├── merge_json_files() +│ ├── handle_vscode_settings() +│ ├── save_init_options() +│ └── load_init_options() +│ +├── _assets.py # Bundled asset location (no console.print) +│ ├── AssetService +│ │ ├── locate_core_pack() -> Path | None +│ │ ├── locate_bundled_extension(id) -> Path | None +│ │ ├── locate_bundled_workflow(id) -> Path | None +│ │ └── locate_bundled_preset(id) -> Path | None +│ +├── _git.py # Git operations (no console.print) +│ ├── GitService +│ │ ├── is_repo(path) -> bool +│ │ └── init_repo(path) -> tuple[bool, str | None] +│ └── ensure_executable_scripts() +│ +├── _version.py # Version management (no console.print) +│ ├── VersionService +│ │ ├── get_installed_version() -> str +│ │ ├── fetch_latest_tag() -> tuple[str | None, str | None] +│ │ └── is_newer(latest, current) -> bool +│ └── _normalize_tag() +│ +└── commands/ # CLI handlers (console.print allowed) + ├── __init__.py # registers all sub-commands onto app + ├── init.py # specify init + ├── integration.py # specify integration * + ├── preset.py # specify preset * + ├── extension.py # specify extension * + └── workflow.py # specify workflow * +``` + +--- + +## Service Interfaces + +### GitService + +```python +class GitService: + def is_repo(self, path: Path) -> bool: ... + def init_repo(self, path: Path) -> tuple[bool, str | None]: + # Returns (success, error_message_or_None) + # Never prints to console +``` + +### VersionService + +```python +class VersionService: + def get_installed_version(self) -> str: ... + def fetch_latest_tag(self) -> tuple[str | None, str | None]: + # Returns (tag, url) or (None, None) on failure + def is_newer(self, latest: str, current: str) -> bool: ... +``` + +### AssetService + +```python +class AssetService: + def locate_core_pack(self) -> Path | None: ... + def locate_bundled_extension(self, extension_id: str) -> Path | None: ... + def locate_bundled_workflow(self, workflow_id: str) -> Path | None: ... + def locate_bundled_preset(self, preset_id: str) -> Path | None: ... +``` + +--- + +## Invariants + +- Files in `_git.py`, `_fs.py`, `_assets.py`, `_version.py` **must not** import from `rich` or call `console.print` +- Files in `commands/` and `_ui.py` **may** use `console.print` +- All existing symbol names remain importable from `specify_cli` (re-exported in `__init__.py`) + +--- + +## Migration Sequence + +### PR-1: Extract UI and File Utilities (Low Risk) + +Move to new files with no logic changes: +- `StepTracker`, `BannerGroup`, `show_banner`, `select_with_arrows`, `get_key` → `_ui.py` +- `merge_json_files`, `handle_vscode_settings`, `save_init_options`, `load_init_options` → `_fs.py` +- `__init__.py` imports and re-exports all moved symbols + +Verification: `pytest` full suite passes unchanged. + +### PR-2: Extract Services (Medium Risk) + +- `GitService` → `_git.py` (wraps existing `is_git_repo`, `init_git_repo`) +- `VersionService` → `_version.py` (wraps existing version functions) +- `AssetService` → `_assets.py` (wraps existing `_locate_*` functions) +- CLI handlers updated to call services; `console.print` stays in handlers only +- Add unit tests for each service using `unittest.mock` + +Verification: existing tests pass; new service unit tests added. + +### PR-3: Split Command Handlers (Medium Risk) + +- Move each command group to `commands/.py` +- `commands/__init__.py` registers all sub-commands +- `__init__.py` shrinks to ~50 lines + +Verification: full test suite passes; manual smoke-test of all CLI commands. + +--- + +## Testing Strategy + +After PR-2, each service is independently testable: + +```python +# Example: test GitService without subprocess +def test_init_repo_success(tmp_path): + svc = GitService() + ok, err = svc.init_repo(tmp_path) + assert ok is True + assert err is None + +# Example: test VersionService with mock HTTP +def test_is_newer(): + svc = VersionService() + assert svc.is_newer("v0.9.0", "v0.8.0") is True + assert svc.is_newer("v0.8.0", "v0.9.0") is False +``` + +--- + +## Out of Scope + +- `presets.py` (3084 lines) and `extensions.py` (2672 lines) — separate effort +- `integrations/base.py` (1486 lines) — separate effort +- Any changes to CLI command signatures or output format From 6a555829ac8f7e90049dca4971f361cb65adb472 Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Sat, 25 Apr 2026 14:31:59 +0800 Subject: [PATCH 02/31] refactor: extract shared console singleton to _console.py --- src/specify_cli/__init__.py | 3 +-- src/specify_cli/_console.py | 3 +++ tests/test_console_singleton.py | 11 +++++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 src/specify_cli/_console.py create mode 100644 tests/test_console_singleton.py diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 176eecc2d4..1245ba5a6d 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -45,7 +45,6 @@ from typing import Any, Optional import typer -from rich.console import Console from rich.panel import Panel from rich.text import Text from rich.live import Live @@ -347,7 +346,7 @@ def run_selection_loop(): return selected_key -console = Console(highlight=False) +from ._console import console class BannerGroup(TyperGroup): """Custom group that shows banner before help.""" diff --git a/src/specify_cli/_console.py b/src/specify_cli/_console.py new file mode 100644 index 0000000000..0d97197ed6 --- /dev/null +++ b/src/specify_cli/_console.py @@ -0,0 +1,3 @@ +from rich.console import Console + +console = Console(highlight=False) diff --git a/tests/test_console_singleton.py b/tests/test_console_singleton.py new file mode 100644 index 0000000000..04c20d10e0 --- /dev/null +++ b/tests/test_console_singleton.py @@ -0,0 +1,11 @@ +from specify_cli._console import console +from rich.console import Console + + +def test_console_is_rich_console(): + assert isinstance(console, Console) + + +def test_console_imported_in_init(): + import specify_cli + assert hasattr(specify_cli, 'console') From 0d742ac9df938b1249855a19ced932efa3dd69ea Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Sat, 25 Apr 2026 14:49:12 +0800 Subject: [PATCH 03/31] refactor: move _console import to top-level block, add highlight test --- src/specify_cli/__init__.py | 2 +- tests/test_console_singleton.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 1245ba5a6d..c70dcc4fe9 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -53,6 +53,7 @@ from rich.tree import Tree from typer.core import TyperGroup +from ._console import console from .integration_runtime import ( invoke_separator_for_integration as _invoke_separator_for_integration, resolve_integration_options as _resolve_integration_options_impl, @@ -346,7 +347,6 @@ def run_selection_loop(): return selected_key -from ._console import console class BannerGroup(TyperGroup): """Custom group that shows banner before help.""" diff --git a/tests/test_console_singleton.py b/tests/test_console_singleton.py index 04c20d10e0..4c813d1aa9 100644 --- a/tests/test_console_singleton.py +++ b/tests/test_console_singleton.py @@ -9,3 +9,7 @@ def test_console_is_rich_console(): def test_console_imported_in_init(): import specify_cli assert hasattr(specify_cli, 'console') + + +def test_console_highlight_disabled(): + assert console._highlight is False From 88bbe6d1aef444a582ff88707066d5de16efce90 Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Sat, 25 Apr 2026 14:53:05 +0800 Subject: [PATCH 04/31] refactor: extract UI utilities to _ui.py Move StepTracker, get_key, select_with_arrows, BannerGroup, show_banner, BANNER, and TAGLINE from __init__.py into the new _ui.py module. Re-export all symbols from __init__.py to preserve the public API. --- src/specify_cli/__init__.py | 215 +-------------------------------- src/specify_cli/_ui.py | 230 ++++++++++++++++++++++++++++++++++++ tests/test_ui.py | 30 +++++ 3 files changed, 261 insertions(+), 214 deletions(-) create mode 100644 src/specify_cli/_ui.py create mode 100644 tests/test_ui.py diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index c70dcc4fe9..0b6a7b5412 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -54,6 +54,7 @@ from typer.core import TyperGroup from ._console import console +from ._ui import StepTracker, get_key, select_with_arrows, BannerGroup, show_banner, BANNER, TAGLINE from .integration_runtime import ( invoke_separator_for_integration as _invoke_separator_for_integration, resolve_integration_options as _resolve_integration_options_impl, @@ -157,206 +158,6 @@ def _build_ai_deprecation_warning( CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude" CLAUDE_NPM_LOCAL_PATH = Path.home() / ".claude" / "local" / "node_modules" / ".bin" / "claude" -BANNER = """ -███████╗██████╗ ███████╗ ██████╗██╗███████╗██╗ ██╗ -██╔════╝██╔══██╗██╔════╝██╔════╝██║██╔════╝╚██╗ ██╔╝ -███████╗██████╔╝█████╗ ██║ ██║█████╗ ╚████╔╝ -╚════██║██╔═══╝ ██╔══╝ ██║ ██║██╔══╝ ╚██╔╝ -███████║██║ ███████╗╚██████╗██║██║ ██║ -╚══════╝╚═╝ ╚══════╝ ╚═════╝╚═╝╚═╝ ╚═╝ -""" - -TAGLINE = "GitHub Spec Kit - Spec-Driven Development Toolkit" -class StepTracker: - """Track and render hierarchical steps without emojis, similar to Claude Code tree output. - Supports live auto-refresh via an attached refresh callback. - """ - def __init__(self, title: str): - self.title = title - self.steps = [] # list of dicts: {key, label, status, detail} - self.status_order = {"pending": 0, "running": 1, "done": 2, "error": 3, "skipped": 4} - self._refresh_cb = None # callable to trigger UI refresh - - def attach_refresh(self, cb): - self._refresh_cb = cb - - def add(self, key: str, label: str): - if key not in [s["key"] for s in self.steps]: - self.steps.append({"key": key, "label": label, "status": "pending", "detail": ""}) - self._maybe_refresh() - - def start(self, key: str, detail: str = ""): - self._update(key, status="running", detail=detail) - - def complete(self, key: str, detail: str = ""): - self._update(key, status="done", detail=detail) - - def error(self, key: str, detail: str = ""): - self._update(key, status="error", detail=detail) - - def skip(self, key: str, detail: str = ""): - self._update(key, status="skipped", detail=detail) - - def _update(self, key: str, status: str, detail: str): - for s in self.steps: - if s["key"] == key: - s["status"] = status - if detail: - s["detail"] = detail - self._maybe_refresh() - return - - self.steps.append({"key": key, "label": key, "status": status, "detail": detail}) - self._maybe_refresh() - - def _maybe_refresh(self): - if self._refresh_cb: - try: - self._refresh_cb() - except Exception: - pass - - def render(self): - tree = Tree(f"[cyan]{self.title}[/cyan]", guide_style="grey50") - for step in self.steps: - label = step["label"] - detail_text = step["detail"].strip() if step["detail"] else "" - - status = step["status"] - if status == "done": - symbol = "[green]●[/green]" - elif status == "pending": - symbol = "[green dim]○[/green dim]" - elif status == "running": - symbol = "[cyan]○[/cyan]" - elif status == "error": - symbol = "[red]●[/red]" - elif status == "skipped": - symbol = "[yellow]○[/yellow]" - else: - symbol = " " - - if status == "pending": - # Entire line light gray (pending) - if detail_text: - line = f"{symbol} [bright_black]{label} ({detail_text})[/bright_black]" - else: - line = f"{symbol} [bright_black]{label}[/bright_black]" - else: - # Label white, detail (if any) light gray in parentheses - if detail_text: - line = f"{symbol} [white]{label}[/white] [bright_black]({detail_text})[/bright_black]" - else: - line = f"{symbol} [white]{label}[/white]" - - tree.add(line) - return tree - -def get_key(): - """Get a single keypress in a cross-platform way using readchar.""" - key = readchar.readkey() - - if key == readchar.key.UP or key == readchar.key.CTRL_P: - return 'up' - if key == readchar.key.DOWN or key == readchar.key.CTRL_N: - return 'down' - - if key == readchar.key.ENTER: - return 'enter' - - if key == readchar.key.ESC: - return 'escape' - - if key == readchar.key.CTRL_C: - raise KeyboardInterrupt - - return key - -def select_with_arrows(options: dict, prompt_text: str = "Select an option", default_key: str = None) -> str: - """ - Interactive selection using arrow keys with Rich Live display. - - Args: - options: Dict with keys as option keys and values as descriptions - prompt_text: Text to show above the options - default_key: Default option key to start with - - Returns: - Selected option key - """ - option_keys = list(options.keys()) - if default_key and default_key in option_keys: - selected_index = option_keys.index(default_key) - else: - selected_index = 0 - - selected_key = None - - def create_selection_panel(): - """Create the selection panel with current selection highlighted.""" - table = Table.grid(padding=(0, 2)) - table.add_column(style="cyan", justify="left", width=3) - table.add_column(style="white", justify="left") - - for i, key in enumerate(option_keys): - if i == selected_index: - table.add_row("▶", f"[cyan]{key}[/cyan] [dim]({options[key]})[/dim]") - else: - table.add_row(" ", f"[cyan]{key}[/cyan] [dim]({options[key]})[/dim]") - - table.add_row("", "") - table.add_row("", "[dim]Use ↑/↓ to navigate, Enter to select, Esc to cancel[/dim]") - - return Panel( - table, - title=f"[bold]{prompt_text}[/bold]", - border_style="cyan", - padding=(1, 2) - ) - - console.print() - - def run_selection_loop(): - nonlocal selected_key, selected_index - with Live(create_selection_panel(), console=console, transient=True, auto_refresh=False) as live: - while True: - try: - key = get_key() - if key == 'up': - selected_index = (selected_index - 1) % len(option_keys) - elif key == 'down': - selected_index = (selected_index + 1) % len(option_keys) - elif key == 'enter': - selected_key = option_keys[selected_index] - break - elif key == 'escape': - console.print("\n[yellow]Selection cancelled[/yellow]") - raise typer.Exit(1) - - live.update(create_selection_panel(), refresh=True) - - except KeyboardInterrupt: - console.print("\n[yellow]Selection cancelled[/yellow]") - raise typer.Exit(1) - - run_selection_loop() - - if selected_key is None: - console.print("\n[red]Selection failed.[/red]") - raise typer.Exit(1) - - return selected_key - - -class BannerGroup(TyperGroup): - """Custom group that shows banner before help.""" - - def format_help(self, ctx, formatter): - # Show banner before help - show_banner() - super().format_help(ctx, formatter) - - app = typer.Typer( name="specify", help="Setup tool for Specify spec-driven development projects", @@ -365,20 +166,6 @@ def format_help(self, ctx, formatter): cls=BannerGroup, ) -def show_banner(): - """Display the ASCII art banner.""" - banner_lines = BANNER.strip().split('\n') - colors = ["bright_blue", "blue", "cyan", "bright_cyan", "white", "bright_white"] - - styled_banner = Text() - for i, line in enumerate(banner_lines): - color = colors[i % len(colors)] - styled_banner.append(line + "\n", style=color) - - console.print(Align.center(styled_banner)) - console.print(Align.center(Text(TAGLINE, style="italic bright_yellow"))) - console.print() - def _version_callback(value: bool): if value: console.print(f"specify {get_speckit_version()}") diff --git a/src/specify_cli/_ui.py b/src/specify_cli/_ui.py new file mode 100644 index 0000000000..b96806ef7d --- /dev/null +++ b/src/specify_cli/_ui.py @@ -0,0 +1,230 @@ +# src/specify_cli/_ui.py +from typing import Any +from rich.console import Console +from rich.tree import Tree +from rich.panel import Panel +from rich.text import Text +from rich.live import Live +from rich.align import Align +from typer.core import TyperGroup +import readchar + +from ._console import console + +import typer + +BANNER = """ +███████╗██████╗ ███████╗ ██████╗██╗███████╗██╗ ██╗ +██╔════╝██╔══██╗██╔════╝██╔════╝██║██╔════╝╚██╗ ██╔╝ +███████╗██████╔╝█████╗ ██║ ██║█████╗ ╚████╔╝ +╚════██║██╔═══╝ ██╔══╝ ██║ ██║██╔══╝ ╚██╔╝ +███████║██║ ███████╗╚██████╗██║██║ ██║ +╚══════╝╚═╝ ╚══════╝ ╚═════╝╚═╝╚═╝ ╚═╝ +""" + +TAGLINE = "GitHub Spec Kit - Spec-Driven Development Toolkit" + +class StepTracker: + """Track and render hierarchical steps without emojis, similar to Claude Code tree output. + Supports live auto-refresh via an attached refresh callback. + """ + def __init__(self, title: str): + self.title = title + self.steps = [] # list of dicts: {key, label, status, detail} + self.status_order = {"pending": 0, "running": 1, "done": 2, "error": 3, "skipped": 4} + self._refresh_cb = None # callable to trigger UI refresh + + def attach_refresh(self, cb): + self._refresh_cb = cb + + def add(self, key: str, label: str): + if key not in [s["key"] for s in self.steps]: + self.steps.append({"key": key, "label": label, "status": "pending", "detail": ""}) + self._maybe_refresh() + + def start(self, key: str, detail: str = ""): + self._update(key, status="running", detail=detail) + + def complete(self, key: str, detail: str = ""): + self._update(key, status="done", detail=detail) + + def error(self, key: str, detail: str = ""): + self._update(key, status="error", detail=detail) + + def skip(self, key: str, detail: str = ""): + self._update(key, status="skipped", detail=detail) + + def _update(self, key: str, status: str, detail: str): + for s in self.steps: + if s["key"] == key: + s["status"] = status + if detail: + s["detail"] = detail + self._maybe_refresh() + return + + self.steps.append({"key": key, "label": key, "status": status, "detail": detail}) + self._maybe_refresh() + + def _maybe_refresh(self): + if self._refresh_cb: + try: + self._refresh_cb() + except Exception: + pass + + def render(self): + tree = Tree(f"[cyan]{self.title}[/cyan]", guide_style="grey50") + for step in self.steps: + label = step["label"] + detail_text = step["detail"].strip() if step["detail"] else "" + + status = step["status"] + if status == "done": + symbol = "[green]●[/green]" + elif status == "pending": + symbol = "[green dim]○[/green dim]" + elif status == "running": + symbol = "[cyan]○[/cyan]" + elif status == "error": + symbol = "[red]●[/red]" + elif status == "skipped": + symbol = "[yellow]○[/yellow]" + else: + symbol = " " + + if status == "pending": + # Entire line light gray (pending) + if detail_text: + line = f"{symbol} [bright_black]{label} ({detail_text})[/bright_black]" + else: + line = f"{symbol} [bright_black]{label}[/bright_black]" + else: + # Label white, detail (if any) light gray in parentheses + if detail_text: + line = f"{symbol} [white]{label}[/white] [bright_black]({detail_text})[/bright_black]" + else: + line = f"{symbol} [white]{label}[/white]" + + tree.add(line) + return tree + +def get_key(): + """Get a single keypress in a cross-platform way using readchar.""" + key = readchar.readkey() + + if key == readchar.key.UP or key == readchar.key.CTRL_P: + return 'up' + if key == readchar.key.DOWN or key == readchar.key.CTRL_N: + return 'down' + + if key == readchar.key.ENTER: + return 'enter' + + if key == readchar.key.ESC: + return 'escape' + + if key == readchar.key.CTRL_C: + raise KeyboardInterrupt + + return key + +def select_with_arrows(options: dict, prompt_text: str = "Select an option", default_key: str = None) -> str: + """ + Interactive selection using arrow keys with Rich Live display. + + Args: + options: Dict with keys as option keys and values as descriptions + prompt_text: Text to show above the options + default_key: Default option key to start with + + Returns: + Selected option key + """ + from rich.table import Table + option_keys = list(options.keys()) + if default_key and default_key in option_keys: + selected_index = option_keys.index(default_key) + else: + selected_index = 0 + + selected_key = None + + def create_selection_panel(): + """Create the selection panel with current selection highlighted.""" + table = Table.grid(padding=(0, 2)) + table.add_column(style="cyan", justify="left", width=3) + table.add_column(style="white", justify="left") + + for i, key in enumerate(option_keys): + if i == selected_index: + table.add_row("▶", f"[cyan]{key}[/cyan] [dim]({options[key]})[/dim]") + else: + table.add_row(" ", f"[cyan]{key}[/cyan] [dim]({options[key]})[/dim]") + + table.add_row("", "") + table.add_row("", "[dim]Use ↑/↓ to navigate, Enter to select, Esc to cancel[/dim]") + + return Panel( + table, + title=f"[bold]{prompt_text}[/bold]", + border_style="cyan", + padding=(1, 2) + ) + + console.print() + + def run_selection_loop(): + nonlocal selected_key, selected_index + with Live(create_selection_panel(), console=console, transient=True, auto_refresh=False) as live: + while True: + try: + key = get_key() + if key == 'up': + selected_index = (selected_index - 1) % len(option_keys) + elif key == 'down': + selected_index = (selected_index + 1) % len(option_keys) + elif key == 'enter': + selected_key = option_keys[selected_index] + break + elif key == 'escape': + console.print("\n[yellow]Selection cancelled[/yellow]") + raise typer.Exit(1) + + live.update(create_selection_panel(), refresh=True) + + except KeyboardInterrupt: + console.print("\n[yellow]Selection cancelled[/yellow]") + raise typer.Exit(1) + + run_selection_loop() + + if selected_key is None: + console.print("\n[red]Selection failed.[/red]") + raise typer.Exit(1) + + return selected_key + + +class BannerGroup(TyperGroup): + """Custom group that shows banner before help.""" + + def format_help(self, ctx, formatter): + # Show banner before help + show_banner() + super().format_help(ctx, formatter) + + +def show_banner(): + """Display the ASCII art banner.""" + banner_lines = BANNER.strip().split('\n') + colors = ["bright_blue", "blue", "cyan", "bright_cyan", "white", "bright_white"] + + styled_banner = Text() + for i, line in enumerate(banner_lines): + color = colors[i % len(colors)] + styled_banner.append(line + "\n", style=color) + + console.print(Align.center(styled_banner)) + console.print(Align.center(Text(TAGLINE, style="italic bright_yellow"))) + console.print() diff --git a/tests/test_ui.py b/tests/test_ui.py new file mode 100644 index 0000000000..5139f7a580 --- /dev/null +++ b/tests/test_ui.py @@ -0,0 +1,30 @@ +from specify_cli._ui import StepTracker, BannerGroup, show_banner, select_with_arrows + +def test_step_tracker_add_and_complete(): + t = StepTracker("test") + t.add("step1", "Step One") + t.complete("step1", "done") + assert t.steps[0]["status"] == "done" + +def test_step_tracker_render_returns_tree(): + from rich.tree import Tree + t = StepTracker("test") + t.add("s", "S") + result = t.render() + assert isinstance(result, Tree) + +def test_step_tracker_error(): + t = StepTracker("test") + t.add("s", "S") + t.error("s", "failed") + assert t.steps[0]["status"] == "error" + +def test_banner_group_is_typer_group(): + from typer.core import TyperGroup + assert issubclass(BannerGroup, TyperGroup) + +def test_symbols_re_exported_from_package(): + import specify_cli + assert hasattr(specify_cli, 'StepTracker') + assert hasattr(specify_cli, 'BannerGroup') + assert hasattr(specify_cli, 'show_banner') From 1a0719b09be83b1d210edf5d51857f1370156f14 Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Sat, 25 Apr 2026 15:23:44 +0800 Subject: [PATCH 05/31] refactor: remove dead imports after _ui.py extraction --- src/specify_cli/__init__.py | 5 ----- src/specify_cli/_ui.py | 2 -- 2 files changed, 7 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 0b6a7b5412..39cca9b1a0 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -50,8 +50,6 @@ from rich.live import Live from rich.align import Align from rich.table import Table -from rich.tree import Tree -from typer.core import TyperGroup from ._console import console from ._ui import StepTracker, get_key, select_with_arrows, BannerGroup, show_banner, BANNER, TAGLINE @@ -76,9 +74,6 @@ refresh_shared_templates as _refresh_shared_templates_impl, ) -# For cross-platform keyboard input -import readchar - GITHUB_API_LATEST = "https://api.github.com/repos/github/spec-kit/releases/latest" def _build_agent_config() -> dict[str, dict[str, Any]]: diff --git a/src/specify_cli/_ui.py b/src/specify_cli/_ui.py index b96806ef7d..e9e596b723 100644 --- a/src/specify_cli/_ui.py +++ b/src/specify_cli/_ui.py @@ -1,6 +1,4 @@ # src/specify_cli/_ui.py -from typing import Any -from rich.console import Console from rich.tree import Tree from rich.panel import Panel from rich.text import Text From 5f992f175663d8c545a946777b2a4ea8a887dfc9 Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Sat, 25 Apr 2026 15:26:43 +0800 Subject: [PATCH 06/31] refactor: extract file system utilities to _fs.py Moves handle_vscode_settings, merge_json_files, save_init_options, and load_init_options (plus INIT_OPTIONS_FILE constant) from __init__.py into the new _fs.py module; re-exports them via __init__.py import. Co-Authored-By: Claude Sonnet 4.6 --- src/specify_cli/__init__.py | 178 +--------------------------------- src/specify_cli/_fs.py | 188 ++++++++++++++++++++++++++++++++++++ 2 files changed, 189 insertions(+), 177 deletions(-) create mode 100644 src/specify_cli/_fs.py diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 39cca9b1a0..2010153373 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -53,6 +53,7 @@ from ._console import console from ._ui import StepTracker, get_key, select_with_arrows, BannerGroup, show_banner, BANNER, TAGLINE +from ._fs import handle_vscode_settings, merge_json_files, save_init_options, load_init_options from .integration_runtime import ( invoke_separator_for_integration as _invoke_separator_for_integration, resolve_integration_options as _resolve_integration_options_impl, @@ -280,154 +281,6 @@ def init_git_repo(project_path: Path, quiet: bool = False) -> tuple[bool, Option os.chdir(original_cwd) -def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker=None) -> None: - """Handle merging or copying of .vscode/settings.json files. - - Note: when merge produces changes, rewritten output is normalized JSON and - existing JSONC comments/trailing commas are not preserved. - """ - def log(message, color="green"): - if verbose and not tracker: - console.print(f"[{color}]{message}[/] {rel_path}") - - def atomic_write_json(target_file: Path, payload: dict[str, Any]) -> None: - """Atomically write JSON while preserving existing mode bits when possible.""" - temp_path: Optional[Path] = None - try: - with tempfile.NamedTemporaryFile( - mode='w', - encoding='utf-8', - dir=target_file.parent, - prefix=f"{target_file.name}.", - suffix=".tmp", - delete=False, - ) as f: - temp_path = Path(f.name) - json.dump(payload, f, indent=4) - f.write('\n') - - if target_file.exists(): - try: - existing_stat = target_file.stat() - os.chmod(temp_path, stat.S_IMODE(existing_stat.st_mode)) - if hasattr(os, "chown"): - try: - os.chown(temp_path, existing_stat.st_uid, existing_stat.st_gid) - except PermissionError: - # Best-effort owner/group preservation without requiring elevated privileges. - pass - except OSError: - # Best-effort metadata preservation; data safety is prioritized. - pass - - os.replace(temp_path, target_file) - except Exception: - if temp_path and temp_path.exists(): - temp_path.unlink() - raise - - try: - with open(sub_item, 'r', encoding='utf-8') as f: - # json5 natively supports comments and trailing commas (JSONC) - new_settings = json5.load(f) - - if dest_file.exists(): - merged = merge_json_files(dest_file, new_settings, verbose=verbose and not tracker) - if merged is not None: - atomic_write_json(dest_file, merged) - log("Merged:", "green") - log("Note: comments/trailing commas are normalized when rewritten", "yellow") - else: - log("Skipped merge (preserved existing settings)", "yellow") - else: - shutil.copy2(sub_item, dest_file) - log("Copied (no existing settings.json):", "blue") - - except Exception as e: - log(f"Warning: Could not merge settings: {e}", "yellow") - if not dest_file.exists(): - shutil.copy2(sub_item, dest_file) - - -def merge_json_files(existing_path: Path, new_content: Any, verbose: bool = False) -> Optional[dict[str, Any]]: - """Merge new JSON content into existing JSON file. - - Performs a polite deep merge where: - - New keys are added - - Existing keys are preserved (not overwritten) unless both values are dictionaries - - Nested dictionaries are merged recursively only when both sides are dictionaries - - Lists and other values are preserved from base if they exist - - Args: - existing_path: Path to existing JSON file - new_content: New JSON content to merge in - verbose: Whether to print merge details - - Returns: - Merged JSON content as dict, or None if the existing file should be left untouched. - """ - # Load existing content first to have a safe fallback - existing_content = None - exists = existing_path.exists() - - if exists: - try: - with open(existing_path, 'r', encoding='utf-8') as f: - # Handle comments (JSONC) natively with json5 - # Note: json5 handles BOM automatically - existing_content = json5.load(f) - except FileNotFoundError: - # Handle race condition where file is deleted after exists() check - exists = False - except Exception as e: - if verbose: - console.print(f"[yellow]Warning: Could not read or parse existing JSON in {existing_path.name} ({e}).[/yellow]") - # Skip merge to preserve existing file if unparseable or inaccessible (e.g. PermissionError) - return None - - # Validate template content - if not isinstance(new_content, dict): - if verbose: - console.print(f"[yellow]Warning: Template content for {existing_path.name} is not a dictionary. Preserving existing settings.[/yellow]") - return None - - if not exists: - return new_content - - # If existing content parsed but is not a dict, skip merge to avoid data loss - if not isinstance(existing_content, dict): - if verbose: - console.print(f"[yellow]Warning: Existing JSON in {existing_path.name} is not an object. Skipping merge to avoid data loss.[/yellow]") - return None - - def deep_merge_polite(base: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]: - """Recursively merge update dict into base dict, preserving base values.""" - result = base.copy() - for key, value in update.items(): - if key not in result: - # Add new key - result[key] = value - elif isinstance(result[key], dict) and isinstance(value, dict): - # Recursively merge nested dictionaries - result[key] = deep_merge_polite(result[key], value) - else: - # Key already exists and values are not both dicts; preserve existing value. - # This ensures user settings aren't overwritten by template defaults. - pass - return result - - merged = deep_merge_polite(existing_content, new_content) - - # Detect if anything actually changed. If not, return None so the caller - # can skip rewriting the file (preserving user's comments/formatting). - if merged == existing_content: - return None - - if verbose: - console.print(f"[cyan]Merged JSON file:[/cyan] {existing_path.name}") - - return merged - def _locate_core_pack() -> Path | None: """Return the filesystem path to the bundled core_pack directory, or None. @@ -683,35 +536,6 @@ def ensure_constitution_from_template(project_path: Path, tracker: StepTracker | console.print(f"[yellow]Warning: Could not initialize constitution: {e}[/yellow]") -INIT_OPTIONS_FILE = ".specify/init-options.json" - - -def save_init_options(project_path: Path, options: dict[str, Any]) -> None: - """Persist the CLI options used during ``specify init``. - - Writes a small JSON file to ``.specify/init-options.json`` so that - later operations (e.g. preset install) can adapt their behaviour - without scanning the filesystem. - """ - dest = project_path / INIT_OPTIONS_FILE - dest.parent.mkdir(parents=True, exist_ok=True) - dest.write_text(json.dumps(options, indent=2, sort_keys=True)) - - -def load_init_options(project_path: Path) -> dict[str, Any]: - """Load the init options previously saved by ``specify init``. - - Returns an empty dict if the file does not exist or cannot be parsed. - """ - path = project_path / INIT_OPTIONS_FILE - if not path.exists(): - return {} - try: - return json.loads(path.read_text()) - except (json.JSONDecodeError, OSError): - return {} - - def _get_skills_dir(project_path: Path, selected_ai: str) -> Path: """Resolve the agent-specific skills directory. diff --git a/src/specify_cli/_fs.py b/src/specify_cli/_fs.py new file mode 100644 index 0000000000..00e84328c5 --- /dev/null +++ b/src/specify_cli/_fs.py @@ -0,0 +1,188 @@ +# src/specify_cli/_fs.py +import json +import json5 +import os +import shutil +import stat +import tempfile +from pathlib import Path +from typing import Any, Optional + +from ._console import console + +INIT_OPTIONS_FILE = ".specify/init-options.json" + + +def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker=None) -> None: + """Handle merging or copying of .vscode/settings.json files. + + Note: when merge produces changes, rewritten output is normalized JSON and + existing JSONC comments/trailing commas are not preserved. + """ + def log(message, color="green"): + if verbose and not tracker: + console.print(f"[{color}]{message}[/] {rel_path}") + + def atomic_write_json(target_file: Path, payload: dict[str, Any]) -> None: + """Atomically write JSON while preserving existing mode bits when possible.""" + temp_path: Optional[Path] = None + try: + with tempfile.NamedTemporaryFile( + mode='w', + encoding='utf-8', + dir=target_file.parent, + prefix=f"{target_file.name}.", + suffix=".tmp", + delete=False, + ) as f: + temp_path = Path(f.name) + json.dump(payload, f, indent=4) + f.write('\n') + + if target_file.exists(): + try: + existing_stat = target_file.stat() + os.chmod(temp_path, stat.S_IMODE(existing_stat.st_mode)) + if hasattr(os, "chown"): + try: + os.chown(temp_path, existing_stat.st_uid, existing_stat.st_gid) + except PermissionError: + # Best-effort owner/group preservation without requiring elevated privileges. + pass + except OSError: + # Best-effort metadata preservation; data safety is prioritized. + pass + + os.replace(temp_path, target_file) + except Exception: + if temp_path and temp_path.exists(): + temp_path.unlink() + raise + + try: + with open(sub_item, 'r', encoding='utf-8') as f: + # json5 natively supports comments and trailing commas (JSONC) + new_settings = json5.load(f) + + if dest_file.exists(): + merged = merge_json_files(dest_file, new_settings, verbose=verbose and not tracker) + if merged is not None: + atomic_write_json(dest_file, merged) + log("Merged:", "green") + log("Note: comments/trailing commas are normalized when rewritten", "yellow") + else: + log("Skipped merge (preserved existing settings)", "yellow") + else: + shutil.copy2(sub_item, dest_file) + log("Copied (no existing settings.json):", "blue") + + except Exception as e: + log(f"Warning: Could not merge settings: {e}", "yellow") + if not dest_file.exists(): + shutil.copy2(sub_item, dest_file) + + +def merge_json_files(existing_path: Path, new_content: Any, verbose: bool = False) -> Optional[dict[str, Any]]: + """Merge new JSON content into existing JSON file. + + Performs a polite deep merge where: + - New keys are added + - Existing keys are preserved (not overwritten) unless both values are dictionaries + - Nested dictionaries are merged recursively only when both sides are dictionaries + - Lists and other values are preserved from base if they exist + + Args: + existing_path: Path to existing JSON file + new_content: New JSON content to merge in + verbose: Whether to print merge details + + Returns: + Merged JSON content as dict, or None if the existing file should be left untouched. + """ + # Load existing content first to have a safe fallback + existing_content = None + exists = existing_path.exists() + + if exists: + try: + with open(existing_path, 'r', encoding='utf-8') as f: + # Handle comments (JSONC) natively with json5 + # Note: json5 handles BOM automatically + existing_content = json5.load(f) + except FileNotFoundError: + # Handle race condition where file is deleted after exists() check + exists = False + except Exception as e: + if verbose: + console.print(f"[yellow]Warning: Could not read or parse existing JSON in {existing_path.name} ({e}).[/yellow]") + # Skip merge to preserve existing file if unparseable or inaccessible (e.g. PermissionError) + return None + + # Validate template content + if not isinstance(new_content, dict): + if verbose: + console.print(f"[yellow]Warning: Template content for {existing_path.name} is not a dictionary. Preserving existing settings.[/yellow]") + return None + + if not exists: + return new_content + + # If existing content parsed but is not a dict, skip merge to avoid data loss + if not isinstance(existing_content, dict): + if verbose: + console.print(f"[yellow]Warning: Existing JSON in {existing_path.name} is not an object. Skipping merge to avoid data loss.[/yellow]") + return None + + def deep_merge_polite(base: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]: + """Recursively merge update dict into base dict, preserving base values.""" + result = base.copy() + for key, value in update.items(): + if key not in result: + # Add new key + result[key] = value + elif isinstance(result[key], dict) and isinstance(value, dict): + # Recursively merge nested dictionaries + result[key] = deep_merge_polite(result[key], value) + else: + # Key already exists and values are not both dicts; preserve existing value. + # This ensures user settings aren't overwritten by template defaults. + pass + return result + + merged = deep_merge_polite(existing_content, new_content) + + # Detect if anything actually changed. If not, return None so the caller + # can skip rewriting the file (preserving user's comments/formatting). + if merged == existing_content: + return None + + if verbose: + console.print(f"[cyan]Merged JSON file:[/cyan] {existing_path.name}") + + return merged + + +def save_init_options(project_path: Path, options: dict[str, Any]) -> None: + """Persist the CLI options used during ``specify init``. + + Writes a small JSON file to ``.specify/init-options.json`` so that + later operations (e.g. preset install) can adapt their behaviour + without scanning the filesystem. + """ + dest = project_path / INIT_OPTIONS_FILE + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(json.dumps(options, indent=2, sort_keys=True)) + + +def load_init_options(project_path: Path) -> dict[str, Any]: + """Load the init options previously saved by ``specify init``. + + Returns an empty dict if the file does not exist or cannot be parsed. + """ + path = project_path / INIT_OPTIONS_FILE + if not path.exists(): + return {} + try: + return json.loads(path.read_text()) + except (json.JSONDecodeError, OSError): + return {} From 45d361ef42f3f8a6be2f328095d4bd5f81dd4cb5 Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Sat, 25 Apr 2026 16:15:50 +0800 Subject: [PATCH 07/31] refactor: remove dead stat and json5 imports after _fs.py extraction --- src/specify_cli/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 2010153373..65c5067db6 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -33,8 +33,6 @@ import tempfile import shutil import json -import json5 -import stat import shlex import urllib.error import urllib.request From d2283c90c3608372c520df680c8959470f95d3a4 Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Sat, 25 Apr 2026 16:17:59 +0800 Subject: [PATCH 08/31] refactor: extract AssetService to _assets.py Move the four _locate_* functions from __init__.py into a dedicated AssetService class in _assets.py. Backward-compatible wrapper functions remain in __init__.py delegating to a module-level singleton. --- src/specify_cli/__init__.py | 82 +++---------------------------------- src/specify_cli/_assets.py | 58 ++++++++++++++++++++++++++ tests/test_asset_service.py | 33 +++++++++++++++ 3 files changed, 96 insertions(+), 77 deletions(-) create mode 100644 src/specify_cli/_assets.py create mode 100644 tests/test_asset_service.py diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 65c5067db6..d29f93ff82 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -52,6 +52,7 @@ from ._console import console from ._ui import StepTracker, get_key, select_with_arrows, BannerGroup, show_banner, BANNER, TAGLINE from ._fs import handle_vscode_settings, merge_json_files, save_init_options, load_init_options +from ._assets import AssetService as _AssetService, _asset_service as _svc from .integration_runtime import ( invoke_separator_for_integration as _invoke_separator_for_integration, resolve_integration_options as _resolve_integration_options_impl, @@ -280,20 +281,7 @@ def init_git_repo(project_path: Path, quiet: bool = False) -> tuple[bool, Option def _locate_core_pack() -> Path | None: - """Return the filesystem path to the bundled core_pack directory, or None. - - Only present in wheel installs: hatchling's force-include copies - templates/, scripts/ etc. into specify_cli/core_pack/ at build time. - - Source-checkout and editable installs do NOT have this directory. - Callers that need to work in both environments must check the repo-root - trees (templates/, scripts/) as a fallback when this returns None. - """ - # Wheel install: core_pack is a sibling directory of this file - candidate = Path(__file__).parent / "core_pack" - if candidate.is_dir(): - return candidate - return None + return _svc.locate_core_pack() def _repo_root() -> Path: @@ -302,75 +290,15 @@ def _repo_root() -> Path: def _locate_bundled_extension(extension_id: str) -> Path | None: - """Return the path to a bundled extension, or None. - - Checks the wheel's core_pack first, then falls back to the - source-checkout ``extensions//`` directory. - """ - import re as _re - if not _re.match(r'^[a-z0-9-]+$', extension_id): - return None - - core = _locate_core_pack() - if core is not None: - candidate = core / "extensions" / extension_id - if (candidate / "extension.yml").is_file(): - return candidate - - # Source-checkout / editable install: look relative to repo root - candidate = _repo_root() / "extensions" / extension_id - if (candidate / "extension.yml").is_file(): - return candidate - - return None + return _svc.locate_bundled_extension(extension_id) def _locate_bundled_workflow(workflow_id: str) -> Path | None: - """Return the path to a bundled workflow directory, or None. - - Checks the wheel's core_pack first, then falls back to the - source-checkout ``workflows//`` directory. - """ - import re as _re - if not _re.match(r'^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$', workflow_id): - return None - - core = _locate_core_pack() - if core is not None: - candidate = core / "workflows" / workflow_id - if (candidate / "workflow.yml").is_file(): - return candidate - - # Source-checkout / editable install: look relative to repo root - candidate = _repo_root() / "workflows" / workflow_id - if (candidate / "workflow.yml").is_file(): - return candidate - - return None + return _svc.locate_bundled_workflow(workflow_id) def _locate_bundled_preset(preset_id: str) -> Path | None: - """Return the path to a bundled preset, or None. - - Checks the wheel's core_pack first, then falls back to the - source-checkout ``presets//`` directory. - """ - import re as _re - if not _re.match(r'^[a-z0-9-]+$', preset_id): - return None - - core = _locate_core_pack() - if core is not None: - candidate = core / "presets" / preset_id - if (candidate / "preset.yml").is_file(): - return candidate - - # Source-checkout / editable install: look relative to repo root - candidate = _repo_root() / "presets" / preset_id - if (candidate / "preset.yml").is_file(): - return candidate - - return None + return _svc.locate_bundled_preset(preset_id) def _refresh_shared_templates( diff --git a/src/specify_cli/_assets.py b/src/specify_cli/_assets.py new file mode 100644 index 0000000000..af184a7a37 --- /dev/null +++ b/src/specify_cli/_assets.py @@ -0,0 +1,58 @@ +import re +from pathlib import Path + + +class AssetService: + """Locates bundled assets (core_pack, extensions, workflows, presets).""" + + def locate_core_pack(self) -> Path | None: + candidate = Path(__file__).parent / "core_pack" + if candidate.is_dir(): + return candidate + return None + + def locate_bundled_extension(self, extension_id: str) -> Path | None: + if not re.match(r'^[a-z0-9-]+$', extension_id): + return None + core = self.locate_core_pack() + if core is not None: + candidate = core / "extensions" / extension_id + if (candidate / "extension.yml").is_file(): + return candidate + repo_root = Path(__file__).parent.parent.parent + candidate = repo_root / "extensions" / extension_id + if (candidate / "extension.yml").is_file(): + return candidate + return None + + def locate_bundled_workflow(self, workflow_id: str) -> Path | None: + if not re.match(r'^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$', workflow_id): + return None + core = self.locate_core_pack() + if core is not None: + candidate = core / "workflows" / workflow_id + if (candidate / "workflow.yml").is_file(): + return candidate + repo_root = Path(__file__).parent.parent.parent + candidate = repo_root / "workflows" / workflow_id + if (candidate / "workflow.yml").is_file(): + return candidate + return None + + def locate_bundled_preset(self, preset_id: str) -> Path | None: + if not re.match(r'^[a-z0-9-]+$', preset_id): + return None + core = self.locate_core_pack() + if core is not None: + candidate = core / "presets" / preset_id + if (candidate / "preset.yml").is_file(): + return candidate + repo_root = Path(__file__).parent.parent.parent + candidate = repo_root / "presets" / preset_id + if (candidate / "preset.yml").is_file(): + return candidate + return None + + +# Module-level singleton for backward compat +_asset_service = AssetService() diff --git a/tests/test_asset_service.py b/tests/test_asset_service.py new file mode 100644 index 0000000000..a0cea74464 --- /dev/null +++ b/tests/test_asset_service.py @@ -0,0 +1,33 @@ +from pathlib import Path +from specify_cli._assets import AssetService + +def test_locate_core_pack_returns_path_or_none(): + svc = AssetService() + result = svc.locate_core_pack() + assert result is None or isinstance(result, Path) + +def test_locate_bundled_extension_invalid_id_returns_none(): + svc = AssetService() + assert svc.locate_bundled_extension("../evil") is None + assert svc.locate_bundled_extension("UPPER") is None + +def test_locate_bundled_extension_valid_id(): + svc = AssetService() + result = svc.locate_bundled_extension("git") + # In source checkout, git extension should exist + assert result is None or isinstance(result, Path) + +def test_locate_bundled_workflow_invalid_id_returns_none(): + svc = AssetService() + assert svc.locate_bundled_workflow("") is None + assert svc.locate_bundled_workflow("BAD ID") is None + +def test_locate_bundled_preset_invalid_id_returns_none(): + svc = AssetService() + assert svc.locate_bundled_preset("../etc/passwd") is None + +def test_backward_compat_module_functions(): + # The old underscore functions must still work via __init__.py + from specify_cli import _locate_core_pack, _locate_bundled_extension + result = _locate_core_pack() + assert result is None or isinstance(result, Path) From 998166e49364a7194ef17822f66f4929b144ba99 Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Sat, 25 Apr 2026 16:40:11 +0800 Subject: [PATCH 09/31] refactor: extract GitService to _git.py Move git operations into a pure GitService class with zero console output. Backward-compatible wrappers in __init__.py retain Rich print calls. --- src/specify_cli/__init__.py | 45 +++++---------------------- src/specify_cli/_git.py | 61 +++++++++++++++++++++++++++++++++++++ tests/test_git_service.py | 43 ++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 37 deletions(-) create mode 100644 src/specify_cli/_git.py create mode 100644 tests/test_git_service.py diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index d29f93ff82..1ead2a5c6d 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -53,6 +53,7 @@ from ._ui import StepTracker, get_key, select_with_arrows, BannerGroup, show_banner, BANNER, TAGLINE from ._fs import handle_vscode_settings, merge_json_files, save_init_options, load_init_options from ._assets import AssetService as _AssetService, _asset_service as _svc +from ._git import GitService as _GitService, _git_service as _git_svc from .integration_runtime import ( invoke_separator_for_integration as _invoke_separator_for_integration, resolve_integration_options as _resolve_integration_options_impl, @@ -236,48 +237,18 @@ def check_tool(tool: str, tracker: StepTracker = None) -> bool: def is_git_repo(path: Path = None) -> bool: """Check if the specified path is inside a git repository.""" - if path is None: - path = Path.cwd() - - if not path.is_dir(): - return False - - try: - subprocess.run( - ["git", "rev-parse", "--is-inside-work-tree"], - check=True, - capture_output=True, - cwd=path, - ) - return True - except (subprocess.CalledProcessError, FileNotFoundError): - return False + return _git_svc.is_repo(path) def init_git_repo(project_path: Path, quiet: bool = False) -> tuple[bool, Optional[str]]: """Initialize a git repository in the specified path.""" - try: - original_cwd = Path.cwd() - os.chdir(project_path) - if not quiet: - console.print("[cyan]Initializing git repository...[/cyan]") - subprocess.run(["git", "init"], check=True, capture_output=True, text=True) - subprocess.run(["git", "add", "."], check=True, capture_output=True, text=True) - subprocess.run(["git", "commit", "-m", "Initial commit from Specify template"], check=True, capture_output=True, text=True) - if not quiet: + ok, err = _git_svc.init_repo(project_path) + if not quiet: + if ok: console.print("[green]✓[/green] Git repository initialized") - return True, None - except subprocess.CalledProcessError as e: - error_msg = f"Command: {' '.join(e.cmd)}\nExit code: {e.returncode}" - if e.stderr: - error_msg += f"\nError: {e.stderr.strip()}" - elif e.stdout: - error_msg += f"\nOutput: {e.stdout.strip()}" - if not quiet: - console.print(f"[red]Error initializing git repository:[/red] {e}") - return False, error_msg - finally: - os.chdir(original_cwd) + else: + console.print(f"[red]Error initializing git repository:[/red] {err}") + return ok, err def _locate_core_pack() -> Path | None: diff --git a/src/specify_cli/_git.py b/src/specify_cli/_git.py new file mode 100644 index 0000000000..0540664de9 --- /dev/null +++ b/src/specify_cli/_git.py @@ -0,0 +1,61 @@ +import os +import subprocess +from pathlib import Path +from typing import Optional + + +class GitService: + """Pure git operations — no console output.""" + + def is_repo(self, path: Path = None) -> bool: + if path is None: + path = Path.cwd() + if not path.is_dir(): + return False + try: + subprocess.run( + ["git", "rev-parse", "--is-inside-work-tree"], + check=True, + capture_output=True, + cwd=path, + ) + return True + except (subprocess.CalledProcessError, FileNotFoundError): + return False + + def init_repo(self, project_path: Path) -> tuple[bool, Optional[str]]: + """Initialize a git repo. Returns (success, error_message_or_None). Never prints.""" + original_cwd = Path.cwd() + try: + os.chdir(project_path) + subprocess.run(["git", "init"], check=True, capture_output=True, text=True) + subprocess.run(["git", "add", "."], check=True, capture_output=True, text=True) + # Only commit if there is something staged + status = subprocess.run( + ["git", "diff", "--cached", "--quiet"], + capture_output=True, + ) + if status.returncode != 0: + subprocess.run( + ["git", "commit", "-m", "Initial commit from Specify template"], + check=True, capture_output=True, text=True, + ) + return True, None + except (subprocess.CalledProcessError, OSError) as e: + if isinstance(e, subprocess.CalledProcessError): + error_msg = f"Command: {' '.join(e.cmd)}\nExit code: {e.returncode}" + if e.stderr: + error_msg += f"\nError: {e.stderr.strip()}" + elif e.stdout: + error_msg += f"\nOutput: {e.stdout.strip()}" + else: + error_msg = str(e) + return False, error_msg + finally: + try: + os.chdir(original_cwd) + except Exception: + pass + + +_git_service = GitService() diff --git a/tests/test_git_service.py b/tests/test_git_service.py new file mode 100644 index 0000000000..6f58638883 --- /dev/null +++ b/tests/test_git_service.py @@ -0,0 +1,43 @@ +import subprocess +from pathlib import Path +from unittest.mock import patch +from specify_cli._git import GitService + +def test_is_repo_true_in_real_git_repo(tmp_path): + subprocess.run(["git", "init"], cwd=tmp_path, check=True, capture_output=True) + svc = GitService() + assert svc.is_repo(tmp_path) is True + +def test_is_repo_false_in_plain_dir(tmp_path): + svc = GitService() + assert svc.is_repo(tmp_path) is False + +def test_init_repo_success(tmp_path): + svc = GitService() + ok, err = svc.init_repo(tmp_path) + assert ok is True + assert err is None + assert (tmp_path / ".git").is_dir() + +def test_init_repo_returns_error_on_failure(): + svc = GitService() + with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, ["git"])): + ok, err = svc.init_repo(Path("/nonexistent")) + assert ok is False + assert err is not None + +def test_init_repo_does_not_print(tmp_path, capsys): + svc = GitService() + svc.init_repo(tmp_path) + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err == "" + +def test_backward_compat_is_git_repo(tmp_path): + from specify_cli import is_git_repo + assert is_git_repo(tmp_path) is False + +def test_backward_compat_init_git_repo(tmp_path): + from specify_cli import init_git_repo + ok, err = init_git_repo(tmp_path, quiet=True) + assert ok is True From bf269b026ce10c11ca7f4fdea409056f63fa6730 Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Sat, 25 Apr 2026 17:33:36 +0800 Subject: [PATCH 10/31] refactor: extract VersionService to _version.py Move version checking logic (get_installed_version, normalize_tag, is_newer, fetch_latest_tag) into a dedicated VersionService class in _version.py; replace the four original functions in __init__.py with thin backward-compatible wrappers. Remove now-dead InvalidVersion/Version imports from __init__.py. --- src/specify_cli/__init__.py | 83 +++-------------------------------- src/specify_cli/_version.py | 68 ++++++++++++++++++++++++++++ tests/test_version_service.py | 58 ++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 78 deletions(-) create mode 100644 src/specify_cli/_version.py create mode 100644 tests/test_version_service.py diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 1ead2a5c6d..f7bd912e4e 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -39,7 +39,6 @@ import yaml from pathlib import Path -from packaging.version import InvalidVersion, Version from typing import Any, Optional import typer @@ -54,6 +53,7 @@ from ._fs import handle_vscode_settings, merge_json_files, save_init_options, load_init_options from ._assets import AssetService as _AssetService, _asset_service as _svc from ._git import GitService as _GitService, _git_service as _git_svc +from ._version import VersionService as _VersionService, _version_service as _ver_svc, GITHUB_API_LATEST from .integration_runtime import ( invoke_separator_for_integration as _invoke_separator_for_integration, resolve_integration_options as _resolve_integration_options_impl, @@ -75,8 +75,6 @@ refresh_shared_templates as _refresh_shared_templates_impl, ) -GITHUB_API_LATEST = "https://api.github.com/repos/github/spec-kit/releases/latest" - def _build_agent_config() -> dict[str, dict[str, Any]]: """Derive AGENT_CONFIG from INTEGRATION_REGISTRY.""" from .integrations import INTEGRATION_REGISTRY @@ -1200,87 +1198,16 @@ def version(): console.print() def _get_installed_version() -> str: - """Return the installed specify-cli distribution version or 'unknown'. - - Uses importlib.metadata so the value reflects what was actually installed - by pip/uv/pipx — not a value read from pyproject.toml. This is - intentional for `specify self check`, which should reason about the - installed distribution rather than a source-tree fallback. Callers must - treat the sentinel string 'unknown' as an indeterminate value (see FR-020). - """ - - import importlib.metadata - - metadata_errors = [importlib.metadata.PackageNotFoundError] - invalid_metadata_error = getattr(importlib.metadata, "InvalidMetadataError", None) - if invalid_metadata_error is not None: - metadata_errors.append(invalid_metadata_error) - - try: - return importlib.metadata.version("specify-cli") - except tuple(metadata_errors): - return "unknown" + return _ver_svc.get_installed_version() def _normalize_tag(tag: str) -> str: - """Strip exactly one leading 'v' from a release tag. - - Returns the rest of the string unchanged. This handles the common - 'vX.Y.Z' tag convention in this repo; it MUST NOT strip more - aggressively (e.g., two leading 'v's keeps one). - """ - return tag[1:] if tag.startswith("v") else tag + return _ver_svc._normalize_tag(tag) def _is_newer(latest: str, current: str) -> bool: - """Return True iff `latest` is strictly greater than `current` under PEP 440. - - Returns False whenever either side is 'unknown' or fails to parse; this - keeps the comparison indeterminate (rather than crashing or falsely - recommending a downgrade) on edge inputs. - """ - if latest == "unknown" or current == "unknown": - return False - try: - return Version(latest) > Version(current) - except InvalidVersion: - return False - + return _ver_svc.is_newer(latest, current) def _fetch_latest_release_tag() -> tuple[str | None, str | None]: - """Return (tag, failure_category). Exactly one outbound call, 5 s timeout. - - On success: (tag_name, None). - On a documented network/HTTP failure (added in T029/T030): (None, category). - On anything else — including a malformed response body — the exception - propagates; there is no catch-all (research D-006). - """ - req = urllib.request.Request( - GITHUB_API_LATEST, - headers={"Accept": "application/vnd.github+json"}, - ) - token = None - for env_var in ("GH_TOKEN", "GITHUB_TOKEN"): - candidate = os.environ.get(env_var) - if candidate is not None: - candidate = candidate.strip() - if candidate: - token = candidate - break - if token: - req.add_header("Authorization", f"Bearer {token}") - try: - with urllib.request.urlopen(req, timeout=5) as resp: - payload = json.loads(resp.read().decode("utf-8")) - tag = payload.get("tag_name") - if not isinstance(tag, str) or not tag: - raise ValueError("GitHub API response missing valid tag_name") - return tag, None - except urllib.error.HTTPError as e: - # Order matters: HTTPError is a subclass of URLError. - if e.code == 403: - return None, "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)" - return None, f"HTTP {e.code}" - except (urllib.error.URLError, OSError): - return None, "offline or timeout" + return _ver_svc.fetch_latest_tag() # ===== Self Commands ===== diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py new file mode 100644 index 0000000000..689b4ee8b4 --- /dev/null +++ b/src/specify_cli/_version.py @@ -0,0 +1,68 @@ +import json +import os +import urllib.error +import urllib.request +from typing import Optional + +from packaging.version import InvalidVersion, Version + +GITHUB_API_LATEST = "https://api.github.com/repos/github/spec-kit/releases/latest" + + +class VersionService: + """Version checking and comparison — no console output.""" + + def get_installed_version(self) -> str: + import importlib.metadata + metadata_errors = [importlib.metadata.PackageNotFoundError] + invalid_err = getattr(importlib.metadata, "InvalidMetadataError", None) + if invalid_err is not None: + metadata_errors.append(invalid_err) + try: + return importlib.metadata.version("specify-cli") + except tuple(metadata_errors): + return "unknown" + + def _normalize_tag(self, tag: str) -> str: + return tag[1:] if tag.startswith("v") else tag + + def is_newer(self, latest: str, current: str) -> bool: + if latest == "unknown" or current == "unknown": + return False + try: + return Version(latest) > Version(current) + except InvalidVersion: + return False + + def fetch_latest_tag(self) -> tuple[Optional[str], Optional[str]]: + """Returns (tag, failure_category). One of the two is always None.""" + req = urllib.request.Request( + GITHUB_API_LATEST, + headers={"Accept": "application/vnd.github+json"}, + ) + token = None + for env_var in ("GH_TOKEN", "GITHUB_TOKEN"): + candidate = os.environ.get(env_var) + if candidate is not None: + candidate = candidate.strip() + if candidate: + token = candidate + break + if token: + req.add_header("Authorization", f"Bearer {token}") + try: + with urllib.request.urlopen(req, timeout=5) as resp: + payload = json.loads(resp.read().decode("utf-8")) + tag = payload.get("tag_name") + if not isinstance(tag, str) or not tag: + raise ValueError("GitHub API response missing valid tag_name") + return tag, None + except urllib.error.HTTPError as e: + if e.code == 403: + return None, "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)" + return None, f"HTTP {e.code}" + except (urllib.error.URLError, OSError): + return None, "offline or timeout" + + +_version_service = VersionService() diff --git a/tests/test_version_service.py b/tests/test_version_service.py new file mode 100644 index 0000000000..4c92a18721 --- /dev/null +++ b/tests/test_version_service.py @@ -0,0 +1,58 @@ +from unittest.mock import patch, MagicMock +import json +from specify_cli._version import VersionService + +def test_is_newer_true(): + svc = VersionService() + assert svc.is_newer("0.9.0", "0.8.0") is True + +def test_is_newer_false_when_equal(): + svc = VersionService() + assert svc.is_newer("0.8.0", "0.8.0") is False + +def test_is_newer_false_when_older(): + svc = VersionService() + assert svc.is_newer("0.7.0", "0.8.0") is False + +def test_is_newer_false_with_unknown(): + svc = VersionService() + assert svc.is_newer("unknown", "0.8.0") is False + assert svc.is_newer("0.9.0", "unknown") is False + +def test_normalize_tag_strips_v(): + svc = VersionService() + assert svc._normalize_tag("v0.9.0") == "0.9.0" + assert svc._normalize_tag("0.9.0") == "0.9.0" + assert svc._normalize_tag("vv0.9.0") == "v0.9.0" + +def test_get_installed_version_returns_string(): + svc = VersionService() + result = svc.get_installed_version() + assert isinstance(result, str) + +def test_fetch_latest_tag_returns_tuple_on_network_error(): + svc = VersionService() + import urllib.error + with patch("urllib.request.urlopen", side_effect=urllib.error.URLError("timeout")): + tag, failure = svc.fetch_latest_tag() + assert tag is None + assert failure == "offline or timeout" + +def test_fetch_latest_tag_success(): + svc = VersionService() + mock_response = MagicMock() + mock_response.read.return_value = json.dumps({"tag_name": "v0.9.0"}).encode() + mock_response.__enter__ = lambda s: s + mock_response.__exit__ = MagicMock(return_value=False) + with patch("urllib.request.urlopen", return_value=mock_response): + tag, failure = svc.fetch_latest_tag() + assert tag == "v0.9.0" + assert failure is None + +def test_backward_compat_normalize_tag(): + from specify_cli import _normalize_tag + assert _normalize_tag("v1.0.0") == "1.0.0" + +def test_backward_compat_is_newer(): + from specify_cli import _is_newer + assert _is_newer("1.0.0", "0.9.0") is True From e6b999d3b0d6b4f22cbde59d9ae90dcbf903e513 Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Sat, 25 Apr 2026 17:51:38 +0800 Subject: [PATCH 11/31] refactor: extract init helper utilities to _helpers.py Moves run_command, check_tool, _install_shared_infra, ensure_executable_scripts, ensure_constitution_from_template, and _get_skills_dir (plus CLAUDE_LOCAL_PATH/CLAUDE_NPM_LOCAL_PATH) out of __init__.py into a new _helpers.py module. Re-exports all symbols from __init__.py for backward compatibility. Removes now-unused top-level subprocess import. Updates test_check_tool.py patch targets to the canonical _helpers namespace. --- src/specify_cli/__init__.py | 70 ++------- src/specify_cli/_helpers.py | 280 ++++++++++++++++++++++++++++++++++++ tests/test_check_tool.py | 20 +-- tests/test_helpers.py | 19 +++ 4 files changed, 318 insertions(+), 71 deletions(-) create mode 100644 src/specify_cli/_helpers.py create mode 100644 tests/test_helpers.py diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index f7bd912e4e..0f067fd777 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -27,7 +27,6 @@ """ import os -import subprocess import sys import zipfile import tempfile @@ -54,6 +53,12 @@ from ._assets import AssetService as _AssetService, _asset_service as _svc from ._git import GitService as _GitService, _git_service as _git_svc from ._version import VersionService as _VersionService, _version_service as _ver_svc, GITHUB_API_LATEST +from ._helpers import ( + run_command, check_tool, + _install_shared_infra, ensure_executable_scripts, + ensure_constitution_from_template, _get_skills_dir, + CLAUDE_LOCAL_PATH, CLAUDE_NPM_LOCAL_PATH, +) from .integration_runtime import ( invoke_separator_for_integration as _invoke_separator_for_integration, resolve_integration_options as _resolve_integration_options_impl, @@ -149,9 +154,6 @@ def _build_ai_deprecation_warning( SCRIPT_TYPE_CHOICES = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"} -CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude" -CLAUDE_NPM_LOCAL_PATH = Path.home() / ".claude" / "local" / "node_modules" / ".bin" / "claude" - app = typer.Typer( name="specify", help="Setup tool for Specify spec-driven development projects", @@ -176,63 +178,6 @@ def callback( console.print(Align.center("[dim]Run 'specify --help' for usage information[/dim]")) console.print() -def run_command(cmd: list[str], check_return: bool = True, capture: bool = False, shell: bool = False) -> Optional[str]: - """Run a shell command and optionally capture output.""" - try: - if capture: - result = subprocess.run(cmd, check=check_return, capture_output=True, text=True, shell=shell) - return result.stdout.strip() - else: - subprocess.run(cmd, check=check_return, shell=shell) - return None - except subprocess.CalledProcessError as e: - if check_return: - console.print(f"[red]Error running command:[/red] {' '.join(cmd)}") - console.print(f"[red]Exit code:[/red] {e.returncode}") - if hasattr(e, 'stderr') and e.stderr: - console.print(f"[red]Error output:[/red] {e.stderr}") - raise - return None - -def check_tool(tool: str, tracker: StepTracker = None) -> bool: - """Check if a tool is installed. Optionally update tracker. - - Args: - tool: Name of the tool to check - tracker: Optional StepTracker to update with results - - Returns: - True if tool is found, False otherwise - """ - # Special handling for Claude CLI local installs - # See: https://github.com/github/spec-kit/issues/123 - # See: https://github.com/github/spec-kit/issues/550 - # Claude Code can be installed in two local paths: - # 1. ~/.claude/local/claude (after `claude migrate-installer`) - # 2. ~/.claude/local/node_modules/.bin/claude (npm-local install, e.g. via nvm) - # Neither path may be on the system PATH, so we check them explicitly. - if tool == "claude": - if CLAUDE_LOCAL_PATH.is_file() or CLAUDE_NPM_LOCAL_PATH.is_file(): - if tracker: - tracker.complete(tool, "available") - return True - - if tool == "kiro-cli": - # Kiro currently supports both executable names. Prefer kiro-cli and - # accept kiro as a compatibility fallback. - found = shutil.which("kiro-cli") is not None or shutil.which("kiro") is not None - else: - found = shutil.which(tool) is not None - - if tracker: - if found: - tracker.complete(tool, "available") - else: - tracker.error(tool, "not found") - - return found - - def is_git_repo(path: Path = None) -> bool: """Check if the specified path is inside a git repository.""" return _git_svc.is_repo(path) @@ -270,6 +215,7 @@ def _locate_bundled_preset(preset_id: str) -> Path | None: return _svc.locate_bundled_preset(preset_id) +<<<<<<< HEAD def _refresh_shared_templates( project_path: Path, *, @@ -444,6 +390,8 @@ def _get_skills_dir(project_path: Path, selected_ai: str) -> Path: return project_path / ".agents" / "skills" +======= +>>>>>>> 232fec5 (refactor: extract init helper utilities to _helpers.py) # Constants kept for backward compatibility with presets and extensions. DEFAULT_SKILLS_DIR = ".agents/skills" SKILL_DESCRIPTIONS = { diff --git a/src/specify_cli/_helpers.py b/src/specify_cli/_helpers.py new file mode 100644 index 0000000000..5e116d4776 --- /dev/null +++ b/src/specify_cli/_helpers.py @@ -0,0 +1,280 @@ +import os +import shlex +import shutil +import subprocess +from pathlib import Path +from typing import Any, Optional + +from ._console import console +from ._ui import StepTracker + +CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude" +CLAUDE_NPM_LOCAL_PATH = Path.home() / ".claude" / "local" / "node_modules" / ".bin" / "claude" + + +def run_command(cmd: list[str], check_return: bool = True, capture: bool = False, shell: bool = False) -> Optional[str]: + """Run a shell command and optionally capture output.""" + try: + if capture: + result = subprocess.run(cmd, check=check_return, capture_output=True, text=True, shell=shell) + return result.stdout.strip() + else: + subprocess.run(cmd, check=check_return, shell=shell) + return None + except subprocess.CalledProcessError as e: + if check_return: + console.print(f"[red]Error running command:[/red] {' '.join(cmd)}") + console.print(f"[red]Exit code:[/red] {e.returncode}") + if hasattr(e, 'stderr') and e.stderr: + console.print(f"[red]Error output:[/red] {e.stderr}") + raise + return None + + +def check_tool(tool: str, tracker: StepTracker = None) -> bool: + """Check if a tool is installed. Optionally update tracker. + + Args: + tool: Name of the tool to check + tracker: Optional StepTracker to update with results + + Returns: + True if tool is found, False otherwise + """ + # Special handling for Claude CLI local installs + # See: https://github.com/github/spec-kit/issues/123 + # See: https://github.com/github/spec-kit/issues/550 + # Claude Code can be installed in two local paths: + # 1. ~/.claude/local/claude (after `claude migrate-installer`) + # 2. ~/.claude/local/node_modules/.bin/claude (npm-local install, e.g. via nvm) + # Neither path may be on the system PATH, so we check them explicitly. + if tool == "claude": + if CLAUDE_LOCAL_PATH.is_file() or CLAUDE_NPM_LOCAL_PATH.is_file(): + if tracker: + tracker.complete(tool, "available") + return True + + if tool == "kiro-cli": + # Kiro currently supports both executable names. Prefer kiro-cli and + # accept kiro as a compatibility fallback. + found = shutil.which("kiro-cli") is not None or shutil.which("kiro") is not None + else: + found = shutil.which(tool) is not None + + if tracker: + if found: + tracker.complete(tool, "available") + else: + tracker.error(tool, "not found") + + return found + + +def _install_shared_infra( + project_path: Path, + script_type: str, + tracker: StepTracker | None = None, + force: bool = False, + invoke_separator: str = ".", +) -> bool: + """Install shared infrastructure files into *project_path*. + + Copies ``.specify/scripts/`` and ``.specify/templates/`` from the + bundled core_pack or source checkout. Tracks all installed files + in ``speckit.manifest.json``. + + Page templates are processed to resolve ``__SPECKIT_COMMAND___`` + placeholders using *invoke_separator* (``"."`` for markdown agents, + ``"-"`` for skills agents). + + When *force* is ``True``, existing files are overwritten with the + latest bundled versions. When ``False`` (default), only missing + files are added and existing ones are skipped. + + Returns ``True`` on success. + """ + import importlib.metadata + + def _get_version() -> str: + try: + return importlib.metadata.version("specify-cli") + except Exception: + return "unknown" + + from .integrations.base import IntegrationBase + from .integrations.manifest import IntegrationManifest + from ._assets import _asset_service as _svc + + core = _svc.locate_core_pack() + manifest = IntegrationManifest("speckit", project_path, version=_get_version()) + + # Scripts + if core and (core / "scripts").is_dir(): + scripts_src = core / "scripts" + else: + repo_root = Path(__file__).parent.parent.parent + scripts_src = repo_root / "scripts" + + skipped_files: list[str] = [] + + if scripts_src.is_dir(): + dest_scripts = project_path / ".specify" / "scripts" + dest_scripts.mkdir(parents=True, exist_ok=True) + variant_dir = "bash" if script_type == "sh" else "powershell" + variant_src = scripts_src / variant_dir + if variant_src.is_dir(): + dest_variant = dest_scripts / variant_dir + dest_variant.mkdir(parents=True, exist_ok=True) + for src_path in variant_src.rglob("*"): + if src_path.is_file(): + rel_path = src_path.relative_to(variant_src) + dst_path = dest_variant / rel_path + if dst_path.exists() and not force: + skipped_files.append(str(dst_path.relative_to(project_path))) + else: + dst_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src_path, dst_path) + rel = dst_path.relative_to(project_path).as_posix() + manifest.record_existing(rel) + + # Page templates (not command templates, not vscode-settings.json) + if core and (core / "templates").is_dir(): + templates_src = core / "templates" + else: + repo_root = Path(__file__).parent.parent.parent + templates_src = repo_root / "templates" + + if templates_src.is_dir(): + dest_templates = project_path / ".specify" / "templates" + dest_templates.mkdir(parents=True, exist_ok=True) + for f in templates_src.iterdir(): + if f.is_file() and f.name != "vscode-settings.json" and not f.name.startswith("."): + dst = dest_templates / f.name + if dst.exists() and not force: + skipped_files.append(str(dst.relative_to(project_path))) + else: + content = f.read_text(encoding="utf-8") + content = IntegrationBase.resolve_command_refs( + content, invoke_separator + ) + dst.write_text(content, encoding="utf-8") + rel = dst.relative_to(project_path).as_posix() + manifest.record_existing(rel) + + if skipped_files: + console.print( + f"[yellow]⚠[/yellow] {len(skipped_files)} shared infrastructure file(s) already exist and were not updated:" + ) + for f in skipped_files: + console.print(f" {f}") + console.print( + "To refresh shared infrastructure, run " + "[cyan]specify init --here --force[/cyan] or " + "[cyan]specify integration upgrade --force[/cyan]." + ) + + manifest.save() + return True + + +def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = None) -> None: + """Ensure POSIX .sh scripts under .specify/scripts and .specify/extensions (recursively) have execute bits (no-op on Windows).""" + if os.name == "nt": + return # Windows: skip silently + scan_roots = [ + project_path / ".specify" / "scripts", + project_path / ".specify" / "extensions", + ] + failures: list[str] = [] + updated = 0 + for scripts_root in scan_roots: + if not scripts_root.is_dir(): + continue + for script in scripts_root.rglob("*.sh"): + try: + if script.is_symlink() or not script.is_file(): + continue + try: + with script.open("rb") as f: + if f.read(2) != b"#!": + continue + except Exception: + continue + st = script.stat() + mode = st.st_mode + if mode & 0o111: + continue + new_mode = mode + if mode & 0o400: + new_mode |= 0o100 + if mode & 0o040: + new_mode |= 0o010 + if mode & 0o004: + new_mode |= 0o001 + if not (new_mode & 0o100): + new_mode |= 0o100 + os.chmod(script, new_mode) + updated += 1 + except Exception as e: + failures.append(f"{script.relative_to(project_path)}: {e}") + if tracker: + detail = f"{updated} updated" + (f", {len(failures)} failed" if failures else "") + tracker.add("chmod", "Set script permissions recursively") + (tracker.error if failures else tracker.complete)("chmod", detail) + else: + if updated: + console.print(f"[cyan]Updated execute permissions on {updated} script(s) recursively[/cyan]") + if failures: + console.print("[yellow]Some scripts could not be updated:[/yellow]") + for f in failures: + console.print(f" - {f}") + + +def ensure_constitution_from_template(project_path: Path, tracker: StepTracker | None = None) -> None: + """Copy constitution template to memory if it doesn't exist (preserves existing constitution on reinitialization).""" + memory_constitution = project_path / ".specify" / "memory" / "constitution.md" + template_constitution = project_path / ".specify" / "templates" / "constitution-template.md" + + # If constitution already exists in memory, preserve it + if memory_constitution.exists(): + if tracker: + tracker.add("constitution", "Constitution setup") + tracker.skip("constitution", "existing file preserved") + return + + # If template doesn't exist, something went wrong with extraction + if not template_constitution.exists(): + if tracker: + tracker.add("constitution", "Constitution setup") + tracker.error("constitution", "template not found") + return + + # Copy template to memory directory + try: + memory_constitution.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(template_constitution, memory_constitution) + if tracker: + tracker.add("constitution", "Constitution setup") + tracker.complete("constitution", "copied from template") + else: + console.print("[cyan]Initialized constitution from template[/cyan]") + except Exception as e: + if tracker: + tracker.add("constitution", "Constitution setup") + tracker.error("constitution", str(e)) + else: + console.print(f"[yellow]Warning: Could not initialize constitution: {e}[/yellow]") + + +def _get_skills_dir(project_path: Path, selected_ai: str) -> Path: + """Resolve the agent-specific skills directory. + + Returns ``project_path / / "skills"``, falling back + to ``project_path / ".agents/skills"`` for unknown agents. + """ + from specify_cli import AGENT_CONFIG + agent_config = AGENT_CONFIG.get(selected_ai, {}) + agent_folder = agent_config.get("folder", "") + if agent_folder: + return project_path / agent_folder.rstrip("/") / "skills" + return project_path / ".agents" / "skills" diff --git a/tests/test_check_tool.py b/tests/test_check_tool.py index 0eb267ba24..7a371206f0 100644 --- a/tests/test_check_tool.py +++ b/tests/test_check_tool.py @@ -21,8 +21,8 @@ def test_detected_via_migrate_installer_path(self, tmp_path): # Ensure npm-local path is missing so we only exercise migrate-installer path fake_missing = tmp_path / "nonexistent" / "claude" - with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_claude), \ - patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_missing), \ + with patch("specify_cli._helpers.CLAUDE_LOCAL_PATH", fake_claude), \ + patch("specify_cli._helpers.CLAUDE_NPM_LOCAL_PATH", fake_missing), \ patch("shutil.which", return_value=None): assert check_tool("claude") is True @@ -35,8 +35,8 @@ def test_detected_via_npm_local_path(self, tmp_path): # Neither the migrate-installer path nor PATH has claude fake_migrate = tmp_path / "nonexistent" / "claude" - with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_migrate), \ - patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_npm_claude), \ + with patch("specify_cli._helpers.CLAUDE_LOCAL_PATH", fake_migrate), \ + patch("specify_cli._helpers.CLAUDE_NPM_LOCAL_PATH", fake_npm_claude), \ patch("shutil.which", return_value=None): assert check_tool("claude") is True @@ -44,8 +44,8 @@ def test_detected_via_path(self, tmp_path): """claude on PATH (global npm install) should still work.""" fake_missing = tmp_path / "nonexistent" / "claude" - with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_missing), \ - patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_missing), \ + with patch("specify_cli._helpers.CLAUDE_LOCAL_PATH", fake_missing), \ + patch("specify_cli._helpers.CLAUDE_NPM_LOCAL_PATH", fake_missing), \ patch("shutil.which", return_value="/usr/local/bin/claude"): assert check_tool("claude") is True @@ -53,8 +53,8 @@ def test_not_found_when_nowhere(self, tmp_path): """Should return False when claude is genuinely not installed.""" fake_missing = tmp_path / "nonexistent" / "claude" - with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_missing), \ - patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_missing), \ + with patch("specify_cli._helpers.CLAUDE_LOCAL_PATH", fake_missing), \ + patch("specify_cli._helpers.CLAUDE_NPM_LOCAL_PATH", fake_missing), \ patch("shutil.which", return_value=None): assert check_tool("claude") is False @@ -67,8 +67,8 @@ def test_tracker_updated_on_npm_local_detection(self, tmp_path): fake_missing = tmp_path / "nonexistent" / "claude" tracker = MagicMock() - with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_missing), \ - patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_npm_claude), \ + with patch("specify_cli._helpers.CLAUDE_LOCAL_PATH", fake_missing), \ + patch("specify_cli._helpers.CLAUDE_NPM_LOCAL_PATH", fake_npm_claude), \ patch("shutil.which", return_value=None): result = check_tool("claude", tracker=tracker) diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 0000000000..1b9485ba45 --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,19 @@ +import shutil +from pathlib import Path +from unittest.mock import patch +from specify_cli._helpers import check_tool, run_command + +def test_check_tool_git_found(): + if shutil.which("git"): + assert check_tool("git") is True + +def test_check_tool_nonexistent_returns_false(): + assert check_tool("__nonexistent_tool_xyz__") is False + +def test_run_command_capture(): + result = run_command(["echo", "hello"], capture=True) + assert result == "hello" + +def test_run_command_no_capture_returns_none(): + result = run_command(["true"]) + assert result is None From a98149053c8acee9218eb7ed4333732672f9c666 Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Sat, 25 Apr 2026 18:47:04 +0800 Subject: [PATCH 12/31] refactor: remove unused shlex and Any imports from _helpers.py --- src/specify_cli/_helpers.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/specify_cli/_helpers.py b/src/specify_cli/_helpers.py index 5e116d4776..042ea73666 100644 --- a/src/specify_cli/_helpers.py +++ b/src/specify_cli/_helpers.py @@ -1,9 +1,8 @@ import os -import shlex import shutil import subprocess from pathlib import Path -from typing import Any, Optional +from typing import Optional from ._console import console from ._ui import StepTracker From 3f9726ce3353cdfa18a26e0349e169f68f06afa7 Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Sat, 25 Apr 2026 18:47:57 +0800 Subject: [PATCH 13/31] refactor: create commands/ package skeleton --- src/specify_cli/commands/__init__.py | 1 + src/specify_cli/commands/extension.py | 1 + src/specify_cli/commands/init.py | 1 + src/specify_cli/commands/integration.py | 1 + src/specify_cli/commands/preset.py | 1 + src/specify_cli/commands/workflow.py | 1 + tests/test_commands_package.py | 17 +++++++++++++++++ 7 files changed, 23 insertions(+) create mode 100644 src/specify_cli/commands/__init__.py create mode 100644 src/specify_cli/commands/extension.py create mode 100644 src/specify_cli/commands/init.py create mode 100644 src/specify_cli/commands/integration.py create mode 100644 src/specify_cli/commands/preset.py create mode 100644 src/specify_cli/commands/workflow.py create mode 100644 tests/test_commands_package.py diff --git a/src/specify_cli/commands/__init__.py b/src/specify_cli/commands/__init__.py new file mode 100644 index 0000000000..388aadc85e --- /dev/null +++ b/src/specify_cli/commands/__init__.py @@ -0,0 +1 @@ +"""CLI command groups — each module defines its own sub-typer and registers commands.""" diff --git a/src/specify_cli/commands/extension.py b/src/specify_cli/commands/extension.py new file mode 100644 index 0000000000..985c192cb6 --- /dev/null +++ b/src/specify_cli/commands/extension.py @@ -0,0 +1 @@ +"""specify extension * and catalog * commands.""" diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py new file mode 100644 index 0000000000..f3e0742455 --- /dev/null +++ b/src/specify_cli/commands/init.py @@ -0,0 +1 @@ +"""specify init command.""" diff --git a/src/specify_cli/commands/integration.py b/src/specify_cli/commands/integration.py new file mode 100644 index 0000000000..c8f7907946 --- /dev/null +++ b/src/specify_cli/commands/integration.py @@ -0,0 +1 @@ +"""specify integration * commands.""" diff --git a/src/specify_cli/commands/preset.py b/src/specify_cli/commands/preset.py new file mode 100644 index 0000000000..fc1064b01e --- /dev/null +++ b/src/specify_cli/commands/preset.py @@ -0,0 +1 @@ +"""specify preset * commands.""" diff --git a/src/specify_cli/commands/workflow.py b/src/specify_cli/commands/workflow.py new file mode 100644 index 0000000000..b140dafe24 --- /dev/null +++ b/src/specify_cli/commands/workflow.py @@ -0,0 +1 @@ +"""specify workflow * commands.""" diff --git a/tests/test_commands_package.py b/tests/test_commands_package.py new file mode 100644 index 0000000000..4d42722e5e --- /dev/null +++ b/tests/test_commands_package.py @@ -0,0 +1,17 @@ +"""Test commands package structure.""" + + +def test_commands_package_importable(): + """Test that commands package can be imported.""" + import specify_cli.commands + assert specify_cli.commands is not None + + +def test_command_modules_importable(): + """Test that all command modules can be imported.""" + from specify_cli.commands import init, integration, preset, extension, workflow + assert init is not None + assert integration is not None + assert preset is not None + assert extension is not None + assert workflow is not None From b6fdd5eea5d4cd5a84b2ba424c5ad840d3a7ff68 Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Sat, 25 Apr 2026 20:44:11 +0800 Subject: [PATCH 14/31] refactor: move init command handler to commands/init.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move `init` command (~630 lines) from `__init__.py` to `commands/init.py` using the `register(app)` pattern to avoid circular imports - Move `AGENT_CONFIG`, `AI_ASSISTANT_ALIASES`, `AI_ASSISTANT_HELP`, `SCRIPT_TYPE_CHOICES`, `get_speckit_version`, `_parse_integration_options` to `_helpers.py`; re-export from `__init__.py` for backward compatibility - Fix `_get_skills_dir` in `_helpers.py` to use module-local `AGENT_CONFIG` instead of circular `from specify_cli import AGENT_CONFIG` - Use `sys.modules` lookup for `select_with_arrows` so existing test patches on `specify_cli.select_with_arrows` continue to work - `__init__.py` reduced from 4501 → 3734 lines --- src/specify_cli/__init__.py | 984 +------------------------------ src/specify_cli/_helpers.py | 133 ++++- src/specify_cli/commands/init.py | 728 +++++++++++++++++++++++ 3 files changed, 863 insertions(+), 982 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 0f067fd777..64699c9be7 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -58,6 +58,8 @@ _install_shared_infra, ensure_executable_scripts, ensure_constitution_from_template, _get_skills_dir, CLAUDE_LOCAL_PATH, CLAUDE_NPM_LOCAL_PATH, + get_speckit_version, _parse_integration_options, + AGENT_CONFIG, AI_ASSISTANT_ALIASES, AI_ASSISTANT_HELP, SCRIPT_TYPE_CHOICES, ) from .integration_runtime import ( invoke_separator_for_integration as _invoke_separator_for_integration, @@ -80,80 +82,9 @@ refresh_shared_templates as _refresh_shared_templates_impl, ) -def _build_agent_config() -> dict[str, dict[str, Any]]: - """Derive AGENT_CONFIG from INTEGRATION_REGISTRY.""" - from .integrations import INTEGRATION_REGISTRY - config: dict[str, dict[str, Any]] = {} - for key, integration in INTEGRATION_REGISTRY.items(): - if integration.config: - config[key] = dict(integration.config) - return config - -AGENT_CONFIG = _build_agent_config() - -AI_ASSISTANT_ALIASES = { - "kiro": "kiro-cli", -} - # Agents that use TOML command format (others use Markdown) _TOML_AGENTS = frozenset({"gemini", "tabnine"}) -def _build_ai_assistant_help() -> str: - """Build the --ai help text from AGENT_CONFIG so it stays in sync with runtime config.""" - - non_generic_agents = sorted(agent for agent in AGENT_CONFIG if agent != "generic") - base_help = ( - f"AI assistant to use: {', '.join(non_generic_agents)}, " - "or generic (requires --ai-commands-dir)." - ) - - if not AI_ASSISTANT_ALIASES: - return base_help - - alias_phrases = [] - for alias, target in sorted(AI_ASSISTANT_ALIASES.items()): - alias_phrases.append(f"'{alias}' as an alias for '{target}'") - - if len(alias_phrases) == 1: - aliases_text = alias_phrases[0] - else: - aliases_text = ', '.join(alias_phrases[:-1]) + ' and ' + alias_phrases[-1] - - return base_help + " Use " + aliases_text + "." -AI_ASSISTANT_HELP = _build_ai_assistant_help() - - -def _build_integration_equivalent( - integration_key: str, - ai_commands_dir: str | None = None, -) -> str: - """Build the modern --integration equivalent for legacy --ai usage.""" - - parts = [f"--integration {integration_key}"] - if integration_key == "generic" and ai_commands_dir: - parts.append( - f'--integration-options="--commands-dir {shlex.quote(ai_commands_dir)}"' - ) - return " ".join(parts) - - -def _build_ai_deprecation_warning( - integration_key: str, - ai_commands_dir: str | None = None, -) -> str: - """Build the legacy --ai deprecation warning message.""" - - replacement = _build_integration_equivalent( - integration_key, - ai_commands_dir=ai_commands_dir, - ) - return ( - "[bold]--ai[/bold] is deprecated and will no longer be available in version 0.10.0 or later.\n\n" - f"Use [bold]{replacement}[/bold] instead." - ) - -SCRIPT_TYPE_CHOICES = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"} - app = typer.Typer( name="specify", help="Setup tool for Specify spec-driven development projects", @@ -214,184 +145,6 @@ def _locate_bundled_workflow(workflow_id: str) -> Path | None: def _locate_bundled_preset(preset_id: str) -> Path | None: return _svc.locate_bundled_preset(preset_id) - -<<<<<<< HEAD -def _refresh_shared_templates( - project_path: Path, - *, - invoke_separator: str, - force: bool = False, -) -> None: - """Refresh default-sensitive shared templates without touching scripts.""" - _refresh_shared_templates_impl( - project_path, - version=get_speckit_version(), - core_pack=_locate_core_pack(), - repo_root=_repo_root(), - console=console, - invoke_separator=invoke_separator, - force=force, - ) - - -def _install_shared_infra( - project_path: Path, - script_type: str, - tracker: StepTracker | None = None, - force: bool = False, - invoke_separator: str = ".", -) -> bool: - """Install shared infrastructure files into *project_path*. - - Copies ``.specify/scripts/`` and ``.specify/templates/`` from the - bundled core_pack or source checkout. Tracks all installed files - in ``speckit.manifest.json``. - - Page templates are processed to resolve ``__SPECKIT_COMMAND___`` - placeholders using *invoke_separator* (``"."`` for markdown agents, - ``"-"`` for skills agents). - - When *force* is ``True``, existing files are overwritten with the - latest bundled versions. When ``False`` (default), only missing - files are added and existing ones are skipped. - - Returns ``True`` on success. - """ - return _install_shared_infra_impl( - project_path, - script_type, - version=get_speckit_version(), - core_pack=_locate_core_pack(), - repo_root=_repo_root(), - console=console, - force=force, - invoke_separator=invoke_separator, - ) - - -def _install_shared_infra_or_exit( - project_path: Path, - script_type: str, - tracker: StepTracker | None = None, - force: bool = False, - invoke_separator: str = ".", -) -> bool: - try: - return _install_shared_infra( - project_path, - script_type, - tracker=tracker, - force=force, - invoke_separator=invoke_separator, - ) - except (ValueError, OSError) as exc: - console.print(f"[red]Error:[/red] Failed to install shared infrastructure: {exc}") - raise typer.Exit(1) - - -def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = None) -> None: - """Ensure POSIX .sh scripts under .specify/scripts and .specify/extensions (recursively) have execute bits (no-op on Windows).""" - if os.name == "nt": - return # Windows: skip silently - scan_roots = [ - project_path / ".specify" / "scripts", - project_path / ".specify" / "extensions", - ] - failures: list[str] = [] - updated = 0 - for scripts_root in scan_roots: - if not scripts_root.is_dir(): - continue - for script in scripts_root.rglob("*.sh"): - try: - if script.is_symlink() or not script.is_file(): - continue - try: - with script.open("rb") as f: - if f.read(2) != b"#!": - continue - except Exception: - continue - st = script.stat() - mode = st.st_mode - if mode & 0o111: - continue - new_mode = mode - if mode & 0o400: - new_mode |= 0o100 - if mode & 0o040: - new_mode |= 0o010 - if mode & 0o004: - new_mode |= 0o001 - if not (new_mode & 0o100): - new_mode |= 0o100 - os.chmod(script, new_mode) - updated += 1 - except Exception as e: - failures.append(f"{_display_project_path(project_path, script)}: {e}") - if tracker: - detail = f"{updated} updated" + (f", {len(failures)} failed" if failures else "") - tracker.add("chmod", "Set script permissions recursively") - (tracker.error if failures else tracker.complete)("chmod", detail) - else: - if updated: - console.print(f"[cyan]Updated execute permissions on {updated} script(s) recursively[/cyan]") - if failures: - console.print("[yellow]Some scripts could not be updated:[/yellow]") - for f in failures: - console.print(f" - {f}") - -def ensure_constitution_from_template(project_path: Path, tracker: StepTracker | None = None) -> None: - """Copy constitution template to memory if it doesn't exist (preserves existing constitution on reinitialization).""" - memory_constitution = project_path / ".specify" / "memory" / "constitution.md" - template_constitution = project_path / ".specify" / "templates" / "constitution-template.md" - - # If constitution already exists in memory, preserve it - if memory_constitution.exists(): - if tracker: - tracker.add("constitution", "Constitution setup") - tracker.skip("constitution", "existing file preserved") - return - - # If template doesn't exist, something went wrong with extraction - if not template_constitution.exists(): - if tracker: - tracker.add("constitution", "Constitution setup") - tracker.error("constitution", "template not found") - return - - # Copy template to memory directory - try: - memory_constitution.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(template_constitution, memory_constitution) - if tracker: - tracker.add("constitution", "Constitution setup") - tracker.complete("constitution", "copied from template") - else: - console.print("[cyan]Initialized constitution from template[/cyan]") - except Exception as e: - if tracker: - tracker.add("constitution", "Constitution setup") - tracker.error("constitution", str(e)) - else: - console.print(f"[yellow]Warning: Could not initialize constitution: {e}[/yellow]") - - -def _get_skills_dir(project_path: Path, selected_ai: str) -> Path: - """Resolve the agent-specific skills directory. - - Returns ``project_path / / "skills"``, falling back - to ``project_path / ".agents/skills"`` for unknown agents. - """ - agent_config = AGENT_CONFIG.get(selected_ai, {}) - agent_folder = agent_config.get("folder", "") - if agent_folder: - return project_path / agent_folder.rstrip("/") / "skills" - return project_path / ".agents" / "skills" - - -======= ->>>>>>> 232fec5 (refactor: extract init helper utilities to _helpers.py) # Constants kept for backward compatibility with presets and extensions. DEFAULT_SKILLS_DIR = ".agents/skills" SKILL_DESCRIPTIONS = { @@ -407,669 +160,8 @@ def _get_skills_dir(project_path: Path, selected_ai: str) -> Path: } -@app.command() -def init( - project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"), - ai_assistant: str = typer.Option(None, "--ai", help=AI_ASSISTANT_HELP), - ai_commands_dir: str = typer.Option(None, "--ai-commands-dir", help="Directory for agent command files (required with --ai generic, e.g. .myagent/commands/)"), - script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"), - ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for coding agent tools like Claude Code"), - no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization"), - here: bool = typer.Option(False, "--here", help="Initialize project in the current directory instead of creating a new one"), - force: bool = typer.Option(False, "--force", help="Force merge/overwrite when using --here (skip confirmation)"), - skip_tls: bool = typer.Option(False, "--skip-tls", help="Deprecated (no-op). Previously: skip SSL/TLS verification.", hidden=True), - debug: bool = typer.Option(False, "--debug", help="Deprecated (no-op). Previously: show verbose diagnostic output.", hidden=True), - github_token: str = typer.Option(None, "--github-token", help="Deprecated (no-op). Previously: GitHub token for API requests.", hidden=True), - ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"), - offline: bool = typer.Option(False, "--offline", help="Deprecated (no-op). All scaffolding now uses bundled assets.", hidden=True), - preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"), - branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, …, 1000, … — expands past 999 automatically) or 'timestamp' (YYYYMMDD-HHMMSS)"), - integration: str = typer.Option(None, "--integration", help="Use the new integration system (e.g. --integration copilot). Mutually exclusive with --ai."), - integration_options: str = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'), -): - """ - Initialize a new Specify project. - - By default, project files are downloaded from the latest GitHub release. - Use --offline to scaffold from assets bundled inside the specify-cli - package instead (no internet access required, ideal for air-gapped or - enterprise environments). - - NOTE: Starting with v0.6.0, bundled assets will be used by default and - the --offline flag will be removed. The GitHub download path will be - retired because bundled assets eliminate the need for network access, - avoid proxy/firewall issues, and guarantee that templates always match - the installed CLI version. - - This command will: - 1. Check that required tools are installed (git is optional) - 2. Let you choose your coding agent integration - 3. Download template from GitHub (or use bundled assets with --offline) - 4. Initialize a fresh git repository (if not --no-git and no existing repo) - 5. Optionally set up coding agent integration commands - - Examples: - specify init my-project - specify init my-project --integration claude - specify init my-project --integration copilot --no-git - specify init --ignore-agent-tools my-project - specify init . --integration claude # Initialize in current directory - specify init . # Initialize in current directory (interactive integration selection) - specify init --here --integration claude # Alternative syntax for current directory - specify init --here --integration codex --integration-options="--skills" - specify init --here --integration codebuddy - specify init --here --integration vibe # Initialize with Mistral Vibe support - specify init --here - specify init --here --force # Skip confirmation when current directory not empty - specify init my-project --integration claude # Claude installs skills by default - specify init --here --integration gemini - specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/" # Bring your own agent; requires --commands-dir - specify init my-project --integration claude --preset healthcare-compliance # With preset - """ - - show_banner() - ai_deprecation_warning: str | None = None - - # Detect when option values are likely misinterpreted flags (parameter ordering issue) - if ai_assistant and ai_assistant.startswith("--"): - console.print(f"[red]Error:[/red] Invalid value for --ai: '{ai_assistant}'") - console.print("[yellow]Hint:[/yellow] Did you forget to provide a value for --ai?") - console.print("[yellow]Example:[/yellow] specify init --integration claude --here") - console.print(f"[yellow]Available agents:[/yellow] {', '.join(AGENT_CONFIG.keys())}") - raise typer.Exit(1) - - if ai_commands_dir and ai_commands_dir.startswith("--"): - console.print(f"[red]Error:[/red] Invalid value for --ai-commands-dir: '{ai_commands_dir}'") - console.print("[yellow]Hint:[/yellow] Did you forget to provide a value for --ai-commands-dir?") - console.print("[yellow]Example:[/yellow] specify init --integration generic --integration-options=\"--commands-dir .myagent/commands/\"") - raise typer.Exit(1) - - if ai_assistant: - ai_assistant = AI_ASSISTANT_ALIASES.get(ai_assistant, ai_assistant) - - # --integration and --ai are mutually exclusive - if integration and ai_assistant: - console.print("[red]Error:[/red] --integration and --ai are mutually exclusive") - raise typer.Exit(1) - - # Resolve the integration — either from --integration or --ai - from .integrations import INTEGRATION_REGISTRY, get_integration - if integration: - resolved_integration = get_integration(integration) - if not resolved_integration: - console.print(f"[red]Error:[/red] Unknown integration: '{integration}'") - available = ", ".join(sorted(INTEGRATION_REGISTRY)) - console.print(f"[yellow]Available integrations:[/yellow] {available}") - raise typer.Exit(1) - ai_assistant = integration - elif ai_assistant: - resolved_integration = get_integration(ai_assistant) - if not resolved_integration: - console.print(f"[red]Error:[/red] Unknown agent '{ai_assistant}'. Choose from: {', '.join(sorted(INTEGRATION_REGISTRY))}") - raise typer.Exit(1) - ai_deprecation_warning = _build_ai_deprecation_warning( - resolved_integration.key, - ai_commands_dir=ai_commands_dir, - ) - - # Deprecation warnings for --ai-skills and --ai-commands-dir (only when - # an integration has been resolved from --ai or --integration) - if ai_assistant or integration: - if ai_skills: - from .integrations.base import SkillsIntegration as _SkillsCheck - if isinstance(resolved_integration, _SkillsCheck): - console.print( - "[dim]Note: --ai-skills is not needed; " - "skills are the default for this integration.[/dim]" - ) - else: - console.print( - "[dim]Note: --ai-skills has no effect with " - f"{resolved_integration.key}; this integration uses commands, not skills.[/dim]" - ) - if ai_commands_dir and resolved_integration.key != "generic": - console.print( - "[dim]Note: --ai-commands-dir is deprecated; " - 'use [bold]--integration generic --integration-options="--commands-dir "[/bold] instead.[/dim]' - ) - - if no_git: - console.print( - "[yellow]⚠️ --no-git is deprecated and will be removed in v0.10.0.[/yellow]\n" - "[yellow]The git extension will no longer be enabled by default " - "— use the [bold]specify extension[/bold] commands to install or enable the git extension if needed.[/yellow]" - ) - - if project_name == ".": - here = True - project_name = None # Clear project_name to use existing validation logic - - if here and project_name: - console.print("[red]Error:[/red] Cannot specify both project name and --here flag") - raise typer.Exit(1) - - if not here and not project_name: - console.print("[red]Error:[/red] Must specify either a project name, use '.' for current directory, or use --here flag") - raise typer.Exit(1) - - if ai_skills and not ai_assistant: - console.print("[red]Error:[/red] --ai-skills requires --ai to be specified") - console.print("[yellow]Usage:[/yellow] specify init --ai --ai-skills") - raise typer.Exit(1) - - BRANCH_NUMBERING_CHOICES = {"sequential", "timestamp"} - if branch_numbering and branch_numbering not in BRANCH_NUMBERING_CHOICES: - console.print(f"[red]Error:[/red] Invalid --branch-numbering value '{branch_numbering}'. Choose from: {', '.join(sorted(BRANCH_NUMBERING_CHOICES))}") - raise typer.Exit(1) - - dir_existed_before = False - if here: - project_name = Path.cwd().name - project_path = Path.cwd() - dir_existed_before = True - - existing_items = list(project_path.iterdir()) - if existing_items: - console.print(f"[yellow]Warning:[/yellow] Current directory is not empty ({len(existing_items)} items)") - console.print("[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]") - if force: - console.print("[cyan]--force supplied: skipping confirmation and proceeding with merge[/cyan]") - else: - response = typer.confirm("Do you want to continue?") - if not response: - console.print("[yellow]Operation cancelled[/yellow]") - raise typer.Exit(0) - else: - project_path = Path(project_name).resolve() - dir_existed_before = project_path.exists() - if project_path.exists(): - if not project_path.is_dir(): - console.print(f"[red]Error:[/red] '{project_name}' exists but is not a directory.") - raise typer.Exit(1) - existing_items = list(project_path.iterdir()) - if force: - if existing_items: - console.print(f"[yellow]Warning:[/yellow] Directory '{project_name}' is not empty ({len(existing_items)} items)") - console.print("[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]") - console.print(f"[cyan]--force supplied: merging into existing directory '[cyan]{project_name}[/cyan]'[/cyan]") - else: - error_panel = Panel( - f"Directory already exists: '[cyan]{project_name}[/cyan]'\n" - "Please choose a different project name or remove the existing directory.\n" - "Use [bold]--force[/bold] to merge into the existing directory.", - title="[red]Directory Conflict[/red]", - border_style="red", - padding=(1, 2) - ) - console.print() - console.print(error_panel) - raise typer.Exit(1) - - if ai_assistant: - if ai_assistant not in AGENT_CONFIG: - console.print(f"[red]Error:[/red] Invalid AI assistant '{ai_assistant}'. Choose from: {', '.join(AGENT_CONFIG.keys())}") - raise typer.Exit(1) - selected_ai = ai_assistant - else: - # Create options dict for selection (agent_key: display_name) - ai_choices = {key: config["name"] for key, config in AGENT_CONFIG.items()} - selected_ai = select_with_arrows( - ai_choices, - "Choose your coding agent integration:", - "copilot" - ) - - # Auto-promote interactively selected agents to the integration path - if not ai_assistant: - resolved_integration = get_integration(selected_ai) - if not resolved_integration: - console.print(f"[red]Error:[/red] Unknown agent '{selected_ai}'") - raise typer.Exit(1) - - # Validate --ai-commands-dir usage. - # Skip validation when --integration-options is provided — the integration - # will validate its own options in setup(). - if selected_ai == "generic" and not integration_options: - if not ai_commands_dir: - console.print("[red]Error:[/red] --ai-commands-dir is required when using --ai generic or --integration generic") - console.print('[dim]Example: specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/"[/dim]') - raise typer.Exit(1) - - current_dir = Path.cwd() - - setup_lines = [ - "[cyan]Specify Project Setup[/cyan]", - "", - f"{'Project':<15} [green]{project_path.name}[/green]", - f"{'Working Path':<15} [dim]{current_dir}[/dim]", - ] - - if not here: - setup_lines.append(f"{'Target Path':<15} [dim]{project_path}[/dim]") - - console.print(Panel("\n".join(setup_lines), border_style="cyan", padding=(1, 2))) - - should_init_git = False - if not no_git: - should_init_git = check_tool("git") - if not should_init_git: - console.print("[yellow]Git not found - will skip repository initialization[/yellow]") - - if not ignore_agent_tools: - agent_config = AGENT_CONFIG.get(selected_ai) - if agent_config and agent_config["requires_cli"]: - install_url = agent_config["install_url"] - if not check_tool(selected_ai): - error_panel = Panel( - f"[cyan]{selected_ai}[/cyan] not found\n" - f"Install from: [cyan]{install_url}[/cyan]\n" - f"{agent_config['name']} is required to continue with this project type.\n\n" - "Tip: Use [cyan]--ignore-agent-tools[/cyan] to skip this check", - title="[red]Agent Detection Error[/red]", - border_style="red", - padding=(1, 2) - ) - console.print() - console.print(error_panel) - raise typer.Exit(1) - - if script_type: - if script_type not in SCRIPT_TYPE_CHOICES: - console.print(f"[red]Error:[/red] Invalid script type '{script_type}'. Choose from: {', '.join(SCRIPT_TYPE_CHOICES.keys())}") - raise typer.Exit(1) - selected_script = script_type - else: - default_script = "ps" if os.name == "nt" else "sh" - - if sys.stdin.isatty(): - selected_script = select_with_arrows(SCRIPT_TYPE_CHOICES, "Choose script type (or press Enter)", default_script) - else: - selected_script = default_script - - console.print(f"[cyan]Selected coding agent integration:[/cyan] {selected_ai}") - console.print(f"[cyan]Selected script type:[/cyan] {selected_script}") - - tracker = StepTracker("Initialize Specify Project") - - sys._specify_tracker_active = True - - tracker.add("precheck", "Check required tools") - tracker.complete("precheck", "ok") - tracker.add("ai-select", "Select coding agent integration") - tracker.complete("ai-select", f"{selected_ai}") - tracker.add("script-select", "Select script type") - tracker.complete("script-select", selected_script) - - tracker.add("integration", "Install integration") - tracker.add("shared-infra", "Install shared infrastructure") - - for key, label in [ - ("chmod", "Ensure scripts executable"), - ("constitution", "Constitution setup"), - ("git", "Install git extension"), - ("workflow", "Install bundled workflow"), - ("final", "Finalize"), - ]: - tracker.add(key, label) - - git_default_notice = False - - with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live: - tracker.attach_refresh(lambda: live.update(tracker.render())) - try: - # Integration-based scaffolding - from .integrations.manifest import IntegrationManifest - tracker.start("integration") - manifest = IntegrationManifest( - resolved_integration.key, project_path, version=get_speckit_version() - ) - - # Forward all legacy CLI flags to the integration as parsed_options. - # Integrations receive every option and decide what to use; - # irrelevant keys are simply ignored by the integration's setup(). - integration_parsed_options: dict[str, Any] = {} - if ai_commands_dir: - integration_parsed_options["commands_dir"] = ai_commands_dir - if ai_skills: - integration_parsed_options["skills"] = True - # Parse --integration-options and merge into parsed_options so - # flags like --skills reach the integration's setup(). - if integration_options: - extra = _parse_integration_options(resolved_integration, integration_options) - if extra: - integration_parsed_options.update(extra) - - resolved_integration.setup( - project_path, manifest, - parsed_options=integration_parsed_options or None, - script_type=selected_script, - raw_options=integration_options, - ) - manifest.save() - - integration_settings = _with_integration_setting( - {}, - resolved_integration.key, - resolved_integration, - script_type=selected_script, - raw_options=integration_options, - parsed_options=integration_parsed_options or None, - ) - _write_integration_json( - project_path, - resolved_integration.key, - [resolved_integration.key], - integration_settings, - ) - - tracker.complete("integration", resolved_integration.config.get("name", resolved_integration.key)) - - # Install shared infrastructure (scripts, templates) - tracker.start("shared-infra") - _install_shared_infra_or_exit( - project_path, - selected_script, - tracker=tracker, - force=force, - invoke_separator=resolved_integration.effective_invoke_separator(integration_parsed_options), - ) - tracker.complete("shared-infra", f"scripts ({selected_script}) + templates") - - ensure_constitution_from_template(project_path, tracker=tracker) - - if not no_git: - tracker.start("git") - git_messages = [] - git_has_error = False - # Step 1: Initialize git repo if needed - if is_git_repo(project_path): - git_messages.append("existing repo detected") - elif should_init_git: - success, error_msg = init_git_repo(project_path, quiet=True) - if success: - git_messages.append("initialized") - else: - git_has_error = True - # Sanitize multi-line error_msg to single line for tracker - if error_msg: - sanitized = error_msg.replace('\n', ' ').strip() - git_messages.append(f"init failed: {sanitized[:120]}") - else: - git_messages.append("init failed") - else: - git_messages.append("git not available") - # Step 2: Install bundled git extension - try: - from .extensions import ExtensionManager - bundled_path = _locate_bundled_extension("git") - if bundled_path: - manager = ExtensionManager(project_path) - if manager.registry.is_installed("git"): - git_messages.append("extension already installed") - else: - manager.install_from_directory( - bundled_path, get_speckit_version() - ) - git_default_notice = True - git_messages.append("extension installed") - else: - git_has_error = True - git_messages.append("bundled extension not found") - except Exception as ext_err: - git_has_error = True - sanitized_ext = str(ext_err).replace('\n', ' ').strip() - git_messages.append( - f"extension install failed: {sanitized_ext[:120]}" - ) - summary = "; ".join(git_messages) - if git_has_error: - tracker.error("git", summary) - else: - tracker.complete("git", summary) - else: - tracker.skip("git", "--no-git flag") - - # Install bundled speckit workflow - try: - bundled_wf = _locate_bundled_workflow("speckit") - if bundled_wf: - from .workflows.catalog import WorkflowRegistry - from .workflows.engine import WorkflowDefinition - wf_registry = WorkflowRegistry(project_path) - if wf_registry.is_installed("speckit"): - tracker.complete("workflow", "already installed") - else: - import shutil as _shutil - dest_wf = project_path / ".specify" / "workflows" / "speckit" - dest_wf.mkdir(parents=True, exist_ok=True) - _shutil.copy2( - bundled_wf / "workflow.yml", - dest_wf / "workflow.yml", - ) - definition = WorkflowDefinition.from_yaml(dest_wf / "workflow.yml") - wf_registry.add("speckit", { - "name": definition.name, - "version": definition.version, - "description": definition.description, - "source": "bundled", - }) - tracker.complete("workflow", "speckit installed") - else: - tracker.skip("workflow", "bundled workflow not found") - except Exception as wf_err: - sanitized_wf = str(wf_err).replace('\n', ' ').strip() - tracker.error("workflow", f"install failed: {sanitized_wf[:120]}") - - # Fix permissions after all installs (scripts + extensions) - ensure_executable_scripts(project_path, tracker=tracker) - - # Persist the CLI options so later operations (e.g. preset add) - # can adapt their behaviour without re-scanning the filesystem. - # Must be saved BEFORE preset install so _get_skills_dir() works. - init_opts = { - "ai": selected_ai, - "integration": resolved_integration.key, - "branch_numbering": branch_numbering or "sequential", - "context_file": resolved_integration.context_file, - "here": here, - "script": selected_script, - "speckit_version": get_speckit_version(), - } - # Ensure ai_skills is set for SkillsIntegration so downstream - # tools (extensions, presets) emit SKILL.md overrides correctly. - # Also set for integrations running in skills mode (e.g. Copilot - # with --skills). - from .integrations.base import SkillsIntegration as _SkillsPersist - if isinstance(resolved_integration, _SkillsPersist) or getattr(resolved_integration, "_skills_mode", False): - init_opts["ai_skills"] = True - save_init_options(project_path, init_opts) - - # Install preset if specified - if preset: - try: - from .presets import PresetManager, PresetCatalog, PresetError - preset_manager = PresetManager(project_path) - speckit_ver = get_speckit_version() - - # Try local directory first, then bundled, then catalog - local_path = Path(preset).resolve() - if local_path.is_dir() and (local_path / "preset.yml").exists(): - preset_manager.install_from_directory(local_path, speckit_ver) - else: - bundled_path = _locate_bundled_preset(preset) - if bundled_path: - preset_manager.install_from_directory(bundled_path, speckit_ver) - else: - preset_catalog = PresetCatalog(project_path) - pack_info = preset_catalog.get_pack_info(preset) - if not pack_info: - console.print(f"[yellow]Warning:[/yellow] Preset '{preset}' not found in catalog. Skipping.") - elif pack_info.get("bundled") and not pack_info.get("download_url"): - from .extensions import REINSTALL_COMMAND - console.print( - f"[yellow]Warning:[/yellow] Preset '{preset}' is bundled with spec-kit " - f"but could not be found in the installed package." - ) - console.print( - "This usually means the spec-kit installation is incomplete or corrupted." - ) - console.print(f"Try reinstalling: {REINSTALL_COMMAND}") - else: - zip_path = None - try: - zip_path = preset_catalog.download_pack(preset) - preset_manager.install_from_zip(zip_path, speckit_ver) - except PresetError as preset_err: - console.print(f"[yellow]Warning:[/yellow] Failed to install preset '{preset}': {preset_err}") - finally: - if zip_path is not None: - # Clean up downloaded ZIP to avoid cache accumulation - try: - zip_path.unlink(missing_ok=True) - except OSError: - # Best-effort cleanup; failure to delete is non-fatal - pass - except Exception as preset_err: - console.print(f"[yellow]Warning:[/yellow] Failed to install preset: {preset_err}") - - tracker.complete("final", "project ready") - except (typer.Exit, SystemExit): - raise - except Exception as e: - tracker.error("final", str(e)) - console.print(Panel(f"Initialization failed: {e}", title="Failure", border_style="red")) - if debug: - _env_pairs = [ - ("Python", sys.version.split()[0]), - ("Platform", sys.platform), - ("CWD", str(Path.cwd())), - ] - _label_width = max(len(k) for k, _ in _env_pairs) - env_lines = [f"{k.ljust(_label_width)} → [bright_black]{v}[/bright_black]" for k, v in _env_pairs] - console.print(Panel("\n".join(env_lines), title="Debug Environment", border_style="magenta")) - if not here and project_path.exists() and not dir_existed_before: - shutil.rmtree(project_path) - raise typer.Exit(1) - finally: - pass - - console.print(tracker.render()) - console.print("\n[bold green]Project ready.[/bold green]") - - # Agent folder security notice - agent_config = AGENT_CONFIG.get(selected_ai) - if agent_config: - agent_folder = ai_commands_dir if selected_ai == "generic" else agent_config["folder"] - if agent_folder: - security_notice = Panel( - f"Some agents may store credentials, auth tokens, or other identifying and private artifacts in the agent folder within your project.\n" - f"Consider adding [cyan]{agent_folder}[/cyan] (or parts of it) to [cyan].gitignore[/cyan] to prevent accidental credential leakage.", - title="[yellow]Agent Folder Security[/yellow]", - border_style="yellow", - padding=(1, 2) - ) - console.print() - console.print(security_notice) - - if ai_deprecation_warning: - deprecation_notice = Panel( - ai_deprecation_warning, - title="[bold red]Deprecation Warning[/bold red]", - border_style="red", - padding=(1, 2), - ) - console.print() - console.print(deprecation_notice) - - if git_default_notice: - default_change_notice = Panel( - "The git extension is currently enabled by default during [bold]specify init[/bold].\n" - "Starting in [bold]v0.10.0[/bold], this will require explicit opt-in.\n" - "Use [bold]specify extension add git[/bold] after init when needed.", - title="[yellow]Notice: Git Default Changing[/yellow]", - border_style="yellow", - padding=(1, 2), - ) - console.print() - console.print(default_change_notice) - - steps_lines = [] - if not here: - steps_lines.append(f"1. Go to the project folder: [cyan]cd {project_name}[/cyan]") - step_num = 2 - else: - steps_lines.append("1. You're already in the project directory!") - step_num = 2 - - # Determine skill display mode for the next-steps panel. - # Skills integrations (codex, claude, kimi, agy, trae, cursor-agent, copilot, devin) should show skill invocation syntax. - from .integrations.base import SkillsIntegration as _SkillsInt - _is_skills_integration = isinstance(resolved_integration, _SkillsInt) or getattr(resolved_integration, "_skills_mode", False) - - codex_skill_mode = selected_ai == "codex" and (ai_skills or _is_skills_integration) - claude_skill_mode = selected_ai == "claude" and (ai_skills or _is_skills_integration) - kimi_skill_mode = selected_ai == "kimi" - agy_skill_mode = selected_ai == "agy" and _is_skills_integration - trae_skill_mode = selected_ai == "trae" - cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _is_skills_integration) - copilot_skill_mode = selected_ai == "copilot" and _is_skills_integration - devin_skill_mode = selected_ai == "devin" - native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode or cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode - - if codex_skill_mode and not ai_skills: - # Integration path installed skills; show the helpful notice - steps_lines.append(f"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]") - step_num += 1 - if claude_skill_mode and not ai_skills: - steps_lines.append(f"{step_num}. Start Claude in this project directory; spec-kit skills were installed to [cyan].claude/skills[/cyan]") - step_num += 1 - if cursor_agent_skill_mode and not ai_skills: - steps_lines.append(f"{step_num}. Start Cursor Agent in this project directory; spec-kit skills were installed to [cyan].cursor/skills[/cyan]") - step_num += 1 - if devin_skill_mode: - steps_lines.append(f"{step_num}. Start Devin in this project directory; spec-kit skills were installed to [cyan].devin/skills[/cyan]") - step_num += 1 - usage_label = "skills" if native_skill_mode else "slash commands" - - def _display_cmd(name: str) -> str: - if codex_skill_mode or agy_skill_mode or trae_skill_mode: - return f"$speckit-{name}" - if claude_skill_mode: - return f"/speckit-{name}" - if kimi_skill_mode: - return f"/skill:speckit-{name}" - if cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode: - return f"/speckit-{name}" - return f"/speckit.{name}" - - steps_lines.append(f"{step_num}. Start using {usage_label} with your coding agent:") - - steps_lines.append(f" {step_num}.1 [cyan]{_display_cmd('constitution')}[/] - Establish project principles") - steps_lines.append(f" {step_num}.2 [cyan]{_display_cmd('specify')}[/] - Create baseline specification") - steps_lines.append(f" {step_num}.3 [cyan]{_display_cmd('plan')}[/] - Create implementation plan") - steps_lines.append(f" {step_num}.4 [cyan]{_display_cmd('tasks')}[/] - Generate actionable tasks") - steps_lines.append(f" {step_num}.5 [cyan]{_display_cmd('implement')}[/] - Execute implementation") - - steps_panel = Panel("\n".join(steps_lines), title="Next Steps", border_style="cyan", padding=(1,2)) - console.print() - console.print(steps_panel) - - enhancement_intro = ( - "Optional skills that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]" - if native_skill_mode - else "Optional commands that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]" - ) - enhancement_lines = [ - enhancement_intro, - "", - f"○ [cyan]{_display_cmd('clarify')}[/] [bright_black](optional)[/bright_black] - Ask structured questions to de-risk ambiguous areas before planning (run before [cyan]{_display_cmd('plan')}[/] if used)", - f"○ [cyan]{_display_cmd('analyze')}[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]{_display_cmd('tasks')}[/], before [cyan]{_display_cmd('implement')}[/])", - f"○ [cyan]{_display_cmd('checklist')}[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]{_display_cmd('plan')}[/])" - ] - enhancements_title = "Enhancement Skills" if native_skill_mode else "Enhancement Commands" - enhancements_panel = Panel("\n".join(enhancement_lines), title=enhancements_title, border_style="cyan", padding=(1,2)) - console.print() - console.print(enhancements_panel) +from .commands import init as _init_cmd +_init_cmd.register(app) @app.command() def check(): @@ -1263,25 +355,6 @@ def self_upgrade() -> None: preset_app.add_typer(preset_catalog_app, name="catalog") -def get_speckit_version() -> str: - """Get current spec-kit version.""" - import importlib.metadata - try: - return importlib.metadata.version("specify-cli") - except Exception: - # Fallback: try reading from pyproject.toml - try: - import tomllib - pyproject_path = _repo_root() / "pyproject.toml" - if pyproject_path.exists(): - with open(pyproject_path, "rb") as f: - data = tomllib.load(f) - return data.get("project", {}).get("version", "unknown") - except Exception: - # Intentionally ignore any errors while reading/parsing pyproject.toml. - # If this lookup fails for any reason, we fall back to returning "unknown" below. - pass - return "unknown" # ===== Integration Commands ===== @@ -1733,55 +806,6 @@ def integration_install( console.print(f"[dim]Default integration remains:[/dim] [cyan]{default_key}[/cyan]") -def _parse_integration_options(integration: Any, raw_options: str) -> dict[str, Any] | None: - """Parse --integration-options string into a dict matching the integration's declared options. - - Returns ``None`` when no options are provided. - """ - import shlex - parsed: dict[str, Any] = {} - tokens = shlex.split(raw_options) - declared_options = list(integration.options()) - declared = {opt.name.lstrip("-"): opt for opt in declared_options} - allowed = ", ".join(sorted(opt.name for opt in declared_options)) - i = 0 - while i < len(tokens): - token = tokens[i] - if not token.startswith("-"): - console.print(f"[red]Error:[/red] Unexpected integration option value '{token}'.") - if allowed: - console.print(f"Allowed options: {allowed}") - raise typer.Exit(1) - name = token.lstrip("-") - value: str | None = None - # Handle --name=value syntax - if "=" in name: - name, value = name.split("=", 1) - opt = declared.get(name) - if not opt: - console.print(f"[red]Error:[/red] Unknown integration option '{token}'.") - if allowed: - console.print(f"Allowed options: {allowed}") - raise typer.Exit(1) - key = name.replace("-", "_") - if opt.is_flag: - if value is not None: - console.print(f"[red]Error:[/red] Option '{opt.name}' is a flag and does not accept a value.") - raise typer.Exit(1) - parsed[key] = True - i += 1 - elif value is not None: - parsed[key] = value - i += 1 - elif i + 1 < len(tokens) and not tokens[i + 1].startswith("-"): - parsed[key] = tokens[i + 1] - i += 2 - else: - console.print(f"[red]Error:[/red] Option '{opt.name}' requires a value.") - raise typer.Exit(1) - return parsed or None - - def _update_init_options_for_integration( project_root: Path, integration: Any, diff --git a/src/specify_cli/_helpers.py b/src/specify_cli/_helpers.py index 042ea73666..9597b38767 100644 --- a/src/specify_cli/_helpers.py +++ b/src/specify_cli/_helpers.py @@ -1,8 +1,11 @@ import os import shutil +import shlex import subprocess from pathlib import Path -from typing import Optional +from typing import Any, Optional + +import typer from ._console import console from ._ui import StepTracker @@ -11,6 +14,133 @@ CLAUDE_NPM_LOCAL_PATH = Path.home() / ".claude" / "local" / "node_modules" / ".bin" / "claude" +# --------------------------------------------------------------------------- +# Version helpers +# --------------------------------------------------------------------------- + +def get_speckit_version() -> str: + """Get current spec-kit version.""" + import importlib.metadata + try: + return importlib.metadata.version("specify-cli") + except Exception: + # Fallback: try reading from pyproject.toml + try: + import tomllib + pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml" + if pyproject_path.exists(): + with open(pyproject_path, "rb") as f: + data = tomllib.load(f) + return data.get("project", {}).get("version", "unknown") + except Exception: + # Intentionally ignore any errors while reading/parsing pyproject.toml. + # If this lookup fails for any reason, we fall back to returning "unknown" below. + pass + return "unknown" + + +# --------------------------------------------------------------------------- +# Integration option parsing +# --------------------------------------------------------------------------- + +def _parse_integration_options(integration: Any, raw_options: str) -> dict[str, Any] | None: + """Parse --integration-options string into a dict matching the integration's declared options. + + Returns ``None`` when no options are provided. + """ + parsed: dict[str, Any] = {} + tokens = shlex.split(raw_options) + declared_options = list(integration.options()) + declared = {opt.name.lstrip("-"): opt for opt in declared_options} + allowed = ", ".join(sorted(opt.name for opt in declared_options)) + i = 0 + while i < len(tokens): + token = tokens[i] + if not token.startswith("-"): + console.print(f"[red]Error:[/red] Unexpected integration option value '{token}'.") + if allowed: + console.print(f"Allowed options: {allowed}") + raise typer.Exit(1) + name = token.lstrip("-") + value: str | None = None + # Handle --name=value syntax + if "=" in name: + name, value = name.split("=", 1) + opt = declared.get(name) + if not opt: + console.print(f"[red]Error:[/red] Unknown integration option '{token}'.") + if allowed: + console.print(f"Allowed options: {allowed}") + raise typer.Exit(1) + key = name.replace("-", "_") + if opt.is_flag: + if value is not None: + console.print(f"[red]Error:[/red] Option '{opt.name}' is a flag and does not accept a value.") + raise typer.Exit(1) + parsed[key] = True + i += 1 + elif value is not None: + parsed[key] = value + i += 1 + elif i + 1 < len(tokens) and not tokens[i + 1].startswith("-"): + parsed[key] = tokens[i + 1] + i += 2 + else: + console.print(f"[red]Error:[/red] Option '{opt.name}' requires a value.") + raise typer.Exit(1) + return parsed or None + + +# --------------------------------------------------------------------------- +# Agent / integration configuration (computed at module load time) +# --------------------------------------------------------------------------- + +def _build_agent_config() -> dict[str, dict[str, Any]]: + """Derive AGENT_CONFIG from INTEGRATION_REGISTRY.""" + from .integrations import INTEGRATION_REGISTRY + config: dict[str, dict[str, Any]] = {} + for key, integration in INTEGRATION_REGISTRY.items(): + if integration.config: + config[key] = dict(integration.config) + return config + + +AGENT_CONFIG: dict[str, dict[str, Any]] = _build_agent_config() + +AI_ASSISTANT_ALIASES: dict[str, str] = { + "kiro": "kiro-cli", +} + +SCRIPT_TYPE_CHOICES: dict[str, str] = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"} + + +def _build_ai_assistant_help() -> str: + """Build the --ai help text from AGENT_CONFIG so it stays in sync with runtime config.""" + + non_generic_agents = sorted(agent for agent in AGENT_CONFIG if agent != "generic") + base_help = ( + f"AI assistant to use: {', '.join(non_generic_agents)}, " + "or generic (requires --ai-commands-dir)." + ) + + if not AI_ASSISTANT_ALIASES: + return base_help + + alias_phrases = [] + for alias, target in sorted(AI_ASSISTANT_ALIASES.items()): + alias_phrases.append(f"'{alias}' as an alias for '{target}'") + + if len(alias_phrases) == 1: + aliases_text = alias_phrases[0] + else: + aliases_text = ', '.join(alias_phrases[:-1]) + ' and ' + alias_phrases[-1] + + return base_help + " Use " + aliases_text + "." + + +AI_ASSISTANT_HELP: str = _build_ai_assistant_help() + + def run_command(cmd: list[str], check_return: bool = True, capture: bool = False, shell: bool = False) -> Optional[str]: """Run a shell command and optionally capture output.""" try: @@ -271,7 +401,6 @@ def _get_skills_dir(project_path: Path, selected_ai: str) -> Path: Returns ``project_path / / "skills"``, falling back to ``project_path / ".agents/skills"`` for unknown agents. """ - from specify_cli import AGENT_CONFIG agent_config = AGENT_CONFIG.get(selected_ai, {}) agent_folder = agent_config.get("folder", "") if agent_folder: diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index f3e0742455..1e0682b30b 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -1 +1,729 @@ """specify init command.""" +import json +import os +import shlex +import shutil +import sys +from pathlib import Path +from typing import Any, Optional + +import typer +from rich.panel import Panel +from rich.live import Live + +from .._console import console +from .._ui import StepTracker, show_banner +from .._fs import save_init_options +from .._git import _git_service as _git_svc +from .._assets import _asset_service as _svc +from .._helpers import ( + run_command, + check_tool, + _install_shared_infra, + ensure_constitution_from_template, + ensure_executable_scripts, + _get_skills_dir, + get_speckit_version, + _parse_integration_options, + AGENT_CONFIG, + AI_ASSISTANT_ALIASES, + AI_ASSISTANT_HELP, + SCRIPT_TYPE_CHOICES, +) + + +def _build_integration_equivalent( + integration_key: str, + ai_commands_dir: str | None = None, +) -> str: + """Build the modern --integration equivalent for legacy --ai usage.""" + parts = [f"--integration {integration_key}"] + if integration_key == "generic" and ai_commands_dir: + parts.append( + f'--integration-options="--commands-dir {shlex.quote(ai_commands_dir)}"' + ) + return " ".join(parts) + + +def _build_ai_deprecation_warning( + integration_key: str, + ai_commands_dir: str | None = None, +) -> str: + """Build the legacy --ai deprecation warning message.""" + replacement = _build_integration_equivalent( + integration_key, + ai_commands_dir=ai_commands_dir, + ) + return ( + "[bold]--ai[/bold] is deprecated and will no longer be available in version 0.10.0 or later.\n\n" + f"Use [bold]{replacement}[/bold] instead." + ) + + +def _is_git_repo(path: Path = None) -> bool: + """Check if the specified path is inside a git repository.""" + return _git_svc.is_repo(path) + + +def _init_git_repo(project_path: Path, quiet: bool = False) -> tuple[bool, Optional[str]]: + """Initialize a git repository in the specified path.""" + ok, err = _git_svc.init_repo(project_path) + if not quiet: + if ok: + console.print("[green]✓[/green] Git repository initialized") + else: + console.print(f"[red]Error initializing git repository:[/red] {err}") + return ok, err + + +def _locate_bundled_extension(extension_id: str) -> Path | None: + return _svc.locate_bundled_extension(extension_id) + + +def _locate_bundled_workflow(workflow_id: str) -> Path | None: + return _svc.locate_bundled_workflow(workflow_id) + + +def _locate_bundled_preset(preset_id: str) -> Path | None: + return _svc.locate_bundled_preset(preset_id) + + +def register(app: typer.Typer) -> None: + @app.command() + def init( + project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"), + ai_assistant: str = typer.Option(None, "--ai", help=AI_ASSISTANT_HELP), + ai_commands_dir: str = typer.Option(None, "--ai-commands-dir", help="Directory for agent command files (required with --ai generic, e.g. .myagent/commands/)"), + script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"), + ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for coding agent tools like Claude Code"), + no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization"), + here: bool = typer.Option(False, "--here", help="Initialize project in the current directory instead of creating a new one"), + force: bool = typer.Option(False, "--force", help="Force merge/overwrite when using --here (skip confirmation)"), + skip_tls: bool = typer.Option(False, "--skip-tls", help="Deprecated (no-op). Previously: skip SSL/TLS verification.", hidden=True), + debug: bool = typer.Option(False, "--debug", help="Deprecated (no-op). Previously: show verbose diagnostic output.", hidden=True), + github_token: str = typer.Option(None, "--github-token", help="Deprecated (no-op). Previously: GitHub token for API requests.", hidden=True), + ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"), + offline: bool = typer.Option(False, "--offline", help="Deprecated (no-op). All scaffolding now uses bundled assets.", hidden=True), + preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"), + branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, …, 1000, … — expands past 999 automatically) or 'timestamp' (YYYYMMDD-HHMMSS)"), + integration: str = typer.Option(None, "--integration", help="Use the new integration system (e.g. --integration copilot). Mutually exclusive with --ai."), + integration_options: str = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'), + ): + """ + Initialize a new Specify project. + + By default, project files are downloaded from the latest GitHub release. + Use --offline to scaffold from assets bundled inside the specify-cli + package instead (no internet access required, ideal for air-gapped or + enterprise environments). + + NOTE: Starting with v0.6.0, bundled assets will be used by default and + the --offline flag will be removed. The GitHub download path will be + retired because bundled assets eliminate the need for network access, + avoid proxy/firewall issues, and guarantee that templates always match + the installed CLI version. + + This command will: + 1. Check that required tools are installed (git is optional) + 2. Let you choose your coding agent integration + 3. Download template from GitHub (or use bundled assets with --offline) + 4. Initialize a fresh git repository (if not --no-git and no existing repo) + 5. Optionally set up coding agent integration commands + + Examples: + specify init my-project + specify init my-project --integration claude + specify init my-project --integration copilot --no-git + specify init --ignore-agent-tools my-project + specify init . --integration claude # Initialize in current directory + specify init . # Initialize in current directory (interactive integration selection) + specify init --here --integration claude # Alternative syntax for current directory + specify init --here --integration codex --integration-options="--skills" + specify init --here --integration codebuddy + specify init --here --integration vibe # Initialize with Mistral Vibe support + specify init --here + specify init --here --force # Skip confirmation when current directory not empty + specify init my-project --integration claude # Claude installs skills by default + specify init --here --integration gemini + specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/" # Bring your own agent; requires --commands-dir + specify init my-project --integration claude --preset healthcare-compliance # With preset + """ + + show_banner() + ai_deprecation_warning: str | None = None + + # Detect when option values are likely misinterpreted flags (parameter ordering issue) + if ai_assistant and ai_assistant.startswith("--"): + console.print(f"[red]Error:[/red] Invalid value for --ai: '{ai_assistant}'") + console.print("[yellow]Hint:[/yellow] Did you forget to provide a value for --ai?") + console.print("[yellow]Example:[/yellow] specify init --integration claude --here") + console.print(f"[yellow]Available agents:[/yellow] {', '.join(AGENT_CONFIG.keys())}") + raise typer.Exit(1) + + if ai_commands_dir and ai_commands_dir.startswith("--"): + console.print(f"[red]Error:[/red] Invalid value for --ai-commands-dir: '{ai_commands_dir}'") + console.print("[yellow]Hint:[/yellow] Did you forget to provide a value for --ai-commands-dir?") + console.print("[yellow]Example:[/yellow] specify init --integration generic --integration-options=\"--commands-dir .myagent/commands/\"") + raise typer.Exit(1) + + if ai_assistant: + ai_assistant = AI_ASSISTANT_ALIASES.get(ai_assistant, ai_assistant) + + # --integration and --ai are mutually exclusive + if integration and ai_assistant: + console.print("[red]Error:[/red] --integration and --ai are mutually exclusive") + raise typer.Exit(1) + + # Resolve the integration — either from --integration or --ai + from ..integrations import INTEGRATION_REGISTRY, get_integration + if integration: + resolved_integration = get_integration(integration) + if not resolved_integration: + console.print(f"[red]Error:[/red] Unknown integration: '{integration}'") + available = ", ".join(sorted(INTEGRATION_REGISTRY)) + console.print(f"[yellow]Available integrations:[/yellow] {available}") + raise typer.Exit(1) + ai_assistant = integration + elif ai_assistant: + resolved_integration = get_integration(ai_assistant) + if not resolved_integration: + console.print(f"[red]Error:[/red] Unknown agent '{ai_assistant}'. Choose from: {', '.join(sorted(INTEGRATION_REGISTRY))}") + raise typer.Exit(1) + ai_deprecation_warning = _build_ai_deprecation_warning( + resolved_integration.key, + ai_commands_dir=ai_commands_dir, + ) + + # Deprecation warnings for --ai-skills and --ai-commands-dir (only when + # an integration has been resolved from --ai or --integration) + if ai_assistant or integration: + if ai_skills: + from ..integrations.base import SkillsIntegration as _SkillsCheck + if isinstance(resolved_integration, _SkillsCheck): + console.print( + "[dim]Note: --ai-skills is not needed; " + "skills are the default for this integration.[/dim]" + ) + else: + console.print( + "[dim]Note: --ai-skills has no effect with " + f"{resolved_integration.key}; this integration uses commands, not skills.[/dim]" + ) + if ai_commands_dir and resolved_integration.key != "generic": + console.print( + "[dim]Note: --ai-commands-dir is deprecated; " + 'use [bold]--integration generic --integration-options="--commands-dir "[/bold] instead.[/dim]' + ) + + if no_git: + console.print( + "[yellow]⚠️ --no-git is deprecated and will be removed in v0.10.0.[/yellow]\n" + "[yellow]The git extension will no longer be enabled by default " + "— use the [bold]specify extension[/bold] commands to install or enable the git extension if needed.[/yellow]" + ) + + if project_name == ".": + here = True + project_name = None # Clear project_name to use existing validation logic + + if here and project_name: + console.print("[red]Error:[/red] Cannot specify both project name and --here flag") + raise typer.Exit(1) + + if not here and not project_name: + console.print("[red]Error:[/red] Must specify either a project name, use '.' for current directory, or use --here flag") + raise typer.Exit(1) + + if ai_skills and not ai_assistant: + console.print("[red]Error:[/red] --ai-skills requires --ai to be specified") + console.print("[yellow]Usage:[/yellow] specify init --ai --ai-skills") + raise typer.Exit(1) + + BRANCH_NUMBERING_CHOICES = {"sequential", "timestamp"} + if branch_numbering and branch_numbering not in BRANCH_NUMBERING_CHOICES: + console.print(f"[red]Error:[/red] Invalid --branch-numbering value '{branch_numbering}'. Choose from: {', '.join(sorted(BRANCH_NUMBERING_CHOICES))}") + raise typer.Exit(1) + + dir_existed_before = False + if here: + project_name = Path.cwd().name + project_path = Path.cwd() + dir_existed_before = True + + existing_items = list(project_path.iterdir()) + if existing_items: + console.print(f"[yellow]Warning:[/yellow] Current directory is not empty ({len(existing_items)} items)") + console.print("[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]") + if force: + console.print("[cyan]--force supplied: skipping confirmation and proceeding with merge[/cyan]") + else: + response = typer.confirm("Do you want to continue?") + if not response: + console.print("[yellow]Operation cancelled[/yellow]") + raise typer.Exit(0) + else: + project_path = Path(project_name).resolve() + dir_existed_before = project_path.exists() + if project_path.exists(): + if not project_path.is_dir(): + console.print(f"[red]Error:[/red] '{project_name}' exists but is not a directory.") + raise typer.Exit(1) + existing_items = list(project_path.iterdir()) + if force: + if existing_items: + console.print(f"[yellow]Warning:[/yellow] Directory '{project_name}' is not empty ({len(existing_items)} items)") + console.print("[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]") + console.print(f"[cyan]--force supplied: merging into existing directory '[cyan]{project_name}[/cyan]'[/cyan]") + else: + error_panel = Panel( + f"Directory already exists: '[cyan]{project_name}[/cyan]'\n" + "Please choose a different project name or remove the existing directory.\n" + "Use [bold]--force[/bold] to merge into the existing directory.", + title="[red]Directory Conflict[/red]", + border_style="red", + padding=(1, 2) + ) + console.print() + console.print(error_panel) + raise typer.Exit(1) + + if ai_assistant: + if ai_assistant not in AGENT_CONFIG: + console.print(f"[red]Error:[/red] Invalid AI assistant '{ai_assistant}'. Choose from: {', '.join(AGENT_CONFIG.keys())}") + raise typer.Exit(1) + selected_ai = ai_assistant + else: + # Create options dict for selection (agent_key: display_name) + ai_choices = {key: config["name"] for key, config in AGENT_CONFIG.items()} + # Access via sys.modules so mock patches on specify_cli.select_with_arrows work. + import sys as _sys + _specify_cli = _sys.modules.get("specify_cli") + _select_fn = getattr(_specify_cli, "select_with_arrows", None) if _specify_cli else None + if _select_fn is None: + from .._ui import select_with_arrows as _select_fn + selected_ai = _select_fn( + ai_choices, + "Choose your coding agent integration:", + "copilot" + ) + + # Auto-promote interactively selected agents to the integration path + if not ai_assistant: + resolved_integration = get_integration(selected_ai) + if not resolved_integration: + console.print(f"[red]Error:[/red] Unknown agent '{selected_ai}'") + raise typer.Exit(1) + + # Validate --ai-commands-dir usage. + # Skip validation when --integration-options is provided — the integration + # will validate its own options in setup(). + if selected_ai == "generic" and not integration_options: + if not ai_commands_dir: + console.print("[red]Error:[/red] --ai-commands-dir is required when using --ai generic or --integration generic") + console.print('[dim]Example: specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/"[/dim]') + raise typer.Exit(1) + + current_dir = Path.cwd() + + setup_lines = [ + "[cyan]Specify Project Setup[/cyan]", + "", + f"{'Project':<15} [green]{project_path.name}[/green]", + f"{'Working Path':<15} [dim]{current_dir}[/dim]", + ] + + if not here: + setup_lines.append(f"{'Target Path':<15} [dim]{project_path}[/dim]") + + console.print(Panel("\n".join(setup_lines), border_style="cyan", padding=(1, 2))) + + should_init_git = False + if not no_git: + should_init_git = check_tool("git") + if not should_init_git: + console.print("[yellow]Git not found - will skip repository initialization[/yellow]") + + if not ignore_agent_tools: + agent_config = AGENT_CONFIG.get(selected_ai) + if agent_config and agent_config["requires_cli"]: + install_url = agent_config["install_url"] + if not check_tool(selected_ai): + error_panel = Panel( + f"[cyan]{selected_ai}[/cyan] not found\n" + f"Install from: [cyan]{install_url}[/cyan]\n" + f"{agent_config['name']} is required to continue with this project type.\n\n" + "Tip: Use [cyan]--ignore-agent-tools[/cyan] to skip this check", + title="[red]Agent Detection Error[/red]", + border_style="red", + padding=(1, 2) + ) + console.print() + console.print(error_panel) + raise typer.Exit(1) + + if script_type: + if script_type not in SCRIPT_TYPE_CHOICES: + console.print(f"[red]Error:[/red] Invalid script type '{script_type}'. Choose from: {', '.join(SCRIPT_TYPE_CHOICES.keys())}") + raise typer.Exit(1) + selected_script = script_type + else: + default_script = "ps" if os.name == "nt" else "sh" + + if sys.stdin.isatty(): + from .._ui import select_with_arrows as _select_script_fn + selected_script = _select_script_fn(SCRIPT_TYPE_CHOICES, "Choose script type (or press Enter)", default_script) + else: + selected_script = default_script + + console.print(f"[cyan]Selected coding agent integration:[/cyan] {selected_ai}") + console.print(f"[cyan]Selected script type:[/cyan] {selected_script}") + + tracker = StepTracker("Initialize Specify Project") + + sys._specify_tracker_active = True + + tracker.add("precheck", "Check required tools") + tracker.complete("precheck", "ok") + tracker.add("ai-select", "Select coding agent integration") + tracker.complete("ai-select", f"{selected_ai}") + tracker.add("script-select", "Select script type") + tracker.complete("script-select", selected_script) + + tracker.add("integration", "Install integration") + tracker.add("shared-infra", "Install shared infrastructure") + + for key, label in [ + ("chmod", "Ensure scripts executable"), + ("constitution", "Constitution setup"), + ("git", "Install git extension"), + ("workflow", "Install bundled workflow"), + ("final", "Finalize"), + ]: + tracker.add(key, label) + + with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live: + tracker.attach_refresh(lambda: live.update(tracker.render())) + try: + # Integration-based scaffolding + from ..integrations.manifest import IntegrationManifest + tracker.start("integration") + manifest = IntegrationManifest( + resolved_integration.key, project_path, version=get_speckit_version() + ) + + # Forward all legacy CLI flags to the integration as parsed_options. + # Integrations receive every option and decide what to use; + # irrelevant keys are simply ignored by the integration's setup(). + integration_parsed_options: dict[str, Any] = {} + if ai_commands_dir: + integration_parsed_options["commands_dir"] = ai_commands_dir + if ai_skills: + integration_parsed_options["skills"] = True + # Parse --integration-options and merge into parsed_options so + # flags like --skills reach the integration's setup(). + if integration_options: + extra = _parse_integration_options(resolved_integration, integration_options) + if extra: + integration_parsed_options.update(extra) + + resolved_integration.setup( + project_path, manifest, + parsed_options=integration_parsed_options or None, + script_type=selected_script, + raw_options=integration_options, + ) + manifest.save() + + # Write .specify/integration.json + integration_json = project_path / ".specify" / "integration.json" + integration_json.parent.mkdir(parents=True, exist_ok=True) + integration_json.write_text(json.dumps({ + "integration": resolved_integration.key, + "version": get_speckit_version(), + }, indent=2) + "\n", encoding="utf-8") + + tracker.complete("integration", resolved_integration.config.get("name", resolved_integration.key)) + + # Install shared infrastructure (scripts, templates) + tracker.start("shared-infra") + _install_shared_infra(project_path, selected_script, tracker=tracker, force=force, invoke_separator=resolved_integration.effective_invoke_separator(integration_parsed_options)) + tracker.complete("shared-infra", f"scripts ({selected_script}) + templates") + + ensure_constitution_from_template(project_path, tracker=tracker) + + if not no_git: + tracker.start("git") + git_messages = [] + git_has_error = False + # Step 1: Initialize git repo if needed + if _is_git_repo(project_path): + git_messages.append("existing repo detected") + elif should_init_git: + success, error_msg = _init_git_repo(project_path, quiet=True) + if success: + git_messages.append("initialized") + else: + git_has_error = True + # Sanitize multi-line error_msg to single line for tracker + if error_msg: + sanitized = error_msg.replace('\n', ' ').strip() + git_messages.append(f"init failed: {sanitized[:120]}") + else: + git_messages.append("init failed") + else: + git_messages.append("git not available") + # Step 2: Install bundled git extension + try: + from ..extensions import ExtensionManager + bundled_path = _locate_bundled_extension("git") + if bundled_path: + manager = ExtensionManager(project_path) + if manager.registry.is_installed("git"): + git_messages.append("extension already installed") + else: + manager.install_from_directory( + bundled_path, get_speckit_version() + ) + git_messages.append("extension installed") + else: + git_has_error = True + git_messages.append("bundled extension not found") + except Exception as ext_err: + git_has_error = True + sanitized_ext = str(ext_err).replace('\n', ' ').strip() + git_messages.append( + f"extension install failed: {sanitized_ext[:120]}" + ) + summary = "; ".join(git_messages) + if git_has_error: + tracker.error("git", summary) + else: + tracker.complete("git", summary) + else: + tracker.skip("git", "--no-git flag") + + # Install bundled speckit workflow + try: + bundled_wf = _locate_bundled_workflow("speckit") + if bundled_wf: + from ..workflows.catalog import WorkflowRegistry + from ..workflows.engine import WorkflowDefinition + wf_registry = WorkflowRegistry(project_path) + if wf_registry.is_installed("speckit"): + tracker.complete("workflow", "already installed") + else: + import shutil as _shutil + dest_wf = project_path / ".specify" / "workflows" / "speckit" + dest_wf.mkdir(parents=True, exist_ok=True) + _shutil.copy2( + bundled_wf / "workflow.yml", + dest_wf / "workflow.yml", + ) + definition = WorkflowDefinition.from_yaml(dest_wf / "workflow.yml") + wf_registry.add("speckit", { + "name": definition.name, + "version": definition.version, + "description": definition.description, + "source": "bundled", + }) + tracker.complete("workflow", "speckit installed") + else: + tracker.skip("workflow", "bundled workflow not found") + except Exception as wf_err: + sanitized_wf = str(wf_err).replace('\n', ' ').strip() + tracker.error("workflow", f"install failed: {sanitized_wf[:120]}") + + # Fix permissions after all installs (scripts + extensions) + ensure_executable_scripts(project_path, tracker=tracker) + + # Persist the CLI options so later operations (e.g. preset add) + # can adapt their behaviour without re-scanning the filesystem. + # Must be saved BEFORE preset install so _get_skills_dir() works. + init_opts = { + "ai": selected_ai, + "integration": resolved_integration.key, + "branch_numbering": branch_numbering or "sequential", + "context_file": resolved_integration.context_file, + "here": here, + "script": selected_script, + "speckit_version": get_speckit_version(), + } + # Ensure ai_skills is set for SkillsIntegration so downstream + # tools (extensions, presets) emit SKILL.md overrides correctly. + # Also set for integrations running in skills mode (e.g. Copilot + # with --skills). + from ..integrations.base import SkillsIntegration as _SkillsPersist + if isinstance(resolved_integration, _SkillsPersist) or getattr(resolved_integration, "_skills_mode", False): + init_opts["ai_skills"] = True + save_init_options(project_path, init_opts) + + # Install preset if specified + if preset: + try: + from ..presets import PresetManager, PresetCatalog, PresetError + preset_manager = PresetManager(project_path) + speckit_ver = get_speckit_version() + + # Try local directory first, then bundled, then catalog + local_path = Path(preset).resolve() + if local_path.is_dir() and (local_path / "preset.yml").exists(): + preset_manager.install_from_directory(local_path, speckit_ver) + else: + bundled_path = _locate_bundled_preset(preset) + if bundled_path: + preset_manager.install_from_directory(bundled_path, speckit_ver) + else: + preset_catalog = PresetCatalog(project_path) + pack_info = preset_catalog.get_pack_info(preset) + if not pack_info: + console.print(f"[yellow]Warning:[/yellow] Preset '{preset}' not found in catalog. Skipping.") + elif pack_info.get("bundled") and not pack_info.get("download_url"): + from ..extensions import REINSTALL_COMMAND + console.print( + f"[yellow]Warning:[/yellow] Preset '{preset}' is bundled with spec-kit " + f"but could not be found in the installed package." + ) + console.print( + "This usually means the spec-kit installation is incomplete or corrupted." + ) + console.print(f"Try reinstalling: {REINSTALL_COMMAND}") + else: + zip_path = None + try: + zip_path = preset_catalog.download_pack(preset) + preset_manager.install_from_zip(zip_path, speckit_ver) + except PresetError as preset_err: + console.print(f"[yellow]Warning:[/yellow] Failed to install preset '{preset}': {preset_err}") + finally: + if zip_path is not None: + # Clean up downloaded ZIP to avoid cache accumulation + try: + zip_path.unlink(missing_ok=True) + except OSError: + # Best-effort cleanup; failure to delete is non-fatal + pass + except Exception as preset_err: + console.print(f"[yellow]Warning:[/yellow] Failed to install preset: {preset_err}") + + tracker.complete("final", "project ready") + except (typer.Exit, SystemExit): + raise + except Exception as e: + tracker.error("final", str(e)) + console.print(Panel(f"Initialization failed: {e}", title="Failure", border_style="red")) + if debug: + _env_pairs = [ + ("Python", sys.version.split()[0]), + ("Platform", sys.platform), + ("CWD", str(Path.cwd())), + ] + _label_width = max(len(k) for k, _ in _env_pairs) + env_lines = [f"{k.ljust(_label_width)} → [bright_black]{v}[/bright_black]" for k, v in _env_pairs] + console.print(Panel("\n".join(env_lines), title="Debug Environment", border_style="magenta")) + if not here and project_path.exists() and not dir_existed_before: + shutil.rmtree(project_path) + raise typer.Exit(1) + finally: + pass + + console.print(tracker.render()) + console.print("\n[bold green]Project ready.[/bold green]") + + # Agent folder security notice + agent_config = AGENT_CONFIG.get(selected_ai) + if agent_config: + agent_folder = ai_commands_dir if selected_ai == "generic" else agent_config["folder"] + if agent_folder: + security_notice = Panel( + f"Some agents may store credentials, auth tokens, or other identifying and private artifacts in the agent folder within your project.\n" + f"Consider adding [cyan]{agent_folder}[/cyan] (or parts of it) to [cyan].gitignore[/cyan] to prevent accidental credential leakage.", + title="[yellow]Agent Folder Security[/yellow]", + border_style="yellow", + padding=(1, 2) + ) + console.print() + console.print(security_notice) + + if ai_deprecation_warning: + deprecation_notice = Panel( + ai_deprecation_warning, + title="[bold red]Deprecation Warning[/bold red]", + border_style="red", + padding=(1, 2), + ) + console.print() + console.print(deprecation_notice) + + steps_lines = [] + if not here: + steps_lines.append(f"1. Go to the project folder: [cyan]cd {project_name}[/cyan]") + step_num = 2 + else: + steps_lines.append("1. You're already in the project directory!") + step_num = 2 + + # Determine skill display mode for the next-steps panel. + # Skills integrations (codex, kimi, agy, trae, cursor-agent) should show skill invocation syntax. + from ..integrations.base import SkillsIntegration as _SkillsInt + _is_skills_integration = isinstance(resolved_integration, _SkillsInt) or getattr(resolved_integration, "_skills_mode", False) + + codex_skill_mode = selected_ai == "codex" and (ai_skills or _is_skills_integration) + claude_skill_mode = selected_ai == "claude" and (ai_skills or _is_skills_integration) + kimi_skill_mode = selected_ai == "kimi" + agy_skill_mode = selected_ai == "agy" and _is_skills_integration + trae_skill_mode = selected_ai == "trae" + cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _is_skills_integration) + copilot_skill_mode = selected_ai == "copilot" and _is_skills_integration + native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode or cursor_agent_skill_mode or copilot_skill_mode + + if codex_skill_mode and not ai_skills: + # Integration path installed skills; show the helpful notice + steps_lines.append(f"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]") + step_num += 1 + if claude_skill_mode and not ai_skills: + steps_lines.append(f"{step_num}. Start Claude in this project directory; spec-kit skills were installed to [cyan].claude/skills[/cyan]") + step_num += 1 + if cursor_agent_skill_mode and not ai_skills: + steps_lines.append(f"{step_num}. Start Cursor Agent in this project directory; spec-kit skills were installed to [cyan].cursor/skills[/cyan]") + step_num += 1 + usage_label = "skills" if native_skill_mode else "slash commands" + + def _display_cmd(name: str) -> str: + if codex_skill_mode or agy_skill_mode or trae_skill_mode: + return f"$speckit-{name}" + if claude_skill_mode: + return f"/speckit-{name}" + if kimi_skill_mode: + return f"/skill:speckit-{name}" + if cursor_agent_skill_mode or copilot_skill_mode: + return f"/speckit-{name}" + return f"/speckit.{name}" + + steps_lines.append(f"{step_num}. Start using {usage_label} with your coding agent:") + + steps_lines.append(f" {step_num}.1 [cyan]{_display_cmd('constitution')}[/] - Establish project principles") + steps_lines.append(f" {step_num}.2 [cyan]{_display_cmd('specify')}[/] - Create baseline specification") + steps_lines.append(f" {step_num}.3 [cyan]{_display_cmd('plan')}[/] - Create implementation plan") + steps_lines.append(f" {step_num}.4 [cyan]{_display_cmd('tasks')}[/] - Generate actionable tasks") + steps_lines.append(f" {step_num}.5 [cyan]{_display_cmd('implement')}[/] - Execute implementation") + + steps_panel = Panel("\n".join(steps_lines), title="Next Steps", border_style="cyan", padding=(1,2)) + console.print() + console.print(steps_panel) + + enhancement_intro = ( + "Optional skills that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]" + if native_skill_mode + else "Optional commands that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]" + ) + enhancement_lines = [ + enhancement_intro, + "", + f"○ [cyan]{_display_cmd('clarify')}[/] [bright_black](optional)[/bright_black] - Ask structured questions to de-risk ambiguous areas before planning (run before [cyan]{_display_cmd('plan')}[/] if used)", + f"○ [cyan]{_display_cmd('analyze')}[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]{_display_cmd('tasks')}[/], before [cyan]{_display_cmd('implement')}[/])", + f"○ [cyan]{_display_cmd('checklist')}[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]{_display_cmd('plan')}[/])" + ] + enhancements_title = "Enhancement Skills" if native_skill_mode else "Enhancement Commands" + enhancements_panel = Panel("\n".join(enhancement_lines), title=enhancements_title, border_style="cyan", padding=(1,2)) + console.print() + console.print(enhancements_panel) From ff4d1937855d960f188461bc943fd05f67640b7b Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Sat, 25 Apr 2026 20:57:56 +0800 Subject: [PATCH 15/31] refactor: fix dead imports and wrapper duplication in commands/init.py - Remove dead import _get_skills_dir from _helpers - Delete five one-line wrapper functions (_is_git_repo, _init_git_repo, _locate_bundled_extension/workflow/preset) and inline direct calls to _git_svc and _svc at all call sites - Remove redundant inline `import shutil as _shutil`; use top-level shutil - Promote BRANCH_NUMBERING_CHOICES to module-level _BRANCH_NUMBERING_CHOICES - Add -> str return type annotation on _build_integration_equivalent - Remove empty finally: pass block --- src/specify_cli/commands/init.py | 51 +++++++------------------------- 1 file changed, 10 insertions(+), 41 deletions(-) diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index 1e0682b30b..35ee4a5a99 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -22,7 +22,6 @@ _install_shared_infra, ensure_constitution_from_template, ensure_executable_scripts, - _get_skills_dir, get_speckit_version, _parse_integration_options, AGENT_CONFIG, @@ -31,6 +30,8 @@ SCRIPT_TYPE_CHOICES, ) +_BRANCH_NUMBERING_CHOICES = {"sequential", "timestamp"} + def _build_integration_equivalent( integration_key: str, @@ -60,34 +61,6 @@ def _build_ai_deprecation_warning( ) -def _is_git_repo(path: Path = None) -> bool: - """Check if the specified path is inside a git repository.""" - return _git_svc.is_repo(path) - - -def _init_git_repo(project_path: Path, quiet: bool = False) -> tuple[bool, Optional[str]]: - """Initialize a git repository in the specified path.""" - ok, err = _git_svc.init_repo(project_path) - if not quiet: - if ok: - console.print("[green]✓[/green] Git repository initialized") - else: - console.print(f"[red]Error initializing git repository:[/red] {err}") - return ok, err - - -def _locate_bundled_extension(extension_id: str) -> Path | None: - return _svc.locate_bundled_extension(extension_id) - - -def _locate_bundled_workflow(workflow_id: str) -> Path | None: - return _svc.locate_bundled_workflow(workflow_id) - - -def _locate_bundled_preset(preset_id: str) -> Path | None: - return _svc.locate_bundled_preset(preset_id) - - def register(app: typer.Typer) -> None: @app.command() def init( @@ -239,9 +212,8 @@ def init( console.print("[yellow]Usage:[/yellow] specify init --ai --ai-skills") raise typer.Exit(1) - BRANCH_NUMBERING_CHOICES = {"sequential", "timestamp"} - if branch_numbering and branch_numbering not in BRANCH_NUMBERING_CHOICES: - console.print(f"[red]Error:[/red] Invalid --branch-numbering value '{branch_numbering}'. Choose from: {', '.join(sorted(BRANCH_NUMBERING_CHOICES))}") + if branch_numbering and branch_numbering not in _BRANCH_NUMBERING_CHOICES: + console.print(f"[red]Error:[/red] Invalid --branch-numbering value '{branch_numbering}'. Choose from: {', '.join(sorted(_BRANCH_NUMBERING_CHOICES))}") raise typer.Exit(1) dir_existed_before = False @@ -456,10 +428,10 @@ def init( git_messages = [] git_has_error = False # Step 1: Initialize git repo if needed - if _is_git_repo(project_path): + if _git_svc.is_repo(project_path): git_messages.append("existing repo detected") elif should_init_git: - success, error_msg = _init_git_repo(project_path, quiet=True) + success, error_msg = _git_svc.init_repo(project_path) if success: git_messages.append("initialized") else: @@ -475,7 +447,7 @@ def init( # Step 2: Install bundled git extension try: from ..extensions import ExtensionManager - bundled_path = _locate_bundled_extension("git") + bundled_path = _svc.locate_bundled_extension("git") if bundled_path: manager = ExtensionManager(project_path) if manager.registry.is_installed("git"): @@ -504,7 +476,7 @@ def init( # Install bundled speckit workflow try: - bundled_wf = _locate_bundled_workflow("speckit") + bundled_wf = _svc.locate_bundled_workflow("speckit") if bundled_wf: from ..workflows.catalog import WorkflowRegistry from ..workflows.engine import WorkflowDefinition @@ -512,10 +484,9 @@ def init( if wf_registry.is_installed("speckit"): tracker.complete("workflow", "already installed") else: - import shutil as _shutil dest_wf = project_path / ".specify" / "workflows" / "speckit" dest_wf.mkdir(parents=True, exist_ok=True) - _shutil.copy2( + shutil.copy2( bundled_wf / "workflow.yml", dest_wf / "workflow.yml", ) @@ -569,7 +540,7 @@ def init( if local_path.is_dir() and (local_path / "preset.yml").exists(): preset_manager.install_from_directory(local_path, speckit_ver) else: - bundled_path = _locate_bundled_preset(preset) + bundled_path = _svc.locate_bundled_preset(preset) if bundled_path: preset_manager.install_from_directory(bundled_path, speckit_ver) else: @@ -623,8 +594,6 @@ def init( if not here and project_path.exists() and not dir_existed_before: shutil.rmtree(project_path) raise typer.Exit(1) - finally: - pass console.print(tracker.render()) console.print("\n[bold green]Project ready.[/bold green]") From 672e7588cb4e74112e4a934064eccf29bf9c19bb Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Sat, 25 Apr 2026 21:07:50 +0800 Subject: [PATCH 16/31] refactor: move integration command handlers to commands/integration.py Extracts integration_app, all @integration_app.command handlers, and helper functions (_read_integration_json, _write_integration_json, _remove_integration_json, _normalize_script_type, _resolve_script_type, _update_init_options_for_integration) from __init__.py into the focused commands/integration.py module. __init__.py re-exports the public symbols via a clean import block. --- src/specify_cli/__init__.py | 1331 +---------------------- src/specify_cli/commands/integration.py | 620 +++++++++++ 2 files changed, 627 insertions(+), 1324 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 64699c9be7..3636befedf 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -359,1333 +359,16 @@ def self_upgrade() -> None: # ===== Integration Commands ===== -integration_app = typer.Typer( - name="integration", - help="Manage coding agent integrations", - add_completion=False, +from .commands.integration import ( + integration_app, + _read_integration_json, + _write_integration_json, + _remove_integration_json, + _normalize_script_type, + _resolve_script_type, ) app.add_typer(integration_app, name="integration") -integration_catalog_app = typer.Typer( - name="catalog", - help="Manage integration catalog sources", - add_completion=False, -) -integration_app.add_typer(integration_catalog_app, name="catalog") - - -def _read_integration_json(project_root: Path) -> dict[str, Any]: - """Load ``.specify/integration.json``. Returns normalized state when present.""" - path = project_root / INTEGRATION_JSON - if not path.exists(): - return {} - try: - data = json.loads(path.read_text(encoding="utf-8")) - except json.JSONDecodeError as exc: - console.print(f"[red]Error:[/red] {path} contains invalid JSON.") - console.print(f"Please fix or delete {INTEGRATION_JSON} and retry.") - console.print(f"[dim]Details:[/dim] {exc}") - raise typer.Exit(1) - except OSError as exc: - console.print(f"[red]Error:[/red] Could not read {path}.") - console.print(f"Please fix file permissions or delete {INTEGRATION_JSON} and retry.") - console.print(f"[dim]Details:[/dim] {exc}") - raise typer.Exit(1) - if not isinstance(data, dict): - console.print(f"[red]Error:[/red] {path} must contain a JSON object, got {type(data).__name__}.") - console.print(f"Please fix or delete {INTEGRATION_JSON} and retry.") - raise typer.Exit(1) - schema = data.get("integration_state_schema") - if isinstance(schema, int) and not isinstance(schema, bool) and schema > INTEGRATION_STATE_SCHEMA: - console.print( - f"[red]Error:[/red] {path} uses integration state schema {schema}, " - f"but this CLI only supports schema {INTEGRATION_STATE_SCHEMA}." - ) - console.print("Please upgrade Spec Kit before modifying integrations.") - raise typer.Exit(1) - return _normalize_integration_state(data) - - -def _write_integration_json( - project_root: Path, - integration_key: str | None, - installed_integrations: list[str] | None = None, - integration_settings: dict[str, dict[str, Any]] | None = None, -) -> None: - """Write ``.specify/integration.json`` with legacy-compatible state.""" - _write_integration_json_file( - project_root, - version=get_speckit_version(), - integration_key=integration_key, - installed_integrations=installed_integrations, - settings=integration_settings, - ) - - -def _clear_init_options_for_integration(project_root: Path, integration_key: str) -> None: - """Clear active integration keys from init-options.json when they match.""" - opts = load_init_options(project_root) - if opts.get("integration") == integration_key or opts.get("ai") == integration_key: - opts.pop("integration", None) - opts.pop("ai", None) - opts.pop("ai_skills", None) - opts.pop("context_file", None) - save_init_options(project_root, opts) - - -def _remove_integration_json(project_root: Path) -> None: - """Remove ``.specify/integration.json`` if it exists.""" - path = project_root / INTEGRATION_JSON - if path.exists(): - path.unlink() - - -_MANIFEST_READ_ERRORS = (ValueError, FileNotFoundError, OSError, UnicodeDecodeError) - - -class _SharedTemplateRefreshError(RuntimeError): - """Raised when default integration metadata should not be persisted.""" - - -def _normalize_script_type(script_type: str, source: str) -> str: - """Normalize and validate a script type from CLI/config sources.""" - normalized = script_type.strip().lower() - if normalized in SCRIPT_TYPE_CHOICES: - return normalized - console.print( - f"[red]Error:[/red] Invalid script type {script_type!r} from {source}. " - f"Expected one of: {', '.join(sorted(SCRIPT_TYPE_CHOICES.keys()))}." - ) - raise typer.Exit(1) - - -def _resolve_script_type(project_root: Path, script_type: str | None) -> str: - """Resolve the script type from the CLI flag or init-options.json.""" - if script_type: - return _normalize_script_type(script_type, "--script") - opts = load_init_options(project_root) - saved = opts.get("script") - if isinstance(saved, str) and saved.strip(): - return _normalize_script_type(saved, ".specify/init-options.json") - return "ps" if os.name == "nt" else "sh" - - -def _resolve_integration_script_type( - project_root: Path, - state: dict[str, Any], - key: str, - script_type: str | None = None, -) -> str: - """Resolve script type for an integration, preferring stored settings.""" - if script_type: - return _normalize_script_type(script_type, "--script") - - stored = _integration_setting(state, key).get("script") - if isinstance(stored, str) and stored.strip(): - return _normalize_script_type(stored, f"{INTEGRATION_JSON} integration_settings.{key}.script") - - return _resolve_script_type(project_root, None) - - -def _resolve_integration_options( - integration: Any, - state: dict[str, Any], - key: str, - raw_options: str | None, -) -> tuple[str | None, dict[str, Any] | None]: - """Resolve raw and parsed options for an integration operation.""" - return _resolve_integration_options_impl( - integration, - state, - key, - raw_options, - parse_options=_parse_integration_options, - ) - - -def _set_default_integration( - project_root: Path, - state: dict[str, Any], - key: str, - integration: Any, - installed_keys: list[str], - *, - script_type: str | None = None, - raw_options: str | None = None, - parsed_options: dict[str, Any] | None = None, - refresh_templates: bool = True, - refresh_templates_force: bool = False, -) -> None: - """Persist *key* as default and align active runtime metadata.""" - resolved_script = _resolve_integration_script_type(project_root, state, key, script_type) - settings = _with_integration_setting( - state, - key, - integration, - script_type=resolved_script, - raw_options=raw_options, - parsed_options=parsed_options, - ) - - if refresh_templates: - try: - _refresh_shared_templates( - project_root, - invoke_separator=_invoke_separator_for_integration( - integration, {"integration_settings": settings}, key, parsed_options - ), - force=refresh_templates_force, - ) - except (ValueError, OSError) as exc: - raise _SharedTemplateRefreshError( - f"Failed to refresh shared templates for '{key}': {exc}" - ) from exc - - _write_integration_json(project_root, key, installed_keys, settings) - _update_init_options_for_integration(project_root, integration, script_type=resolved_script) - - -def _set_default_integration_or_exit(*args: Any, **kwargs: Any) -> None: - try: - _set_default_integration(*args, **kwargs) - except _SharedTemplateRefreshError as exc: - console.print(f"[red]Error:[/red] {exc}") - raise typer.Exit(1) - - -def _display_project_path(project_root: Path, path: str | Path) -> str: - """Return a stable POSIX-style display path for paths under a project.""" - path_obj = Path(path) - try: - rel_path = path_obj.relative_to(project_root) if path_obj.is_absolute() else path_obj - except ValueError: - try: - rel_path = path_obj.resolve().relative_to(project_root.resolve()) - except (OSError, ValueError): - return path_obj.as_posix() - return rel_path.as_posix() - - -def _require_specify_project() -> Path: - """Return the current project root if it is a spec-kit project, else exit.""" - project_root = Path.cwd() - if (project_root / ".specify").is_dir(): - return project_root - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - - -@integration_app.command("list") -def integration_list( - catalog: bool = typer.Option(False, "--catalog", help="Browse full catalog (built-in + community)"), -): - """List available integrations and installed status.""" - from .integrations import INTEGRATION_REGISTRY - - project_root = _require_specify_project() - current = _read_integration_json(project_root) - default_key = _default_integration_key(current) - installed_keys = set(_installed_integration_keys(current)) - - if catalog: - from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError - - ic = IntegrationCatalog(project_root) - try: - entries = ic.search() - except IntegrationCatalogError as exc: - console.print(f"[red]Error:[/red] {exc}") - raise typer.Exit(1) - - if not entries: - console.print("[yellow]No integrations found in catalog.[/yellow]") - return - - table = Table(title="Integration Catalog") - table.add_column("ID", style="cyan") - table.add_column("Name") - table.add_column("Version") - table.add_column("Source") - table.add_column("Status") - table.add_column("Multi-install Safe") - - for entry in sorted(entries, key=lambda e: e["id"]): - eid = entry["id"] - cat_name = entry.get("_catalog_name", "") - install_allowed = entry.get("_install_allowed", True) - if eid == default_key: - status = "[green]installed (default)[/green]" - elif eid in installed_keys: - status = "[green]installed[/green]" - elif eid in INTEGRATION_REGISTRY: - status = "built-in" - elif install_allowed is False: - status = "discovery-only" - else: - status = "" - safe = "" - if eid in INTEGRATION_REGISTRY: - safe = "yes" if getattr(INTEGRATION_REGISTRY[eid], "multi_install_safe", False) else "no" - table.add_row( - eid, - entry.get("name", eid), - entry.get("version", ""), - cat_name, - status, - safe, - ) - - console.print(table) - return - - table = Table(title="Coding Agent Integrations") - table.add_column("Key", style="cyan") - table.add_column("Name") - table.add_column("Status") - table.add_column("CLI Required") - table.add_column("Multi-install Safe") - - for key in sorted(INTEGRATION_REGISTRY.keys()): - integration = INTEGRATION_REGISTRY[key] - cfg = integration.config or {} - name = cfg.get("name", key) - requires_cli = cfg.get("requires_cli", False) - - if key == default_key: - status = "[green]installed (default)[/green]" - elif key in installed_keys: - status = "[green]installed[/green]" - else: - status = "" - - cli_req = "yes" if requires_cli else "no (IDE)" - safe = "yes" if getattr(integration, "multi_install_safe", False) else "no" - table.add_row(key, name, status, cli_req, safe) - - console.print(table) - - if installed_keys: - console.print(f"\n[dim]Default integration:[/dim] [cyan]{default_key or 'none'}[/cyan]") - console.print(f"[dim]Installed integrations:[/dim] [cyan]{', '.join(sorted(installed_keys))}[/cyan]") - else: - console.print("\n[yellow]No integration currently installed.[/yellow]") - console.print("Install one with: [cyan]specify integration install [/cyan]") - - -@integration_app.command("install") -def integration_install( - key: str = typer.Argument(help="Integration key to install (e.g. claude, copilot)"), - script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"), - force: bool = typer.Option(False, "--force", help="Allow multi-install when integrations are not declared safe"), - integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'), -): - """Install an integration into an existing project.""" - from .integrations import INTEGRATION_REGISTRY, get_integration - from .integrations.manifest import IntegrationManifest - - project_root = _require_specify_project() - integration = get_integration(key) - if integration is None: - console.print(f"[red]Error:[/red] Unknown integration '{key}'") - available = ", ".join(sorted(INTEGRATION_REGISTRY.keys())) - console.print(f"Available integrations: {available}") - raise typer.Exit(1) - - current = _read_integration_json(project_root) - default_key = _default_integration_key(current) - installed_keys = _installed_integration_keys(current) - - if key in installed_keys: - console.print(f"[yellow]Integration '{key}' is already installed.[/yellow]") - console.print( - f"Run [cyan]specify integration upgrade {key}[/cyan] to reinstall managed files, " - f"or [cyan]specify integration uninstall {key}[/cyan] first." - ) - raise typer.Exit(0) - - if installed_keys and not force: - unsafe_keys = [] - for installed_key in installed_keys: - installed_integration = get_integration(installed_key) - if not installed_integration or not getattr(installed_integration, "multi_install_safe", False): - unsafe_keys.append(installed_key) - if unsafe_keys or not getattr(integration, "multi_install_safe", False): - console.print( - f"[red]Error:[/red] Installed integrations: {', '.join(installed_keys)}." - ) - if default_key: - console.print(f"Default integration: [cyan]{default_key}[/cyan].") - console.print( - "Installing multiple integrations is only automatic when all involved " - "integrations are declared multi-install safe." - ) - console.print( - f"Run [cyan]specify integration switch {key}[/cyan] to replace the default " - f"integration, or retry with [cyan]--force[/cyan] to opt in." - ) - raise typer.Exit(1) - - selected_script = _resolve_script_type(project_root, script) - - # Build parsed options from --integration-options so the integration - # can determine its effective invoke separator before shared infra - # is installed. - raw_options, parsed_options = _resolve_integration_options( - integration, current, key, integration_options - ) - - # Ensure shared infrastructure is present (safe to run unconditionally; - # _install_shared_infra merges missing files without overwriting). - infra_integration = integration - infra_key = key - infra_parsed = parsed_options - if default_key: - default_integration = get_integration(default_key) - if default_integration is not None: - infra_integration = default_integration - infra_key = default_key - _, infra_parsed = _resolve_integration_options( - default_integration, current, default_key, None - ) - _install_shared_infra_or_exit( - project_root, - selected_script, - invoke_separator=_invoke_separator_for_integration( - infra_integration, current, infra_key, infra_parsed - ), - ) - if os.name != "nt": - ensure_executable_scripts(project_root) - - manifest = IntegrationManifest( - integration.key, project_root, version=get_speckit_version() - ) - - try: - integration.setup( - project_root, manifest, - parsed_options=parsed_options, - script_type=selected_script, - raw_options=raw_options, - ) - manifest.save() - new_installed = _dedupe_integration_keys([*installed_keys, integration.key]) - new_default = default_key or integration.key - settings = _with_integration_setting( - current, - integration.key, - integration, - script_type=selected_script, - raw_options=raw_options, - parsed_options=parsed_options, - ) - _write_integration_json(project_root, new_default, new_installed, settings) - if new_default == integration.key: - _update_init_options_for_integration(project_root, integration, script_type=selected_script) - - except Exception as e: - # Attempt rollback of any files written by setup - try: - integration.teardown(project_root, manifest, force=True) - except Exception as rollback_err: - # Suppress so the original setup error remains the primary failure - console.print(f"[yellow]Warning:[/yellow] Failed to roll back integration changes: {rollback_err}") - if installed_keys: - _write_integration_json( - project_root, default_key, installed_keys, _integration_settings(current) - ) - else: - _remove_integration_json(project_root) - console.print(f"[red]Error:[/red] Failed to install integration: {e}") - raise typer.Exit(1) - - name = (integration.config or {}).get("name", key) - console.print(f"\n[green]✓[/green] Integration '{name}' installed successfully") - if default_key: - console.print(f"[dim]Default integration remains:[/dim] [cyan]{default_key}[/cyan]") - - -def _update_init_options_for_integration( - project_root: Path, - integration: Any, - script_type: str | None = None, -) -> None: - """Update ``init-options.json`` to reflect *integration* as the active one.""" - from .integrations.base import SkillsIntegration - opts = load_init_options(project_root) - opts["integration"] = integration.key - opts["ai"] = integration.key - opts["context_file"] = integration.context_file - if script_type: - opts["script"] = script_type - if isinstance(integration, SkillsIntegration) or getattr(integration, "_skills_mode", False): - opts["ai_skills"] = True - else: - opts.pop("ai_skills", None) - save_init_options(project_root, opts) - - -@integration_app.command("use") -def integration_use( - key: str = typer.Argument(help="Installed integration key to make the default"), - force: bool = typer.Option(False, "--force", help="Overwrite managed shared templates while changing the default"), -): - """Set the default integration without uninstalling other integrations.""" - from .integrations import get_integration - - project_root = _require_specify_project() - current = _read_integration_json(project_root) - installed_keys = _installed_integration_keys(current) - if key not in installed_keys: - console.print(f"[red]Error:[/red] Integration '{key}' is not installed.") - if installed_keys: - console.print(f"[yellow]Installed integrations:[/yellow] {', '.join(installed_keys)}") - else: - console.print("Install one with: [cyan]specify integration install [/cyan]") - raise typer.Exit(1) - - integration = get_integration(key) - if integration is None: - console.print(f"[red]Error:[/red] Unknown integration '{key}'") - raise typer.Exit(1) - - raw_options, parsed_options = _resolve_integration_options(integration, current, key, None) - _set_default_integration_or_exit( - project_root, - current, - key, - integration, - installed_keys, - raw_options=raw_options, - parsed_options=parsed_options, - refresh_templates_force=force, - ) - console.print(f"[green]✓[/green] Default integration set to [bold]{key}[/bold].") - - -@integration_app.command("uninstall") -def integration_uninstall( - key: str = typer.Argument(None, help="Integration key to uninstall (default: current integration)"), - force: bool = typer.Option(False, "--force", help="Remove files even if modified"), -): - """Uninstall an integration, safely preserving modified files.""" - from .integrations import get_integration - from .integrations.manifest import IntegrationManifest - - project_root = _require_specify_project() - current = _read_integration_json(project_root) - default_key = _default_integration_key(current) - installed_keys = _installed_integration_keys(current) - - if key is None: - if not default_key: - console.print("[yellow]No integration is currently installed.[/yellow]") - raise typer.Exit(0) - key = default_key - - if key not in installed_keys: - console.print(f"[red]Error:[/red] Integration '{key}' is not installed.") - raise typer.Exit(1) - - integration = get_integration(key) - - manifest_path = project_root / ".specify" / "integrations" / f"{key}.manifest.json" - if not manifest_path.exists(): - console.print(f"[yellow]No manifest found for integration '{key}'. Nothing to uninstall.[/yellow]") - remaining = [installed for installed in installed_keys if installed != key] - new_default = default_key if default_key != key else (remaining[0] if remaining else None) - if remaining: - if default_key == key and new_default and (new_integration := get_integration(new_default)): - raw_options, parsed_options = _resolve_integration_options( - new_integration, current, new_default, None - ) - _set_default_integration_or_exit( - project_root, - current, - new_default, - new_integration, - remaining, - raw_options=raw_options, - parsed_options=parsed_options, - ) - else: - _write_integration_json( - project_root, new_default, remaining, _integration_settings(current) - ) - else: - _remove_integration_json(project_root) - if default_key == key: - _clear_init_options_for_integration(project_root, key) - raise typer.Exit(0) - - try: - manifest = IntegrationManifest.load(key, project_root) - except _MANIFEST_READ_ERRORS as exc: - console.print(f"[red]Error:[/red] Integration manifest for '{key}' is unreadable.") - console.print(f"Manifest: {manifest_path}") - console.print( - f"To recover, delete the unreadable manifest, run " - f"[cyan]specify integration uninstall {key}[/cyan] to clear stale metadata, " - f"then run [cyan]specify integration install {key}[/cyan] to regenerate." - ) - console.print(f"[dim]Details:[/dim] {exc}") - raise typer.Exit(1) - - removed, skipped = manifest.uninstall(project_root, force=force) - - # Remove managed context section from the agent context file - if integration: - integration.remove_context_section(project_root) - - remaining = [installed for installed in installed_keys if installed != key] - new_default = default_key if default_key != key else (remaining[0] if remaining else None) - if remaining: - if default_key == key and new_default and (new_integration := get_integration(new_default)): - raw_options, parsed_options = _resolve_integration_options( - new_integration, current, new_default, None - ) - _set_default_integration_or_exit( - project_root, - current, - new_default, - new_integration, - remaining, - raw_options=raw_options, - parsed_options=parsed_options, - ) - else: - _write_integration_json( - project_root, new_default, remaining, _integration_settings(current) - ) - else: - _remove_integration_json(project_root) - - if default_key == key: - _clear_init_options_for_integration(project_root, key) - - name = (integration.config or {}).get("name", key) if integration else key - console.print(f"\n[green]✓[/green] Integration '{name}' uninstalled") - if removed: - console.print(f" Removed {len(removed)} file(s)") - if skipped: - console.print(f"\n[yellow]⚠[/yellow] {len(skipped)} modified file(s) were preserved:") - for path in skipped: - rel = _display_project_path(project_root, path) - console.print(f" {rel}") - - -@integration_app.command("switch") -def integration_switch( - target: str = typer.Argument(help="Integration key to switch to"), - script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"), - force: bool = typer.Option(False, "--force", help="Force removal of modified files during uninstall"), - integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the target integration'), -): - """Switch from the current integration to a different one.""" - from .integrations import INTEGRATION_REGISTRY, get_integration - from .integrations.manifest import IntegrationManifest - - project_root = _require_specify_project() - target_integration = get_integration(target) - if target_integration is None: - console.print(f"[red]Error:[/red] Unknown integration '{target}'") - available = ", ".join(sorted(INTEGRATION_REGISTRY.keys())) - console.print(f"Available integrations: {available}") - raise typer.Exit(1) - - current = _read_integration_json(project_root) - installed_keys = _installed_integration_keys(current) - installed_key = _default_integration_key(current) - - if installed_key == target: - if integration_options is not None: - console.print( - "[red]Error:[/red] --integration-options cannot be used when switching " - "to an already installed integration." - ) - console.print( - f"Run [cyan]specify integration upgrade {target} --integration-options ...[/cyan] " - "to update managed files/options." - ) - raise typer.Exit(1) - if force: - raw_options, parsed_options = _resolve_integration_options( - target_integration, current, target, None - ) - _set_default_integration_or_exit( - project_root, - current, - target, - target_integration, - installed_keys, - raw_options=raw_options, - parsed_options=parsed_options, - refresh_templates_force=True, - ) - console.print( - f"\n[green]✓[/green] Default integration remains [bold]{target}[/bold]; " - "managed shared templates refreshed." - ) - raise typer.Exit(0) - console.print(f"[yellow]Integration '{target}' is already the default integration. Nothing to switch.[/yellow]") - raise typer.Exit(0) - - if target in installed_keys: - if integration_options is not None: - console.print( - "[red]Error:[/red] --integration-options cannot be used when switching " - "to an already installed integration." - ) - console.print( - f"Run [cyan]specify integration upgrade {target} --integration-options ...[/cyan] " - f"to update managed files/options, then [cyan]specify integration use {target}[/cyan]." - ) - raise typer.Exit(1) - raw_options, parsed_options = _resolve_integration_options( - target_integration, current, target, None - ) - _set_default_integration_or_exit( - project_root, - current, - target, - target_integration, - installed_keys, - raw_options=raw_options, - parsed_options=parsed_options, - refresh_templates_force=force, - ) - console.print(f"\n[green]✓[/green] Default integration set to [bold]{target}[/bold].") - raise typer.Exit(0) - - selected_script = _resolve_script_type(project_root, script) - - # Phase 1: Uninstall current integration (if any) - if installed_key: - current_integration = get_integration(installed_key) - manifest_path = project_root / ".specify" / "integrations" / f"{installed_key}.manifest.json" - - if current_integration and manifest_path.exists(): - console.print(f"Uninstalling current integration: [cyan]{installed_key}[/cyan]") - try: - old_manifest = IntegrationManifest.load(installed_key, project_root) - except _MANIFEST_READ_ERRORS as exc: - console.print(f"[red]Error:[/red] Could not read integration manifest for '{installed_key}': {manifest_path}") - console.print(f"[dim]{exc}[/dim]") - console.print( - f"To recover, delete the unreadable manifest at {manifest_path}, " - f"run [cyan]specify integration uninstall {installed_key}[/cyan], then retry." - ) - raise typer.Exit(1) - removed, skipped = old_manifest.uninstall(project_root, force=force) - current_integration.remove_context_section(project_root) - if removed: - console.print(f" Removed {len(removed)} file(s)") - if skipped: - console.print(f" [yellow]⚠[/yellow] {len(skipped)} modified file(s) preserved") - elif not current_integration and manifest_path.exists(): - # Integration removed from registry but manifest exists — use manifest-only uninstall - console.print(f"Uninstalling unknown integration '{installed_key}' via manifest") - try: - old_manifest = IntegrationManifest.load(installed_key, project_root) - removed, skipped = old_manifest.uninstall(project_root, force=force) - if removed: - console.print(f" Removed {len(removed)} file(s)") - if skipped: - console.print(f" [yellow]⚠[/yellow] {len(skipped)} modified file(s) preserved") - except _MANIFEST_READ_ERRORS as exc: - console.print(f"[yellow]Warning:[/yellow] Could not read manifest for '{installed_key}': {exc}") - else: - console.print(f"[red]Error:[/red] Integration '{installed_key}' is installed but has no manifest.") - console.print( - f"Run [cyan]specify integration uninstall {installed_key}[/cyan] to clear metadata, " - f"then retry [cyan]specify integration switch {target}[/cyan]." - ) - raise typer.Exit(1) - - # Unregister extension commands for the old agent so they don't - # remain as orphans in the old agent's directory. - try: - from .extensions import ExtensionManager - - ext_mgr = ExtensionManager(project_root) - ext_mgr.unregister_agent_artifacts(installed_key) - except Exception as ext_err: - console.print( - f"[yellow]Warning:[/yellow] Could not clean up extension artifacts " - f"(commands, skills, registry entries) for '{installed_key}': {ext_err}" - ) - - # Clear metadata so a failed Phase 2 doesn't leave stale references - installed_keys = [installed for installed in installed_keys if installed != installed_key] - _clear_init_options_for_integration(project_root, installed_key) - if installed_keys: - fallback_key = installed_keys[0] - fallback_integration = get_integration(fallback_key) - if fallback_integration is not None: - raw_options, parsed_options = _resolve_integration_options( - fallback_integration, current, fallback_key, None - ) - _set_default_integration_or_exit( - project_root, - current, - fallback_key, - fallback_integration, - installed_keys, - raw_options=raw_options, - parsed_options=parsed_options, - ) - else: - _write_integration_json( - project_root, fallback_key, installed_keys, _integration_settings(current) - ) - else: - _remove_integration_json(project_root) - current = _read_integration_json(project_root) - - # Build parsed options from --integration-options so the integration - # can determine its effective invoke separator before shared infra - # is installed. - raw_options, parsed_options = _resolve_integration_options( - target_integration, current, target, integration_options - ) - - # Ensure shared infrastructure is present (safe to run unconditionally; - # _install_shared_infra merges missing files without overwriting). - _install_shared_infra_or_exit( - project_root, - selected_script, - invoke_separator=_invoke_separator_for_integration( - target_integration, current, target, parsed_options - ), - ) - if os.name != "nt": - ensure_executable_scripts(project_root) - - # Phase 2: Install target integration - console.print(f"Installing integration: [cyan]{target}[/cyan]") - manifest = IntegrationManifest( - target_integration.key, project_root, version=get_speckit_version() - ) - - try: - target_integration.setup( - project_root, manifest, - parsed_options=parsed_options, - script_type=selected_script, - raw_options=raw_options, - ) - manifest.save() - _set_default_integration( - project_root, - current, - target_integration.key, - target_integration, - _dedupe_integration_keys([*installed_keys, target_integration.key]), - script_type=selected_script, - raw_options=raw_options, - parsed_options=parsed_options, - ) - - # Re-register extension commands for the new agent so that - # previously-installed extensions are available in the new integration. - try: - from .extensions import ExtensionManager - - ext_mgr = ExtensionManager(project_root) - ext_mgr.register_enabled_extensions_for_agent(target) - except Exception as ext_err: - console.print( - f"[yellow]Warning:[/yellow] Could not register extension commands, skills, " - f"or related artifacts for '{target}': {ext_err}" - ) - - except Exception as e: - # Attempt rollback of any files written by setup - try: - target_integration.teardown(project_root, manifest, force=True) - except Exception as rollback_err: - # Suppress so the original setup error remains the primary failure - console.print(f"[yellow]Warning:[/yellow] Failed to roll back integration '{target}': {rollback_err}") - if installed_keys: - fallback_key = installed_keys[0] - fallback_integration = get_integration(fallback_key) - if fallback_integration is not None: - raw_options, parsed_options = _resolve_integration_options( - fallback_integration, current, fallback_key, None - ) - try: - _set_default_integration( - project_root, - current, - fallback_key, - fallback_integration, - installed_keys, - raw_options=raw_options, - parsed_options=parsed_options, - ) - except _SharedTemplateRefreshError as restore_err: - console.print( - f"[yellow]Warning:[/yellow] Failed to restore default " - f"integration '{fallback_key}': {restore_err}" - ) - else: - _write_integration_json( - project_root, fallback_key, installed_keys, _integration_settings(current) - ) - else: - _remove_integration_json(project_root) - console.print(f"[red]Error:[/red] Failed to install integration '{target}': {e}") - raise typer.Exit(1) - - name = (target_integration.config or {}).get("name", target) - console.print(f"\n[green]✓[/green] Switched to integration '{name}'") - - -@integration_app.command("upgrade") -def integration_upgrade( - key: str | None = typer.Argument(None, help="Integration key to upgrade (default: current integration)"), - force: bool = typer.Option(False, "--force", help="Force upgrade even if files are modified"), - script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"), - integration_options: str | None = typer.Option(None, "--integration-options", help="Options for the integration"), -): - """Upgrade an integration by reinstalling with diff-aware file handling. - - Compares manifest hashes to detect locally modified files and - blocks the upgrade unless --force is used. - """ - from .integrations import get_integration - from .integrations.manifest import IntegrationManifest - - project_root = _require_specify_project() - current = _read_integration_json(project_root) - installed_key = _default_integration_key(current) - installed_keys = _installed_integration_keys(current) - - if key is None: - if not installed_key: - console.print("[yellow]No integration is currently installed.[/yellow]") - raise typer.Exit(0) - key = installed_key - - if key not in installed_keys: - console.print(f"[red]Error:[/red] Integration '{key}' is not installed.") - raise typer.Exit(1) - - integration = get_integration(key) - if integration is None: - console.print(f"[red]Error:[/red] Unknown integration '{key}'") - raise typer.Exit(1) - - manifest_path = project_root / ".specify" / "integrations" / f"{key}.manifest.json" - if not manifest_path.exists(): - console.print(f"[yellow]No manifest found for integration '{key}'. Nothing to upgrade.[/yellow]") - console.print(f"Run [cyan]specify integration install {key}[/cyan] to perform a fresh install.") - raise typer.Exit(0) - - try: - old_manifest = IntegrationManifest.load(key, project_root) - except _MANIFEST_READ_ERRORS as exc: - console.print(f"[red]Error:[/red] Integration manifest for '{key}' is unreadable: {exc}") - raise typer.Exit(1) - - # Detect modified files via manifest hashes - modified = old_manifest.check_modified() - if modified and not force: - console.print(f"[yellow]⚠[/yellow] {len(modified)} file(s) have been modified since installation:") - for rel in modified: - console.print(f" {rel}") - console.print("\nUse [cyan]--force[/cyan] to overwrite modified files, or resolve manually.") - raise typer.Exit(1) - - selected_script = _resolve_integration_script_type(project_root, current, key, script) - - # Build parsed options from --integration-options so the integration - # can determine its effective invoke separator before shared infra - # is installed. - raw_options, parsed_options = _resolve_integration_options( - integration, current, key, integration_options - ) - - # Ensure shared infrastructure is up to date; --force overwrites existing files. - infra_integration = integration - infra_key = key - infra_parsed = parsed_options - if installed_key and installed_key != key: - default_integration = get_integration(installed_key) - if default_integration is not None: - infra_integration = default_integration - infra_key = installed_key - _, infra_parsed = _resolve_integration_options( - default_integration, current, installed_key, None - ) - _install_shared_infra_or_exit( - project_root, - selected_script, - force=force, - invoke_separator=_invoke_separator_for_integration( - infra_integration, current, infra_key, infra_parsed - ), - ) - if os.name != "nt": - ensure_executable_scripts(project_root) - - # Phase 1: Install new files (overwrites existing; old-only files remain) - console.print(f"Upgrading integration: [cyan]{key}[/cyan]") - new_manifest = IntegrationManifest(key, project_root, version=get_speckit_version()) - - try: - integration.setup( - project_root, - new_manifest, - parsed_options=parsed_options, - script_type=selected_script, - raw_options=raw_options, - ) - settings = _with_integration_setting( - current, - key, - integration, - script_type=selected_script, - raw_options=raw_options, - parsed_options=parsed_options, - ) - if installed_key == key: - try: - _refresh_shared_templates( - project_root, - invoke_separator=_invoke_separator_for_integration( - integration, {"integration_settings": settings}, key, parsed_options - ), - force=force, - ) - except (ValueError, OSError) as exc: - raise _SharedTemplateRefreshError( - f"Failed to refresh shared templates for '{key}': {exc}" - ) from exc - new_manifest.save() - _write_integration_json(project_root, installed_key, installed_keys, settings) - if installed_key == key: - _update_init_options_for_integration(project_root, integration, script_type=selected_script) - except Exception as exc: - # Don't teardown — setup overwrites in-place, so teardown would - # delete files that were working before the upgrade. Just report. - console.print(f"[red]Error:[/red] Failed to upgrade integration: {exc}") - console.print("[yellow]The previous integration files may still be in place.[/yellow]") - raise typer.Exit(1) - - # Phase 2: Remove stale files from old manifest that are not in the new one - old_files = old_manifest.files - new_files = new_manifest.files - stale_keys = set(old_files) - set(new_files) - if stale_keys: - stale_manifest = IntegrationManifest(key, project_root, version="stale-cleanup") - stale_manifest._files = {k: old_files[k] for k in stale_keys} - stale_removed, _ = stale_manifest.uninstall(project_root, force=True) - if stale_removed: - console.print(f" Removed {len(stale_removed)} stale file(s) from previous install") - - name = (integration.config or {}).get("name", key) - console.print(f"\n[green]✓[/green] Integration '{name}' upgraded successfully") - - -# ===== Integration catalog discovery commands ===== -# -# These commands mirror the workflow catalog CLI shape: -# - `search` / `info` for discovery over the active catalog stack -# - `catalog list/add/remove` for managing catalog sources -# -# They deliberately do NOT add `integration add/remove/enable/disable/ -# set-priority`: integrations are single-active (install / uninstall / switch), -# not additive like extensions and presets. - - -@integration_app.command("search") -def integration_search( - query: Optional[str] = typer.Argument(None, help="Search query (optional)"), - tag: Optional[str] = typer.Option(None, "--tag", help="Filter by tag"), - author: Optional[str] = typer.Option(None, "--author", help="Filter by author"), -): - """Search for integrations in the active catalog stack.""" - from .integrations import INTEGRATION_REGISTRY - from .integrations.catalog import ( - IntegrationCatalog, - IntegrationCatalogError, - IntegrationValidationError, - ) - - project_root = _require_specify_project() - integration_config = _read_integration_json(project_root) - installed_key = integration_config.get("integration") - catalog = IntegrationCatalog(project_root) - - try: - results = catalog.search(query=query, tag=tag, author=author) - except IntegrationValidationError as exc: - console.print(f"[red]Error:[/red] {exc}") - console.print( - "\nTip: Check the configuration file path shown above for invalid catalog configuration " - "(for example, .specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml)." - ) - raise typer.Exit(1) - except IntegrationCatalogError as exc: - console.print(f"[red]Error:[/red] {exc}") - if os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip(): - console.print( - "\nTip: Check the SPECKIT_INTEGRATION_CATALOG_URL environment variable for an invalid " - "catalog URL, or unset it to use the configured catalog files " - "(.specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml)." - ) - else: - console.print("\nTip: The catalog may be temporarily unavailable. Try again later.") - raise typer.Exit(1) - - if not results: - console.print("\n[yellow]No integrations found matching criteria[/yellow]") - if query or tag or author: - console.print("\nTry:") - console.print(" • Broader search terms") - console.print(" • Remove filters") - console.print(" • specify integration search (show all)") - return - - console.print(f"\n[green]Found {len(results)} integration(s):[/green]\n") - for integ in sorted(results, key=lambda e: e.get("id", "")): - iid = integ.get("id", "?") - name = integ.get("name", iid) - version = integ.get("version", "?") - console.print(f"[bold]{name}[/bold] ({iid}) v{version}") - desc = integ.get("description", "") - if desc: - console.print(f" {desc}") - - console.print(f"\n [dim]Author:[/dim] {integ.get('author', 'Unknown')}") - tags = integ.get("tags", []) - if isinstance(tags, list) and tags: - console.print(f" [dim]Tags:[/dim] {', '.join(str(t) for t in tags)}") - - cat_name = integ.get("_catalog_name", "") - install_allowed = integ.get("_install_allowed", True) - if cat_name: - if install_allowed: - console.print(f" [dim]Catalog:[/dim] {cat_name}") - else: - console.print( - f" [dim]Catalog:[/dim] {cat_name} " - "[yellow](discovery only — not installable)[/yellow]" - ) - - if iid == installed_key: - console.print("\n [green]✓ Installed[/green] (currently active)") - elif iid in INTEGRATION_REGISTRY: - console.print(f"\n [cyan]Install:[/cyan] specify integration install {iid}") - elif install_allowed: - console.print( - "\n [yellow]Found in catalog.[/yellow] Only built-in integration IDs " - "can be installed with 'specify integration install'." - ) - else: - console.print( - f"\n [yellow]⚠[/yellow] Not directly installable from '{cat_name}'." - ) - console.print() - - -@integration_app.command("info") -def integration_info( - integration_id: str = typer.Argument(..., help="Integration ID"), -): - """Show catalog details for a single integration.""" - from .integrations import INTEGRATION_REGISTRY - from .integrations.catalog import ( - IntegrationCatalog, - IntegrationCatalogError, - IntegrationValidationError, - ) - - project_root = _require_specify_project() - catalog = IntegrationCatalog(project_root) - installed_key = _read_integration_json(project_root).get("integration") - - try: - info = catalog.get_integration_info(integration_id) - except IntegrationCatalogError as exc: - info = None - # Keep the live exception so the fallback branch below can give - # different guidance for local-config vs. network failures. - catalog_error: Optional[IntegrationCatalogError] = exc - else: - catalog_error = None - - if info: - name = info.get("name", integration_id) - version = info.get("version", "?") - console.print(f"\n[bold cyan]{name}[/bold cyan] ({integration_id}) v{version}") - if info.get("description"): - console.print(f" {info['description']}") - console.print() - - console.print(f" [dim]Author:[/dim] {info.get('author', 'Unknown')}") - if info.get("license"): - console.print(f" [dim]License:[/dim] {info['license']}") - - tags = info.get("tags", []) - if isinstance(tags, list) and tags: - console.print(f" [dim]Tags:[/dim] {', '.join(str(t) for t in tags)}") - - cat_name = info.get("_catalog_name", "") - install_allowed = info.get("_install_allowed", True) - if cat_name: - install_note = "" if install_allowed else " [yellow](discovery only)[/yellow]" - console.print(f" [dim]Source catalog:[/dim] {cat_name}{install_note}") - - if info.get("repository"): - console.print(f" [dim]Repository:[/dim] {info['repository']}") - - if integration_id == installed_key: - console.print("\n [green]✓ Installed[/green] (currently active)") - elif integration_id in INTEGRATION_REGISTRY: - console.print("\n [dim]Built-in integration (not currently active)[/dim]") - return - - if integration_id in INTEGRATION_REGISTRY: - integration = INTEGRATION_REGISTRY[integration_id] - cfg = integration.config or {} - name = cfg.get("name", integration_id) - console.print(f"\n[bold cyan]{name}[/bold cyan] ({integration_id})") - console.print(" [dim]Built-in integration (not listed in catalog)[/dim]") - if integration_id == installed_key: - console.print("\n [green]✓ Installed[/green] (currently active)") - if catalog_error: - console.print(f"\n[yellow]Catalog unavailable:[/yellow] {catalog_error}") - return - - if catalog_error: - console.print(f"[red]Error:[/red] Could not query integration catalog: {catalog_error}") - if isinstance(catalog_error, IntegrationValidationError): - console.print( - "\nCheck the configuration file path shown above " - "(.specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml), " - "or use a built-in integration ID directly." - ) - elif os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip(): - console.print( - "\nCheck whether SPECKIT_INTEGRATION_CATALOG_URL is set correctly and reachable, " - "or unset it to use the configured catalog files, or use a built-in integration ID directly." - ) - else: - console.print("\nTry again when online, or use a built-in integration ID directly.") - else: - console.print(f"[red]Error:[/red] Integration '{integration_id}' not found") - console.print("\nTry: specify integration search") - raise typer.Exit(1) - - -@integration_catalog_app.command("list") -def integration_catalog_list(): - """List configured integration catalog sources.""" - from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError - - project_root = _require_specify_project() - catalog = IntegrationCatalog(project_root) - env_override = os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip() - - try: - if env_override: - project_configs = None - configs = catalog.get_catalog_configs() - else: - project_configs = catalog.get_project_catalog_configs() - configs = project_configs if project_configs is not None else catalog.get_catalog_configs() - except IntegrationCatalogError as exc: - console.print(f"[red]Error:[/red] {exc}") - raise typer.Exit(1) - - console.print("\n[bold cyan]Integration Catalog Sources:[/bold cyan]\n") - if env_override: - console.print( - " SPECKIT_INTEGRATION_CATALOG_URL is set; it supersedes configured catalog files." - ) - console.print( - " Project/user catalog sources are not active while the env override is set.\n" - ) - console.print("[bold]Active catalog source from environment (non-removable here):[/bold]\n") - elif project_configs is None: - console.print(" No project-level catalog sources configured.\n") - console.print("[bold]Active catalog sources (non-removable here):[/bold]\n") - else: - console.print("[bold]Project catalog sources (removable):[/bold]\n") - - for i, cfg in enumerate(configs): - install_status = ( - "[green]install allowed[/green]" - if cfg.get("install_allowed") - else "[yellow]discovery only[/yellow]" - ) - raw_name = cfg.get("name") - display_name = str(raw_name).strip() if raw_name is not None else "" - if not display_name: - display_name = f"catalog-{i + 1}" - if env_override or project_configs is None: - console.print(f" - [bold]{display_name}[/bold] — {install_status}") - else: - console.print(f" [{i}] [bold]{display_name}[/bold] — {install_status}") - console.print(f" {cfg.get('url', '')}") - if cfg.get("description"): - console.print(f" [dim]{cfg['description']}[/dim]") - console.print() - - -@integration_catalog_app.command("add") -def integration_catalog_add( - url: str = typer.Argument( - ..., - help=( - "Catalog URL to add (HTTPS required, except http://localhost, " - "http://127.0.0.1, or http://[::1] for local testing)" - ), - ), - name: Optional[str] = typer.Option(None, "--name", help="Catalog name"), -): - """Add an integration catalog source to the project config.""" - from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError - - project_root = _require_specify_project() - catalog = IntegrationCatalog(project_root) - - # Normalize once here so the success message reflects what was actually - # stored. ``IntegrationCatalog.add_catalog`` strips again defensively. - normalized_url = url.strip() - - try: - catalog.add_catalog(normalized_url, name) - except IntegrationCatalogError as exc: - # Covers both URL validation (base class) and config-file validation - # (IntegrationValidationError subclass). - console.print(f"[red]Error:[/red] {exc}") - raise typer.Exit(1) - - console.print(f"[green]✓[/green] Catalog source added: {normalized_url}") - - -@integration_catalog_app.command("remove") -def integration_catalog_remove( - index: int = typer.Argument(..., help="Catalog index to remove (from 'catalog list')"), -): - """Remove an integration catalog source by 0-based index.""" - from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError - - project_root = _require_specify_project() - catalog = IntegrationCatalog(project_root) - - try: - removed_name = catalog.remove_catalog(index) - except IntegrationCatalogError as exc: - console.print(f"[red]Error:[/red] {exc}") - raise typer.Exit(1) - - console.print(f"[green]✓[/green] Catalog source '{removed_name}' removed") - # ===== Preset Commands ===== diff --git a/src/specify_cli/commands/integration.py b/src/specify_cli/commands/integration.py index c8f7907946..a7ffbec431 100644 --- a/src/specify_cli/commands/integration.py +++ b/src/specify_cli/commands/integration.py @@ -1 +1,621 @@ """specify integration * commands.""" +import os +import json +from pathlib import Path +from typing import Any, Optional + +import typer +from rich.table import Table + +from .._console import console +from .._fs import save_init_options, load_init_options +from .._helpers import ( + run_command, check_tool, + _install_shared_infra, ensure_executable_scripts, + get_speckit_version, _parse_integration_options, + AGENT_CONFIG, SCRIPT_TYPE_CHOICES, +) + +integration_app = typer.Typer( + name="integration", + help="Manage coding agent integrations", + add_completion=False, +) + +INTEGRATION_JSON = ".specify/integration.json" + + +def _read_integration_json(project_root: Path) -> dict[str, Any]: + """Load ``.specify/integration.json``. Returns ``{}`` when missing.""" + path = project_root / INTEGRATION_JSON + if not path.exists(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + console.print(f"[red]Error:[/red] {path} contains invalid JSON.") + console.print(f"Please fix or delete {INTEGRATION_JSON} and retry.") + console.print(f"[dim]Details:[/dim] {exc}") + raise typer.Exit(1) + except OSError as exc: + console.print(f"[red]Error:[/red] Could not read {path}.") + console.print(f"Please fix file permissions or delete {INTEGRATION_JSON} and retry.") + console.print(f"[dim]Details:[/dim] {exc}") + raise typer.Exit(1) + if not isinstance(data, dict): + console.print(f"[red]Error:[/red] {path} must contain a JSON object, got {type(data).__name__}.") + console.print(f"Please fix or delete {INTEGRATION_JSON} and retry.") + raise typer.Exit(1) + return data + + +def _write_integration_json( + project_root: Path, + integration_key: str, +) -> None: + """Write ``.specify/integration.json`` for *integration_key*.""" + dest = project_root / INTEGRATION_JSON + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(json.dumps({ + "integration": integration_key, + "version": get_speckit_version(), + }, indent=2) + "\n", encoding="utf-8") + + +def _remove_integration_json(project_root: Path) -> None: + """Remove ``.specify/integration.json`` if it exists.""" + path = project_root / INTEGRATION_JSON + if path.exists(): + path.unlink() + + +def _normalize_script_type(script_type: str, source: str) -> str: + """Normalize and validate a script type from CLI/config sources.""" + normalized = script_type.strip().lower() + if normalized in SCRIPT_TYPE_CHOICES: + return normalized + console.print( + f"[red]Error:[/red] Invalid script type {script_type!r} from {source}. " + f"Expected one of: {', '.join(sorted(SCRIPT_TYPE_CHOICES.keys()))}." + ) + raise typer.Exit(1) + + +def _resolve_script_type(project_root: Path, script_type: str | None) -> str: + """Resolve the script type from the CLI flag or init-options.json.""" + if script_type: + return _normalize_script_type(script_type, "--script") + opts = load_init_options(project_root) + saved = opts.get("script") + if isinstance(saved, str) and saved.strip(): + return _normalize_script_type(saved, ".specify/init-options.json") + return "ps" if os.name == "nt" else "sh" + + +def _update_init_options_for_integration( + project_root: Path, + integration: Any, + script_type: str | None = None, +) -> None: + """Update ``init-options.json`` to reflect *integration* as the active one.""" + from ..integrations.base import SkillsIntegration + opts = load_init_options(project_root) + opts["integration"] = integration.key + opts["ai"] = integration.key + opts["context_file"] = integration.context_file + if script_type: + opts["script"] = script_type + if isinstance(integration, SkillsIntegration) or getattr(integration, "_skills_mode", False): + opts["ai_skills"] = True + else: + opts.pop("ai_skills", None) + save_init_options(project_root, opts) + + +@integration_app.command("list") +def integration_list( + catalog: bool = typer.Option(False, "--catalog", help="Browse full catalog (built-in + community)"), +): + """List available integrations and installed status.""" + from ..integrations import INTEGRATION_REGISTRY + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + current = _read_integration_json(project_root) + installed_key = current.get("integration") + + if catalog: + from ..integrations.catalog import IntegrationCatalog, IntegrationCatalogError + + ic = IntegrationCatalog(project_root) + try: + entries = ic.search() + except IntegrationCatalogError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + if not entries: + console.print("[yellow]No integrations found in catalog.[/yellow]") + return + + table = Table(title="Integration Catalog") + table.add_column("ID", style="cyan") + table.add_column("Name") + table.add_column("Version") + table.add_column("Source") + table.add_column("Status") + + for entry in sorted(entries, key=lambda e: e["id"]): + eid = entry["id"] + cat_name = entry.get("_catalog_name", "") + install_allowed = entry.get("_install_allowed", True) + if eid == installed_key: + status = "[green]installed[/green]" + elif eid in INTEGRATION_REGISTRY: + status = "built-in" + elif install_allowed is False: + status = "discovery-only" + else: + status = "" + table.add_row( + eid, + entry.get("name", eid), + entry.get("version", ""), + cat_name, + status, + ) + + console.print(table) + return + + table = Table(title="Coding Agent Integrations") + table.add_column("Key", style="cyan") + table.add_column("Name") + table.add_column("Status") + table.add_column("CLI Required") + + for key in sorted(INTEGRATION_REGISTRY.keys()): + integration = INTEGRATION_REGISTRY[key] + cfg = integration.config or {} + name = cfg.get("name", key) + requires_cli = cfg.get("requires_cli", False) + + if key == installed_key: + status = "[green]installed[/green]" + else: + status = "" + + cli_req = "yes" if requires_cli else "no (IDE)" + table.add_row(key, name, status, cli_req) + + console.print(table) + + if installed_key: + console.print(f"\n[dim]Current integration:[/dim] [cyan]{installed_key}[/cyan]") + else: + console.print("\n[yellow]No integration currently installed.[/yellow]") + console.print("Install one with: [cyan]specify integration install [/cyan]") + + +@integration_app.command("install") +def integration_install( + key: str = typer.Argument(help="Integration key to install (e.g. claude, copilot)"), + script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"), + integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'), +): + """Install an integration into an existing project.""" + from ..integrations import INTEGRATION_REGISTRY, get_integration + from ..integrations.manifest import IntegrationManifest + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + integration = get_integration(key) + if integration is None: + console.print(f"[red]Error:[/red] Unknown integration '{key}'") + available = ", ".join(sorted(INTEGRATION_REGISTRY.keys())) + console.print(f"Available integrations: {available}") + raise typer.Exit(1) + + current = _read_integration_json(project_root) + installed_key = current.get("integration") + + if installed_key and installed_key == key: + console.print(f"[yellow]Integration '{key}' is already installed.[/yellow]") + console.print("Run [cyan]specify integration uninstall[/cyan] first, then reinstall.") + raise typer.Exit(0) + + if installed_key: + console.print(f"[red]Error:[/red] Integration '{installed_key}' is already installed.") + console.print(f"Run [cyan]specify integration uninstall[/cyan] first, or use [cyan]specify integration switch {key}[/cyan].") + raise typer.Exit(1) + + selected_script = _resolve_script_type(project_root, script) + + # Build parsed options from --integration-options so the integration + # can determine its effective invoke separator before shared infra + # is installed. + parsed_options: dict[str, Any] | None = None + if integration_options: + parsed_options = _parse_integration_options(integration, integration_options) + + # Ensure shared infrastructure is present (safe to run unconditionally; + # _install_shared_infra merges missing files without overwriting). + _install_shared_infra(project_root, selected_script, invoke_separator=integration.effective_invoke_separator(parsed_options)) + if os.name != "nt": + ensure_executable_scripts(project_root) + + manifest = IntegrationManifest( + integration.key, project_root, version=get_speckit_version() + ) + + try: + integration.setup( + project_root, manifest, + parsed_options=parsed_options, + script_type=selected_script, + raw_options=integration_options, + ) + manifest.save() + _write_integration_json(project_root, integration.key) + _update_init_options_for_integration(project_root, integration, script_type=selected_script) + + except Exception as e: + # Attempt rollback of any files written by setup + try: + integration.teardown(project_root, manifest, force=True) + except Exception as rollback_err: + # Suppress so the original setup error remains the primary failure + console.print(f"[yellow]Warning:[/yellow] Failed to roll back integration changes: {rollback_err}") + _remove_integration_json(project_root) + console.print(f"[red]Error:[/red] Failed to install integration: {e}") + raise typer.Exit(1) + + name = (integration.config or {}).get("name", key) + console.print(f"\n[green]✓[/green] Integration '{name}' installed successfully") + + +@integration_app.command("uninstall") +def integration_uninstall( + key: str = typer.Argument(None, help="Integration key to uninstall (default: current integration)"), + force: bool = typer.Option(False, "--force", help="Remove files even if modified"), +): + """Uninstall an integration, safely preserving modified files.""" + from ..integrations import get_integration + from ..integrations.manifest import IntegrationManifest + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + current = _read_integration_json(project_root) + installed_key = current.get("integration") + + if key is None: + if not installed_key: + console.print("[yellow]No integration is currently installed.[/yellow]") + raise typer.Exit(0) + key = installed_key + + if installed_key and installed_key != key: + console.print(f"[red]Error:[/red] Integration '{key}' is not the currently installed integration ('{installed_key}').") + raise typer.Exit(1) + + integration = get_integration(key) + + manifest_path = project_root / ".specify" / "integrations" / f"{key}.manifest.json" + if not manifest_path.exists(): + console.print(f"[yellow]No manifest found for integration '{key}'. Nothing to uninstall.[/yellow]") + _remove_integration_json(project_root) + # Clear integration-related keys from init-options.json + opts = load_init_options(project_root) + if opts.get("integration") == key or opts.get("ai") == key: + opts.pop("integration", None) + opts.pop("ai", None) + opts.pop("ai_skills", None) + opts.pop("context_file", None) + save_init_options(project_root, opts) + raise typer.Exit(0) + + try: + manifest = IntegrationManifest.load(key, project_root) + except (ValueError, FileNotFoundError) as exc: + console.print(f"[red]Error:[/red] Integration manifest for '{key}' is unreadable.") + console.print(f"Manifest: {manifest_path}") + console.print( + f"To recover, delete the unreadable manifest, run " + f"[cyan]specify integration uninstall {key}[/cyan] to clear stale metadata, " + f"then run [cyan]specify integration install {key}[/cyan] to regenerate." + ) + console.print(f"[dim]Details:[/dim] {exc}") + raise typer.Exit(1) + + removed, skipped = manifest.uninstall(project_root, force=force) + + # Remove managed context section from the agent context file + if integration: + integration.remove_context_section(project_root) + + _remove_integration_json(project_root) + + # Update init-options.json to clear the integration + opts = load_init_options(project_root) + if opts.get("integration") == key or opts.get("ai") == key: + opts.pop("integration", None) + opts.pop("ai", None) + opts.pop("ai_skills", None) + opts.pop("context_file", None) + save_init_options(project_root, opts) + + name = (integration.config or {}).get("name", key) if integration else key + console.print(f"\n[green]✓[/green] Integration '{name}' uninstalled") + if removed: + console.print(f" Removed {len(removed)} file(s)") + if skipped: + console.print(f"\n[yellow]⚠[/yellow] {len(skipped)} modified file(s) were preserved:") + for path in skipped: + rel = path.relative_to(project_root) if path.is_absolute() else path + console.print(f" {rel}") + + +@integration_app.command("switch") +def integration_switch( + target: str = typer.Argument(help="Integration key to switch to"), + script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"), + force: bool = typer.Option(False, "--force", help="Force removal of modified files during uninstall"), + integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the target integration'), +): + """Switch from the current integration to a different one.""" + from ..integrations import INTEGRATION_REGISTRY, get_integration + from ..integrations.manifest import IntegrationManifest + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + target_integration = get_integration(target) + if target_integration is None: + console.print(f"[red]Error:[/red] Unknown integration '{target}'") + available = ", ".join(sorted(INTEGRATION_REGISTRY.keys())) + console.print(f"Available integrations: {available}") + raise typer.Exit(1) + + current = _read_integration_json(project_root) + installed_key = current.get("integration") + + if installed_key == target: + console.print(f"[yellow]Integration '{target}' is already installed. Nothing to switch.[/yellow]") + raise typer.Exit(0) + + selected_script = _resolve_script_type(project_root, script) + + # Phase 1: Uninstall current integration (if any) + if installed_key: + current_integration = get_integration(installed_key) + manifest_path = project_root / ".specify" / "integrations" / f"{installed_key}.manifest.json" + + if current_integration and manifest_path.exists(): + console.print(f"Uninstalling current integration: [cyan]{installed_key}[/cyan]") + try: + old_manifest = IntegrationManifest.load(installed_key, project_root) + except (ValueError, FileNotFoundError) as exc: + console.print(f"[red]Error:[/red] Could not read integration manifest for '{installed_key}': {manifest_path}") + console.print(f"[dim]{exc}[/dim]") + console.print( + f"To recover, delete the unreadable manifest at {manifest_path}, " + f"run [cyan]specify integration uninstall {installed_key}[/cyan], then retry." + ) + raise typer.Exit(1) + removed, skipped = old_manifest.uninstall(project_root, force=force) + current_integration.remove_context_section(project_root) + if removed: + console.print(f" Removed {len(removed)} file(s)") + if skipped: + console.print(f" [yellow]⚠[/yellow] {len(skipped)} modified file(s) preserved") + elif not current_integration and manifest_path.exists(): + # Integration removed from registry but manifest exists — use manifest-only uninstall + console.print(f"Uninstalling unknown integration '{installed_key}' via manifest") + try: + old_manifest = IntegrationManifest.load(installed_key, project_root) + removed, skipped = old_manifest.uninstall(project_root, force=force) + if removed: + console.print(f" Removed {len(removed)} file(s)") + if skipped: + console.print(f" [yellow]⚠[/yellow] {len(skipped)} modified file(s) preserved") + except (ValueError, FileNotFoundError) as exc: + console.print(f"[yellow]Warning:[/yellow] Could not read manifest for '{installed_key}': {exc}") + else: + console.print(f"[red]Error:[/red] Integration '{installed_key}' is installed but has no manifest.") + console.print( + f"Run [cyan]specify integration uninstall {installed_key}[/cyan] to clear metadata, " + f"then retry [cyan]specify integration switch {target}[/cyan]." + ) + raise typer.Exit(1) + + # Clear metadata so a failed Phase 2 doesn't leave stale references + _remove_integration_json(project_root) + opts = load_init_options(project_root) + opts.pop("integration", None) + opts.pop("ai", None) + opts.pop("ai_skills", None) + opts.pop("context_file", None) + save_init_options(project_root, opts) + + # Build parsed options from --integration-options so the integration + # can determine its effective invoke separator before shared infra + # is installed. + parsed_options: dict[str, Any] | None = None + if integration_options: + parsed_options = _parse_integration_options(target_integration, integration_options) + + # Ensure shared infrastructure is present (safe to run unconditionally; + # _install_shared_infra merges missing files without overwriting). + _install_shared_infra(project_root, selected_script, invoke_separator=target_integration.effective_invoke_separator(parsed_options)) + if os.name != "nt": + ensure_executable_scripts(project_root) + + # Phase 2: Install target integration + console.print(f"Installing integration: [cyan]{target}[/cyan]") + manifest = IntegrationManifest( + target_integration.key, project_root, version=get_speckit_version() + ) + + try: + target_integration.setup( + project_root, manifest, + parsed_options=parsed_options, + script_type=selected_script, + raw_options=integration_options, + ) + manifest.save() + _write_integration_json(project_root, target_integration.key) + _update_init_options_for_integration(project_root, target_integration, script_type=selected_script) + + except Exception as e: + # Attempt rollback of any files written by setup + try: + target_integration.teardown(project_root, manifest, force=True) + except Exception as rollback_err: + # Suppress so the original setup error remains the primary failure + console.print(f"[yellow]Warning:[/yellow] Failed to roll back integration '{target}': {rollback_err}") + _remove_integration_json(project_root) + console.print(f"[red]Error:[/red] Failed to install integration '{target}': {e}") + raise typer.Exit(1) + + name = (target_integration.config or {}).get("name", target) + console.print(f"\n[green]✓[/green] Switched to integration '{name}'") + + +@integration_app.command("upgrade") +def integration_upgrade( + key: str | None = typer.Argument(None, help="Integration key to upgrade (default: current integration)"), + force: bool = typer.Option(False, "--force", help="Force upgrade even if files are modified"), + script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"), + integration_options: str | None = typer.Option(None, "--integration-options", help="Options for the integration"), +): + """Upgrade an integration by reinstalling with diff-aware file handling. + + Compares manifest hashes to detect locally modified files and + blocks the upgrade unless --force is used. + """ + from ..integrations import get_integration + from ..integrations.manifest import IntegrationManifest + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + current = _read_integration_json(project_root) + installed_key = current.get("integration") + + if key is None: + if not installed_key: + console.print("[yellow]No integration is currently installed.[/yellow]") + raise typer.Exit(0) + key = installed_key + + if installed_key and installed_key != key: + console.print( + f"[red]Error:[/red] Integration '{key}' is not the currently installed integration ('{installed_key}')." + ) + console.print(f"Use [cyan]specify integration switch {key}[/cyan] instead.") + raise typer.Exit(1) + + integration = get_integration(key) + if integration is None: + console.print(f"[red]Error:[/red] Unknown integration '{key}'") + raise typer.Exit(1) + + manifest_path = project_root / ".specify" / "integrations" / f"{key}.manifest.json" + if not manifest_path.exists(): + console.print(f"[yellow]No manifest found for integration '{key}'. Nothing to upgrade.[/yellow]") + console.print(f"Run [cyan]specify integration install {key}[/cyan] to perform a fresh install.") + raise typer.Exit(0) + + try: + old_manifest = IntegrationManifest.load(key, project_root) + except (ValueError, FileNotFoundError) as exc: + console.print(f"[red]Error:[/red] Integration manifest for '{key}' is unreadable: {exc}") + raise typer.Exit(1) + + # Detect modified files via manifest hashes + modified = old_manifest.check_modified() + if modified and not force: + console.print(f"[yellow]⚠[/yellow] {len(modified)} file(s) have been modified since installation:") + for rel in modified: + console.print(f" {rel}") + console.print("\nUse [cyan]--force[/cyan] to overwrite modified files, or resolve manually.") + raise typer.Exit(1) + + selected_script = _resolve_script_type(project_root, script) + + # Build parsed options from --integration-options so the integration + # can determine its effective invoke separator before shared infra + # is installed. + parsed_options: dict[str, Any] | None = None + if integration_options: + parsed_options = _parse_integration_options(integration, integration_options) + + # Ensure shared infrastructure is up to date; --force overwrites existing files. + _install_shared_infra(project_root, selected_script, force=force, invoke_separator=integration.effective_invoke_separator(parsed_options)) + if os.name != "nt": + ensure_executable_scripts(project_root) + + # Phase 1: Install new files (overwrites existing; old-only files remain) + console.print(f"Upgrading integration: [cyan]{key}[/cyan]") + new_manifest = IntegrationManifest(key, project_root, version=get_speckit_version()) + + try: + integration.setup( + project_root, + new_manifest, + parsed_options=parsed_options, + script_type=selected_script, + raw_options=integration_options, + ) + new_manifest.save() + _write_integration_json(project_root, key) + _update_init_options_for_integration(project_root, integration, script_type=selected_script) + except Exception as exc: + # Don't teardown — setup overwrites in-place, so teardown would + # delete files that were working before the upgrade. Just report. + console.print(f"[red]Error:[/red] Failed to upgrade integration: {exc}") + console.print("[yellow]The previous integration files may still be in place.[/yellow]") + raise typer.Exit(1) + + # Phase 2: Remove stale files from old manifest that are not in the new one + old_files = old_manifest.files + new_files = new_manifest.files + stale_keys = set(old_files) - set(new_files) + if stale_keys: + stale_manifest = IntegrationManifest(key, project_root, version="stale-cleanup") + stale_manifest._files = {k: old_files[k] for k in stale_keys} + stale_removed, _ = stale_manifest.uninstall(project_root, force=True) + if stale_removed: + console.print(f" Removed {len(stale_removed)} stale file(s) from previous install") + + name = (integration.config or {}).get("name", key) + console.print(f"\n[green]✓[/green] Integration '{name}' upgraded successfully") From 9f870ff200ba4de4112bf2241c9a5ea778724b00 Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Sat, 25 Apr 2026 21:11:33 +0800 Subject: [PATCH 17/31] refactor: fix dead imports and naming in commands/integration.py --- src/specify_cli/commands/integration.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/specify_cli/commands/integration.py b/src/specify_cli/commands/integration.py index a7ffbec431..d928ece80d 100644 --- a/src/specify_cli/commands/integration.py +++ b/src/specify_cli/commands/integration.py @@ -2,7 +2,7 @@ import os import json from pathlib import Path -from typing import Any, Optional +from typing import Any import typer from rich.table import Table @@ -13,7 +13,7 @@ run_command, check_tool, _install_shared_infra, ensure_executable_scripts, get_speckit_version, _parse_integration_options, - AGENT_CONFIG, SCRIPT_TYPE_CHOICES, + SCRIPT_TYPE_CHOICES, ) integration_app = typer.Typer( @@ -271,7 +271,7 @@ def integration_install( _write_integration_json(project_root, integration.key) _update_init_options_for_integration(project_root, integration, script_type=selected_script) - except Exception as e: + except Exception as exc: # Attempt rollback of any files written by setup try: integration.teardown(project_root, manifest, force=True) @@ -279,7 +279,7 @@ def integration_install( # Suppress so the original setup error remains the primary failure console.print(f"[yellow]Warning:[/yellow] Failed to roll back integration changes: {rollback_err}") _remove_integration_json(project_root) - console.print(f"[red]Error:[/red] Failed to install integration: {e}") + console.print(f"[red]Error:[/red] Failed to install integration: {exc}") raise typer.Exit(1) name = (integration.config or {}).get("name", key) @@ -490,7 +490,7 @@ def integration_switch( _write_integration_json(project_root, target_integration.key) _update_init_options_for_integration(project_root, target_integration, script_type=selected_script) - except Exception as e: + except Exception as exc: # Attempt rollback of any files written by setup try: target_integration.teardown(project_root, manifest, force=True) @@ -498,7 +498,7 @@ def integration_switch( # Suppress so the original setup error remains the primary failure console.print(f"[yellow]Warning:[/yellow] Failed to roll back integration '{target}': {rollback_err}") _remove_integration_json(project_root) - console.print(f"[red]Error:[/red] Failed to install integration '{target}': {e}") + console.print(f"[red]Error:[/red] Failed to install integration '{target}': {exc}") raise typer.Exit(1) name = (target_integration.config or {}).get("name", target) From c0bf6c5db730de7c3e0f3de2e6ec63292bd91798 Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Sat, 25 Apr 2026 21:19:41 +0800 Subject: [PATCH 18/31] refactor: move preset command handlers to commands/preset.py Extracts all preset_app and preset_catalog_app command handlers out of the monolithic __init__.py into src/specify_cli/commands/preset.py. Updates test patch target for _locate_bundled_preset to the new module path. --- src/specify_cli/__init__.py | 625 +------------------------ src/specify_cli/commands/preset.py | 719 +++++++++++++++++++++++++++++ tests/test_presets.py | 2 +- 3 files changed, 722 insertions(+), 624 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 3636befedf..accf2204bc 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -340,23 +340,6 @@ def self_upgrade() -> None: ) extension_app.add_typer(catalog_app, name="catalog") -preset_app = typer.Typer( - name="preset", - help="Manage spec-kit presets", - add_completion=False, -) -app.add_typer(preset_app, name="preset") - -preset_catalog_app = typer.Typer( - name="catalog", - help="Manage preset catalogs", - add_completion=False, -) -preset_app.add_typer(preset_catalog_app, name="catalog") - - - - # ===== Integration Commands ===== from .commands.integration import ( @@ -372,612 +355,8 @@ def self_upgrade() -> None: # ===== Preset Commands ===== - -@preset_app.command("list") -def preset_list(): - """List installed presets.""" - from .presets import PresetManager - - project_root = _require_specify_project() - manager = PresetManager(project_root) - installed = manager.list_installed() - - if not installed: - console.print("[yellow]No presets installed.[/yellow]") - console.print("\nInstall a preset with:") - console.print(" [cyan]specify preset add [/cyan]") - return - - console.print("\n[bold cyan]Installed Presets:[/bold cyan]\n") - for pack in installed: - status = "[green]enabled[/green]" if pack.get("enabled", True) else "[red]disabled[/red]" - pri = pack.get('priority', 10) - console.print(f" [bold]{pack['name']}[/bold] ({pack['id']}) v{pack['version']} — {status} — priority {pri}") - console.print(f" {pack['description']}") - if pack.get("tags"): - tags_str = ", ".join(pack["tags"]) - console.print(f" [dim]Tags: {tags_str}[/dim]") - console.print(f" [dim]Templates: {pack['template_count']}[/dim]") - console.print() - - -@preset_app.command("add") -def preset_add( - preset_id: str = typer.Argument(None, help="Preset ID to install from catalog"), - from_url: str = typer.Option(None, "--from", help="Install from a URL (ZIP file)"), - dev: str = typer.Option(None, "--dev", help="Install from local directory (development mode)"), - priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"), -): - """Install a preset.""" - from .presets import ( - PresetManager, - PresetCatalog, - PresetError, - PresetValidationError, - PresetCompatibilityError, - ) - - project_root = _require_specify_project() - # Validate priority - if priority < 1: - console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)") - raise typer.Exit(1) - - manager = PresetManager(project_root) - speckit_version = get_speckit_version() - - try: - if dev: - dev_path = Path(dev).resolve() - if not dev_path.exists(): - console.print(f"[red]Error:[/red] Directory not found: {dev}") - raise typer.Exit(1) - - console.print(f"Installing preset from [cyan]{dev_path}[/cyan]...") - manifest = manager.install_from_directory(dev_path, speckit_version, priority) - console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") - - elif from_url: - # Validate URL scheme before downloading - from urllib.parse import urlparse as _urlparse - _parsed = _urlparse(from_url) - _is_localhost = _parsed.hostname in ("localhost", "127.0.0.1", "::1") - if _parsed.scheme != "https" and not (_parsed.scheme == "http" and _is_localhost): - console.print(f"[red]Error:[/red] URL must use HTTPS (got {_parsed.scheme}://). HTTP is only allowed for localhost.") - raise typer.Exit(1) - - console.print(f"Installing preset from [cyan]{from_url}[/cyan]...") - import urllib.request - import urllib.error - import tempfile - - with tempfile.TemporaryDirectory() as tmpdir: - zip_path = Path(tmpdir) / "preset.zip" - try: - with urllib.request.urlopen(from_url, timeout=60) as response: - zip_path.write_bytes(response.read()) - except urllib.error.URLError as e: - console.print(f"[red]Error:[/red] Failed to download: {e}") - raise typer.Exit(1) - - manifest = manager.install_from_zip(zip_path, speckit_version, priority) - - console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") - - elif preset_id: - # Try bundled preset first, then catalog - bundled_path = _locate_bundled_preset(preset_id) - if bundled_path: - console.print(f"Installing bundled preset [cyan]{preset_id}[/cyan]...") - manifest = manager.install_from_directory(bundled_path, speckit_version, priority) - console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") - else: - catalog = PresetCatalog(project_root) - pack_info = catalog.get_pack_info(preset_id) - - if not pack_info: - console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in catalog") - raise typer.Exit(1) - - # Bundled presets should have been caught above; if we reach - # here the bundled files are missing from the installation. - if pack_info.get("bundled") and not pack_info.get("download_url"): - from .extensions import REINSTALL_COMMAND - console.print( - f"[red]Error:[/red] Preset '{preset_id}' is bundled with spec-kit " - f"but could not be found in the installed package." - ) - console.print( - "\nThis usually means the spec-kit installation is incomplete or corrupted." - ) - console.print("Try reinstalling spec-kit:") - console.print(f" {REINSTALL_COMMAND}") - raise typer.Exit(1) - - if not pack_info.get("_install_allowed", True): - catalog_name = pack_info.get("_catalog_name", "unknown") - console.print(f"[red]Error:[/red] Preset '{preset_id}' is from the '{catalog_name}' catalog which is discovery-only (install not allowed).") - console.print("Add the catalog with --install-allowed or install from the preset's repository directly with --from.") - raise typer.Exit(1) - - console.print(f"Installing preset [cyan]{pack_info.get('name', preset_id)}[/cyan]...") - - try: - zip_path = catalog.download_pack(preset_id) - manifest = manager.install_from_zip(zip_path, speckit_version, priority) - console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") - finally: - if 'zip_path' in locals() and zip_path.exists(): - zip_path.unlink(missing_ok=True) - else: - console.print("[red]Error:[/red] Specify a preset ID, --from URL, or --dev path") - raise typer.Exit(1) - - except PresetCompatibilityError as e: - console.print(f"[red]Compatibility Error:[/red] {e}") - raise typer.Exit(1) - except PresetValidationError as e: - console.print(f"[red]Validation Error:[/red] {e}") - raise typer.Exit(1) - except PresetError as e: - console.print(f"[red]Error:[/red] {e}") - raise typer.Exit(1) - - -@preset_app.command("remove") -def preset_remove( - preset_id: str = typer.Argument(..., help="Preset ID to remove"), -): - """Remove an installed preset.""" - from .presets import PresetManager - - project_root = _require_specify_project() - manager = PresetManager(project_root) - - if not manager.registry.is_installed(preset_id): - console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") - raise typer.Exit(1) - - if manager.remove(preset_id): - console.print(f"[green]✓[/green] Preset '{preset_id}' removed successfully") - else: - console.print(f"[red]Error:[/red] Failed to remove preset '{preset_id}'") - raise typer.Exit(1) - - -@preset_app.command("search") -def preset_search( - query: str = typer.Argument(None, help="Search query"), - tag: str = typer.Option(None, "--tag", help="Filter by tag"), - author: str = typer.Option(None, "--author", help="Filter by author"), -): - """Search for presets in the catalog.""" - from .presets import PresetCatalog, PresetError - - project_root = _require_specify_project() - catalog = PresetCatalog(project_root) - - try: - results = catalog.search(query=query, tag=tag, author=author) - except PresetError as e: - console.print(f"[red]Error:[/red] {e}") - raise typer.Exit(1) - - if not results: - console.print("[yellow]No presets found matching your criteria.[/yellow]") - return - - console.print(f"\n[bold cyan]Presets ({len(results)} found):[/bold cyan]\n") - for pack in results: - console.print(f" [bold]{pack.get('name', pack['id'])}[/bold] ({pack['id']}) v{pack.get('version', '?')}") - console.print(f" {pack.get('description', '')}") - if pack.get("tags"): - tags_str = ", ".join(pack["tags"]) - console.print(f" [dim]Tags: {tags_str}[/dim]") - console.print() - - -@preset_app.command("resolve") -def preset_resolve( - template_name: str = typer.Argument(..., help="Template name to resolve (e.g., spec-template)"), -): - """Show which template will be resolved for a given name.""" - from .presets import PresetResolver - - project_root = _require_specify_project() - resolver = PresetResolver(project_root) - layers = resolver.collect_all_layers(template_name) - - if layers: - # Use the highest-priority layer for display because the final output - # may be composed and may not map to resolve_with_source()'s single path. - display_layer = layers[0] - console.print(f" [bold]{template_name}[/bold]: {display_layer['path']}") - console.print(f" [dim](top layer from: {display_layer['source']})[/dim]") - - has_composition = ( - layers[0]["strategy"] != "replace" - and any(layer["strategy"] != "replace" for layer in layers) - ) - if has_composition: - # Verify composition is actually possible - try: - composed = resolver.resolve_content(template_name) - except Exception as exc: - composed = None - console.print(f" [yellow]Warning: composition error: {exc}[/yellow]") - if composed is None: - console.print(" [yellow]Warning: composition cannot produce output (no base layer with 'replace' strategy)[/yellow]") - else: - console.print(" [dim]Final output is composed from multiple preset layers; the path above is the highest-priority contributing layer.[/dim]") - console.print("\n [bold]Composition chain:[/bold]") - # Compute the effective base: first replace layer scanning from - # highest priority (matching resolve_content top-down logic). - # Only show layers from the base upward (lower layers are ignored). - effective_base_idx = None - for idx, lyr in enumerate(layers): - if lyr["strategy"] == "replace": - effective_base_idx = idx - break - # Show only contributing layers (base and above) - if effective_base_idx is not None: - contributing = layers[:effective_base_idx + 1] - else: - contributing = layers - for i, layer in enumerate(reversed(contributing)): - strategy_label = layer["strategy"] - if strategy_label == "replace" and i == 0: - strategy_label = "base" - console.print(f" {i + 1}. [{strategy_label}] {layer['source']} → {layer['path']}") - else: - # No layers found — fall back to resolve_with_source for non-composition cases - result = resolver.resolve_with_source(template_name) - if result: - console.print(f" [bold]{template_name}[/bold]: {result['path']}") - console.print(f" [dim](from: {result['source']})[/dim]") - else: - console.print(f" [yellow]{template_name}[/yellow]: not found") - console.print(" [dim]No template with this name exists in the resolution stack[/dim]") - - -@preset_app.command("info") -def preset_info( - preset_id: str = typer.Argument(..., help="Preset ID to get info about"), -): - """Show detailed information about a preset.""" - from .extensions import normalize_priority - from .presets import PresetCatalog, PresetManager, PresetError - - project_root = _require_specify_project() - # Check if installed locally first - manager = PresetManager(project_root) - local_pack = manager.get_pack(preset_id) - - if local_pack: - console.print(f"\n[bold cyan]Preset: {local_pack.name}[/bold cyan]\n") - console.print(f" ID: {local_pack.id}") - console.print(f" Version: {local_pack.version}") - console.print(f" Description: {local_pack.description}") - if local_pack.author: - console.print(f" Author: {local_pack.author}") - if local_pack.tags: - console.print(f" Tags: {', '.join(local_pack.tags)}") - console.print(f" Templates: {len(local_pack.templates)}") - for tmpl in local_pack.templates: - console.print(f" - {tmpl['name']} ({tmpl['type']}): {tmpl.get('description', '')}") - repo = local_pack.data.get("preset", {}).get("repository") - if repo: - console.print(f" Repository: {repo}") - license_val = local_pack.data.get("preset", {}).get("license") - if license_val: - console.print(f" License: {license_val}") - console.print("\n [green]Status: installed[/green]") - # Get priority from registry - pack_metadata = manager.registry.get(preset_id) - priority = normalize_priority(pack_metadata.get("priority") if isinstance(pack_metadata, dict) else None) - console.print(f" [dim]Priority:[/dim] {priority}") - console.print() - return - - # Fall back to catalog - catalog = PresetCatalog(project_root) - try: - pack_info = catalog.get_pack_info(preset_id) - except PresetError: - pack_info = None - - if not pack_info: - console.print(f"[red]Error:[/red] Preset '{preset_id}' not found (not installed and not in catalog)") - raise typer.Exit(1) - - console.print(f"\n[bold cyan]Preset: {pack_info.get('name', preset_id)}[/bold cyan]\n") - console.print(f" ID: {pack_info['id']}") - console.print(f" Version: {pack_info.get('version', '?')}") - console.print(f" Description: {pack_info.get('description', '')}") - if pack_info.get("author"): - console.print(f" Author: {pack_info['author']}") - if pack_info.get("tags"): - console.print(f" Tags: {', '.join(pack_info['tags'])}") - if pack_info.get("repository"): - console.print(f" Repository: {pack_info['repository']}") - if pack_info.get("license"): - console.print(f" License: {pack_info['license']}") - console.print("\n [yellow]Status: not installed[/yellow]") - console.print(f" Install with: [cyan]specify preset add {preset_id}[/cyan]") - console.print() - - -@preset_app.command("set-priority") -def preset_set_priority( - preset_id: str = typer.Argument(help="Preset ID"), - priority: int = typer.Argument(help="New priority (lower = higher precedence)"), -): - """Set the resolution priority of an installed preset.""" - from .presets import PresetManager - - project_root = _require_specify_project() - # Validate priority - if priority < 1: - console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)") - raise typer.Exit(1) - - manager = PresetManager(project_root) - - # Check if preset is installed - if not manager.registry.is_installed(preset_id): - console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") - raise typer.Exit(1) - - # Get current metadata - metadata = manager.registry.get(preset_id) - if metadata is None or not isinstance(metadata, dict): - console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)") - raise typer.Exit(1) - - from .extensions import normalize_priority - raw_priority = metadata.get("priority") - # Only skip if the stored value is already a valid int equal to requested priority - # This ensures corrupted values (e.g., "high") get repaired even when setting to default (10) - if isinstance(raw_priority, int) and raw_priority == priority: - console.print(f"[yellow]Preset '{preset_id}' already has priority {priority}[/yellow]") - raise typer.Exit(0) - - old_priority = normalize_priority(raw_priority) - - # Update priority - manager.registry.update(preset_id, {"priority": priority}) - - console.print(f"[green]✓[/green] Preset '{preset_id}' priority changed: {old_priority} → {priority}") - console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]") - - -@preset_app.command("enable") -def preset_enable( - preset_id: str = typer.Argument(help="Preset ID to enable"), -): - """Enable a disabled preset.""" - from .presets import PresetManager - - project_root = _require_specify_project() - manager = PresetManager(project_root) - - # Check if preset is installed - if not manager.registry.is_installed(preset_id): - console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") - raise typer.Exit(1) - - # Get current metadata - metadata = manager.registry.get(preset_id) - if metadata is None or not isinstance(metadata, dict): - console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)") - raise typer.Exit(1) - - if metadata.get("enabled", True): - console.print(f"[yellow]Preset '{preset_id}' is already enabled[/yellow]") - raise typer.Exit(0) - - # Enable the preset - manager.registry.update(preset_id, {"enabled": True}) - - console.print(f"[green]✓[/green] Preset '{preset_id}' enabled") - console.print("\nTemplates from this preset will now be included in resolution.") - console.print("[dim]Note: Previously registered commands/skills remain active.[/dim]") - - -@preset_app.command("disable") -def preset_disable( - preset_id: str = typer.Argument(help="Preset ID to disable"), -): - """Disable a preset without removing it.""" - from .presets import PresetManager - - project_root = _require_specify_project() - manager = PresetManager(project_root) - - # Check if preset is installed - if not manager.registry.is_installed(preset_id): - console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") - raise typer.Exit(1) - - # Get current metadata - metadata = manager.registry.get(preset_id) - if metadata is None or not isinstance(metadata, dict): - console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)") - raise typer.Exit(1) - - if not metadata.get("enabled", True): - console.print(f"[yellow]Preset '{preset_id}' is already disabled[/yellow]") - raise typer.Exit(0) - - # Disable the preset - manager.registry.update(preset_id, {"enabled": False}) - - console.print(f"[green]✓[/green] Preset '{preset_id}' disabled") - console.print("\nTemplates from this preset will be skipped during resolution.") - console.print("[dim]Note: Previously registered commands/skills remain active until preset removal.[/dim]") - console.print(f"To re-enable: specify preset enable {preset_id}") - - -# ===== Preset Catalog Commands ===== - - -@preset_catalog_app.command("list") -def preset_catalog_list(): - """List all active preset catalogs.""" - from .presets import PresetCatalog, PresetValidationError - - project_root = _require_specify_project() - catalog = PresetCatalog(project_root) - - try: - active_catalogs = catalog.get_active_catalogs() - except PresetValidationError as e: - console.print(f"[red]Error:[/red] {e}") - raise typer.Exit(1) - - console.print("\n[bold cyan]Active Preset Catalogs:[/bold cyan]\n") - for entry in active_catalogs: - install_str = ( - "[green]install allowed[/green]" - if entry.install_allowed - else "[yellow]discovery only[/yellow]" - ) - console.print(f" [bold]{entry.name}[/bold] (priority {entry.priority})") - if entry.description: - console.print(f" {entry.description}") - console.print(f" URL: {entry.url}") - console.print(f" Install: {install_str}") - console.print() - - config_path = project_root / ".specify" / "preset-catalogs.yml" - user_config_path = Path.home() / ".specify" / "preset-catalogs.yml" - if os.environ.get("SPECKIT_PRESET_CATALOG_URL"): - console.print("[dim]Catalog configured via SPECKIT_PRESET_CATALOG_URL environment variable.[/dim]") - else: - try: - proj_loaded = config_path.exists() and catalog._load_catalog_config(config_path) is not None - except PresetValidationError: - proj_loaded = False - if proj_loaded: - console.print(f"[dim]Config: {_display_project_path(project_root, config_path)}[/dim]") - else: - try: - user_loaded = user_config_path.exists() and catalog._load_catalog_config(user_config_path) is not None - except PresetValidationError: - user_loaded = False - if user_loaded: - console.print("[dim]Config: ~/.specify/preset-catalogs.yml[/dim]") - else: - console.print("[dim]Using built-in default catalog stack.[/dim]") - console.print( - "[dim]Add .specify/preset-catalogs.yml to customize.[/dim]" - ) - - -@preset_catalog_app.command("add") -def preset_catalog_add( - url: str = typer.Argument(help="Catalog URL (must use HTTPS)"), - name: str = typer.Option(..., "--name", help="Catalog name"), - priority: int = typer.Option(10, "--priority", help="Priority (lower = higher priority)"), - install_allowed: bool = typer.Option( - False, "--install-allowed/--no-install-allowed", - help="Allow presets from this catalog to be installed", - ), - description: str = typer.Option("", "--description", help="Description of the catalog"), -): - """Add a catalog to .specify/preset-catalogs.yml.""" - from .presets import PresetCatalog, PresetValidationError - - project_root = _require_specify_project() - specify_dir = project_root / ".specify" - - # Validate URL - tmp_catalog = PresetCatalog(project_root) - try: - tmp_catalog._validate_catalog_url(url) - except PresetValidationError as e: - console.print(f"[red]Error:[/red] {e}") - raise typer.Exit(1) - - config_path = specify_dir / "preset-catalogs.yml" - - # Load existing config - if config_path.exists(): - try: - config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} - except Exception as e: - config_label = _display_project_path(project_root, config_path) - console.print(f"[red]Error:[/red] Failed to read {config_label}: {e}") - raise typer.Exit(1) - else: - config = {} - - catalogs = config.get("catalogs", []) - if not isinstance(catalogs, list): - console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.") - raise typer.Exit(1) - - # Check for duplicate name - for existing in catalogs: - if isinstance(existing, dict) and existing.get("name") == name: - console.print(f"[yellow]Warning:[/yellow] A catalog named '{name}' already exists.") - console.print("Use 'specify preset catalog remove' first, or choose a different name.") - raise typer.Exit(1) - - catalogs.append({ - "name": name, - "url": url, - "priority": priority, - "install_allowed": install_allowed, - "description": description, - }) - - config["catalogs"] = catalogs - config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8") - - install_label = "install allowed" if install_allowed else "discovery only" - console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})") - console.print(f" URL: {url}") - console.print(f" Priority: {priority}") - console.print(f"\nConfig saved to {_display_project_path(project_root, config_path)}") - - -@preset_catalog_app.command("remove") -def preset_catalog_remove( - name: str = typer.Argument(help="Catalog name to remove"), -): - """Remove a catalog from .specify/preset-catalogs.yml.""" - project_root = _require_specify_project() - specify_dir = project_root / ".specify" - - config_path = specify_dir / "preset-catalogs.yml" - if not config_path.exists(): - console.print("[red]Error:[/red] No preset catalog config found. Nothing to remove.") - raise typer.Exit(1) - - try: - config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} - except Exception: - console.print("[red]Error:[/red] Failed to read preset catalog config.") - raise typer.Exit(1) - - catalogs = config.get("catalogs", []) - if not isinstance(catalogs, list): - console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.") - raise typer.Exit(1) - original_count = len(catalogs) - catalogs = [c for c in catalogs if isinstance(c, dict) and c.get("name") != name] - - if len(catalogs) == original_count: - console.print(f"[red]Error:[/red] Catalog '{name}' not found.") - raise typer.Exit(1) - - config["catalogs"] = catalogs - config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8") - - console.print(f"[green]✓[/green] Removed catalog '{name}'") - if not catalogs: - console.print("\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]") +from .commands.preset import preset_app, preset_catalog_app +app.add_typer(preset_app, name="preset") # ===== Extension Commands ===== diff --git a/src/specify_cli/commands/preset.py b/src/specify_cli/commands/preset.py index fc1064b01e..32d2a86c89 100644 --- a/src/specify_cli/commands/preset.py +++ b/src/specify_cli/commands/preset.py @@ -1 +1,720 @@ """specify preset * commands.""" + +import os +import yaml +from pathlib import Path + +import typer + +from .._console import console +from .._assets import _asset_service as _svc +from .._helpers import get_speckit_version + +preset_app = typer.Typer( + name="preset", + help="Manage spec-kit presets", + add_completion=False, +) + +preset_catalog_app = typer.Typer( + name="catalog", + help="Manage preset catalogs", + add_completion=False, +) +preset_app.add_typer(preset_catalog_app, name="catalog") + + +def _locate_bundled_preset(preset_id: str) -> Path | None: + return _svc.locate_bundled_preset(preset_id) + + +# ===== Preset Commands ===== + + +@preset_app.command("list") +def preset_list(): + """List installed presets.""" + from ..presets import PresetManager + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + manager = PresetManager(project_root) + installed = manager.list_installed() + + if not installed: + console.print("[yellow]No presets installed.[/yellow]") + console.print("\nInstall a preset with:") + console.print(" [cyan]specify preset add [/cyan]") + return + + console.print("\n[bold cyan]Installed Presets:[/bold cyan]\n") + for pack in installed: + status = "[green]enabled[/green]" if pack.get("enabled", True) else "[red]disabled[/red]" + pri = pack.get('priority', 10) + console.print(f" [bold]{pack['name']}[/bold] ({pack['id']}) v{pack['version']} — {status} — priority {pri}") + console.print(f" {pack['description']}") + if pack.get("tags"): + tags_str = ", ".join(pack["tags"]) + console.print(f" [dim]Tags: {tags_str}[/dim]") + console.print(f" [dim]Templates: {pack['template_count']}[/dim]") + console.print() + + +@preset_app.command("add") +def preset_add( + preset_id: str = typer.Argument(None, help="Preset ID to install from catalog"), + from_url: str = typer.Option(None, "--from", help="Install from a URL (ZIP file)"), + dev: str = typer.Option(None, "--dev", help="Install from local directory (development mode)"), + priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"), +): + """Install a preset.""" + from ..presets import ( + PresetManager, + PresetCatalog, + PresetError, + PresetValidationError, + PresetCompatibilityError, + ) + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + # Validate priority + if priority < 1: + console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)") + raise typer.Exit(1) + + manager = PresetManager(project_root) + speckit_version = get_speckit_version() + + try: + if dev: + dev_path = Path(dev).resolve() + if not dev_path.exists(): + console.print(f"[red]Error:[/red] Directory not found: {dev}") + raise typer.Exit(1) + + console.print(f"Installing preset from [cyan]{dev_path}[/cyan]...") + manifest = manager.install_from_directory(dev_path, speckit_version, priority) + console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") + + elif from_url: + # Validate URL scheme before downloading + from urllib.parse import urlparse as _urlparse + _parsed = _urlparse(from_url) + _is_localhost = _parsed.hostname in ("localhost", "127.0.0.1", "::1") + if _parsed.scheme != "https" and not (_parsed.scheme == "http" and _is_localhost): + console.print(f"[red]Error:[/red] URL must use HTTPS (got {_parsed.scheme}://). HTTP is only allowed for localhost.") + raise typer.Exit(1) + + console.print(f"Installing preset from [cyan]{from_url}[/cyan]...") + import urllib.request + import urllib.error + import tempfile + + with tempfile.TemporaryDirectory() as tmpdir: + zip_path = Path(tmpdir) / "preset.zip" + try: + with urllib.request.urlopen(from_url, timeout=60) as response: + zip_path.write_bytes(response.read()) + except urllib.error.URLError as e: + console.print(f"[red]Error:[/red] Failed to download: {e}") + raise typer.Exit(1) + + manifest = manager.install_from_zip(zip_path, speckit_version, priority) + + console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") + + elif preset_id: + # Try bundled preset first, then catalog + bundled_path = _locate_bundled_preset(preset_id) + if bundled_path: + console.print(f"Installing bundled preset [cyan]{preset_id}[/cyan]...") + manifest = manager.install_from_directory(bundled_path, speckit_version, priority) + console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") + else: + catalog = PresetCatalog(project_root) + pack_info = catalog.get_pack_info(preset_id) + + if not pack_info: + console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in catalog") + raise typer.Exit(1) + + # Bundled presets should have been caught above; if we reach + # here the bundled files are missing from the installation. + if pack_info.get("bundled") and not pack_info.get("download_url"): + from ..extensions import REINSTALL_COMMAND + console.print( + f"[red]Error:[/red] Preset '{preset_id}' is bundled with spec-kit " + f"but could not be found in the installed package." + ) + console.print( + "\nThis usually means the spec-kit installation is incomplete or corrupted." + ) + console.print("Try reinstalling spec-kit:") + console.print(f" {REINSTALL_COMMAND}") + raise typer.Exit(1) + + if not pack_info.get("_install_allowed", True): + catalog_name = pack_info.get("_catalog_name", "unknown") + console.print(f"[red]Error:[/red] Preset '{preset_id}' is from the '{catalog_name}' catalog which is discovery-only (install not allowed).") + console.print("Add the catalog with --install-allowed or install from the preset's repository directly with --from.") + raise typer.Exit(1) + + console.print(f"Installing preset [cyan]{pack_info.get('name', preset_id)}[/cyan]...") + + try: + zip_path = catalog.download_pack(preset_id) + manifest = manager.install_from_zip(zip_path, speckit_version, priority) + console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") + finally: + if 'zip_path' in locals() and zip_path.exists(): + zip_path.unlink(missing_ok=True) + else: + console.print("[red]Error:[/red] Specify a preset ID, --from URL, or --dev path") + raise typer.Exit(1) + + except PresetCompatibilityError as e: + console.print(f"[red]Compatibility Error:[/red] {e}") + raise typer.Exit(1) + except PresetValidationError as e: + console.print(f"[red]Validation Error:[/red] {e}") + raise typer.Exit(1) + except PresetError as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(1) + + +@preset_app.command("remove") +def preset_remove( + preset_id: str = typer.Argument(..., help="Preset ID to remove"), +): + """Remove an installed preset.""" + from ..presets import PresetManager + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + manager = PresetManager(project_root) + + if not manager.registry.is_installed(preset_id): + console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") + raise typer.Exit(1) + + if manager.remove(preset_id): + console.print(f"[green]✓[/green] Preset '{preset_id}' removed successfully") + else: + console.print(f"[red]Error:[/red] Failed to remove preset '{preset_id}'") + raise typer.Exit(1) + + +@preset_app.command("search") +def preset_search( + query: str = typer.Argument(None, help="Search query"), + tag: str = typer.Option(None, "--tag", help="Filter by tag"), + author: str = typer.Option(None, "--author", help="Filter by author"), +): + """Search for presets in the catalog.""" + from ..presets import PresetCatalog, PresetError + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + catalog = PresetCatalog(project_root) + + try: + results = catalog.search(query=query, tag=tag, author=author) + except PresetError as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(1) + + if not results: + console.print("[yellow]No presets found matching your criteria.[/yellow]") + return + + console.print(f"\n[bold cyan]Presets ({len(results)} found):[/bold cyan]\n") + for pack in results: + console.print(f" [bold]{pack.get('name', pack['id'])}[/bold] ({pack['id']}) v{pack.get('version', '?')}") + console.print(f" {pack.get('description', '')}") + if pack.get("tags"): + tags_str = ", ".join(pack["tags"]) + console.print(f" [dim]Tags: {tags_str}[/dim]") + console.print() + + +@preset_app.command("resolve") +def preset_resolve( + template_name: str = typer.Argument(..., help="Template name to resolve (e.g., spec-template)"), +): + """Show which template will be resolved for a given name.""" + from ..presets import PresetResolver + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + resolver = PresetResolver(project_root) + layers = resolver.collect_all_layers(template_name) + + if layers: + # Use the highest-priority layer for display because the final output + # may be composed and may not map to resolve_with_source()'s single path. + display_layer = layers[0] + console.print(f" [bold]{template_name}[/bold]: {display_layer['path']}") + console.print(f" [dim](top layer from: {display_layer['source']})[/dim]") + + has_composition = ( + layers[0]["strategy"] != "replace" + and any(layer["strategy"] != "replace" for layer in layers) + ) + if has_composition: + # Verify composition is actually possible + try: + composed = resolver.resolve_content(template_name) + except Exception as exc: + composed = None + console.print(f" [yellow]Warning: composition error: {exc}[/yellow]") + if composed is None: + console.print(" [yellow]Warning: composition cannot produce output (no base layer with 'replace' strategy)[/yellow]") + else: + console.print(" [dim]Final output is composed from multiple preset layers; the path above is the highest-priority contributing layer.[/dim]") + console.print("\n [bold]Composition chain:[/bold]") + # Compute the effective base: first replace layer scanning from + # highest priority (matching resolve_content top-down logic). + # Only show layers from the base upward (lower layers are ignored). + effective_base_idx = None + for idx, lyr in enumerate(layers): + if lyr["strategy"] == "replace": + effective_base_idx = idx + break + # Show only contributing layers (base and above) + if effective_base_idx is not None: + contributing = layers[:effective_base_idx + 1] + else: + contributing = layers + for i, layer in enumerate(reversed(contributing)): + strategy_label = layer["strategy"] + if strategy_label == "replace" and i == 0: + strategy_label = "base" + console.print(f" {i + 1}. [{strategy_label}] {layer['source']} → {layer['path']}") + else: + # No layers found — fall back to resolve_with_source for non-composition cases + result = resolver.resolve_with_source(template_name) + if result: + console.print(f" [bold]{template_name}[/bold]: {result['path']}") + console.print(f" [dim](from: {result['source']})[/dim]") + else: + console.print(f" [yellow]{template_name}[/yellow]: not found") + console.print(" [dim]No template with this name exists in the resolution stack[/dim]") + + +@preset_app.command("info") +def preset_info( + preset_id: str = typer.Argument(..., help="Preset ID to get info about"), +): + """Show detailed information about a preset.""" + from ..extensions import normalize_priority + from ..presets import PresetCatalog, PresetManager, PresetError + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + # Check if installed locally first + manager = PresetManager(project_root) + local_pack = manager.get_pack(preset_id) + + if local_pack: + console.print(f"\n[bold cyan]Preset: {local_pack.name}[/bold cyan]\n") + console.print(f" ID: {local_pack.id}") + console.print(f" Version: {local_pack.version}") + console.print(f" Description: {local_pack.description}") + if local_pack.author: + console.print(f" Author: {local_pack.author}") + if local_pack.tags: + console.print(f" Tags: {', '.join(local_pack.tags)}") + console.print(f" Templates: {len(local_pack.templates)}") + for tmpl in local_pack.templates: + console.print(f" - {tmpl['name']} ({tmpl['type']}): {tmpl.get('description', '')}") + repo = local_pack.data.get("preset", {}).get("repository") + if repo: + console.print(f" Repository: {repo}") + license_val = local_pack.data.get("preset", {}).get("license") + if license_val: + console.print(f" License: {license_val}") + console.print("\n [green]Status: installed[/green]") + # Get priority from registry + pack_metadata = manager.registry.get(preset_id) + priority = normalize_priority(pack_metadata.get("priority") if isinstance(pack_metadata, dict) else None) + console.print(f" [dim]Priority:[/dim] {priority}") + console.print() + return + + # Fall back to catalog + catalog = PresetCatalog(project_root) + try: + pack_info = catalog.get_pack_info(preset_id) + except PresetError: + pack_info = None + + if not pack_info: + console.print(f"[red]Error:[/red] Preset '{preset_id}' not found (not installed and not in catalog)") + raise typer.Exit(1) + + console.print(f"\n[bold cyan]Preset: {pack_info.get('name', preset_id)}[/bold cyan]\n") + console.print(f" ID: {pack_info['id']}") + console.print(f" Version: {pack_info.get('version', '?')}") + console.print(f" Description: {pack_info.get('description', '')}") + if pack_info.get("author"): + console.print(f" Author: {pack_info['author']}") + if pack_info.get("tags"): + console.print(f" Tags: {', '.join(pack_info['tags'])}") + if pack_info.get("repository"): + console.print(f" Repository: {pack_info['repository']}") + if pack_info.get("license"): + console.print(f" License: {pack_info['license']}") + console.print("\n [yellow]Status: not installed[/yellow]") + console.print(f" Install with: [cyan]specify preset add {preset_id}[/cyan]") + console.print() + + +@preset_app.command("set-priority") +def preset_set_priority( + preset_id: str = typer.Argument(help="Preset ID"), + priority: int = typer.Argument(help="New priority (lower = higher precedence)"), +): + """Set the resolution priority of an installed preset.""" + from ..presets import PresetManager + + project_root = Path.cwd() + + # Check if we're in a spec-kit project + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + # Validate priority + if priority < 1: + console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)") + raise typer.Exit(1) + + manager = PresetManager(project_root) + + # Check if preset is installed + if not manager.registry.is_installed(preset_id): + console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") + raise typer.Exit(1) + + # Get current metadata + metadata = manager.registry.get(preset_id) + if metadata is None or not isinstance(metadata, dict): + console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)") + raise typer.Exit(1) + + from ..extensions import normalize_priority + raw_priority = metadata.get("priority") + # Only skip if the stored value is already a valid int equal to requested priority + # This ensures corrupted values (e.g., "high") get repaired even when setting to default (10) + if isinstance(raw_priority, int) and raw_priority == priority: + console.print(f"[yellow]Preset '{preset_id}' already has priority {priority}[/yellow]") + raise typer.Exit(0) + + old_priority = normalize_priority(raw_priority) + + # Update priority + manager.registry.update(preset_id, {"priority": priority}) + + console.print(f"[green]✓[/green] Preset '{preset_id}' priority changed: {old_priority} → {priority}") + console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]") + + +@preset_app.command("enable") +def preset_enable( + preset_id: str = typer.Argument(help="Preset ID to enable"), +): + """Enable a disabled preset.""" + from ..presets import PresetManager + + project_root = Path.cwd() + + # Check if we're in a spec-kit project + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + manager = PresetManager(project_root) + + # Check if preset is installed + if not manager.registry.is_installed(preset_id): + console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") + raise typer.Exit(1) + + # Get current metadata + metadata = manager.registry.get(preset_id) + if metadata is None or not isinstance(metadata, dict): + console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)") + raise typer.Exit(1) + + if metadata.get("enabled", True): + console.print(f"[yellow]Preset '{preset_id}' is already enabled[/yellow]") + raise typer.Exit(0) + + # Enable the preset + manager.registry.update(preset_id, {"enabled": True}) + + console.print(f"[green]✓[/green] Preset '{preset_id}' enabled") + console.print("\nTemplates from this preset will now be included in resolution.") + console.print("[dim]Note: Previously registered commands/skills remain active.[/dim]") + + +@preset_app.command("disable") +def preset_disable( + preset_id: str = typer.Argument(help="Preset ID to disable"), +): + """Disable a preset without removing it.""" + from ..presets import PresetManager + + project_root = Path.cwd() + + # Check if we're in a spec-kit project + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + manager = PresetManager(project_root) + + # Check if preset is installed + if not manager.registry.is_installed(preset_id): + console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") + raise typer.Exit(1) + + # Get current metadata + metadata = manager.registry.get(preset_id) + if metadata is None or not isinstance(metadata, dict): + console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)") + raise typer.Exit(1) + + if not metadata.get("enabled", True): + console.print(f"[yellow]Preset '{preset_id}' is already disabled[/yellow]") + raise typer.Exit(0) + + # Disable the preset + manager.registry.update(preset_id, {"enabled": False}) + + console.print(f"[green]✓[/green] Preset '{preset_id}' disabled") + console.print("\nTemplates from this preset will be skipped during resolution.") + console.print("[dim]Note: Previously registered commands/skills remain active until preset removal.[/dim]") + console.print(f"To re-enable: specify preset enable {preset_id}") + + +# ===== Preset Catalog Commands ===== + + +@preset_catalog_app.command("list") +def preset_catalog_list(): + """List all active preset catalogs.""" + from ..presets import PresetCatalog, PresetValidationError + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + catalog = PresetCatalog(project_root) + + try: + active_catalogs = catalog.get_active_catalogs() + except PresetValidationError as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(1) + + console.print("\n[bold cyan]Active Preset Catalogs:[/bold cyan]\n") + for entry in active_catalogs: + install_str = ( + "[green]install allowed[/green]" + if entry.install_allowed + else "[yellow]discovery only[/yellow]" + ) + console.print(f" [bold]{entry.name}[/bold] (priority {entry.priority})") + if entry.description: + console.print(f" {entry.description}") + console.print(f" URL: {entry.url}") + console.print(f" Install: {install_str}") + console.print() + + config_path = project_root / ".specify" / "preset-catalogs.yml" + user_config_path = Path.home() / ".specify" / "preset-catalogs.yml" + if os.environ.get("SPECKIT_PRESET_CATALOG_URL"): + console.print("[dim]Catalog configured via SPECKIT_PRESET_CATALOG_URL environment variable.[/dim]") + else: + try: + proj_loaded = config_path.exists() and catalog._load_catalog_config(config_path) is not None + except PresetValidationError: + proj_loaded = False + if proj_loaded: + console.print(f"[dim]Config: {config_path.relative_to(project_root)}[/dim]") + else: + try: + user_loaded = user_config_path.exists() and catalog._load_catalog_config(user_config_path) is not None + except PresetValidationError: + user_loaded = False + if user_loaded: + console.print("[dim]Config: ~/.specify/preset-catalogs.yml[/dim]") + else: + console.print("[dim]Using built-in default catalog stack.[/dim]") + console.print( + "[dim]Add .specify/preset-catalogs.yml to customize.[/dim]" + ) + + +@preset_catalog_app.command("add") +def preset_catalog_add( + url: str = typer.Argument(help="Catalog URL (must use HTTPS)"), + name: str = typer.Option(..., "--name", help="Catalog name"), + priority: int = typer.Option(10, "--priority", help="Priority (lower = higher priority)"), + install_allowed: bool = typer.Option( + False, "--install-allowed/--no-install-allowed", + help="Allow presets from this catalog to be installed", + ), + description: str = typer.Option("", "--description", help="Description of the catalog"), +): + """Add a catalog to .specify/preset-catalogs.yml.""" + from ..presets import PresetCatalog, PresetValidationError + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + # Validate URL + tmp_catalog = PresetCatalog(project_root) + try: + tmp_catalog._validate_catalog_url(url) + except PresetValidationError as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(1) + + config_path = specify_dir / "preset-catalogs.yml" + + # Load existing config + if config_path.exists(): + try: + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + except Exception as e: + console.print(f"[red]Error:[/red] Failed to read {config_path}: {e}") + raise typer.Exit(1) + else: + config = {} + + catalogs = config.get("catalogs", []) + if not isinstance(catalogs, list): + console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.") + raise typer.Exit(1) + + # Check for duplicate name + for existing in catalogs: + if isinstance(existing, dict) and existing.get("name") == name: + console.print(f"[yellow]Warning:[/yellow] A catalog named '{name}' already exists.") + console.print("Use 'specify preset catalog remove' first, or choose a different name.") + raise typer.Exit(1) + + catalogs.append({ + "name": name, + "url": url, + "priority": priority, + "install_allowed": install_allowed, + "description": description, + }) + + config["catalogs"] = catalogs + config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8") + + install_label = "install allowed" if install_allowed else "discovery only" + console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})") + console.print(f" URL: {url}") + console.print(f" Priority: {priority}") + console.print(f"\nConfig saved to {config_path.relative_to(project_root)}") + + +@preset_catalog_app.command("remove") +def preset_catalog_remove( + name: str = typer.Argument(help="Catalog name to remove"), +): + """Remove a catalog from .specify/preset-catalogs.yml.""" + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + config_path = specify_dir / "preset-catalogs.yml" + if not config_path.exists(): + console.print("[red]Error:[/red] No preset catalog config found. Nothing to remove.") + raise typer.Exit(1) + + try: + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + except Exception: + console.print("[red]Error:[/red] Failed to read preset catalog config.") + raise typer.Exit(1) + + catalogs = config.get("catalogs", []) + if not isinstance(catalogs, list): + console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.") + raise typer.Exit(1) + original_count = len(catalogs) + catalogs = [c for c in catalogs if isinstance(c, dict) and c.get("name") != name] + + if len(catalogs) == original_count: + console.print(f"[red]Error:[/red] Catalog '{name}' not found.") + raise typer.Exit(1) + + config["catalogs"] = catalogs + config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8") + + console.print(f"[green]✓[/green] Removed catalog '{name}'") + if not catalogs: + console.print("\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]") diff --git a/tests/test_presets.py b/tests/test_presets.py index 848c072dd0..196ab090f5 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -3317,7 +3317,7 @@ def test_bundled_preset_missing_locally_cli_error(self, project_dir): "_install_allowed": True, } with patch.object(Path, "cwd", return_value=project_dir), \ - patch("specify_cli._locate_bundled_preset", return_value=None), \ + patch("specify_cli.commands.preset._locate_bundled_preset", return_value=None), \ patch("specify_cli.presets.PresetCatalog") as MockCatalog: MockCatalog.return_value.get_pack_info.return_value = fake_pack_info result = runner.invoke(app, ["preset", "add", "lean"]) From 567181b33f07437b4db775c22d1f237f6b23f75c Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Sat, 25 Apr 2026 21:25:56 +0800 Subject: [PATCH 19/31] refactor: fix type annotations and remove wrapper in commands/preset.py --- src/specify_cli/commands/preset.py | 30 +++++++++++++----------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/src/specify_cli/commands/preset.py b/src/specify_cli/commands/preset.py index 32d2a86c89..387c220e67 100644 --- a/src/specify_cli/commands/preset.py +++ b/src/specify_cli/commands/preset.py @@ -24,15 +24,11 @@ preset_app.add_typer(preset_catalog_app, name="catalog") -def _locate_bundled_preset(preset_id: str) -> Path | None: - return _svc.locate_bundled_preset(preset_id) - - # ===== Preset Commands ===== @preset_app.command("list") -def preset_list(): +def preset_list() -> None: """List installed presets.""" from ..presets import PresetManager @@ -72,7 +68,7 @@ def preset_add( from_url: str = typer.Option(None, "--from", help="Install from a URL (ZIP file)"), dev: str = typer.Option(None, "--dev", help="Install from local directory (development mode)"), priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"), -): +) -> None: """Install a preset.""" from ..presets import ( PresetManager, @@ -138,7 +134,7 @@ def preset_add( elif preset_id: # Try bundled preset first, then catalog - bundled_path = _locate_bundled_preset(preset_id) + bundled_path = _svc.locate_bundled_preset(preset_id) if bundled_path: console.print(f"Installing bundled preset [cyan]{preset_id}[/cyan]...") manifest = manager.install_from_directory(bundled_path, speckit_version, priority) @@ -199,7 +195,7 @@ def preset_add( @preset_app.command("remove") def preset_remove( preset_id: str = typer.Argument(..., help="Preset ID to remove"), -): +) -> None: """Remove an installed preset.""" from ..presets import PresetManager @@ -229,7 +225,7 @@ def preset_search( query: str = typer.Argument(None, help="Search query"), tag: str = typer.Option(None, "--tag", help="Filter by tag"), author: str = typer.Option(None, "--author", help="Filter by author"), -): +) -> None: """Search for presets in the catalog.""" from ..presets import PresetCatalog, PresetError @@ -266,7 +262,7 @@ def preset_search( @preset_app.command("resolve") def preset_resolve( template_name: str = typer.Argument(..., help="Template name to resolve (e.g., spec-template)"), -): +) -> None: """Show which template will be resolved for a given name.""" from ..presets import PresetResolver @@ -336,7 +332,7 @@ def preset_resolve( @preset_app.command("info") def preset_info( preset_id: str = typer.Argument(..., help="Preset ID to get info about"), -): +) -> None: """Show detailed information about a preset.""" from ..extensions import normalize_priority from ..presets import PresetCatalog, PresetManager, PresetError @@ -411,7 +407,7 @@ def preset_info( def preset_set_priority( preset_id: str = typer.Argument(help="Preset ID"), priority: int = typer.Argument(help="New priority (lower = higher precedence)"), -): +) -> None: """Set the resolution priority of an installed preset.""" from ..presets import PresetManager @@ -462,7 +458,7 @@ def preset_set_priority( @preset_app.command("enable") def preset_enable( preset_id: str = typer.Argument(help="Preset ID to enable"), -): +) -> None: """Enable a disabled preset.""" from ..presets import PresetManager @@ -503,7 +499,7 @@ def preset_enable( @preset_app.command("disable") def preset_disable( preset_id: str = typer.Argument(help="Preset ID to disable"), -): +) -> None: """Disable a preset without removing it.""" from ..presets import PresetManager @@ -546,7 +542,7 @@ def preset_disable( @preset_catalog_app.command("list") -def preset_catalog_list(): +def preset_catalog_list() -> None: """List all active preset catalogs.""" from ..presets import PresetCatalog, PresetValidationError @@ -615,7 +611,7 @@ def preset_catalog_add( help="Allow presets from this catalog to be installed", ), description: str = typer.Option("", "--description", help="Description of the catalog"), -): +) -> None: """Add a catalog to .specify/preset-catalogs.yml.""" from ..presets import PresetCatalog, PresetValidationError @@ -680,7 +676,7 @@ def preset_catalog_add( @preset_catalog_app.command("remove") def preset_catalog_remove( name: str = typer.Argument(help="Catalog name to remove"), -): +) -> None: """Remove a catalog from .specify/preset-catalogs.yml.""" project_root = Path.cwd() From 16eb7dc300e2d05ba8e76f4591815fab6cfd27b6 Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Sat, 25 Apr 2026 21:27:28 +0800 Subject: [PATCH 20/31] test: update patch target after _locate_bundled_preset removal --- tests/test_presets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_presets.py b/tests/test_presets.py index 196ab090f5..91db03d870 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -3317,7 +3317,7 @@ def test_bundled_preset_missing_locally_cli_error(self, project_dir): "_install_allowed": True, } with patch.object(Path, "cwd", return_value=project_dir), \ - patch("specify_cli.commands.preset._locate_bundled_preset", return_value=None), \ + patch.object(__import__("specify_cli._assets", fromlist=["_asset_service"]).AssetService, "locate_bundled_preset", return_value=None), \ patch("specify_cli.presets.PresetCatalog") as MockCatalog: MockCatalog.return_value.get_pack_info.return_value = fake_pack_info result = runner.invoke(app, ["preset", "add", "lean"]) From b78dc3262c29984aad70761ce2aded9d482fe0df Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Sat, 25 Apr 2026 21:34:40 +0800 Subject: [PATCH 21/31] refactor: move extension command handlers to commands/extension.py Extracted all extension_app and catalog_app command handlers and private helpers (_resolve_installed_extension, _resolve_catalog_extension, _print_extension_info, _locate_bundled_extension) from __init__.py into the new commands/extension.py module. Updated the single patched test target from specify_cli._locate_bundled_extension to specify_cli.commands.extension._locate_bundled_extension. Co-Authored-By: Claude Sonnet 4.6 --- src/specify_cli/__init__.py | 1355 +---------------------- src/specify_cli/commands/extension.py | 1458 +++++++++++++++++++++++++ tests/test_extensions.py | 2 +- 3 files changed, 1460 insertions(+), 1355 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index accf2204bc..c5cbb1d153 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -326,20 +326,9 @@ def self_upgrade() -> None: # ===== Extension Commands ===== -extension_app = typer.Typer( - name="extension", - help="Manage spec-kit extensions", - add_completion=False, -) +from .commands.extension import extension_app, catalog_app app.add_typer(extension_app, name="extension") -catalog_app = typer.Typer( - name="catalog", - help="Manage extension catalogs", - add_completion=False, -) -extension_app.add_typer(catalog_app, name="catalog") - # ===== Integration Commands ===== from .commands.integration import ( @@ -359,1348 +348,6 @@ def self_upgrade() -> None: app.add_typer(preset_app, name="preset") -# ===== Extension Commands ===== - - -def _resolve_installed_extension( - argument: str, - installed_extensions: list, - command_name: str = "command", - allow_not_found: bool = False, -) -> tuple[Optional[str], Optional[str]]: - """Resolve an extension argument (ID or display name) to an installed extension. - - Args: - argument: Extension ID or display name provided by user - installed_extensions: List of installed extension dicts from manager.list_installed() - command_name: Name of the command for error messages (e.g., "enable", "disable") - allow_not_found: If True, return (None, None) when not found instead of raising - - Returns: - Tuple of (extension_id, display_name), or (None, None) if allow_not_found=True and not found - - Raises: - typer.Exit: If extension not found (and allow_not_found=False) or name is ambiguous - """ - from rich.table import Table - - # First, try exact ID match - for ext in installed_extensions: - if ext["id"] == argument: - return (ext["id"], ext["name"]) - - # If not found by ID, try display name match - name_matches = [ext for ext in installed_extensions if ext["name"].lower() == argument.lower()] - - if len(name_matches) == 1: - # Unique display-name match - return (name_matches[0]["id"], name_matches[0]["name"]) - elif len(name_matches) > 1: - # Ambiguous display-name match - console.print( - f"[red]Error:[/red] Extension name '{argument}' is ambiguous. " - "Multiple installed extensions share this name:" - ) - table = Table(title="Matching extensions") - table.add_column("ID", style="cyan", no_wrap=True) - table.add_column("Name", style="white") - table.add_column("Version", style="green") - for ext in name_matches: - table.add_row(ext.get("id", ""), ext.get("name", ""), str(ext.get("version", ""))) - console.print(table) - console.print("\nPlease rerun using the extension ID:") - console.print(f" [bold]specify extension {command_name} [/bold]") - raise typer.Exit(1) - else: - # No match by ID or display name - if allow_not_found: - return (None, None) - console.print(f"[red]Error:[/red] Extension '{argument}' is not installed") - raise typer.Exit(1) - - -def _resolve_catalog_extension( - argument: str, - catalog, - command_name: str = "info", -) -> tuple[Optional[dict], Optional[Exception]]: - """Resolve an extension argument (ID or display name) from the catalog. - - Args: - argument: Extension ID or display name provided by user - catalog: ExtensionCatalog instance - command_name: Name of the command for error messages - - Returns: - Tuple of (extension_info, catalog_error) - - If found: (ext_info_dict, None) - - If catalog error: (None, error) - - If not found: (None, None) - """ - from rich.table import Table - from .extensions import ExtensionError - - try: - # First try by ID - ext_info = catalog.get_extension_info(argument) - if ext_info: - return (ext_info, None) - - # Try by display name - search using argument as query, then filter for exact match - search_results = catalog.search(query=argument) - name_matches = [ext for ext in search_results if ext["name"].lower() == argument.lower()] - - if len(name_matches) == 1: - return (name_matches[0], None) - elif len(name_matches) > 1: - # Ambiguous display-name match in catalog - console.print( - f"[red]Error:[/red] Extension name '{argument}' is ambiguous. " - "Multiple catalog extensions share this name:" - ) - table = Table(title="Matching extensions") - table.add_column("ID", style="cyan", no_wrap=True) - table.add_column("Name", style="white") - table.add_column("Version", style="green") - table.add_column("Catalog", style="dim") - for ext in name_matches: - table.add_row( - ext.get("id", ""), - ext.get("name", ""), - str(ext.get("version", "")), - ext.get("_catalog_name", ""), - ) - console.print(table) - console.print("\nPlease rerun using the extension ID:") - console.print(f" [bold]specify extension {command_name} [/bold]") - raise typer.Exit(1) - - # Not found - return (None, None) - - except ExtensionError as e: - return (None, e) - - -@extension_app.command("list") -def extension_list( - available: bool = typer.Option(False, "--available", help="Show available extensions from catalog"), - all_extensions: bool = typer.Option(False, "--all", help="Show both installed and available"), -): - """List installed extensions.""" - from .extensions import ExtensionManager - - project_root = _require_specify_project() - manager = ExtensionManager(project_root) - installed = manager.list_installed() - - if not installed and not (available or all_extensions): - console.print("[yellow]No extensions installed.[/yellow]") - console.print("\nInstall an extension with:") - console.print(" specify extension add ") - return - - if installed: - console.print("\n[bold cyan]Installed Extensions:[/bold cyan]\n") - - for ext in installed: - status_icon = "✓" if ext["enabled"] else "✗" - status_color = "green" if ext["enabled"] else "red" - - console.print(f" [{status_color}]{status_icon}[/{status_color}] [bold]{ext['name']}[/bold] (v{ext['version']})") - console.print(f" [dim]{ext['id']}[/dim]") - console.print(f" {ext['description']}") - console.print(f" Commands: {ext['command_count']} | Hooks: {ext['hook_count']} | Priority: {ext['priority']} | Status: {'Enabled' if ext['enabled'] else 'Disabled'}") - console.print() - - if available or all_extensions: - console.print("\nInstall an extension:") - console.print(" [cyan]specify extension add [/cyan]") - - -@catalog_app.command("list") -def catalog_list(): - """List all active extension catalogs.""" - from .extensions import ExtensionCatalog, ValidationError - - project_root = _require_specify_project() - catalog = ExtensionCatalog(project_root) - - try: - active_catalogs = catalog.get_active_catalogs() - except ValidationError as e: - console.print(f"[red]Error:[/red] {e}") - raise typer.Exit(1) - - console.print("\n[bold cyan]Active Extension Catalogs:[/bold cyan]\n") - for entry in active_catalogs: - install_str = ( - "[green]install allowed[/green]" - if entry.install_allowed - else "[yellow]discovery only[/yellow]" - ) - console.print(f" [bold]{entry.name}[/bold] (priority {entry.priority})") - if entry.description: - console.print(f" {entry.description}") - console.print(f" URL: {entry.url}") - console.print(f" Install: {install_str}") - console.print() - - config_path = project_root / ".specify" / "extension-catalogs.yml" - user_config_path = Path.home() / ".specify" / "extension-catalogs.yml" - if os.environ.get("SPECKIT_CATALOG_URL"): - console.print("[dim]Catalog configured via SPECKIT_CATALOG_URL environment variable.[/dim]") - else: - try: - proj_loaded = config_path.exists() and catalog._load_catalog_config(config_path) is not None - except ValidationError: - proj_loaded = False - if proj_loaded: - console.print(f"[dim]Config: {_display_project_path(project_root, config_path)}[/dim]") - else: - try: - user_loaded = user_config_path.exists() and catalog._load_catalog_config(user_config_path) is not None - except ValidationError: - user_loaded = False - if user_loaded: - console.print("[dim]Config: ~/.specify/extension-catalogs.yml[/dim]") - else: - console.print("[dim]Using built-in default catalog stack.[/dim]") - console.print( - "[dim]Add .specify/extension-catalogs.yml to customize.[/dim]" - ) - - -@catalog_app.command("add") -def catalog_add( - url: str = typer.Argument(help="Catalog URL (must use HTTPS)"), - name: str = typer.Option(..., "--name", help="Catalog name"), - priority: int = typer.Option(10, "--priority", help="Priority (lower = higher priority)"), - install_allowed: bool = typer.Option( - False, "--install-allowed/--no-install-allowed", - help="Allow extensions from this catalog to be installed", - ), - description: str = typer.Option("", "--description", help="Description of the catalog"), -): - """Add a catalog to .specify/extension-catalogs.yml.""" - from .extensions import ExtensionCatalog, ValidationError - - project_root = _require_specify_project() - specify_dir = project_root / ".specify" - - # Validate URL - tmp_catalog = ExtensionCatalog(project_root) - try: - tmp_catalog._validate_catalog_url(url) - except ValidationError as e: - console.print(f"[red]Error:[/red] {e}") - raise typer.Exit(1) - - config_path = specify_dir / "extension-catalogs.yml" - - # Load existing config - if config_path.exists(): - try: - config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} - except Exception as e: - config_label = _display_project_path(project_root, config_path) - console.print(f"[red]Error:[/red] Failed to read {config_label}: {e}") - raise typer.Exit(1) - else: - config = {} - - catalogs = config.get("catalogs", []) - if not isinstance(catalogs, list): - console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.") - raise typer.Exit(1) - - # Check for duplicate name - for existing in catalogs: - if isinstance(existing, dict) and existing.get("name") == name: - console.print(f"[yellow]Warning:[/yellow] A catalog named '{name}' already exists.") - console.print("Use 'specify extension catalog remove' first, or choose a different name.") - raise typer.Exit(1) - - catalogs.append({ - "name": name, - "url": url, - "priority": priority, - "install_allowed": install_allowed, - "description": description, - }) - - config["catalogs"] = catalogs - config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8") - - install_label = "install allowed" if install_allowed else "discovery only" - console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})") - console.print(f" URL: {url}") - console.print(f" Priority: {priority}") - console.print(f"\nConfig saved to {_display_project_path(project_root, config_path)}") - - -@catalog_app.command("remove") -def catalog_remove( - name: str = typer.Argument(help="Catalog name to remove"), -): - """Remove a catalog from .specify/extension-catalogs.yml.""" - project_root = _require_specify_project() - specify_dir = project_root / ".specify" - - config_path = specify_dir / "extension-catalogs.yml" - if not config_path.exists(): - console.print("[red]Error:[/red] No catalog config found. Nothing to remove.") - raise typer.Exit(1) - - try: - config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} - except Exception: - console.print("[red]Error:[/red] Failed to read catalog config.") - raise typer.Exit(1) - - catalogs = config.get("catalogs", []) - if not isinstance(catalogs, list): - console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.") - raise typer.Exit(1) - original_count = len(catalogs) - catalogs = [c for c in catalogs if isinstance(c, dict) and c.get("name") != name] - - if len(catalogs) == original_count: - console.print(f"[red]Error:[/red] Catalog '{name}' not found.") - raise typer.Exit(1) - - config["catalogs"] = catalogs - config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8") - - console.print(f"[green]✓[/green] Removed catalog '{name}'") - if not catalogs: - console.print("\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]") - - -@extension_app.command("add") -def extension_add( - extension: str = typer.Argument(help="Extension name or path"), - dev: bool = typer.Option(False, "--dev", help="Install from local directory"), - from_url: Optional[str] = typer.Option(None, "--from", help="Install from custom URL"), - priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"), -): - """Install an extension.""" - from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError, REINSTALL_COMMAND - - project_root = _require_specify_project() - # Validate priority - if priority < 1: - console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)") - raise typer.Exit(1) - - manager = ExtensionManager(project_root) - speckit_version = get_speckit_version() - - try: - with console.status(f"[cyan]Installing extension: {extension}[/cyan]"): - if dev: - # Install from local directory - source_path = Path(extension).expanduser().resolve() - if not source_path.exists(): - console.print(f"[red]Error:[/red] Directory not found: {source_path}") - raise typer.Exit(1) - - if not (source_path / "extension.yml").exists(): - console.print(f"[red]Error:[/red] No extension.yml found in {source_path}") - raise typer.Exit(1) - - manifest = manager.install_from_directory(source_path, speckit_version, priority=priority) - - elif from_url: - # Install from URL (ZIP file) - import urllib.request - import urllib.error - from urllib.parse import urlparse - - # Validate URL - parsed = urlparse(from_url) - is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1") - - if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost): - console.print("[red]Error:[/red] URL must use HTTPS for security.") - console.print("HTTP is only allowed for localhost URLs.") - raise typer.Exit(1) - - # Warn about untrusted sources - console.print("[yellow]Warning:[/yellow] Installing from external URL.") - console.print("Only install extensions from sources you trust.\n") - console.print(f"Downloading from {from_url}...") - - # Download ZIP to temp location - download_dir = project_root / ".specify" / "extensions" / ".cache" / "downloads" - download_dir.mkdir(parents=True, exist_ok=True) - zip_path = download_dir / f"{extension}-url-download.zip" - - try: - with urllib.request.urlopen(from_url, timeout=60) as response: - zip_data = response.read() - zip_path.write_bytes(zip_data) - - # Install from downloaded ZIP - manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority) - except urllib.error.URLError as e: - console.print(f"[red]Error:[/red] Failed to download from {from_url}: {e}") - raise typer.Exit(1) - finally: - # Clean up downloaded ZIP - if zip_path.exists(): - zip_path.unlink() - - else: - # Try bundled extensions first (shipped with spec-kit) - bundled_path = _locate_bundled_extension(extension) - if bundled_path is not None: - manifest = manager.install_from_directory(bundled_path, speckit_version, priority=priority) - else: - # Install from catalog (also resolves display names to IDs) - catalog = ExtensionCatalog(project_root) - - # Check if extension exists in catalog (supports both ID and display name) - ext_info, catalog_error = _resolve_catalog_extension(extension, catalog, "add") - if catalog_error: - console.print(f"[red]Error:[/red] Could not query extension catalog: {catalog_error}") - raise typer.Exit(1) - if not ext_info: - console.print(f"[red]Error:[/red] Extension '{extension}' not found in catalog") - console.print("\nSearch available extensions:") - console.print(" specify extension search") - raise typer.Exit(1) - - # If catalog resolved a display name to an ID, check bundled again - resolved_id = ext_info['id'] - if resolved_id != extension: - bundled_path = _locate_bundled_extension(resolved_id) - if bundled_path is not None: - manifest = manager.install_from_directory(bundled_path, speckit_version, priority=priority) - - if bundled_path is None: - # Bundled extensions without a download URL must come from the local package - if ext_info.get("bundled") and not ext_info.get("download_url"): - console.print( - f"[red]Error:[/red] Extension '{ext_info['id']}' is bundled with spec-kit " - f"but could not be found in the installed package." - ) - console.print( - "\nThis usually means the spec-kit installation is incomplete or corrupted." - ) - console.print("Try reinstalling spec-kit:") - console.print(f" {REINSTALL_COMMAND}") - raise typer.Exit(1) - - # Enforce install_allowed policy - if not ext_info.get("_install_allowed", True): - catalog_name = ext_info.get("_catalog_name", "community") - console.print( - f"[red]Error:[/red] '{extension}' is available in the " - f"'{catalog_name}' catalog but installation is not allowed from that catalog." - ) - console.print( - f"\nTo enable installation, add '{extension}' to an approved catalog " - f"(install_allowed: true) in .specify/extension-catalogs.yml." - ) - raise typer.Exit(1) - - # Download extension ZIP (use resolved ID, not original argument which may be display name) - extension_id = ext_info['id'] - console.print(f"Downloading {ext_info['name']} v{ext_info.get('version', 'unknown')}...") - zip_path = catalog.download_extension(extension_id) - - try: - # Install from downloaded ZIP - manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority) - finally: - # Clean up downloaded ZIP - if zip_path.exists(): - zip_path.unlink() - - console.print("\n[green]✓[/green] Extension installed successfully!") - console.print(f"\n[bold]{manifest.name}[/bold] (v{manifest.version})") - console.print(f" {manifest.description}") - - for warning in manifest.warnings: - console.print(f"\n[yellow]⚠ Compatibility warning:[/yellow] {warning}") - - console.print("\n[bold cyan]Provided commands:[/bold cyan]") - for cmd in manifest.commands: - console.print(f" • {cmd['name']} - {cmd.get('description', '')}") - - # Report agent skills registration - reg_meta = manager.registry.get(manifest.id) - reg_skills = reg_meta.get("registered_skills", []) if reg_meta else [] - # Normalize to guard against corrupted registry entries - if not isinstance(reg_skills, list): - reg_skills = [] - if reg_skills: - console.print(f"\n[green]✓[/green] {len(reg_skills)} agent skill(s) auto-registered") - - console.print("\n[yellow]⚠[/yellow] Configuration may be required") - console.print(f" Check: .specify/extensions/{manifest.id}/") - - except ValidationError as e: - console.print(f"\n[red]Validation Error:[/red] {e}") - raise typer.Exit(1) - except CompatibilityError as e: - console.print(f"\n[red]Compatibility Error:[/red] {e}") - raise typer.Exit(1) - except ExtensionError as e: - console.print(f"\n[red]Error:[/red] {e}") - raise typer.Exit(1) - - -@extension_app.command("remove") -def extension_remove( - extension: str = typer.Argument(help="Extension ID or name to remove"), - keep_config: bool = typer.Option(False, "--keep-config", help="Don't remove config files"), - force: bool = typer.Option(False, "--force", help="Skip confirmation"), -): - """Uninstall an extension.""" - from .extensions import ExtensionManager - - project_root = _require_specify_project() - manager = ExtensionManager(project_root) - - # Resolve extension ID from argument (handles ambiguous names) - installed = manager.list_installed() - extension_id, display_name = _resolve_installed_extension(extension, installed, "remove") - - # Get extension info for command and skill counts - ext_manifest = manager.get_extension(extension_id) - reg_meta = manager.registry.get(extension_id) - # Derive cmd_count from the registry's registered_commands (includes aliases) - # rather than from the manifest (primary commands only). Use max() across - # agents to get the per-agent count; sum() would double-count since users - # think in logical commands, not per-agent file counts. - # Use get() without a default so we can distinguish "key missing" (fall back - # to manifest) from "key present but empty dict" (zero commands registered). - registered_commands = reg_meta.get("registered_commands") if isinstance(reg_meta, dict) else None - if isinstance(registered_commands, dict): - cmd_count = max( - (len(v) for v in registered_commands.values() if isinstance(v, list)), - default=0, - ) - else: - cmd_count = len(ext_manifest.commands) if ext_manifest else 0 - raw_skills = reg_meta.get("registered_skills") if reg_meta else None - skill_count = len(raw_skills) if isinstance(raw_skills, list) else 0 - - # Confirm removal - if not force: - console.print("\n[yellow]⚠ This will remove:[/yellow]") - console.print(f" • {cmd_count} command{'s' if cmd_count != 1 else ''} per agent") - if skill_count: - console.print(f" • {skill_count} agent skill(s)") - console.print(f" • Extension directory: .specify/extensions/{extension_id}/") - if not keep_config: - console.print(" • Config files (will be backed up)") - console.print() - - confirm = typer.confirm("Continue?") - if not confirm: - console.print("Cancelled") - raise typer.Exit(0) - - # Remove extension - success = manager.remove(extension_id, keep_config=keep_config) - - if success: - console.print(f"\n[green]✓[/green] Extension '{display_name}' removed successfully") - if keep_config: - console.print(f"\nConfig files preserved in .specify/extensions/{extension_id}/") - else: - console.print(f"\nConfig files backed up to .specify/extensions/.backup/{extension_id}/") - console.print(f"\nTo reinstall: specify extension add {extension_id}") - else: - console.print("[red]Error:[/red] Failed to remove extension") - raise typer.Exit(1) - - -@extension_app.command("search") -def extension_search( - query: str = typer.Argument(None, help="Search query (optional)"), - tag: Optional[str] = typer.Option(None, "--tag", help="Filter by tag"), - author: Optional[str] = typer.Option(None, "--author", help="Filter by author"), - verified: bool = typer.Option(False, "--verified", help="Show only verified extensions"), -): - """Search for available extensions in catalog.""" - from .extensions import ExtensionCatalog, ExtensionError - - project_root = _require_specify_project() - catalog = ExtensionCatalog(project_root) - - try: - console.print("🔍 Searching extension catalog...") - results = catalog.search(query=query, tag=tag, author=author, verified_only=verified) - - if not results: - console.print("\n[yellow]No extensions found matching criteria[/yellow]") - if query or tag or author or verified: - console.print("\nTry:") - console.print(" • Broader search terms") - console.print(" • Remove filters") - console.print(" • specify extension search (show all)") - raise typer.Exit(0) - - console.print(f"\n[green]Found {len(results)} extension(s):[/green]\n") - - for ext in results: - # Extension header - verified_badge = " [green]✓ Verified[/green]" if ext.get("verified") else "" - console.print(f"[bold]{ext['name']}[/bold] (v{ext['version']}){verified_badge}") - console.print(f" {ext['description']}") - - # Metadata - console.print(f"\n [dim]Author:[/dim] {ext.get('author', 'Unknown')}") - if ext.get('tags'): - tags_str = ", ".join(ext['tags']) - console.print(f" [dim]Tags:[/dim] {tags_str}") - - # Source catalog - catalog_name = ext.get("_catalog_name", "") - install_allowed = ext.get("_install_allowed", True) - if catalog_name: - if install_allowed: - console.print(f" [dim]Catalog:[/dim] {catalog_name}") - else: - console.print(f" [dim]Catalog:[/dim] {catalog_name} [yellow](discovery only — not installable)[/yellow]") - - # Stats - stats = [] - if ext.get('downloads') is not None: - stats.append(f"Downloads: {ext['downloads']:,}") - if ext.get('stars') is not None: - stats.append(f"Stars: {ext['stars']}") - if stats: - console.print(f" [dim]{' | '.join(stats)}[/dim]") - - # Links - if ext.get('repository'): - console.print(f" [dim]Repository:[/dim] {ext['repository']}") - - # Install command (show warning if not installable) - if install_allowed: - console.print(f"\n [cyan]Install:[/cyan] specify extension add {ext['id']}") - else: - console.print(f"\n [yellow]⚠[/yellow] Not directly installable from '{catalog_name}'.") - console.print( - f" Add to an approved catalog with install_allowed: true, " - f"or install from a ZIP URL: specify extension add {ext['id']} --from " - ) - console.print() - - except ExtensionError as e: - console.print(f"\n[red]Error:[/red] {e}") - console.print("\nTip: The catalog may be temporarily unavailable. Try again later.") - raise typer.Exit(1) - - -@extension_app.command("info") -def extension_info( - extension: str = typer.Argument(help="Extension ID or name"), -): - """Show detailed information about an extension.""" - from .extensions import ExtensionCatalog, ExtensionManager, normalize_priority - - project_root = _require_specify_project() - catalog = ExtensionCatalog(project_root) - manager = ExtensionManager(project_root) - installed = manager.list_installed() - - # Try to resolve from installed extensions first (by ID or name) - # Use allow_not_found=True since the extension may be catalog-only - resolved_installed_id, resolved_installed_name = _resolve_installed_extension( - extension, installed, "info", allow_not_found=True - ) - - # Try catalog lookup (with error handling) - # If we resolved an installed extension by display name, use its ID for catalog lookup - # to ensure we get the correct catalog entry (not a different extension with same name) - lookup_key = resolved_installed_id if resolved_installed_id else extension - ext_info, catalog_error = _resolve_catalog_extension(lookup_key, catalog, "info") - - # Case 1: Found in catalog - show full catalog info - if ext_info: - _print_extension_info(ext_info, manager) - return - - # Case 2: Installed locally but catalog lookup failed or not in catalog - if resolved_installed_id: - # Get local manifest info - ext_manifest = manager.get_extension(resolved_installed_id) - metadata = manager.registry.get(resolved_installed_id) - metadata_is_dict = isinstance(metadata, dict) - if not metadata_is_dict: - console.print( - "[yellow]Warning:[/yellow] Extension metadata appears to be corrupted; " - "some information may be unavailable." - ) - version = metadata.get("version", "unknown") if metadata_is_dict else "unknown" - - console.print(f"\n[bold]{resolved_installed_name}[/bold] (v{version})") - console.print(f"ID: {resolved_installed_id}") - console.print() - - if ext_manifest: - console.print(f"{ext_manifest.description}") - console.print() - # Author is optional in extension.yml, safely retrieve it - author = ext_manifest.data.get("extension", {}).get("author") - if author: - console.print(f"[dim]Author:[/dim] {author}") - console.print() - - if ext_manifest.commands: - console.print("[bold]Commands:[/bold]") - for cmd in ext_manifest.commands: - console.print(f" • {cmd['name']}: {cmd.get('description', '')}") - console.print() - - # Show catalog status - if catalog_error: - console.print(f"[yellow]Catalog unavailable:[/yellow] {catalog_error}") - console.print("[dim]Note: Using locally installed extension; catalog info could not be verified.[/dim]") - else: - console.print("[yellow]Note:[/yellow] Not found in catalog (custom/local extension)") - - console.print() - console.print("[green]✓ Installed[/green]") - priority = normalize_priority(metadata.get("priority") if metadata_is_dict else None) - console.print(f"[dim]Priority:[/dim] {priority}") - console.print(f"\nTo remove: specify extension remove {resolved_installed_id}") - return - - # Case 3: Not found anywhere - if catalog_error: - console.print(f"[red]Error:[/red] Could not query extension catalog: {catalog_error}") - console.print("\nTry again when online, or use the extension ID directly.") - else: - console.print(f"[red]Error:[/red] Extension '{extension}' not found") - console.print("\nTry: specify extension search") - raise typer.Exit(1) - - -def _print_extension_info(ext_info: dict, manager): - """Print formatted extension info from catalog data.""" - from .extensions import normalize_priority - - # Header - verified_badge = " [green]✓ Verified[/green]" if ext_info.get("verified") else "" - console.print(f"\n[bold]{ext_info['name']}[/bold] (v{ext_info['version']}){verified_badge}") - console.print(f"ID: {ext_info['id']}") - console.print() - - # Description - console.print(f"{ext_info['description']}") - console.print() - - # Author and License - console.print(f"[dim]Author:[/dim] {ext_info.get('author', 'Unknown')}") - console.print(f"[dim]License:[/dim] {ext_info.get('license', 'Unknown')}") - - # Source catalog - if ext_info.get("_catalog_name"): - install_allowed = ext_info.get("_install_allowed", True) - install_note = "" if install_allowed else " [yellow](discovery only)[/yellow]" - console.print(f"[dim]Source catalog:[/dim] {ext_info['_catalog_name']}{install_note}") - console.print() - - # Requirements - if ext_info.get('requires'): - console.print("[bold]Requirements:[/bold]") - reqs = ext_info['requires'] - if reqs.get('speckit_version'): - console.print(f" • Spec Kit: {reqs['speckit_version']}") - if reqs.get('tools'): - for tool in reqs['tools']: - tool_name = tool['name'] - tool_version = tool.get('version', 'any') - required = " (required)" if tool.get('required') else " (optional)" - console.print(f" • {tool_name}: {tool_version}{required}") - console.print() - - # Provides - if ext_info.get('provides'): - console.print("[bold]Provides:[/bold]") - provides = ext_info['provides'] - if provides.get('commands'): - console.print(f" • Commands: {provides['commands']}") - if provides.get('hooks'): - console.print(f" • Hooks: {provides['hooks']}") - console.print() - - # Tags - if ext_info.get('tags'): - tags_str = ", ".join(ext_info['tags']) - console.print(f"[bold]Tags:[/bold] {tags_str}") - console.print() - - # Statistics - stats = [] - if ext_info.get('downloads') is not None: - stats.append(f"Downloads: {ext_info['downloads']:,}") - if ext_info.get('stars') is not None: - stats.append(f"Stars: {ext_info['stars']}") - if stats: - console.print(f"[bold]Statistics:[/bold] {' | '.join(stats)}") - console.print() - - # Links - console.print("[bold]Links:[/bold]") - if ext_info.get('repository'): - console.print(f" • Repository: {ext_info['repository']}") - if ext_info.get('homepage'): - console.print(f" • Homepage: {ext_info['homepage']}") - if ext_info.get('documentation'): - console.print(f" • Documentation: {ext_info['documentation']}") - if ext_info.get('changelog'): - console.print(f" • Changelog: {ext_info['changelog']}") - console.print() - - # Installation status and command - is_installed = manager.registry.is_installed(ext_info['id']) - install_allowed = ext_info.get("_install_allowed", True) - if is_installed: - console.print("[green]✓ Installed[/green]") - metadata = manager.registry.get(ext_info['id']) - priority = normalize_priority(metadata.get("priority") if isinstance(metadata, dict) else None) - console.print(f"[dim]Priority:[/dim] {priority}") - console.print(f"\nTo remove: specify extension remove {ext_info['id']}") - elif install_allowed: - console.print("[yellow]Not installed[/yellow]") - console.print(f"\n[cyan]Install:[/cyan] specify extension add {ext_info['id']}") - else: - catalog_name = ext_info.get("_catalog_name", "community") - console.print("[yellow]Not installed[/yellow]") - console.print( - f"\n[yellow]⚠[/yellow] '{ext_info['id']}' is available in the '{catalog_name}' catalog " - f"but not in your approved catalog. Add it to .specify/extension-catalogs.yml " - f"with install_allowed: true to enable installation." - ) - - -@extension_app.command("update") -def extension_update( - extension: str = typer.Argument(None, help="Extension ID or name to update (or all)"), -): - """Update extension(s) to latest version.""" - from .extensions import ( - ExtensionManager, - ExtensionCatalog, - ExtensionError, - ValidationError, - CommandRegistrar, - HookExecutor, - normalize_priority, - ) - from packaging import version as pkg_version - import shutil - - project_root = _require_specify_project() - manager = ExtensionManager(project_root) - catalog = ExtensionCatalog(project_root) - speckit_version = get_speckit_version() - - try: - # Get list of extensions to update - installed = manager.list_installed() - if extension: - # Update specific extension - resolve ID from argument (handles ambiguous names) - extension_id, _ = _resolve_installed_extension(extension, installed, "update") - extensions_to_update = [extension_id] - else: - # Update all extensions - extensions_to_update = [ext["id"] for ext in installed] - - if not extensions_to_update: - console.print("[yellow]No extensions installed[/yellow]") - raise typer.Exit(0) - - console.print("🔄 Checking for updates...\n") - - updates_available = [] - - for ext_id in extensions_to_update: - # Get installed version - metadata = manager.registry.get(ext_id) - if metadata is None or not isinstance(metadata, dict) or "version" not in metadata: - console.print(f"⚠ {ext_id}: Registry entry corrupted or missing (skipping)") - continue - try: - installed_version = pkg_version.Version(metadata["version"]) - except pkg_version.InvalidVersion: - console.print( - f"⚠ {ext_id}: Invalid installed version '{metadata.get('version')}' in registry (skipping)" - ) - continue - - # Get catalog info - ext_info = catalog.get_extension_info(ext_id) - if not ext_info: - console.print(f"⚠ {ext_id}: Not found in catalog (skipping)") - continue - - # Check if installation is allowed from this catalog - if not ext_info.get("_install_allowed", True): - console.print(f"⚠ {ext_id}: Updates not allowed from '{ext_info.get('_catalog_name', 'catalog')}' (skipping)") - continue - - try: - catalog_version = pkg_version.Version(ext_info["version"]) - except pkg_version.InvalidVersion: - console.print( - f"⚠ {ext_id}: Invalid catalog version '{ext_info.get('version')}' (skipping)" - ) - continue - - if catalog_version > installed_version: - updates_available.append( - { - "id": ext_id, - "name": ext_info.get("name", ext_id), # Display name for status messages - "installed": str(installed_version), - "available": str(catalog_version), - "download_url": ext_info.get("download_url"), - } - ) - else: - console.print(f"✓ {ext_id}: Up to date (v{installed_version})") - - if not updates_available: - console.print("\n[green]All extensions are up to date![/green]") - raise typer.Exit(0) - - # Show available updates - console.print("\n[bold]Updates available:[/bold]\n") - for update in updates_available: - console.print( - f" • {update['id']}: {update['installed']} → {update['available']}" - ) - - console.print() - confirm = typer.confirm("Update these extensions?") - if not confirm: - console.print("Cancelled") - raise typer.Exit(0) - - # Perform updates with atomic backup/restore - console.print() - updated_extensions = [] - failed_updates = [] - registrar = CommandRegistrar() - hook_executor = HookExecutor(project_root) - - for update in updates_available: - extension_id = update["id"] - ext_name = update["name"] # Use display name for user-facing messages - console.print(f"📦 Updating {ext_name}...") - - # Backup paths - backup_base = manager.extensions_dir / ".backup" / f"{extension_id}-update" - backup_ext_dir = backup_base / "extension" - backup_commands_dir = backup_base / "commands" - backup_config_dir = backup_base / "config" - - # Store backup state - backup_registry_entry = None - backup_hooks = None # None means no hooks key in config; {} means hooks key existed - backed_up_command_files = {} - - try: - # 1. Backup registry entry (always, even if extension dir doesn't exist) - backup_registry_entry = manager.registry.get(extension_id) - - # 2. Backup extension directory - extension_dir = manager.extensions_dir / extension_id - if extension_dir.exists(): - backup_base.mkdir(parents=True, exist_ok=True) - if backup_ext_dir.exists(): - shutil.rmtree(backup_ext_dir) - shutil.copytree(extension_dir, backup_ext_dir) - - # Backup config files separately so they can be restored - # after a successful install (install_from_directory clears dest dir). - config_files = list(extension_dir.glob("*-config.yml")) + list( - extension_dir.glob("*-config.local.yml") - ) - for cfg_file in config_files: - backup_config_dir.mkdir(parents=True, exist_ok=True) - shutil.copy2(cfg_file, backup_config_dir / cfg_file.name) - - # 3. Backup command files for all agents - from .agents import CommandRegistrar as _AgentReg - registered_commands = backup_registry_entry.get("registered_commands", {}) - for agent_name, cmd_names in registered_commands.items(): - if agent_name not in registrar.AGENT_CONFIGS: - continue - agent_config = registrar.AGENT_CONFIGS[agent_name] - commands_dir = project_root / agent_config["dir"] - - for cmd_name in cmd_names: - output_name = _AgentReg._compute_output_name(agent_name, cmd_name, agent_config) - cmd_file = commands_dir / f"{output_name}{agent_config['extension']}" - if cmd_file.exists(): - backup_cmd_path = backup_commands_dir / agent_name / cmd_file.name - backup_cmd_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(cmd_file, backup_cmd_path) - backed_up_command_files[str(cmd_file)] = str(backup_cmd_path) - - # Also backup copilot prompt files - if agent_name == "copilot": - prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md" - if prompt_file.exists(): - backup_prompt_path = backup_commands_dir / "copilot-prompts" / prompt_file.name - backup_prompt_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(prompt_file, backup_prompt_path) - backed_up_command_files[str(prompt_file)] = str(backup_prompt_path) - - # 4. Backup hooks from extensions.yml - # Use backup_hooks=None to indicate config had no "hooks" key (don't create on restore) - # Use backup_hooks={} to indicate config had "hooks" key with no hooks for this extension - config = hook_executor.get_project_config() - if "hooks" in config: - backup_hooks = {} # Config has hooks key - preserve this fact - for hook_name, hook_list in config["hooks"].items(): - ext_hooks = [h for h in hook_list if h.get("extension") == extension_id] - if ext_hooks: - backup_hooks[hook_name] = ext_hooks - - # 5. Download new version - zip_path = catalog.download_extension(extension_id) - try: - # 6. Validate extension ID from ZIP BEFORE modifying installation - # Handle both root-level and nested extension.yml (GitHub auto-generated ZIPs) - with zipfile.ZipFile(zip_path, "r") as zf: - import yaml - manifest_data = None - namelist = zf.namelist() - - # First try root-level extension.yml - if "extension.yml" in namelist: - with zf.open("extension.yml") as f: - manifest_data = yaml.safe_load(f) or {} - else: - # Look for extension.yml in a single top-level subdirectory - # (e.g., "repo-name-branch/extension.yml") - manifest_paths = [n for n in namelist if n.endswith("/extension.yml") and n.count("/") == 1] - if len(manifest_paths) == 1: - with zf.open(manifest_paths[0]) as f: - manifest_data = yaml.safe_load(f) or {} - - if manifest_data is None: - raise ValueError("Downloaded extension archive is missing 'extension.yml'") - - zip_extension_id = manifest_data.get("extension", {}).get("id") - if zip_extension_id != extension_id: - raise ValueError( - f"Extension ID mismatch: expected '{extension_id}', got '{zip_extension_id}'" - ) - - # 7. Remove old extension (handles command file cleanup and registry removal) - manager.remove(extension_id, keep_config=True) - - # 8. Install new version - _ = manager.install_from_zip(zip_path, speckit_version) - - # Restore user config files from backup after successful install. - new_extension_dir = manager.extensions_dir / extension_id - if backup_config_dir.exists() and new_extension_dir.exists(): - for cfg_file in backup_config_dir.iterdir(): - if cfg_file.is_file(): - shutil.copy2(cfg_file, new_extension_dir / cfg_file.name) - - # 9. Restore metadata from backup (installed_at, enabled state) - if backup_registry_entry and isinstance(backup_registry_entry, dict): - # Copy current registry entry to avoid mutating internal - # registry state before explicit restore(). - current_metadata = manager.registry.get(extension_id) - if current_metadata is None or not isinstance(current_metadata, dict): - raise RuntimeError( - f"Registry entry for '{extension_id}' missing or corrupted after install — update incomplete" - ) - new_metadata = dict(current_metadata) - - # Preserve the original installation timestamp - if "installed_at" in backup_registry_entry: - new_metadata["installed_at"] = backup_registry_entry["installed_at"] - - # Preserve the original priority (normalized to handle corruption) - if "priority" in backup_registry_entry: - new_metadata["priority"] = normalize_priority(backup_registry_entry["priority"]) - - # If extension was disabled before update, disable it again - if not backup_registry_entry.get("enabled", True): - new_metadata["enabled"] = False - - # Use restore() instead of update() because update() always - # preserves the existing installed_at, ignoring our override - manager.registry.restore(extension_id, new_metadata) - - # Also disable hooks in extensions.yml if extension was disabled - if not backup_registry_entry.get("enabled", True): - config = hook_executor.get_project_config() - if "hooks" in config: - for hook_name in config["hooks"]: - for hook in config["hooks"][hook_name]: - if hook.get("extension") == extension_id: - hook["enabled"] = False - hook_executor.save_project_config(config) - finally: - # Clean up downloaded ZIP - if zip_path.exists(): - zip_path.unlink() - - # 10. Clean up backup on success - if backup_base.exists(): - shutil.rmtree(backup_base) - - console.print(f" [green]✓[/green] Updated to v{update['available']}") - updated_extensions.append(ext_name) - - except KeyboardInterrupt: - raise - except Exception as e: - console.print(f" [red]✗[/red] Failed: {e}") - failed_updates.append((ext_name, str(e))) - - # Rollback on failure - console.print(f" [yellow]↩[/yellow] Rolling back {ext_name}...") - - try: - # Restore extension directory - # Only perform destructive rollback if backup exists (meaning we - # actually modified the extension). This avoids deleting a valid - # installation when failure happened before changes were made. - extension_dir = manager.extensions_dir / extension_id - if backup_ext_dir.exists(): - if extension_dir.exists(): - shutil.rmtree(extension_dir) - shutil.copytree(backup_ext_dir, extension_dir) - - # Remove any NEW command files created by failed install - # (files that weren't in the original backup) - try: - new_registry_entry = manager.registry.get(extension_id) - if new_registry_entry is None or not isinstance(new_registry_entry, dict): - new_registered_commands = {} - else: - new_registered_commands = new_registry_entry.get("registered_commands", {}) - for agent_name, cmd_names in new_registered_commands.items(): - if agent_name not in registrar.AGENT_CONFIGS: - continue - agent_config = registrar.AGENT_CONFIGS[agent_name] - commands_dir = project_root / agent_config["dir"] - - for cmd_name in cmd_names: - output_name = _AgentReg._compute_output_name(agent_name, cmd_name, agent_config) - cmd_file = commands_dir / f"{output_name}{agent_config['extension']}" - # Delete if it exists and wasn't in our backup - if cmd_file.exists() and str(cmd_file) not in backed_up_command_files: - cmd_file.unlink() - - # Also handle copilot prompt files - if agent_name == "copilot": - prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md" - if prompt_file.exists() and str(prompt_file) not in backed_up_command_files: - prompt_file.unlink() - except KeyError: - pass # No new registry entry exists, nothing to clean up - - # Restore backed up command files - for original_path, backup_path in backed_up_command_files.items(): - backup_file = Path(backup_path) - if backup_file.exists(): - original_file = Path(original_path) - original_file.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(backup_file, original_file) - - # Restore hooks in extensions.yml - # - backup_hooks=None means original config had no "hooks" key - # - backup_hooks={} or {...} means config had hooks key - config = hook_executor.get_project_config() - if "hooks" in config: - modified = False - - if backup_hooks is None: - # Original config had no "hooks" key; remove it entirely - del config["hooks"] - modified = True - else: - # Remove any hooks for this extension added by failed install - for hook_name, hooks_list in config["hooks"].items(): - original_len = len(hooks_list) - config["hooks"][hook_name] = [ - h for h in hooks_list - if h.get("extension") != extension_id - ] - if len(config["hooks"][hook_name]) != original_len: - modified = True - - # Add back the backed up hooks if any - if backup_hooks: - for hook_name, hooks in backup_hooks.items(): - if hook_name not in config["hooks"]: - config["hooks"][hook_name] = [] - config["hooks"][hook_name].extend(hooks) - modified = True - - if modified: - hook_executor.save_project_config(config) - - # Restore registry entry (use restore() since entry was removed) - if backup_registry_entry: - manager.registry.restore(extension_id, backup_registry_entry) - - console.print(" [green]✓[/green] Rollback successful") - # Clean up backup directory only on successful rollback - if backup_base.exists(): - shutil.rmtree(backup_base) - except Exception as rollback_error: - console.print(f" [red]✗[/red] Rollback failed: {rollback_error}") - console.print(f" [dim]Backup preserved at: {backup_base}[/dim]") - - # Summary - console.print() - if updated_extensions: - console.print(f"[green]✓[/green] Successfully updated {len(updated_extensions)} extension(s)") - if failed_updates: - console.print(f"[red]✗[/red] Failed to update {len(failed_updates)} extension(s):") - for ext_name, error in failed_updates: - console.print(f" • {ext_name}: {error}") - raise typer.Exit(1) - - except ValidationError as e: - console.print(f"\n[red]Validation Error:[/red] {e}") - raise typer.Exit(1) - except ExtensionError as e: - console.print(f"\n[red]Error:[/red] {e}") - raise typer.Exit(1) - - -@extension_app.command("enable") -def extension_enable( - extension: str = typer.Argument(help="Extension ID or name to enable"), -): - """Enable a disabled extension.""" - from .extensions import ExtensionManager, HookExecutor - - project_root = _require_specify_project() - manager = ExtensionManager(project_root) - hook_executor = HookExecutor(project_root) - - # Resolve extension ID from argument (handles ambiguous names) - installed = manager.list_installed() - extension_id, display_name = _resolve_installed_extension(extension, installed, "enable") - - # Update registry - metadata = manager.registry.get(extension_id) - if metadata is None or not isinstance(metadata, dict): - console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)") - raise typer.Exit(1) - - if metadata.get("enabled", True): - console.print(f"[yellow]Extension '{display_name}' is already enabled[/yellow]") - raise typer.Exit(0) - - manager.registry.update(extension_id, {"enabled": True}) - - # Enable hooks in extensions.yml - config = hook_executor.get_project_config() - if "hooks" in config: - for hook_name in config["hooks"]: - for hook in config["hooks"][hook_name]: - if hook.get("extension") == extension_id: - hook["enabled"] = True - hook_executor.save_project_config(config) - - console.print(f"[green]✓[/green] Extension '{display_name}' enabled") - - -@extension_app.command("disable") -def extension_disable( - extension: str = typer.Argument(help="Extension ID or name to disable"), -): - """Disable an extension without removing it.""" - from .extensions import ExtensionManager, HookExecutor - - project_root = _require_specify_project() - manager = ExtensionManager(project_root) - hook_executor = HookExecutor(project_root) - - # Resolve extension ID from argument (handles ambiguous names) - installed = manager.list_installed() - extension_id, display_name = _resolve_installed_extension(extension, installed, "disable") - - # Update registry - metadata = manager.registry.get(extension_id) - if metadata is None or not isinstance(metadata, dict): - console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)") - raise typer.Exit(1) - - if not metadata.get("enabled", True): - console.print(f"[yellow]Extension '{display_name}' is already disabled[/yellow]") - raise typer.Exit(0) - - manager.registry.update(extension_id, {"enabled": False}) - - # Disable hooks in extensions.yml - config = hook_executor.get_project_config() - if "hooks" in config: - for hook_name in config["hooks"]: - for hook in config["hooks"][hook_name]: - if hook.get("extension") == extension_id: - hook["enabled"] = False - hook_executor.save_project_config(config) - - console.print(f"[green]✓[/green] Extension '{display_name}' disabled") - console.print("\nCommands will no longer be available. Hooks will not execute.") - console.print(f"To re-enable: specify extension enable {extension_id}") - - -@extension_app.command("set-priority") -def extension_set_priority( - extension: str = typer.Argument(help="Extension ID or name"), - priority: int = typer.Argument(help="New priority (lower = higher precedence)"), -): - """Set the resolution priority of an installed extension.""" - from .extensions import ExtensionManager - - project_root = _require_specify_project() - # Validate priority - if priority < 1: - console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)") - raise typer.Exit(1) - - manager = ExtensionManager(project_root) - - # Resolve extension ID from argument (handles ambiguous names) - installed = manager.list_installed() - extension_id, display_name = _resolve_installed_extension(extension, installed, "set-priority") - - # Get current metadata - metadata = manager.registry.get(extension_id) - if metadata is None or not isinstance(metadata, dict): - console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)") - raise typer.Exit(1) - - from .extensions import normalize_priority - raw_priority = metadata.get("priority") - # Only skip if the stored value is already a valid int equal to requested priority - # This ensures corrupted values (e.g., "high") get repaired even when setting to default (10) - if isinstance(raw_priority, int) and raw_priority == priority: - console.print(f"[yellow]Extension '{display_name}' already has priority {priority}[/yellow]") - raise typer.Exit(0) - - old_priority = normalize_priority(raw_priority) - - # Update priority - manager.registry.update(extension_id, {"priority": priority}) - - console.print(f"[green]✓[/green] Extension '{display_name}' priority changed: {old_priority} → {priority}") - console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]") # ===== Workflow Commands ===== diff --git a/src/specify_cli/commands/extension.py b/src/specify_cli/commands/extension.py index 985c192cb6..748ba7b920 100644 --- a/src/specify_cli/commands/extension.py +++ b/src/specify_cli/commands/extension.py @@ -1 +1,1459 @@ """specify extension * and catalog * commands.""" + +import os +import zipfile +import shutil +import yaml +from pathlib import Path +from typing import Optional + +import typer + +from .._console import console +from .._assets import _asset_service as _svc +from .._helpers import get_speckit_version + +extension_app = typer.Typer( + name="extension", + help="Manage spec-kit extensions", + add_completion=False, +) + +catalog_app = typer.Typer( + name="catalog", + help="Manage extension catalogs", + add_completion=False, +) + +extension_app.add_typer(catalog_app, name="catalog") + + +def _locate_bundled_extension(extension_id: str) -> Path | None: + return _svc.locate_bundled_extension(extension_id) + + +def _resolve_installed_extension( + argument: str, + installed_extensions: list, + command_name: str = "command", + allow_not_found: bool = False, +) -> tuple[Optional[str], Optional[str]]: + """Resolve an extension argument (ID or display name) to an installed extension. + + Args: + argument: Extension ID or display name provided by user + installed_extensions: List of installed extension dicts from manager.list_installed() + command_name: Name of the command for error messages (e.g., "enable", "disable") + allow_not_found: If True, return (None, None) when not found instead of raising + + Returns: + Tuple of (extension_id, display_name), or (None, None) if allow_not_found=True and not found + + Raises: + typer.Exit: If extension not found (and allow_not_found=False) or name is ambiguous + """ + from rich.table import Table + + # First, try exact ID match + for ext in installed_extensions: + if ext["id"] == argument: + return (ext["id"], ext["name"]) + + # If not found by ID, try display name match + name_matches = [ext for ext in installed_extensions if ext["name"].lower() == argument.lower()] + + if len(name_matches) == 1: + # Unique display-name match + return (name_matches[0]["id"], name_matches[0]["name"]) + elif len(name_matches) > 1: + # Ambiguous display-name match + console.print( + f"[red]Error:[/red] Extension name '{argument}' is ambiguous. " + "Multiple installed extensions share this name:" + ) + table = Table(title="Matching extensions") + table.add_column("ID", style="cyan", no_wrap=True) + table.add_column("Name", style="white") + table.add_column("Version", style="green") + for ext in name_matches: + table.add_row(ext.get("id", ""), ext.get("name", ""), str(ext.get("version", ""))) + console.print(table) + console.print("\nPlease rerun using the extension ID:") + console.print(f" [bold]specify extension {command_name} [/bold]") + raise typer.Exit(1) + else: + # No match by ID or display name + if allow_not_found: + return (None, None) + console.print(f"[red]Error:[/red] Extension '{argument}' is not installed") + raise typer.Exit(1) + + +def _resolve_catalog_extension( + argument: str, + catalog, + command_name: str = "info", +) -> tuple[Optional[dict], Optional[Exception]]: + """Resolve an extension argument (ID or display name) from the catalog. + + Args: + argument: Extension ID or display name provided by user + catalog: ExtensionCatalog instance + command_name: Name of the command for error messages + + Returns: + Tuple of (extension_info, catalog_error) + - If found: (ext_info_dict, None) + - If catalog error: (None, error) + - If not found: (None, None) + """ + from rich.table import Table + from ..extensions import ExtensionError + + try: + # First try by ID + ext_info = catalog.get_extension_info(argument) + if ext_info: + return (ext_info, None) + + # Try by display name - search using argument as query, then filter for exact match + search_results = catalog.search(query=argument) + name_matches = [ext for ext in search_results if ext["name"].lower() == argument.lower()] + + if len(name_matches) == 1: + return (name_matches[0], None) + elif len(name_matches) > 1: + # Ambiguous display-name match in catalog + console.print( + f"[red]Error:[/red] Extension name '{argument}' is ambiguous. " + "Multiple catalog extensions share this name:" + ) + table = Table(title="Matching extensions") + table.add_column("ID", style="cyan", no_wrap=True) + table.add_column("Name", style="white") + table.add_column("Version", style="green") + table.add_column("Catalog", style="dim") + for ext in name_matches: + table.add_row( + ext.get("id", ""), + ext.get("name", ""), + str(ext.get("version", "")), + ext.get("_catalog_name", ""), + ) + console.print(table) + console.print("\nPlease rerun using the extension ID:") + console.print(f" [bold]specify extension {command_name} [/bold]") + raise typer.Exit(1) + + # Not found + return (None, None) + + except ExtensionError as e: + return (None, e) + + +def _print_extension_info(ext_info: dict, manager) -> None: + """Print formatted extension info from catalog data.""" + from ..extensions import normalize_priority + + # Header + verified_badge = " [green]✓ Verified[/green]" if ext_info.get("verified") else "" + console.print(f"\n[bold]{ext_info['name']}[/bold] (v{ext_info['version']}){verified_badge}") + console.print(f"ID: {ext_info['id']}") + console.print() + + # Description + console.print(f"{ext_info['description']}") + console.print() + + # Author and License + console.print(f"[dim]Author:[/dim] {ext_info.get('author', 'Unknown')}") + console.print(f"[dim]License:[/dim] {ext_info.get('license', 'Unknown')}") + + # Source catalog + if ext_info.get("_catalog_name"): + install_allowed = ext_info.get("_install_allowed", True) + install_note = "" if install_allowed else " [yellow](discovery only)[/yellow]" + console.print(f"[dim]Source catalog:[/dim] {ext_info['_catalog_name']}{install_note}") + console.print() + + # Requirements + if ext_info.get('requires'): + console.print("[bold]Requirements:[/bold]") + reqs = ext_info['requires'] + if reqs.get('speckit_version'): + console.print(f" • Spec Kit: {reqs['speckit_version']}") + if reqs.get('tools'): + for tool in reqs['tools']: + tool_name = tool['name'] + tool_version = tool.get('version', 'any') + required = " (required)" if tool.get('required') else " (optional)" + console.print(f" • {tool_name}: {tool_version}{required}") + console.print() + + # Provides + if ext_info.get('provides'): + console.print("[bold]Provides:[/bold]") + provides = ext_info['provides'] + if provides.get('commands'): + console.print(f" • Commands: {provides['commands']}") + if provides.get('hooks'): + console.print(f" • Hooks: {provides['hooks']}") + console.print() + + # Tags + if ext_info.get('tags'): + tags_str = ", ".join(ext_info['tags']) + console.print(f"[bold]Tags:[/bold] {tags_str}") + console.print() + + # Statistics + stats = [] + if ext_info.get('downloads') is not None: + stats.append(f"Downloads: {ext_info['downloads']:,}") + if ext_info.get('stars') is not None: + stats.append(f"Stars: {ext_info['stars']}") + if stats: + console.print(f"[bold]Statistics:[/bold] {' | '.join(stats)}") + console.print() + + # Links + console.print("[bold]Links:[/bold]") + if ext_info.get('repository'): + console.print(f" • Repository: {ext_info['repository']}") + if ext_info.get('homepage'): + console.print(f" • Homepage: {ext_info['homepage']}") + if ext_info.get('documentation'): + console.print(f" • Documentation: {ext_info['documentation']}") + if ext_info.get('changelog'): + console.print(f" • Changelog: {ext_info['changelog']}") + console.print() + + # Installation status and command + is_installed = manager.registry.is_installed(ext_info['id']) + install_allowed = ext_info.get("_install_allowed", True) + if is_installed: + console.print("[green]✓ Installed[/green]") + metadata = manager.registry.get(ext_info['id']) + priority = normalize_priority(metadata.get("priority") if isinstance(metadata, dict) else None) + console.print(f"[dim]Priority:[/dim] {priority}") + console.print(f"\nTo remove: specify extension remove {ext_info['id']}") + elif install_allowed: + console.print("[yellow]Not installed[/yellow]") + console.print(f"\n[cyan]Install:[/cyan] specify extension add {ext_info['id']}") + else: + catalog_name = ext_info.get("_catalog_name", "community") + console.print("[yellow]Not installed[/yellow]") + console.print( + f"\n[yellow]⚠[/yellow] '{ext_info['id']}' is available in the '{catalog_name}' catalog " + f"but not in your approved catalog. Add it to .specify/extension-catalogs.yml " + f"with install_allowed: true to enable installation." + ) + + +@extension_app.command("list") +def extension_list( + available: bool = typer.Option(False, "--available", help="Show available extensions from catalog"), + all_extensions: bool = typer.Option(False, "--all", help="Show both installed and available"), +) -> None: + """List installed extensions.""" + from ..extensions import ExtensionManager + + project_root = Path.cwd() + + # Check if we're in a spec-kit project + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + manager = ExtensionManager(project_root) + installed = manager.list_installed() + + if not installed and not (available or all_extensions): + console.print("[yellow]No extensions installed.[/yellow]") + console.print("\nInstall an extension with:") + console.print(" specify extension add ") + return + + if installed: + console.print("\n[bold cyan]Installed Extensions:[/bold cyan]\n") + + for ext in installed: + status_icon = "✓" if ext["enabled"] else "✗" + status_color = "green" if ext["enabled"] else "red" + + console.print(f" [{status_color}]{status_icon}[/{status_color}] [bold]{ext['name']}[/bold] (v{ext['version']})") + console.print(f" [dim]{ext['id']}[/dim]") + console.print(f" {ext['description']}") + console.print(f" Commands: {ext['command_count']} | Hooks: {ext['hook_count']} | Priority: {ext['priority']} | Status: {'Enabled' if ext['enabled'] else 'Disabled'}") + console.print() + + if available or all_extensions: + console.print("\nInstall an extension:") + console.print(" [cyan]specify extension add [/cyan]") + + +@catalog_app.command("list") +def catalog_list() -> None: + """List all active extension catalogs.""" + from ..extensions import ExtensionCatalog, ValidationError + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + catalog = ExtensionCatalog(project_root) + + try: + active_catalogs = catalog.get_active_catalogs() + except ValidationError as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(1) + + console.print("\n[bold cyan]Active Extension Catalogs:[/bold cyan]\n") + for entry in active_catalogs: + install_str = ( + "[green]install allowed[/green]" + if entry.install_allowed + else "[yellow]discovery only[/yellow]" + ) + console.print(f" [bold]{entry.name}[/bold] (priority {entry.priority})") + if entry.description: + console.print(f" {entry.description}") + console.print(f" URL: {entry.url}") + console.print(f" Install: {install_str}") + console.print() + + config_path = project_root / ".specify" / "extension-catalogs.yml" + user_config_path = Path.home() / ".specify" / "extension-catalogs.yml" + if os.environ.get("SPECKIT_CATALOG_URL"): + console.print("[dim]Catalog configured via SPECKIT_CATALOG_URL environment variable.[/dim]") + else: + try: + proj_loaded = config_path.exists() and catalog._load_catalog_config(config_path) is not None + except ValidationError: + proj_loaded = False + if proj_loaded: + console.print(f"[dim]Config: {config_path.relative_to(project_root)}[/dim]") + else: + try: + user_loaded = user_config_path.exists() and catalog._load_catalog_config(user_config_path) is not None + except ValidationError: + user_loaded = False + if user_loaded: + console.print("[dim]Config: ~/.specify/extension-catalogs.yml[/dim]") + else: + console.print("[dim]Using built-in default catalog stack.[/dim]") + console.print( + "[dim]Add .specify/extension-catalogs.yml to customize.[/dim]" + ) + + +@catalog_app.command("add") +def catalog_add( + url: str = typer.Argument(help="Catalog URL (must use HTTPS)"), + name: str = typer.Option(..., "--name", help="Catalog name"), + priority: int = typer.Option(10, "--priority", help="Priority (lower = higher priority)"), + install_allowed: bool = typer.Option( + False, "--install-allowed/--no-install-allowed", + help="Allow extensions from this catalog to be installed", + ), + description: str = typer.Option("", "--description", help="Description of the catalog"), +) -> None: + """Add a catalog to .specify/extension-catalogs.yml.""" + from ..extensions import ExtensionCatalog, ValidationError + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + # Validate URL + tmp_catalog = ExtensionCatalog(project_root) + try: + tmp_catalog._validate_catalog_url(url) + except ValidationError as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(1) + + config_path = specify_dir / "extension-catalogs.yml" + + # Load existing config + if config_path.exists(): + try: + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + except Exception as e: + console.print(f"[red]Error:[/red] Failed to read {config_path}: {e}") + raise typer.Exit(1) + else: + config = {} + + catalogs = config.get("catalogs", []) + if not isinstance(catalogs, list): + console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.") + raise typer.Exit(1) + + # Check for duplicate name + for existing in catalogs: + if isinstance(existing, dict) and existing.get("name") == name: + console.print(f"[yellow]Warning:[/yellow] A catalog named '{name}' already exists.") + console.print("Use 'specify extension catalog remove' first, or choose a different name.") + raise typer.Exit(1) + + catalogs.append({ + "name": name, + "url": url, + "priority": priority, + "install_allowed": install_allowed, + "description": description, + }) + + config["catalogs"] = catalogs + config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8") + + install_label = "install allowed" if install_allowed else "discovery only" + console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})") + console.print(f" URL: {url}") + console.print(f" Priority: {priority}") + console.print(f"\nConfig saved to {config_path.relative_to(project_root)}") + + +@catalog_app.command("remove") +def catalog_remove( + name: str = typer.Argument(help="Catalog name to remove"), +) -> None: + """Remove a catalog from .specify/extension-catalogs.yml.""" + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + config_path = specify_dir / "extension-catalogs.yml" + if not config_path.exists(): + console.print("[red]Error:[/red] No catalog config found. Nothing to remove.") + raise typer.Exit(1) + + try: + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + except Exception: + console.print("[red]Error:[/red] Failed to read catalog config.") + raise typer.Exit(1) + + catalogs = config.get("catalogs", []) + if not isinstance(catalogs, list): + console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.") + raise typer.Exit(1) + original_count = len(catalogs) + catalogs = [c for c in catalogs if isinstance(c, dict) and c.get("name") != name] + + if len(catalogs) == original_count: + console.print(f"[red]Error:[/red] Catalog '{name}' not found.") + raise typer.Exit(1) + + config["catalogs"] = catalogs + config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8") + + console.print(f"[green]✓[/green] Removed catalog '{name}'") + if not catalogs: + console.print("\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]") + + +@extension_app.command("add") +def extension_add( + extension: str = typer.Argument(help="Extension name or path"), + dev: bool = typer.Option(False, "--dev", help="Install from local directory"), + from_url: Optional[str] = typer.Option(None, "--from", help="Install from custom URL"), + priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"), +) -> None: + """Install an extension.""" + from ..extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError, REINSTALL_COMMAND + + project_root = Path.cwd() + + # Check if we're in a spec-kit project + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + # Validate priority + if priority < 1: + console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)") + raise typer.Exit(1) + + manager = ExtensionManager(project_root) + speckit_version = get_speckit_version() + + try: + with console.status(f"[cyan]Installing extension: {extension}[/cyan]"): + if dev: + # Install from local directory + source_path = Path(extension).expanduser().resolve() + if not source_path.exists(): + console.print(f"[red]Error:[/red] Directory not found: {source_path}") + raise typer.Exit(1) + + if not (source_path / "extension.yml").exists(): + console.print(f"[red]Error:[/red] No extension.yml found in {source_path}") + raise typer.Exit(1) + + manifest = manager.install_from_directory(source_path, speckit_version, priority=priority) + + elif from_url: + # Install from URL (ZIP file) + import urllib.request + import urllib.error + from urllib.parse import urlparse + + # Validate URL + parsed = urlparse(from_url) + is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1") + + if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost): + console.print("[red]Error:[/red] URL must use HTTPS for security.") + console.print("HTTP is only allowed for localhost URLs.") + raise typer.Exit(1) + + # Warn about untrusted sources + console.print("[yellow]Warning:[/yellow] Installing from external URL.") + console.print("Only install extensions from sources you trust.\n") + console.print(f"Downloading from {from_url}...") + + # Download ZIP to temp location + download_dir = project_root / ".specify" / "extensions" / ".cache" / "downloads" + download_dir.mkdir(parents=True, exist_ok=True) + zip_path = download_dir / f"{extension}-url-download.zip" + + try: + with urllib.request.urlopen(from_url, timeout=60) as response: + zip_data = response.read() + zip_path.write_bytes(zip_data) + + # Install from downloaded ZIP + manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority) + except urllib.error.URLError as e: + console.print(f"[red]Error:[/red] Failed to download from {from_url}: {e}") + raise typer.Exit(1) + finally: + # Clean up downloaded ZIP + if zip_path.exists(): + zip_path.unlink() + + else: + # Try bundled extensions first (shipped with spec-kit) + bundled_path = _locate_bundled_extension(extension) + if bundled_path is not None: + manifest = manager.install_from_directory(bundled_path, speckit_version, priority=priority) + else: + # Install from catalog (also resolves display names to IDs) + catalog = ExtensionCatalog(project_root) + + # Check if extension exists in catalog (supports both ID and display name) + ext_info, catalog_error = _resolve_catalog_extension(extension, catalog, "add") + if catalog_error: + console.print(f"[red]Error:[/red] Could not query extension catalog: {catalog_error}") + raise typer.Exit(1) + if not ext_info: + console.print(f"[red]Error:[/red] Extension '{extension}' not found in catalog") + console.print("\nSearch available extensions:") + console.print(" specify extension search") + raise typer.Exit(1) + + # If catalog resolved a display name to an ID, check bundled again + resolved_id = ext_info['id'] + if resolved_id != extension: + bundled_path = _locate_bundled_extension(resolved_id) + if bundled_path is not None: + manifest = manager.install_from_directory(bundled_path, speckit_version, priority=priority) + + if bundled_path is None: + # Bundled extensions without a download URL must come from the local package + if ext_info.get("bundled") and not ext_info.get("download_url"): + console.print( + f"[red]Error:[/red] Extension '{ext_info['id']}' is bundled with spec-kit " + f"but could not be found in the installed package." + ) + console.print( + "\nThis usually means the spec-kit installation is incomplete or corrupted." + ) + console.print("Try reinstalling spec-kit:") + console.print(f" {REINSTALL_COMMAND}") + raise typer.Exit(1) + + # Enforce install_allowed policy + if not ext_info.get("_install_allowed", True): + catalog_name = ext_info.get("_catalog_name", "community") + console.print( + f"[red]Error:[/red] '{extension}' is available in the " + f"'{catalog_name}' catalog but installation is not allowed from that catalog." + ) + console.print( + f"\nTo enable installation, add '{extension}' to an approved catalog " + f"(install_allowed: true) in .specify/extension-catalogs.yml." + ) + raise typer.Exit(1) + + # Download extension ZIP (use resolved ID, not original argument which may be display name) + extension_id = ext_info['id'] + console.print(f"Downloading {ext_info['name']} v{ext_info.get('version', 'unknown')}...") + zip_path = catalog.download_extension(extension_id) + + try: + # Install from downloaded ZIP + manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority) + finally: + # Clean up downloaded ZIP + if zip_path.exists(): + zip_path.unlink() + + console.print("\n[green]✓[/green] Extension installed successfully!") + console.print(f"\n[bold]{manifest.name}[/bold] (v{manifest.version})") + console.print(f" {manifest.description}") + + for warning in manifest.warnings: + console.print(f"\n[yellow]⚠ Compatibility warning:[/yellow] {warning}") + + console.print("\n[bold cyan]Provided commands:[/bold cyan]") + for cmd in manifest.commands: + console.print(f" • {cmd['name']} - {cmd.get('description', '')}") + + # Report agent skills registration + reg_meta = manager.registry.get(manifest.id) + reg_skills = reg_meta.get("registered_skills", []) if reg_meta else [] + # Normalize to guard against corrupted registry entries + if not isinstance(reg_skills, list): + reg_skills = [] + if reg_skills: + console.print(f"\n[green]✓[/green] {len(reg_skills)} agent skill(s) auto-registered") + + console.print("\n[yellow]⚠[/yellow] Configuration may be required") + console.print(f" Check: .specify/extensions/{manifest.id}/") + + except ValidationError as e: + console.print(f"\n[red]Validation Error:[/red] {e}") + raise typer.Exit(1) + except CompatibilityError as e: + console.print(f"\n[red]Compatibility Error:[/red] {e}") + raise typer.Exit(1) + except ExtensionError as e: + console.print(f"\n[red]Error:[/red] {e}") + raise typer.Exit(1) + + +@extension_app.command("remove") +def extension_remove( + extension: str = typer.Argument(help="Extension ID or name to remove"), + keep_config: bool = typer.Option(False, "--keep-config", help="Don't remove config files"), + force: bool = typer.Option(False, "--force", help="Skip confirmation"), +) -> None: + """Uninstall an extension.""" + from ..extensions import ExtensionManager + + project_root = Path.cwd() + + # Check if we're in a spec-kit project + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + manager = ExtensionManager(project_root) + + # Resolve extension ID from argument (handles ambiguous names) + installed = manager.list_installed() + extension_id, display_name = _resolve_installed_extension(extension, installed, "remove") + + # Get extension info for command and skill counts + ext_manifest = manager.get_extension(extension_id) + reg_meta = manager.registry.get(extension_id) + # Derive cmd_count from the registry's registered_commands (includes aliases) + # rather than from the manifest (primary commands only). Use max() across + # agents to get the per-agent count; sum() would double-count since users + # think in logical commands, not per-agent file counts. + # Use get() without a default so we can distinguish "key missing" (fall back + # to manifest) from "key present but empty dict" (zero commands registered). + registered_commands = reg_meta.get("registered_commands") if isinstance(reg_meta, dict) else None + if isinstance(registered_commands, dict): + cmd_count = max( + (len(v) for v in registered_commands.values() if isinstance(v, list)), + default=0, + ) + else: + cmd_count = len(ext_manifest.commands) if ext_manifest else 0 + raw_skills = reg_meta.get("registered_skills") if reg_meta else None + skill_count = len(raw_skills) if isinstance(raw_skills, list) else 0 + + # Confirm removal + if not force: + console.print("\n[yellow]⚠ This will remove:[/yellow]") + console.print(f" • {cmd_count} command{'s' if cmd_count != 1 else ''} per agent") + if skill_count: + console.print(f" • {skill_count} agent skill(s)") + console.print(f" • Extension directory: .specify/extensions/{extension_id}/") + if not keep_config: + console.print(" • Config files (will be backed up)") + console.print() + + confirm = typer.confirm("Continue?") + if not confirm: + console.print("Cancelled") + raise typer.Exit(0) + + # Remove extension + success = manager.remove(extension_id, keep_config=keep_config) + + if success: + console.print(f"\n[green]✓[/green] Extension '{display_name}' removed successfully") + if keep_config: + console.print(f"\nConfig files preserved in .specify/extensions/{extension_id}/") + else: + console.print(f"\nConfig files backed up to .specify/extensions/.backup/{extension_id}/") + console.print(f"\nTo reinstall: specify extension add {extension_id}") + else: + console.print("[red]Error:[/red] Failed to remove extension") + raise typer.Exit(1) + + +@extension_app.command("search") +def extension_search( + query: str = typer.Argument(None, help="Search query (optional)"), + tag: Optional[str] = typer.Option(None, "--tag", help="Filter by tag"), + author: Optional[str] = typer.Option(None, "--author", help="Filter by author"), + verified: bool = typer.Option(False, "--verified", help="Show only verified extensions"), +) -> None: + """Search for available extensions in catalog.""" + from ..extensions import ExtensionCatalog, ExtensionError + + project_root = Path.cwd() + + # Check if we're in a spec-kit project + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + catalog = ExtensionCatalog(project_root) + + try: + console.print("🔍 Searching extension catalog...") + results = catalog.search(query=query, tag=tag, author=author, verified_only=verified) + + if not results: + console.print("\n[yellow]No extensions found matching criteria[/yellow]") + if query or tag or author or verified: + console.print("\nTry:") + console.print(" • Broader search terms") + console.print(" • Remove filters") + console.print(" • specify extension search (show all)") + raise typer.Exit(0) + + console.print(f"\n[green]Found {len(results)} extension(s):[/green]\n") + + for ext in results: + # Extension header + verified_badge = " [green]✓ Verified[/green]" if ext.get("verified") else "" + console.print(f"[bold]{ext['name']}[/bold] (v{ext['version']}){verified_badge}") + console.print(f" {ext['description']}") + + # Metadata + console.print(f"\n [dim]Author:[/dim] {ext.get('author', 'Unknown')}") + if ext.get('tags'): + tags_str = ", ".join(ext['tags']) + console.print(f" [dim]Tags:[/dim] {tags_str}") + + # Source catalog + catalog_name = ext.get("_catalog_name", "") + install_allowed = ext.get("_install_allowed", True) + if catalog_name: + if install_allowed: + console.print(f" [dim]Catalog:[/dim] {catalog_name}") + else: + console.print(f" [dim]Catalog:[/dim] {catalog_name} [yellow](discovery only — not installable)[/yellow]") + + # Stats + stats = [] + if ext.get('downloads') is not None: + stats.append(f"Downloads: {ext['downloads']:,}") + if ext.get('stars') is not None: + stats.append(f"Stars: {ext['stars']}") + if stats: + console.print(f" [dim]{' | '.join(stats)}[/dim]") + + # Links + if ext.get('repository'): + console.print(f" [dim]Repository:[/dim] {ext['repository']}") + + # Install command (show warning if not installable) + if install_allowed: + console.print(f"\n [cyan]Install:[/cyan] specify extension add {ext['id']}") + else: + console.print(f"\n [yellow]⚠[/yellow] Not directly installable from '{catalog_name}'.") + console.print( + f" Add to an approved catalog with install_allowed: true, " + f"or install from a ZIP URL: specify extension add {ext['id']} --from " + ) + console.print() + + except ExtensionError as e: + console.print(f"\n[red]Error:[/red] {e}") + console.print("\nTip: The catalog may be temporarily unavailable. Try again later.") + raise typer.Exit(1) + + +@extension_app.command("info") +def extension_info( + extension: str = typer.Argument(help="Extension ID or name"), +) -> None: + """Show detailed information about an extension.""" + from ..extensions import ExtensionCatalog, ExtensionManager, normalize_priority + + project_root = Path.cwd() + + # Check if we're in a spec-kit project + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + catalog = ExtensionCatalog(project_root) + manager = ExtensionManager(project_root) + installed = manager.list_installed() + + # Try to resolve from installed extensions first (by ID or name) + # Use allow_not_found=True since the extension may be catalog-only + resolved_installed_id, resolved_installed_name = _resolve_installed_extension( + extension, installed, "info", allow_not_found=True + ) + + # Try catalog lookup (with error handling) + # If we resolved an installed extension by display name, use its ID for catalog lookup + # to ensure we get the correct catalog entry (not a different extension with same name) + lookup_key = resolved_installed_id if resolved_installed_id else extension + ext_info, catalog_error = _resolve_catalog_extension(lookup_key, catalog, "info") + + # Case 1: Found in catalog - show full catalog info + if ext_info: + _print_extension_info(ext_info, manager) + return + + # Case 2: Installed locally but catalog lookup failed or not in catalog + if resolved_installed_id: + # Get local manifest info + ext_manifest = manager.get_extension(resolved_installed_id) + metadata = manager.registry.get(resolved_installed_id) + metadata_is_dict = isinstance(metadata, dict) + if not metadata_is_dict: + console.print( + "[yellow]Warning:[/yellow] Extension metadata appears to be corrupted; " + "some information may be unavailable." + ) + version = metadata.get("version", "unknown") if metadata_is_dict else "unknown" + + console.print(f"\n[bold]{resolved_installed_name}[/bold] (v{version})") + console.print(f"ID: {resolved_installed_id}") + console.print() + + if ext_manifest: + console.print(f"{ext_manifest.description}") + console.print() + # Author is optional in extension.yml, safely retrieve it + author = ext_manifest.data.get("extension", {}).get("author") + if author: + console.print(f"[dim]Author:[/dim] {author}") + console.print() + + if ext_manifest.commands: + console.print("[bold]Commands:[/bold]") + for cmd in ext_manifest.commands: + console.print(f" • {cmd['name']}: {cmd.get('description', '')}") + console.print() + + # Show catalog status + if catalog_error: + console.print(f"[yellow]Catalog unavailable:[/yellow] {catalog_error}") + console.print("[dim]Note: Using locally installed extension; catalog info could not be verified.[/dim]") + else: + console.print("[yellow]Note:[/yellow] Not found in catalog (custom/local extension)") + + console.print() + console.print("[green]✓ Installed[/green]") + priority = normalize_priority(metadata.get("priority") if metadata_is_dict else None) + console.print(f"[dim]Priority:[/dim] {priority}") + console.print(f"\nTo remove: specify extension remove {resolved_installed_id}") + return + + # Case 3: Not found anywhere + if catalog_error: + console.print(f"[red]Error:[/red] Could not query extension catalog: {catalog_error}") + console.print("\nTry again when online, or use the extension ID directly.") + else: + console.print(f"[red]Error:[/red] Extension '{extension}' not found") + console.print("\nTry: specify extension search") + raise typer.Exit(1) + + +@extension_app.command("update") +def extension_update( + extension: str = typer.Argument(None, help="Extension ID or name to update (or all)"), +) -> None: + """Update extension(s) to latest version.""" + from ..extensions import ( + ExtensionManager, + ExtensionCatalog, + ExtensionError, + ValidationError, + CommandRegistrar, + HookExecutor, + normalize_priority, + ) + from packaging import version as pkg_version + + project_root = Path.cwd() + + # Check if we're in a spec-kit project + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + manager = ExtensionManager(project_root) + catalog = ExtensionCatalog(project_root) + speckit_version = get_speckit_version() + + try: + # Get list of extensions to update + installed = manager.list_installed() + if extension: + # Update specific extension - resolve ID from argument (handles ambiguous names) + extension_id, _ = _resolve_installed_extension(extension, installed, "update") + extensions_to_update = [extension_id] + else: + # Update all extensions + extensions_to_update = [ext["id"] for ext in installed] + + if not extensions_to_update: + console.print("[yellow]No extensions installed[/yellow]") + raise typer.Exit(0) + + console.print("🔄 Checking for updates...\n") + + updates_available = [] + + for ext_id in extensions_to_update: + # Get installed version + metadata = manager.registry.get(ext_id) + if metadata is None or not isinstance(metadata, dict) or "version" not in metadata: + console.print(f"⚠ {ext_id}: Registry entry corrupted or missing (skipping)") + continue + try: + installed_version = pkg_version.Version(metadata["version"]) + except pkg_version.InvalidVersion: + console.print( + f"⚠ {ext_id}: Invalid installed version '{metadata.get('version')}' in registry (skipping)" + ) + continue + + # Get catalog info + ext_info = catalog.get_extension_info(ext_id) + if not ext_info: + console.print(f"⚠ {ext_id}: Not found in catalog (skipping)") + continue + + # Check if installation is allowed from this catalog + if not ext_info.get("_install_allowed", True): + console.print(f"⚠ {ext_id}: Updates not allowed from '{ext_info.get('_catalog_name', 'catalog')}' (skipping)") + continue + + try: + catalog_version = pkg_version.Version(ext_info["version"]) + except pkg_version.InvalidVersion: + console.print( + f"⚠ {ext_id}: Invalid catalog version '{ext_info.get('version')}' (skipping)" + ) + continue + + if catalog_version > installed_version: + updates_available.append( + { + "id": ext_id, + "name": ext_info.get("name", ext_id), # Display name for status messages + "installed": str(installed_version), + "available": str(catalog_version), + "download_url": ext_info.get("download_url"), + } + ) + else: + console.print(f"✓ {ext_id}: Up to date (v{installed_version})") + + if not updates_available: + console.print("\n[green]All extensions are up to date![/green]") + raise typer.Exit(0) + + # Show available updates + console.print("\n[bold]Updates available:[/bold]\n") + for update in updates_available: + console.print( + f" • {update['id']}: {update['installed']} → {update['available']}" + ) + + console.print() + confirm = typer.confirm("Update these extensions?") + if not confirm: + console.print("Cancelled") + raise typer.Exit(0) + + # Perform updates with atomic backup/restore + console.print() + updated_extensions = [] + failed_updates = [] + registrar = CommandRegistrar() + hook_executor = HookExecutor(project_root) + + for update in updates_available: + extension_id = update["id"] + ext_name = update["name"] # Use display name for user-facing messages + console.print(f"📦 Updating {ext_name}...") + + # Backup paths + backup_base = manager.extensions_dir / ".backup" / f"{extension_id}-update" + backup_ext_dir = backup_base / "extension" + backup_commands_dir = backup_base / "commands" + backup_config_dir = backup_base / "config" + + # Store backup state + backup_registry_entry = None + backup_hooks = None # None means no hooks key in config; {} means hooks key existed + backed_up_command_files = {} + + try: + # 1. Backup registry entry (always, even if extension dir doesn't exist) + backup_registry_entry = manager.registry.get(extension_id) + + # 2. Backup extension directory + extension_dir = manager.extensions_dir / extension_id + if extension_dir.exists(): + backup_base.mkdir(parents=True, exist_ok=True) + if backup_ext_dir.exists(): + shutil.rmtree(backup_ext_dir) + shutil.copytree(extension_dir, backup_ext_dir) + + # Backup config files separately so they can be restored + # after a successful install (install_from_directory clears dest dir). + config_files = list(extension_dir.glob("*-config.yml")) + list( + extension_dir.glob("*-config.local.yml") + ) + for cfg_file in config_files: + backup_config_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(cfg_file, backup_config_dir / cfg_file.name) + + # 3. Backup command files for all agents + from ..agents import CommandRegistrar as _AgentReg + registered_commands = backup_registry_entry.get("registered_commands", {}) + for agent_name, cmd_names in registered_commands.items(): + if agent_name not in registrar.AGENT_CONFIGS: + continue + agent_config = registrar.AGENT_CONFIGS[agent_name] + commands_dir = project_root / agent_config["dir"] + + for cmd_name in cmd_names: + output_name = _AgentReg._compute_output_name(agent_name, cmd_name, agent_config) + cmd_file = commands_dir / f"{output_name}{agent_config['extension']}" + if cmd_file.exists(): + backup_cmd_path = backup_commands_dir / agent_name / cmd_file.name + backup_cmd_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(cmd_file, backup_cmd_path) + backed_up_command_files[str(cmd_file)] = str(backup_cmd_path) + + # Also backup copilot prompt files + if agent_name == "copilot": + prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md" + if prompt_file.exists(): + backup_prompt_path = backup_commands_dir / "copilot-prompts" / prompt_file.name + backup_prompt_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(prompt_file, backup_prompt_path) + backed_up_command_files[str(prompt_file)] = str(backup_prompt_path) + + # 4. Backup hooks from extensions.yml + # Use backup_hooks=None to indicate config had no "hooks" key (don't create on restore) + # Use backup_hooks={} to indicate config had "hooks" key with no hooks for this extension + config = hook_executor.get_project_config() + if "hooks" in config: + backup_hooks = {} # Config has hooks key - preserve this fact + for hook_name, hook_list in config["hooks"].items(): + ext_hooks = [h for h in hook_list if h.get("extension") == extension_id] + if ext_hooks: + backup_hooks[hook_name] = ext_hooks + + # 5. Download new version + zip_path = catalog.download_extension(extension_id) + try: + # 6. Validate extension ID from ZIP BEFORE modifying installation + # Handle both root-level and nested extension.yml (GitHub auto-generated ZIPs) + with zipfile.ZipFile(zip_path, "r") as zf: + import yaml as _yaml + manifest_data = None + namelist = zf.namelist() + + # First try root-level extension.yml + if "extension.yml" in namelist: + with zf.open("extension.yml") as f: + manifest_data = _yaml.safe_load(f) or {} + else: + # Look for extension.yml in a single top-level subdirectory + # (e.g., "repo-name-branch/extension.yml") + manifest_paths = [n for n in namelist if n.endswith("/extension.yml") and n.count("/") == 1] + if len(manifest_paths) == 1: + with zf.open(manifest_paths[0]) as f: + manifest_data = _yaml.safe_load(f) or {} + + if manifest_data is None: + raise ValueError("Downloaded extension archive is missing 'extension.yml'") + + zip_extension_id = manifest_data.get("extension", {}).get("id") + if zip_extension_id != extension_id: + raise ValueError( + f"Extension ID mismatch: expected '{extension_id}', got '{zip_extension_id}'" + ) + + # 7. Remove old extension (handles command file cleanup and registry removal) + manager.remove(extension_id, keep_config=True) + + # 8. Install new version + _ = manager.install_from_zip(zip_path, speckit_version) + + # Restore user config files from backup after successful install. + new_extension_dir = manager.extensions_dir / extension_id + if backup_config_dir.exists() and new_extension_dir.exists(): + for cfg_file in backup_config_dir.iterdir(): + if cfg_file.is_file(): + shutil.copy2(cfg_file, new_extension_dir / cfg_file.name) + + # 9. Restore metadata from backup (installed_at, enabled state) + if backup_registry_entry and isinstance(backup_registry_entry, dict): + # Copy current registry entry to avoid mutating internal + # registry state before explicit restore(). + current_metadata = manager.registry.get(extension_id) + if current_metadata is None or not isinstance(current_metadata, dict): + raise RuntimeError( + f"Registry entry for '{extension_id}' missing or corrupted after install — update incomplete" + ) + new_metadata = dict(current_metadata) + + # Preserve the original installation timestamp + if "installed_at" in backup_registry_entry: + new_metadata["installed_at"] = backup_registry_entry["installed_at"] + + # Preserve the original priority (normalized to handle corruption) + if "priority" in backup_registry_entry: + new_metadata["priority"] = normalize_priority(backup_registry_entry["priority"]) + + # If extension was disabled before update, disable it again + if not backup_registry_entry.get("enabled", True): + new_metadata["enabled"] = False + + # Use restore() instead of update() because update() always + # preserves the existing installed_at, ignoring our override + manager.registry.restore(extension_id, new_metadata) + + # Also disable hooks in extensions.yml if extension was disabled + if not backup_registry_entry.get("enabled", True): + config = hook_executor.get_project_config() + if "hooks" in config: + for hook_name in config["hooks"]: + for hook in config["hooks"][hook_name]: + if hook.get("extension") == extension_id: + hook["enabled"] = False + hook_executor.save_project_config(config) + finally: + # Clean up downloaded ZIP + if zip_path.exists(): + zip_path.unlink() + + # 10. Clean up backup on success + if backup_base.exists(): + shutil.rmtree(backup_base) + + console.print(f" [green]✓[/green] Updated to v{update['available']}") + updated_extensions.append(ext_name) + + except KeyboardInterrupt: + raise + except Exception as e: + console.print(f" [red]✗[/red] Failed: {e}") + failed_updates.append((ext_name, str(e))) + + # Rollback on failure + console.print(f" [yellow]↩[/yellow] Rolling back {ext_name}...") + + try: + # Restore extension directory + # Only perform destructive rollback if backup exists (meaning we + # actually modified the extension). This avoids deleting a valid + # installation when failure happened before changes were made. + extension_dir = manager.extensions_dir / extension_id + if backup_ext_dir.exists(): + if extension_dir.exists(): + shutil.rmtree(extension_dir) + shutil.copytree(backup_ext_dir, extension_dir) + + # Remove any NEW command files created by failed install + # (files that weren't in the original backup) + try: + new_registry_entry = manager.registry.get(extension_id) + if new_registry_entry is None or not isinstance(new_registry_entry, dict): + new_registered_commands = {} + else: + new_registered_commands = new_registry_entry.get("registered_commands", {}) + for agent_name, cmd_names in new_registered_commands.items(): + if agent_name not in registrar.AGENT_CONFIGS: + continue + agent_config = registrar.AGENT_CONFIGS[agent_name] + commands_dir = project_root / agent_config["dir"] + + for cmd_name in cmd_names: + output_name = _AgentReg._compute_output_name(agent_name, cmd_name, agent_config) + cmd_file = commands_dir / f"{output_name}{agent_config['extension']}" + # Delete if it exists and wasn't in our backup + if cmd_file.exists() and str(cmd_file) not in backed_up_command_files: + cmd_file.unlink() + + # Also handle copilot prompt files + if agent_name == "copilot": + prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md" + if prompt_file.exists() and str(prompt_file) not in backed_up_command_files: + prompt_file.unlink() + except KeyError: + pass # No new registry entry exists, nothing to clean up + + # Restore backed up command files + for original_path, backup_path in backed_up_command_files.items(): + backup_file = Path(backup_path) + if backup_file.exists(): + original_file = Path(original_path) + original_file.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(backup_file, original_file) + + # Restore hooks in extensions.yml + # - backup_hooks=None means original config had no "hooks" key + # - backup_hooks={} or {...} means config had hooks key + config = hook_executor.get_project_config() + if "hooks" in config: + modified = False + + if backup_hooks is None: + # Original config had no "hooks" key; remove it entirely + del config["hooks"] + modified = True + else: + # Remove any hooks for this extension added by failed install + for hook_name, hooks_list in config["hooks"].items(): + original_len = len(hooks_list) + config["hooks"][hook_name] = [ + h for h in hooks_list + if h.get("extension") != extension_id + ] + if len(config["hooks"][hook_name]) != original_len: + modified = True + + # Add back the backed up hooks if any + if backup_hooks: + for hook_name, hooks in backup_hooks.items(): + if hook_name not in config["hooks"]: + config["hooks"][hook_name] = [] + config["hooks"][hook_name].extend(hooks) + modified = True + + if modified: + hook_executor.save_project_config(config) + + # Restore registry entry (use restore() since entry was removed) + if backup_registry_entry: + manager.registry.restore(extension_id, backup_registry_entry) + + console.print(" [green]✓[/green] Rollback successful") + # Clean up backup directory only on successful rollback + if backup_base.exists(): + shutil.rmtree(backup_base) + except Exception as rollback_error: + console.print(f" [red]✗[/red] Rollback failed: {rollback_error}") + console.print(f" [dim]Backup preserved at: {backup_base}[/dim]") + + # Summary + console.print() + if updated_extensions: + console.print(f"[green]✓[/green] Successfully updated {len(updated_extensions)} extension(s)") + if failed_updates: + console.print(f"[red]✗[/red] Failed to update {len(failed_updates)} extension(s):") + for ext_name, error in failed_updates: + console.print(f" • {ext_name}: {error}") + raise typer.Exit(1) + + except ValidationError as e: + console.print(f"\n[red]Validation Error:[/red] {e}") + raise typer.Exit(1) + except ExtensionError as e: + console.print(f"\n[red]Error:[/red] {e}") + raise typer.Exit(1) + + +@extension_app.command("enable") +def extension_enable( + extension: str = typer.Argument(help="Extension ID or name to enable"), +) -> None: + """Enable a disabled extension.""" + from ..extensions import ExtensionManager, HookExecutor + + project_root = Path.cwd() + + # Check if we're in a spec-kit project + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + manager = ExtensionManager(project_root) + hook_executor = HookExecutor(project_root) + + # Resolve extension ID from argument (handles ambiguous names) + installed = manager.list_installed() + extension_id, display_name = _resolve_installed_extension(extension, installed, "enable") + + # Update registry + metadata = manager.registry.get(extension_id) + if metadata is None or not isinstance(metadata, dict): + console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)") + raise typer.Exit(1) + + if metadata.get("enabled", True): + console.print(f"[yellow]Extension '{display_name}' is already enabled[/yellow]") + raise typer.Exit(0) + + manager.registry.update(extension_id, {"enabled": True}) + + # Enable hooks in extensions.yml + config = hook_executor.get_project_config() + if "hooks" in config: + for hook_name in config["hooks"]: + for hook in config["hooks"][hook_name]: + if hook.get("extension") == extension_id: + hook["enabled"] = True + hook_executor.save_project_config(config) + + console.print(f"[green]✓[/green] Extension '{display_name}' enabled") + + +@extension_app.command("disable") +def extension_disable( + extension: str = typer.Argument(help="Extension ID or name to disable"), +) -> None: + """Disable an extension without removing it.""" + from ..extensions import ExtensionManager, HookExecutor + + project_root = Path.cwd() + + # Check if we're in a spec-kit project + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + manager = ExtensionManager(project_root) + hook_executor = HookExecutor(project_root) + + # Resolve extension ID from argument (handles ambiguous names) + installed = manager.list_installed() + extension_id, display_name = _resolve_installed_extension(extension, installed, "disable") + + # Update registry + metadata = manager.registry.get(extension_id) + if metadata is None or not isinstance(metadata, dict): + console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)") + raise typer.Exit(1) + + if not metadata.get("enabled", True): + console.print(f"[yellow]Extension '{display_name}' is already disabled[/yellow]") + raise typer.Exit(0) + + manager.registry.update(extension_id, {"enabled": False}) + + # Disable hooks in extensions.yml + config = hook_executor.get_project_config() + if "hooks" in config: + for hook_name in config["hooks"]: + for hook in config["hooks"][hook_name]: + if hook.get("extension") == extension_id: + hook["enabled"] = False + hook_executor.save_project_config(config) + + console.print(f"[green]✓[/green] Extension '{display_name}' disabled") + console.print("\nCommands will no longer be available. Hooks will not execute.") + console.print(f"To re-enable: specify extension enable {extension_id}") + + +@extension_app.command("set-priority") +def extension_set_priority( + extension: str = typer.Argument(help="Extension ID or name"), + priority: int = typer.Argument(help="New priority (lower = higher precedence)"), +) -> None: + """Set the resolution priority of an installed extension.""" + from ..extensions import ExtensionManager, normalize_priority + + project_root = Path.cwd() + + # Check if we're in a spec-kit project + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + # Validate priority + if priority < 1: + console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)") + raise typer.Exit(1) + + manager = ExtensionManager(project_root) + + # Resolve extension ID from argument (handles ambiguous names) + installed = manager.list_installed() + extension_id, display_name = _resolve_installed_extension(extension, installed, "set-priority") + + # Get current metadata + metadata = manager.registry.get(extension_id) + if metadata is None or not isinstance(metadata, dict): + console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)") + raise typer.Exit(1) + + raw_priority = metadata.get("priority") + # Only skip if the stored value is already a valid int equal to requested priority + # This ensures corrupted values (e.g., "high") get repaired even when setting to default (10) + if isinstance(raw_priority, int) and raw_priority == priority: + console.print(f"[yellow]Extension '{display_name}' already has priority {priority}[/yellow]") + raise typer.Exit(0) + + old_priority = normalize_priority(raw_priority) + + # Update priority + manager.registry.update(extension_id, {"priority": priority}) + + console.print(f"[green]✓[/green] Extension '{display_name}' priority changed: {old_priority} → {priority}") + console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]") diff --git a/tests/test_extensions.py b/tests/test_extensions.py index c5be0ab4f3..0b2a6b4c77 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -3469,7 +3469,7 @@ def test_add_bundled_extension_not_found_gives_clear_error(self, tmp_path): mock_catalog.search.return_value = [] with patch("specify_cli.extensions.ExtensionCatalog", return_value=mock_catalog), \ - patch("specify_cli._locate_bundled_extension", return_value=None), \ + patch("specify_cli.commands.extension._locate_bundled_extension", return_value=None), \ patch.object(Path, "cwd", return_value=project_dir): result = runner.invoke( app, From 2ba0197c8b05859c7a971b7e7c49ede6986f263e Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Sat, 25 Apr 2026 21:46:14 +0800 Subject: [PATCH 22/31] refactor: fix dead code, constants, and patch targets in commands/extension.py --- src/specify_cli/__init__.py | 2 +- src/specify_cli/commands/extension.py | 49 +++++++++++++-------------- tests/test_extensions.py | 2 +- 3 files changed, 25 insertions(+), 28 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index c5cbb1d153..35fb3e6373 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -326,7 +326,7 @@ def self_upgrade() -> None: # ===== Extension Commands ===== -from .commands.extension import extension_app, catalog_app +from .commands.extension import extension_app app.add_typer(extension_app, name="extension") # ===== Integration Commands ===== diff --git a/src/specify_cli/commands/extension.py b/src/specify_cli/commands/extension.py index 748ba7b920..7d788e7ee1 100644 --- a/src/specify_cli/commands/extension.py +++ b/src/specify_cli/commands/extension.py @@ -8,11 +8,15 @@ from typing import Optional import typer +from rich.table import Table from .._console import console from .._assets import _asset_service as _svc from .._helpers import get_speckit_version +_SPECIFY_DIR = ".specify" +_EXTENSION_CATALOGS_FILE = "extension-catalogs.yml" + extension_app = typer.Typer( name="extension", help="Manage spec-kit extensions", @@ -28,10 +32,6 @@ extension_app.add_typer(catalog_app, name="catalog") -def _locate_bundled_extension(extension_id: str) -> Path | None: - return _svc.locate_bundled_extension(extension_id) - - def _resolve_installed_extension( argument: str, installed_extensions: list, @@ -52,8 +52,6 @@ def _resolve_installed_extension( Raises: typer.Exit: If extension not found (and allow_not_found=False) or name is ambiguous """ - from rich.table import Table - # First, try exact ID match for ext in installed_extensions: if ext["id"] == argument: @@ -107,7 +105,6 @@ def _resolve_catalog_extension( - If catalog error: (None, error) - If not found: (None, None) """ - from rich.table import Table from ..extensions import ExtensionError try: @@ -262,7 +259,7 @@ def extension_list( project_root = Path.cwd() # Check if we're in a spec-kit project - specify_dir = project_root / ".specify" + specify_dir = project_root / _SPECIFY_DIR if not specify_dir.exists(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") @@ -302,7 +299,7 @@ def catalog_list() -> None: project_root = Path.cwd() - specify_dir = project_root / ".specify" + specify_dir = project_root / _SPECIFY_DIR if not specify_dir.exists(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") @@ -330,8 +327,8 @@ def catalog_list() -> None: console.print(f" Install: {install_str}") console.print() - config_path = project_root / ".specify" / "extension-catalogs.yml" - user_config_path = Path.home() / ".specify" / "extension-catalogs.yml" + config_path = project_root / _SPECIFY_DIR / _EXTENSION_CATALOGS_FILE + user_config_path = Path.home() / _SPECIFY_DIR / _EXTENSION_CATALOGS_FILE if os.environ.get("SPECKIT_CATALOG_URL"): console.print("[dim]Catalog configured via SPECKIT_CATALOG_URL environment variable.[/dim]") else: @@ -371,7 +368,7 @@ def catalog_add( project_root = Path.cwd() - specify_dir = project_root / ".specify" + specify_dir = project_root / _SPECIFY_DIR if not specify_dir.exists(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") @@ -385,7 +382,7 @@ def catalog_add( console.print(f"[red]Error:[/red] {e}") raise typer.Exit(1) - config_path = specify_dir / "extension-catalogs.yml" + config_path = specify_dir / _EXTENSION_CATALOGS_FILE # Load existing config if config_path.exists(): @@ -434,13 +431,13 @@ def catalog_remove( """Remove a catalog from .specify/extension-catalogs.yml.""" project_root = Path.cwd() - specify_dir = project_root / ".specify" + specify_dir = project_root / _SPECIFY_DIR if not specify_dir.exists(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") raise typer.Exit(1) - config_path = specify_dir / "extension-catalogs.yml" + config_path = specify_dir / _EXTENSION_CATALOGS_FILE if not config_path.exists(): console.print("[red]Error:[/red] No catalog config found. Nothing to remove.") raise typer.Exit(1) @@ -483,7 +480,7 @@ def extension_add( project_root = Path.cwd() # Check if we're in a spec-kit project - specify_dir = project_root / ".specify" + specify_dir = project_root / _SPECIFY_DIR if not specify_dir.exists(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") @@ -533,7 +530,7 @@ def extension_add( console.print(f"Downloading from {from_url}...") # Download ZIP to temp location - download_dir = project_root / ".specify" / "extensions" / ".cache" / "downloads" + download_dir = project_root / _SPECIFY_DIR / "extensions" / ".cache" / "downloads" download_dir.mkdir(parents=True, exist_ok=True) zip_path = download_dir / f"{extension}-url-download.zip" @@ -554,7 +551,7 @@ def extension_add( else: # Try bundled extensions first (shipped with spec-kit) - bundled_path = _locate_bundled_extension(extension) + bundled_path = _svc.locate_bundled_extension(extension) if bundled_path is not None: manifest = manager.install_from_directory(bundled_path, speckit_version, priority=priority) else: @@ -575,7 +572,7 @@ def extension_add( # If catalog resolved a display name to an ID, check bundled again resolved_id = ext_info['id'] if resolved_id != extension: - bundled_path = _locate_bundled_extension(resolved_id) + bundled_path = _svc.locate_bundled_extension(resolved_id) if bundled_path is not None: manifest = manager.install_from_directory(bundled_path, speckit_version, priority=priority) @@ -665,7 +662,7 @@ def extension_remove( project_root = Path.cwd() # Check if we're in a spec-kit project - specify_dir = project_root / ".specify" + specify_dir = project_root / _SPECIFY_DIR if not specify_dir.exists(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") @@ -741,7 +738,7 @@ def extension_search( project_root = Path.cwd() # Check if we're in a spec-kit project - specify_dir = project_root / ".specify" + specify_dir = project_root / _SPECIFY_DIR if not specify_dir.exists(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") @@ -825,7 +822,7 @@ def extension_info( project_root = Path.cwd() # Check if we're in a spec-kit project - specify_dir = project_root / ".specify" + specify_dir = project_root / _SPECIFY_DIR if not specify_dir.exists(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") @@ -927,7 +924,7 @@ def extension_update( project_root = Path.cwd() # Check if we're in a spec-kit project - specify_dir = project_root / ".specify" + specify_dir = project_root / _SPECIFY_DIR if not specify_dir.exists(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") @@ -1323,7 +1320,7 @@ def extension_enable( project_root = Path.cwd() # Check if we're in a spec-kit project - specify_dir = project_root / ".specify" + specify_dir = project_root / _SPECIFY_DIR if not specify_dir.exists(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") @@ -1370,7 +1367,7 @@ def extension_disable( project_root = Path.cwd() # Check if we're in a spec-kit project - specify_dir = project_root / ".specify" + specify_dir = project_root / _SPECIFY_DIR if not specify_dir.exists(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") @@ -1420,7 +1417,7 @@ def extension_set_priority( project_root = Path.cwd() # Check if we're in a spec-kit project - specify_dir = project_root / ".specify" + specify_dir = project_root / _SPECIFY_DIR if not specify_dir.exists(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 0b2a6b4c77..60ed76dae4 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -3469,7 +3469,7 @@ def test_add_bundled_extension_not_found_gives_clear_error(self, tmp_path): mock_catalog.search.return_value = [] with patch("specify_cli.extensions.ExtensionCatalog", return_value=mock_catalog), \ - patch("specify_cli.commands.extension._locate_bundled_extension", return_value=None), \ + patch.object(__import__("specify_cli._assets", fromlist=["AssetService"]).AssetService, "locate_bundled_extension", return_value=None), \ patch.object(Path, "cwd", return_value=project_dir): result = runner.invoke( app, From 4cb29628cabab8124cb8f868c86d7b6f671bceca Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Sat, 25 Apr 2026 21:53:56 +0800 Subject: [PATCH 23/31] refactor: move workflow command handlers to commands/workflow.py Extracted all workflow_app and workflow_catalog_app command handlers from __init__.py into src/specify_cli/commands/workflow.py using the sub-typer pattern consistent with preset.py and extension.py. - Replaced 660 lines of inline handlers with a 3-line import + add_typer - Added module-level constants _SPECIFY_DIR and _WORKFLOWS_SUBDIR - All handlers carry -> None return type annotations - Lazy inline imports used for WorkflowEngine, WorkflowRegistry, etc. - No circular imports; __init__.py now ~340 lines (was 995) --- src/specify_cli/__init__.py | 618 +----------------------- src/specify_cli/commands/workflow.py | 670 +++++++++++++++++++++++++++ 2 files changed, 671 insertions(+), 617 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 35fb3e6373..a7e5edee67 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -352,625 +352,9 @@ def self_upgrade() -> None: # ===== Workflow Commands ===== -workflow_app = typer.Typer( - name="workflow", - help="Manage and run automation workflows", - add_completion=False, -) +from .commands.workflow import workflow_app app.add_typer(workflow_app, name="workflow") -workflow_catalog_app = typer.Typer( - name="catalog", - help="Manage workflow catalogs", - add_completion=False, -) -workflow_app.add_typer(workflow_catalog_app, name="catalog") - - -@workflow_app.command("run") -def workflow_run( - source: str = typer.Argument(..., help="Workflow ID or YAML file path"), - input_values: list[str] | None = typer.Option( - None, "--input", "-i", help="Input values as key=value pairs" - ), -): - """Run a workflow from an installed ID or local YAML path.""" - from .workflows.engine import WorkflowEngine - - project_root = _require_specify_project() - engine = WorkflowEngine(project_root) - engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026") - - try: - definition = engine.load_workflow(source) - except FileNotFoundError: - console.print(f"[red]Error:[/red] Workflow not found: {source}") - raise typer.Exit(1) - except ValueError as exc: - console.print(f"[red]Error:[/red] Invalid workflow: {exc}") - raise typer.Exit(1) - - # Validate - errors = engine.validate(definition) - if errors: - console.print("[red]Workflow validation failed:[/red]") - for err in errors: - console.print(f" • {err}") - raise typer.Exit(1) - - # Parse inputs - inputs: dict[str, Any] = {} - if input_values: - for kv in input_values: - if "=" not in kv: - console.print(f"[red]Error:[/red] Invalid input format: {kv!r} (expected key=value)") - raise typer.Exit(1) - key, _, value = kv.partition("=") - inputs[key.strip()] = value.strip() - - console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})") - console.print(f"[dim]Version: {definition.version}[/dim]\n") - - try: - state = engine.execute(definition, inputs) - except ValueError as exc: - console.print(f"[red]Error:[/red] {exc}") - raise typer.Exit(1) - except Exception as exc: - console.print(f"[red]Workflow failed:[/red] {exc}") - raise typer.Exit(1) - - status_colors = { - "completed": "green", - "paused": "yellow", - "failed": "red", - "aborted": "red", - } - color = status_colors.get(state.status.value, "white") - console.print(f"\n[{color}]Status: {state.status.value}[/{color}]") - console.print(f"[dim]Run ID: {state.run_id}[/dim]") - - if state.status.value == "paused": - console.print(f"\nResume with: [cyan]specify workflow resume {state.run_id}[/cyan]") - - -@workflow_app.command("resume") -def workflow_resume( - run_id: str = typer.Argument(..., help="Run ID to resume"), -): - """Resume a paused or failed workflow run.""" - from .workflows.engine import WorkflowEngine - - project_root = _require_specify_project() - engine = WorkflowEngine(project_root) - engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026") - - try: - state = engine.resume(run_id) - except FileNotFoundError: - console.print(f"[red]Error:[/red] Run not found: {run_id}") - raise typer.Exit(1) - except ValueError as exc: - console.print(f"[red]Error:[/red] {exc}") - raise typer.Exit(1) - except Exception as exc: - console.print(f"[red]Resume failed:[/red] {exc}") - raise typer.Exit(1) - - status_colors = { - "completed": "green", - "paused": "yellow", - "failed": "red", - "aborted": "red", - } - color = status_colors.get(state.status.value, "white") - console.print(f"\n[{color}]Status: {state.status.value}[/{color}]") - - -@workflow_app.command("status") -def workflow_status( - run_id: str | None = typer.Argument(None, help="Run ID to inspect (shows all if omitted)"), -): - """Show workflow run status.""" - from .workflows.engine import WorkflowEngine - - project_root = _require_specify_project() - engine = WorkflowEngine(project_root) - - if run_id: - try: - from .workflows.engine import RunState - state = RunState.load(run_id, project_root) - except FileNotFoundError: - console.print(f"[red]Error:[/red] Run not found: {run_id}") - raise typer.Exit(1) - - status_colors = { - "completed": "green", - "paused": "yellow", - "failed": "red", - "aborted": "red", - "running": "blue", - "created": "dim", - } - color = status_colors.get(state.status.value, "white") - - console.print(f"\n[bold cyan]Workflow Run: {state.run_id}[/bold cyan]") - console.print(f" Workflow: {state.workflow_id}") - console.print(f" Status: [{color}]{state.status.value}[/{color}]") - console.print(f" Created: {state.created_at}") - console.print(f" Updated: {state.updated_at}") - - if state.current_step_id: - console.print(f" Current: {state.current_step_id}") - - if state.step_results: - console.print(f"\n [bold]Steps ({len(state.step_results)}):[/bold]") - for step_id, step_data in state.step_results.items(): - s = step_data.get("status", "unknown") - sc = {"completed": "green", "failed": "red", "paused": "yellow"}.get(s, "white") - console.print(f" [{sc}]●[/{sc}] {step_id}: {s}") - else: - runs = engine.list_runs() - if not runs: - console.print("[yellow]No workflow runs found.[/yellow]") - return - - console.print("\n[bold cyan]Workflow Runs:[/bold cyan]\n") - for run_data in runs: - s = run_data.get("status", "unknown") - sc = {"completed": "green", "failed": "red", "paused": "yellow", "running": "blue"}.get(s, "white") - console.print( - f" [{sc}]●[/{sc}] {run_data['run_id']} " - f"{run_data.get('workflow_id', '?')} " - f"[{sc}]{s}[/{sc}] " - f"[dim]{run_data.get('updated_at', '?')}[/dim]" - ) - - -@workflow_app.command("list") -def workflow_list(): - """List installed workflows.""" - from .workflows.catalog import WorkflowRegistry - - project_root = _require_specify_project() - registry = WorkflowRegistry(project_root) - installed = registry.list() - - if not installed: - console.print("[yellow]No workflows installed.[/yellow]") - console.print("\nInstall a workflow with:") - console.print(" [cyan]specify workflow add [/cyan]") - return - - console.print("\n[bold cyan]Installed Workflows:[/bold cyan]\n") - for wf_id, wf_data in installed.items(): - console.print(f" [bold]{wf_data.get('name', wf_id)}[/bold] ({wf_id}) v{wf_data.get('version', '?')}") - desc = wf_data.get("description", "") - if desc: - console.print(f" {desc}") - console.print() - - -@workflow_app.command("add") -def workflow_add( - source: str = typer.Argument(..., help="Workflow ID, URL, or local path"), -): - """Install a workflow from catalog, URL, or local path.""" - from .workflows.catalog import WorkflowCatalog, WorkflowRegistry, WorkflowCatalogError - from .workflows.engine import WorkflowDefinition - - project_root = _require_specify_project() - registry = WorkflowRegistry(project_root) - workflows_dir = project_root / ".specify" / "workflows" - - def _validate_and_install_local(yaml_path: Path, source_label: str) -> None: - """Validate and install a workflow from a local YAML file.""" - try: - definition = WorkflowDefinition.from_yaml(yaml_path) - except (ValueError, yaml.YAMLError) as exc: - console.print(f"[red]Error:[/red] Invalid workflow YAML: {exc}") - raise typer.Exit(1) - if not definition.id or not definition.id.strip(): - console.print("[red]Error:[/red] Workflow definition has an empty or missing 'id'") - raise typer.Exit(1) - - from .workflows.engine import validate_workflow - errors = validate_workflow(definition) - if errors: - console.print("[red]Error:[/red] Workflow validation failed:") - for err in errors: - console.print(f" \u2022 {err}") - raise typer.Exit(1) - - dest_dir = workflows_dir / definition.id - dest_dir.mkdir(parents=True, exist_ok=True) - import shutil - shutil.copy2(yaml_path, dest_dir / "workflow.yml") - registry.add(definition.id, { - "name": definition.name, - "version": definition.version, - "description": definition.description, - "source": source_label, - }) - console.print(f"[green]✓[/green] Workflow '{definition.name}' ({definition.id}) installed") - - # Try as URL (http/https) - if source.startswith("http://") or source.startswith("https://"): - from ipaddress import ip_address - from urllib.parse import urlparse - from urllib.request import urlopen # noqa: S310 - - parsed_src = urlparse(source) - src_host = parsed_src.hostname or "" - src_loopback = src_host == "localhost" - if not src_loopback: - try: - src_loopback = ip_address(src_host).is_loopback - except ValueError: - # Host is not an IP literal (e.g., a DNS name); keep default non-loopback. - pass - if parsed_src.scheme != "https" and not (parsed_src.scheme == "http" and src_loopback): - console.print("[red]Error:[/red] Only HTTPS URLs are allowed, except HTTP for localhost.") - raise typer.Exit(1) - - import tempfile - try: - with urlopen(source, timeout=30) as resp: # noqa: S310 - final_url = resp.geturl() - final_parsed = urlparse(final_url) - final_host = final_parsed.hostname or "" - final_lb = final_host == "localhost" - if not final_lb: - try: - final_lb = ip_address(final_host).is_loopback - except ValueError: - # Redirect host is not an IP literal; keep loopback as determined above. - pass - if final_parsed.scheme != "https" and not (final_parsed.scheme == "http" and final_lb): - console.print(f"[red]Error:[/red] URL redirected to non-HTTPS: {final_url}") - raise typer.Exit(1) - with tempfile.NamedTemporaryFile(suffix=".yml", delete=False) as tmp: - tmp.write(resp.read()) - tmp_path = Path(tmp.name) - except typer.Exit: - raise - except Exception as exc: - console.print(f"[red]Error:[/red] Failed to download workflow: {exc}") - raise typer.Exit(1) - try: - _validate_and_install_local(tmp_path, source) - finally: - tmp_path.unlink(missing_ok=True) - return - - # Try as a local file/directory - source_path = Path(source) - if source_path.exists(): - if source_path.is_file() and source_path.suffix in (".yml", ".yaml"): - _validate_and_install_local(source_path, str(source_path)) - return - elif source_path.is_dir(): - wf_file = source_path / "workflow.yml" - if not wf_file.exists(): - console.print(f"[red]Error:[/red] No workflow.yml found in {source}") - raise typer.Exit(1) - _validate_and_install_local(wf_file, str(source_path)) - return - - # Try from catalog - catalog = WorkflowCatalog(project_root) - try: - info = catalog.get_workflow_info(source) - except WorkflowCatalogError as exc: - console.print(f"[red]Error:[/red] {exc}") - raise typer.Exit(1) - - if not info: - console.print(f"[red]Error:[/red] Workflow '{source}' not found in catalog") - raise typer.Exit(1) - - if not info.get("_install_allowed", True): - console.print(f"[yellow]Warning:[/yellow] Workflow '{source}' is from a discovery-only catalog") - console.print("Direct installation is not enabled for this catalog source.") - raise typer.Exit(1) - - workflow_url = info.get("url") - if not workflow_url: - console.print(f"[red]Error:[/red] Workflow '{source}' does not have an install URL in the catalog") - raise typer.Exit(1) - - # Validate URL scheme (HTTPS required, HTTP allowed for localhost only) - from ipaddress import ip_address - from urllib.parse import urlparse - - parsed_url = urlparse(workflow_url) - url_host = parsed_url.hostname or "" - is_loopback = False - if url_host == "localhost": - is_loopback = True - else: - try: - is_loopback = ip_address(url_host).is_loopback - except ValueError: - # Host is not an IP literal (e.g., a regular hostname); treat as non-loopback. - pass - if parsed_url.scheme != "https" and not (parsed_url.scheme == "http" and is_loopback): - console.print( - f"[red]Error:[/red] Workflow '{source}' has an invalid install URL. " - "Only HTTPS URLs are allowed, except HTTP for localhost/loopback." - ) - raise typer.Exit(1) - - workflow_dir = workflows_dir / source - # Validate that source is a safe directory name (no path traversal) - try: - workflow_dir.resolve().relative_to(workflows_dir.resolve()) - except ValueError: - console.print(f"[red]Error:[/red] Invalid workflow ID: {source!r}") - raise typer.Exit(1) - workflow_file = workflow_dir / "workflow.yml" - - try: - from urllib.request import urlopen # noqa: S310 — URL comes from catalog - - workflow_dir.mkdir(parents=True, exist_ok=True) - with urlopen(workflow_url, timeout=30) as response: # noqa: S310 - # Validate final URL after redirects - final_url = response.geturl() - final_parsed = urlparse(final_url) - final_host = final_parsed.hostname or "" - final_loopback = final_host == "localhost" - if not final_loopback: - try: - final_loopback = ip_address(final_host).is_loopback - except ValueError: - # Host is not an IP literal (e.g., a regular hostname); treat as non-loopback. - pass - if final_parsed.scheme != "https" and not (final_parsed.scheme == "http" and final_loopback): - if workflow_dir.exists(): - import shutil - shutil.rmtree(workflow_dir, ignore_errors=True) - console.print( - f"[red]Error:[/red] Workflow '{source}' redirected to non-HTTPS URL: {final_url}" - ) - raise typer.Exit(1) - workflow_file.write_bytes(response.read()) - except Exception as exc: - if workflow_dir.exists(): - import shutil - shutil.rmtree(workflow_dir, ignore_errors=True) - console.print(f"[red]Error:[/red] Failed to install workflow '{source}' from catalog: {exc}") - raise typer.Exit(1) - - # Validate the downloaded workflow before registering - try: - definition = WorkflowDefinition.from_yaml(workflow_file) - except (ValueError, yaml.YAMLError) as exc: - import shutil - shutil.rmtree(workflow_dir, ignore_errors=True) - console.print(f"[red]Error:[/red] Downloaded workflow is invalid: {exc}") - raise typer.Exit(1) - - from .workflows.engine import validate_workflow - errors = validate_workflow(definition) - if errors: - import shutil - shutil.rmtree(workflow_dir, ignore_errors=True) - console.print("[red]Error:[/red] Downloaded workflow validation failed:") - for err in errors: - console.print(f" \u2022 {err}") - raise typer.Exit(1) - - # Enforce that the workflow's internal ID matches the catalog key - if definition.id and definition.id != source: - import shutil - shutil.rmtree(workflow_dir, ignore_errors=True) - console.print( - f"[red]Error:[/red] Workflow ID in YAML ({definition.id!r}) " - f"does not match catalog key ({source!r}). " - f"The catalog entry may be misconfigured." - ) - raise typer.Exit(1) - - registry.add(source, { - "name": definition.name or info.get("name", source), - "version": definition.version or info.get("version", "0.0.0"), - "description": definition.description or info.get("description", ""), - "source": "catalog", - "catalog_name": info.get("_catalog_name", ""), - "url": workflow_url, - }) - console.print(f"[green]✓[/green] Workflow '{info.get('name', source)}' installed from catalog") - - -@workflow_app.command("remove") -def workflow_remove( - workflow_id: str = typer.Argument(..., help="Workflow ID to uninstall"), -): - """Uninstall a workflow.""" - from .workflows.catalog import WorkflowRegistry - - project_root = _require_specify_project() - registry = WorkflowRegistry(project_root) - - if not registry.is_installed(workflow_id): - console.print(f"[red]Error:[/red] Workflow '{workflow_id}' is not installed") - raise typer.Exit(1) - - # Remove workflow files - workflow_dir = project_root / ".specify" / "workflows" / workflow_id - if workflow_dir.exists(): - import shutil - shutil.rmtree(workflow_dir) - - registry.remove(workflow_id) - console.print(f"[green]✓[/green] Workflow '{workflow_id}' removed") - - -@workflow_app.command("search") -def workflow_search( - query: str | None = typer.Argument(None, help="Search query"), - tag: str | None = typer.Option(None, "--tag", help="Filter by tag"), -): - """Search workflow catalogs.""" - from .workflows.catalog import WorkflowCatalog, WorkflowCatalogError - - project_root = _require_specify_project() - catalog = WorkflowCatalog(project_root) - - try: - results = catalog.search(query=query, tag=tag) - except WorkflowCatalogError as exc: - console.print(f"[red]Error:[/red] {exc}") - raise typer.Exit(1) - - if not results: - console.print("[yellow]No workflows found.[/yellow]") - return - - console.print(f"\n[bold cyan]Workflows ({len(results)}):[/bold cyan]\n") - for wf in results: - console.print(f" [bold]{wf.get('name', wf.get('id', '?'))}[/bold] ({wf.get('id', '?')}) v{wf.get('version', '?')}") - desc = wf.get("description", "") - if desc: - console.print(f" {desc}") - tags = wf.get("tags", []) - if tags: - console.print(f" [dim]Tags: {', '.join(tags)}[/dim]") - console.print() - - -@workflow_app.command("info") -def workflow_info( - workflow_id: str = typer.Argument(..., help="Workflow ID"), -): - """Show workflow details and step graph.""" - from .workflows.catalog import WorkflowCatalog, WorkflowRegistry, WorkflowCatalogError - from .workflows.engine import WorkflowEngine - - project_root = _require_specify_project() - - # Check installed first - registry = WorkflowRegistry(project_root) - installed = registry.get(workflow_id) - - engine = WorkflowEngine(project_root) - - definition = None - try: - definition = engine.load_workflow(workflow_id) - except FileNotFoundError: - # Local workflow definition not found on disk; fall back to - # catalog/registry lookup below. - pass - - if definition: - console.print(f"\n[bold cyan]{definition.name}[/bold cyan] ({definition.id})") - console.print(f" Version: {definition.version}") - if definition.author: - console.print(f" Author: {definition.author}") - if definition.description: - console.print(f" Description: {definition.description}") - if definition.default_integration: - console.print(f" Integration: {definition.default_integration}") - if installed: - console.print(" [green]Installed[/green]") - - if definition.inputs: - console.print("\n [bold]Inputs:[/bold]") - for name, inp in definition.inputs.items(): - if isinstance(inp, dict): - req = "required" if inp.get("required") else "optional" - console.print(f" {name} ({inp.get('type', 'string')}) — {req}") - - if definition.steps: - console.print(f"\n [bold]Steps ({len(definition.steps)}):[/bold]") - for step in definition.steps: - stype = step.get("type", "command") - console.print(f" → {step.get('id', '?')} [{stype}]") - return - - # Try catalog - catalog = WorkflowCatalog(project_root) - try: - info = catalog.get_workflow_info(workflow_id) - except WorkflowCatalogError: - info = None - - if info: - console.print(f"\n[bold cyan]{info.get('name', workflow_id)}[/bold cyan] ({workflow_id})") - console.print(f" Version: {info.get('version', '?')}") - if info.get("description"): - console.print(f" Description: {info['description']}") - if info.get("tags"): - console.print(f" Tags: {', '.join(info['tags'])}") - console.print(" [yellow]Not installed[/yellow]") - else: - console.print(f"[red]Error:[/red] Workflow '{workflow_id}' not found") - raise typer.Exit(1) - - -@workflow_catalog_app.command("list") -def workflow_catalog_list(): - """List configured workflow catalog sources.""" - from .workflows.catalog import WorkflowCatalog, WorkflowCatalogError - - project_root = _require_specify_project() - catalog = WorkflowCatalog(project_root) - - try: - configs = catalog.get_catalog_configs() - except WorkflowCatalogError as exc: - console.print(f"[red]Error:[/red] {exc}") - raise typer.Exit(1) - - console.print("\n[bold cyan]Workflow Catalog Sources:[/bold cyan]\n") - for i, cfg in enumerate(configs): - install_status = "[green]install allowed[/green]" if cfg["install_allowed"] else "[yellow]discovery only[/yellow]" - console.print(f" [{i}] [bold]{cfg['name']}[/bold] — {install_status}") - console.print(f" {cfg['url']}") - if cfg.get("description"): - console.print(f" [dim]{cfg['description']}[/dim]") - console.print() - - -@workflow_catalog_app.command("add") -def workflow_catalog_add( - url: str = typer.Argument(..., help="Catalog URL to add"), - name: str = typer.Option(None, "--name", help="Catalog name"), -): - """Add a workflow catalog source.""" - from .workflows.catalog import WorkflowCatalog, WorkflowValidationError - - project_root = _require_specify_project() - catalog = WorkflowCatalog(project_root) - try: - catalog.add_catalog(url, name) - except WorkflowValidationError as exc: - console.print(f"[red]Error:[/red] {exc}") - raise typer.Exit(1) - - console.print(f"[green]✓[/green] Catalog source added: {url}") - - -@workflow_catalog_app.command("remove") -def workflow_catalog_remove( - index: int = typer.Argument(..., help="Catalog index to remove (from 'catalog list')"), -): - """Remove a workflow catalog source by index.""" - from .workflows.catalog import WorkflowCatalog, WorkflowValidationError - - project_root = _require_specify_project() - catalog = WorkflowCatalog(project_root) - try: - removed_name = catalog.remove_catalog(index) - except WorkflowValidationError as exc: - console.print(f"[red]Error:[/red] {exc}") - raise typer.Exit(1) - - console.print(f"[green]✓[/green] Catalog source '{removed_name}' removed") - def main(): app() diff --git a/src/specify_cli/commands/workflow.py b/src/specify_cli/commands/workflow.py index b140dafe24..104958d916 100644 --- a/src/specify_cli/commands/workflow.py +++ b/src/specify_cli/commands/workflow.py @@ -1 +1,671 @@ """specify workflow * commands.""" + +import shutil +import tempfile +import yaml +from pathlib import Path +from typing import Any, Optional + +import typer + +from .._console import console + +_SPECIFY_DIR = ".specify" +_WORKFLOWS_SUBDIR = "workflows" + +workflow_app = typer.Typer( + name="workflow", + help="Manage and run automation workflows", + add_completion=False, +) + +workflow_catalog_app = typer.Typer( + name="catalog", + help="Manage workflow catalogs", + add_completion=False, +) +workflow_app.add_typer(workflow_catalog_app, name="catalog") + + +# ===== Workflow Commands ===== + + +@workflow_app.command("run") +def workflow_run( + source: str = typer.Argument(..., help="Workflow ID or YAML file path"), + input_values: list[str] | None = typer.Option( + None, "--input", "-i", help="Input values as key=value pairs" + ), +) -> None: + """Run a workflow from an installed ID or local YAML path.""" + from ..workflows.engine import WorkflowEngine + + project_root = Path.cwd() + if not (project_root / _SPECIFY_DIR).exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + engine = WorkflowEngine(project_root) + engine.on_step_start = lambda sid, label: console.print(f" ▸ [{sid}] {label} …") + + try: + definition = engine.load_workflow(source) + except FileNotFoundError: + console.print(f"[red]Error:[/red] Workflow not found: {source}") + raise typer.Exit(1) + except ValueError as exc: + console.print(f"[red]Error:[/red] Invalid workflow: {exc}") + raise typer.Exit(1) + + # Validate + errors = engine.validate(definition) + if errors: + console.print("[red]Workflow validation failed:[/red]") + for err in errors: + console.print(f" • {err}") + raise typer.Exit(1) + + # Parse inputs + inputs: dict[str, Any] = {} + if input_values: + for kv in input_values: + if "=" not in kv: + console.print(f"[red]Error:[/red] Invalid input format: {kv!r} (expected key=value)") + raise typer.Exit(1) + key, _, value = kv.partition("=") + inputs[key.strip()] = value.strip() + + console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})") + console.print(f"[dim]Version: {definition.version}[/dim]\n") + + try: + state = engine.execute(definition, inputs) + except ValueError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + except Exception as exc: + console.print(f"[red]Workflow failed:[/red] {exc}") + raise typer.Exit(1) + + status_colors = { + "completed": "green", + "paused": "yellow", + "failed": "red", + "aborted": "red", + } + color = status_colors.get(state.status.value, "white") + console.print(f"\n[{color}]Status: {state.status.value}[/{color}]") + console.print(f"[dim]Run ID: {state.run_id}[/dim]") + + if state.status.value == "paused": + console.print(f"\nResume with: [cyan]specify workflow resume {state.run_id}[/cyan]") + + +@workflow_app.command("resume") +def workflow_resume( + run_id: str = typer.Argument(..., help="Run ID to resume"), +) -> None: + """Resume a paused or failed workflow run.""" + from ..workflows.engine import WorkflowEngine + + project_root = Path.cwd() + if not (project_root / _SPECIFY_DIR).exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + engine = WorkflowEngine(project_root) + engine.on_step_start = lambda sid, label: console.print(f" ▸ [{sid}] {label} …") + + try: + state = engine.resume(run_id) + except FileNotFoundError: + console.print(f"[red]Error:[/red] Run not found: {run_id}") + raise typer.Exit(1) + except ValueError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + except Exception as exc: + console.print(f"[red]Resume failed:[/red] {exc}") + raise typer.Exit(1) + + status_colors = { + "completed": "green", + "paused": "yellow", + "failed": "red", + "aborted": "red", + } + color = status_colors.get(state.status.value, "white") + console.print(f"\n[{color}]Status: {state.status.value}[/{color}]") + + +@workflow_app.command("status") +def workflow_status( + run_id: str | None = typer.Argument(None, help="Run ID to inspect (shows all if omitted)"), +) -> None: + """Show workflow run status.""" + from ..workflows.engine import WorkflowEngine + + project_root = Path.cwd() + if not (project_root / _SPECIFY_DIR).exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + engine = WorkflowEngine(project_root) + + if run_id: + try: + from ..workflows.engine import RunState + state = RunState.load(run_id, project_root) + except FileNotFoundError: + console.print(f"[red]Error:[/red] Run not found: {run_id}") + raise typer.Exit(1) + + status_colors = { + "completed": "green", + "paused": "yellow", + "failed": "red", + "aborted": "red", + "running": "blue", + "created": "dim", + } + color = status_colors.get(state.status.value, "white") + + console.print(f"\n[bold cyan]Workflow Run: {state.run_id}[/bold cyan]") + console.print(f" Workflow: {state.workflow_id}") + console.print(f" Status: [{color}]{state.status.value}[/{color}]") + console.print(f" Created: {state.created_at}") + console.print(f" Updated: {state.updated_at}") + + if state.current_step_id: + console.print(f" Current: {state.current_step_id}") + + if state.step_results: + console.print(f"\n [bold]Steps ({len(state.step_results)}):[/bold]") + for step_id, step_data in state.step_results.items(): + s = step_data.get("status", "unknown") + sc = {"completed": "green", "failed": "red", "paused": "yellow"}.get(s, "white") + console.print(f" [{sc}]●[/{sc}] {step_id}: {s}") + else: + runs = engine.list_runs() + if not runs: + console.print("[yellow]No workflow runs found.[/yellow]") + return + + console.print("\n[bold cyan]Workflow Runs:[/bold cyan]\n") + for run_data in runs: + s = run_data.get("status", "unknown") + sc = {"completed": "green", "failed": "red", "paused": "yellow", "running": "blue"}.get(s, "white") + console.print( + f" [{sc}]●[/{sc}] {run_data['run_id']} " + f"{run_data.get('workflow_id', '?')} " + f"[{sc}]{s}[/{sc}] " + f"[dim]{run_data.get('updated_at', '?')}[/dim]" + ) + + +@workflow_app.command("list") +def workflow_list() -> None: + """List installed workflows.""" + from ..workflows.catalog import WorkflowRegistry + + project_root = Path.cwd() + specify_dir = project_root / _SPECIFY_DIR + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + + registry = WorkflowRegistry(project_root) + installed = registry.list() + + if not installed: + console.print("[yellow]No workflows installed.[/yellow]") + console.print("\nInstall a workflow with:") + console.print(" [cyan]specify workflow add [/cyan]") + return + + console.print("\n[bold cyan]Installed Workflows:[/bold cyan]\n") + for wf_id, wf_data in installed.items(): + console.print(f" [bold]{wf_data.get('name', wf_id)}[/bold] ({wf_id}) v{wf_data.get('version', '?')}") + desc = wf_data.get("description", "") + if desc: + console.print(f" {desc}") + console.print() + + +@workflow_app.command("add") +def workflow_add( + source: str = typer.Argument(..., help="Workflow ID, URL, or local path"), +) -> None: + """Install a workflow from catalog, URL, or local path.""" + from ..workflows.catalog import WorkflowCatalog, WorkflowRegistry, WorkflowCatalogError + from ..workflows.engine import WorkflowDefinition + + project_root = Path.cwd() + specify_dir = project_root / _SPECIFY_DIR + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + + registry = WorkflowRegistry(project_root) + workflows_dir = project_root / _SPECIFY_DIR / _WORKFLOWS_SUBDIR + + def _validate_and_install_local(yaml_path: Path, source_label: str) -> None: + """Validate and install a workflow from a local YAML file.""" + try: + definition = WorkflowDefinition.from_yaml(yaml_path) + except (ValueError, yaml.YAMLError) as exc: + console.print(f"[red]Error:[/red] Invalid workflow YAML: {exc}") + raise typer.Exit(1) + if not definition.id or not definition.id.strip(): + console.print("[red]Error:[/red] Workflow definition has an empty or missing 'id'") + raise typer.Exit(1) + + from ..workflows.engine import validate_workflow + errors = validate_workflow(definition) + if errors: + console.print("[red]Error:[/red] Workflow validation failed:") + for err in errors: + console.print(f" • {err}") + raise typer.Exit(1) + + dest_dir = workflows_dir / definition.id + dest_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(yaml_path, dest_dir / "workflow.yml") + registry.add(definition.id, { + "name": definition.name, + "version": definition.version, + "description": definition.description, + "source": source_label, + }) + console.print(f"[green]✓[/green] Workflow '{definition.name}' ({definition.id}) installed") + + # Try as URL (http/https) + if source.startswith("http://") or source.startswith("https://"): + from ipaddress import ip_address + from urllib.parse import urlparse + from urllib.request import urlopen # noqa: S310 + + parsed_src = urlparse(source) + src_host = parsed_src.hostname or "" + src_loopback = src_host == "localhost" + if not src_loopback: + try: + src_loopback = ip_address(src_host).is_loopback + except ValueError: + # Host is not an IP literal (e.g., a DNS name); keep default non-loopback. + pass + if parsed_src.scheme != "https" and not (parsed_src.scheme == "http" and src_loopback): + console.print("[red]Error:[/red] Only HTTPS URLs are allowed, except HTTP for localhost.") + raise typer.Exit(1) + + try: + with urlopen(source, timeout=30) as resp: # noqa: S310 + final_url = resp.geturl() + final_parsed = urlparse(final_url) + final_host = final_parsed.hostname or "" + final_lb = final_host == "localhost" + if not final_lb: + try: + final_lb = ip_address(final_host).is_loopback + except ValueError: + # Redirect host is not an IP literal; keep loopback as determined above. + pass + if final_parsed.scheme != "https" and not (final_parsed.scheme == "http" and final_lb): + console.print(f"[red]Error:[/red] URL redirected to non-HTTPS: {final_url}") + raise typer.Exit(1) + with tempfile.NamedTemporaryFile(suffix=".yml", delete=False) as tmp: + tmp.write(resp.read()) + tmp_path = Path(tmp.name) + except typer.Exit: + raise + except Exception as exc: + console.print(f"[red]Error:[/red] Failed to download workflow: {exc}") + raise typer.Exit(1) + try: + _validate_and_install_local(tmp_path, source) + finally: + tmp_path.unlink(missing_ok=True) + return + + # Try as a local file/directory + source_path = Path(source) + if source_path.exists(): + if source_path.is_file() and source_path.suffix in (".yml", ".yaml"): + _validate_and_install_local(source_path, str(source_path)) + return + elif source_path.is_dir(): + wf_file = source_path / "workflow.yml" + if not wf_file.exists(): + console.print(f"[red]Error:[/red] No workflow.yml found in {source}") + raise typer.Exit(1) + _validate_and_install_local(wf_file, str(source_path)) + return + + # Try from catalog + catalog = WorkflowCatalog(project_root) + try: + info = catalog.get_workflow_info(source) + except WorkflowCatalogError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + if not info: + console.print(f"[red]Error:[/red] Workflow '{source}' not found in catalog") + raise typer.Exit(1) + + if not info.get("_install_allowed", True): + console.print(f"[yellow]Warning:[/yellow] Workflow '{source}' is from a discovery-only catalog") + console.print("Direct installation is not enabled for this catalog source.") + raise typer.Exit(1) + + workflow_url = info.get("url") + if not workflow_url: + console.print(f"[red]Error:[/red] Workflow '{source}' does not have an install URL in the catalog") + raise typer.Exit(1) + + # Validate URL scheme (HTTPS required, HTTP allowed for localhost only) + from ipaddress import ip_address + from urllib.parse import urlparse + + parsed_url = urlparse(workflow_url) + url_host = parsed_url.hostname or "" + is_loopback = False + if url_host == "localhost": + is_loopback = True + else: + try: + is_loopback = ip_address(url_host).is_loopback + except ValueError: + # Host is not an IP literal (e.g., a regular hostname); treat as non-loopback. + pass + if parsed_url.scheme != "https" and not (parsed_url.scheme == "http" and is_loopback): + console.print( + f"[red]Error:[/red] Workflow '{source}' has an invalid install URL. " + "Only HTTPS URLs are allowed, except HTTP for localhost/loopback." + ) + raise typer.Exit(1) + + workflow_dir = workflows_dir / source + # Validate that source is a safe directory name (no path traversal) + try: + workflow_dir.resolve().relative_to(workflows_dir.resolve()) + except ValueError: + console.print(f"[red]Error:[/red] Invalid workflow ID: {source!r}") + raise typer.Exit(1) + workflow_file = workflow_dir / "workflow.yml" + + try: + from urllib.request import urlopen # noqa: S310 — URL comes from catalog + from urllib.parse import urlparse as _urlparse + + workflow_dir.mkdir(parents=True, exist_ok=True) + with urlopen(workflow_url, timeout=30) as response: # noqa: S310 + # Validate final URL after redirects + final_url = response.geturl() + final_parsed = _urlparse(final_url) + final_host = final_parsed.hostname or "" + final_loopback = final_host == "localhost" + if not final_loopback: + try: + final_loopback = ip_address(final_host).is_loopback + except ValueError: + # Host is not an IP literal (e.g., a regular hostname); treat as non-loopback. + pass + if final_parsed.scheme != "https" and not (final_parsed.scheme == "http" and final_loopback): + if workflow_dir.exists(): + shutil.rmtree(workflow_dir, ignore_errors=True) + console.print( + f"[red]Error:[/red] Workflow '{source}' redirected to non-HTTPS URL: {final_url}" + ) + raise typer.Exit(1) + workflow_file.write_bytes(response.read()) + except Exception as exc: + if workflow_dir.exists(): + shutil.rmtree(workflow_dir, ignore_errors=True) + console.print(f"[red]Error:[/red] Failed to install workflow '{source}' from catalog: {exc}") + raise typer.Exit(1) + + # Validate the downloaded workflow before registering + try: + definition = WorkflowDefinition.from_yaml(workflow_file) + except (ValueError, yaml.YAMLError) as exc: + shutil.rmtree(workflow_dir, ignore_errors=True) + console.print(f"[red]Error:[/red] Downloaded workflow is invalid: {exc}") + raise typer.Exit(1) + + from ..workflows.engine import validate_workflow + errors = validate_workflow(definition) + if errors: + shutil.rmtree(workflow_dir, ignore_errors=True) + console.print("[red]Error:[/red] Downloaded workflow validation failed:") + for err in errors: + console.print(f" • {err}") + raise typer.Exit(1) + + # Enforce that the workflow's internal ID matches the catalog key + if definition.id and definition.id != source: + shutil.rmtree(workflow_dir, ignore_errors=True) + console.print( + f"[red]Error:[/red] Workflow ID in YAML ({definition.id!r}) " + f"does not match catalog key ({source!r}). " + f"The catalog entry may be misconfigured." + ) + raise typer.Exit(1) + + registry.add(source, { + "name": definition.name or info.get("name", source), + "version": definition.version or info.get("version", "0.0.0"), + "description": definition.description or info.get("description", ""), + "source": "catalog", + "catalog_name": info.get("_catalog_name", ""), + "url": workflow_url, + }) + console.print(f"[green]✓[/green] Workflow '{info.get('name', source)}' installed from catalog") + + +@workflow_app.command("remove") +def workflow_remove( + workflow_id: str = typer.Argument(..., help="Workflow ID to uninstall"), +) -> None: + """Uninstall a workflow.""" + from ..workflows.catalog import WorkflowRegistry + + project_root = Path.cwd() + specify_dir = project_root / _SPECIFY_DIR + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + + registry = WorkflowRegistry(project_root) + + if not registry.is_installed(workflow_id): + console.print(f"[red]Error:[/red] Workflow '{workflow_id}' is not installed") + raise typer.Exit(1) + + # Remove workflow files + workflow_dir = project_root / _SPECIFY_DIR / _WORKFLOWS_SUBDIR / workflow_id + if workflow_dir.exists(): + shutil.rmtree(workflow_dir) + + registry.remove(workflow_id) + console.print(f"[green]✓[/green] Workflow '{workflow_id}' removed") + + +@workflow_app.command("search") +def workflow_search( + query: str | None = typer.Argument(None, help="Search query"), + tag: str | None = typer.Option(None, "--tag", help="Filter by tag"), +) -> None: + """Search workflow catalogs.""" + from ..workflows.catalog import WorkflowCatalog, WorkflowCatalogError + + project_root = Path.cwd() + if not (project_root / _SPECIFY_DIR).exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + catalog = WorkflowCatalog(project_root) + + try: + results = catalog.search(query=query, tag=tag) + except WorkflowCatalogError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + if not results: + console.print("[yellow]No workflows found.[/yellow]") + return + + console.print(f"\n[bold cyan]Workflows ({len(results)}):[/bold cyan]\n") + for wf in results: + console.print(f" [bold]{wf.get('name', wf.get('id', '?'))}[/bold] ({wf.get('id', '?')}) v{wf.get('version', '?')}") + desc = wf.get("description", "") + if desc: + console.print(f" {desc}") + tags = wf.get("tags", []) + if tags: + console.print(f" [dim]Tags: {', '.join(tags)}[/dim]") + console.print() + + +@workflow_app.command("info") +def workflow_info( + workflow_id: str = typer.Argument(..., help="Workflow ID"), +) -> None: + """Show workflow details and step graph.""" + from ..workflows.catalog import WorkflowCatalog, WorkflowRegistry, WorkflowCatalogError + from ..workflows.engine import WorkflowEngine + + project_root = Path.cwd() + if not (project_root / _SPECIFY_DIR).exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + + # Check installed first + registry = WorkflowRegistry(project_root) + installed = registry.get(workflow_id) + + engine = WorkflowEngine(project_root) + + definition = None + try: + definition = engine.load_workflow(workflow_id) + except FileNotFoundError: + # Local workflow definition not found on disk; fall back to + # catalog/registry lookup below. + pass + + if definition: + console.print(f"\n[bold cyan]{definition.name}[/bold cyan] ({definition.id})") + console.print(f" Version: {definition.version}") + if definition.author: + console.print(f" Author: {definition.author}") + if definition.description: + console.print(f" Description: {definition.description}") + if definition.default_integration: + console.print(f" Integration: {definition.default_integration}") + if installed: + console.print(" [green]Installed[/green]") + + if definition.inputs: + console.print("\n [bold]Inputs:[/bold]") + for name, inp in definition.inputs.items(): + if isinstance(inp, dict): + req = "required" if inp.get("required") else "optional" + console.print(f" {name} ({inp.get('type', 'string')}) — {req}") + + if definition.steps: + console.print(f"\n [bold]Steps ({len(definition.steps)}):[/bold]") + for step in definition.steps: + stype = step.get("type", "command") + console.print(f" → {step.get('id', '?')} [{stype}]") + return + + # Try catalog + catalog = WorkflowCatalog(project_root) + try: + info = catalog.get_workflow_info(workflow_id) + except WorkflowCatalogError: + info = None + + if info: + console.print(f"\n[bold cyan]{info.get('name', workflow_id)}[/bold cyan] ({workflow_id})") + console.print(f" Version: {info.get('version', '?')}") + if info.get("description"): + console.print(f" Description: {info['description']}") + if info.get("tags"): + console.print(f" Tags: {', '.join(info['tags'])}") + console.print(" [yellow]Not installed[/yellow]") + else: + console.print(f"[red]Error:[/red] Workflow '{workflow_id}' not found") + raise typer.Exit(1) + + +# ===== Workflow Catalog Commands ===== + + +@workflow_catalog_app.command("list") +def workflow_catalog_list() -> None: + """List configured workflow catalog sources.""" + from ..workflows.catalog import WorkflowCatalog, WorkflowCatalogError + + project_root = Path.cwd() + catalog = WorkflowCatalog(project_root) + + try: + configs = catalog.get_catalog_configs() + except WorkflowCatalogError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + console.print("\n[bold cyan]Workflow Catalog Sources:[/bold cyan]\n") + for i, cfg in enumerate(configs): + install_status = "[green]install allowed[/green]" if cfg["install_allowed"] else "[yellow]discovery only[/yellow]" + console.print(f" [{i}] [bold]{cfg['name']}[/bold] — {install_status}") + console.print(f" {cfg['url']}") + if cfg.get("description"): + console.print(f" [dim]{cfg['description']}[/dim]") + console.print() + + +@workflow_catalog_app.command("add") +def workflow_catalog_add( + url: str = typer.Argument(..., help="Catalog URL to add"), + name: str = typer.Option(None, "--name", help="Catalog name"), +) -> None: + """Add a workflow catalog source.""" + from ..workflows.catalog import WorkflowCatalog, WorkflowValidationError + + project_root = Path.cwd() + specify_dir = project_root / _SPECIFY_DIR + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + + catalog = WorkflowCatalog(project_root) + try: + catalog.add_catalog(url, name) + except WorkflowValidationError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + console.print(f"[green]✓[/green] Catalog source added: {url}") + + +@workflow_catalog_app.command("remove") +def workflow_catalog_remove( + index: int = typer.Argument(..., help="Catalog index to remove (from 'catalog list')"), +) -> None: + """Remove a workflow catalog source by index.""" + from ..workflows.catalog import WorkflowCatalog, WorkflowValidationError + + project_root = Path.cwd() + specify_dir = project_root / _SPECIFY_DIR + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + + catalog = WorkflowCatalog(project_root) + try: + removed_name = catalog.remove_catalog(index) + except WorkflowValidationError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + console.print(f"[green]✓[/green] Catalog source '{removed_name}' removed") From 9c728d81c084d0d9783581fae84d7ced385ab6fe Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Sat, 25 Apr 2026 21:57:05 +0800 Subject: [PATCH 24/31] refactor: remove dead Optional import in commands/workflow.py --- src/specify_cli/commands/workflow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/specify_cli/commands/workflow.py b/src/specify_cli/commands/workflow.py index 104958d916..5d14c6b615 100644 --- a/src/specify_cli/commands/workflow.py +++ b/src/specify_cli/commands/workflow.py @@ -4,7 +4,7 @@ import tempfile import yaml from pathlib import Path -from typing import Any, Optional +from typing import Any import typer From a0e110be8a884c9f9ce620df9737506840d1b2a0 Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Sat, 25 Apr 2026 22:04:54 +0800 Subject: [PATCH 25/31] =?UTF-8?q?refactor:=20final=20=5F=5Finit=5F=5F.py?= =?UTF-8?q?=20cleanup=20=E2=80=94=20remove=20dead=20imports=20and=20wrappe?= =?UTF-8?q?rs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove stdlib imports no longer used in the file body (zipfile, tempfile, shutil, json, shlex, yaml, os), unused Rich imports (Text, Live), dead _TOML_AGENTS constant, and slim down _ui/_helpers/_assets/_git/_version imports to only what is actually needed. Retain urllib.request/urllib.error and all backward-compat re-exports (select_with_arrows, _get_skills_dir, AI_ASSISTANT_ALIASES, AI_ASSISTANT_HELP, _install_shared_infra, _parse_integration_options, _fs helpers) that are patched or imported by the test suite or by internal sibling modules. All 1765 tests pass. --- src/specify_cli/__init__.py | 49 ++++++++++++------------------------- 1 file changed, 15 insertions(+), 34 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index a7e5edee67..58483973d3 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -26,40 +26,33 @@ specify init --here """ -import os import sys -import zipfile -import tempfile -import shutil -import json -import shlex import urllib.error import urllib.request -import yaml from pathlib import Path -from typing import Any, Optional +from typing import Optional import typer from rich.panel import Panel -from rich.text import Text -from rich.live import Live from rich.align import Align from rich.table import Table from ._console import console -from ._ui import StepTracker, get_key, select_with_arrows, BannerGroup, show_banner, BANNER, TAGLINE +from ._ui import StepTracker, BannerGroup, show_banner, select_with_arrows from ._fs import handle_vscode_settings, merge_json_files, save_init_options, load_init_options -from ._assets import AssetService as _AssetService, _asset_service as _svc -from ._git import GitService as _GitService, _git_service as _git_svc -from ._version import VersionService as _VersionService, _version_service as _ver_svc, GITHUB_API_LATEST +from ._assets import _asset_service as _svc +from ._git import _git_service as _git_svc +from ._version import _version_service as _ver_svc from ._helpers import ( - run_command, check_tool, - _install_shared_infra, ensure_executable_scripts, - ensure_constitution_from_template, _get_skills_dir, - CLAUDE_LOCAL_PATH, CLAUDE_NPM_LOCAL_PATH, - get_speckit_version, _parse_integration_options, - AGENT_CONFIG, AI_ASSISTANT_ALIASES, AI_ASSISTANT_HELP, SCRIPT_TYPE_CHOICES, + check_tool, + get_speckit_version, + AGENT_CONFIG, + AI_ASSISTANT_ALIASES, + AI_ASSISTANT_HELP, + _install_shared_infra, + _parse_integration_options, + _get_skills_dir, ) from .integration_runtime import ( invoke_separator_for_integration as _invoke_separator_for_integration, @@ -82,9 +75,6 @@ refresh_shared_templates as _refresh_shared_templates_impl, ) -# Agents that use TOML command format (others use Markdown) -_TOML_AGENTS = frozenset({"gemini", "tabnine"}) - app = typer.Typer( name="specify", help="Setup tool for Specify spec-driven development projects", @@ -331,25 +321,16 @@ def self_upgrade() -> None: # ===== Integration Commands ===== -from .commands.integration import ( - integration_app, - _read_integration_json, - _write_integration_json, - _remove_integration_json, - _normalize_script_type, - _resolve_script_type, -) +from .commands.integration import integration_app app.add_typer(integration_app, name="integration") # ===== Preset Commands ===== -from .commands.preset import preset_app, preset_catalog_app +from .commands.preset import preset_app app.add_typer(preset_app, name="preset") - - # ===== Workflow Commands ===== from .commands.workflow import workflow_app From d9d62ceb83263f3b7721ccc438adb38ef613ca7d Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Wed, 6 May 2026 12:17:53 +0800 Subject: [PATCH 26/31] chore: fix ruff linting errors and perform code cleanup Resolved 60+ linting issues across the source code and test suite to ensure consistency following the modularization refactor. Key changes: - Cleaned up unused imports (F401) and redundant variable assignments (F841) in src/specify_cli/ and tests/. - Fixed ambiguous variable names (E741) by renaming 'l' to 'line' in list comprehensions within test files. - Removed extraneous f-string prefixes (F541) from static strings. - Standardized import ordering and resolved module-level import placement issues (E402) in src/specify_cli/__init__.py. - Split multiple imports on single lines (E401) to comply with PEP 8. - Restored essential service singletons (_svc, _git_svc, _ver_svc) in the main entry point required by wrapper functions. All checks passed via . --- src/specify_cli/__init__.py | 40 +++---------------- src/specify_cli/commands/init.py | 3 +- src/specify_cli/commands/integration.py | 1 - tests/extensions/git/test_git_extension.py | 2 +- .../test_integration_base_markdown.py | 6 +-- .../test_integration_base_yaml.py | 4 +- tests/integrations/test_integration_claude.py | 4 +- tests/test_asset_service.py | 2 +- tests/test_extension_skills.py | 14 +++---- tests/test_extensions.py | 5 ++- tests/test_helpers.py | 2 - tests/test_setup_tasks.py | 18 ++++----- tests/test_timestamp_branches.py | 2 +- tests/test_ui.py | 2 +- 14 files changed, 36 insertions(+), 69 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 58483973d3..14348b324c 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -27,8 +27,6 @@ """ import sys -import urllib.error -import urllib.request from pathlib import Path from typing import Optional @@ -39,8 +37,7 @@ from rich.table import Table from ._console import console -from ._ui import StepTracker, BannerGroup, show_banner, select_with_arrows -from ._fs import handle_vscode_settings, merge_json_files, save_init_options, load_init_options +from ._ui import StepTracker, BannerGroup, show_banner from ._assets import _asset_service as _svc from ._git import _git_service as _git_svc from ._version import _version_service as _ver_svc @@ -48,32 +45,12 @@ check_tool, get_speckit_version, AGENT_CONFIG, - AI_ASSISTANT_ALIASES, - AI_ASSISTANT_HELP, - _install_shared_infra, - _parse_integration_options, - _get_skills_dir, -) -from .integration_runtime import ( - invoke_separator_for_integration as _invoke_separator_for_integration, - resolve_integration_options as _resolve_integration_options_impl, - with_integration_setting as _with_integration_setting, -) -from .integration_state import ( - INTEGRATION_JSON, - INTEGRATION_STATE_SCHEMA, - dedupe_integration_keys as _dedupe_integration_keys, - default_integration_key as _default_integration_key, - installed_integration_keys as _installed_integration_keys, - integration_setting as _integration_setting, - integration_settings as _integration_settings, - normalize_integration_state as _normalize_integration_state, - write_integration_json as _write_integration_json_file, -) -from .shared_infra import ( - install_shared_infra as _install_shared_infra_impl, - refresh_shared_templates as _refresh_shared_templates_impl, ) +from .commands import init as _init_cmd +from .commands.extension import extension_app +from .commands.integration import integration_app +from .commands.preset import preset_app +from .commands.workflow import workflow_app app = typer.Typer( name="specify", @@ -150,7 +127,6 @@ def _locate_bundled_preset(preset_id: str) -> Path | None: } -from .commands import init as _init_cmd _init_cmd.register(app) @app.command() @@ -316,24 +292,20 @@ def self_upgrade() -> None: # ===== Extension Commands ===== -from .commands.extension import extension_app app.add_typer(extension_app, name="extension") # ===== Integration Commands ===== -from .commands.integration import integration_app app.add_typer(integration_app, name="integration") # ===== Preset Commands ===== -from .commands.preset import preset_app app.add_typer(preset_app, name="preset") # ===== Workflow Commands ===== -from .commands.workflow import workflow_app app.add_typer(workflow_app, name="workflow") diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index 35ee4a5a99..67538e8e97 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -5,7 +5,7 @@ import shutil import sys from pathlib import Path -from typing import Any, Optional +from typing import Any import typer from rich.panel import Panel @@ -17,7 +17,6 @@ from .._git import _git_service as _git_svc from .._assets import _asset_service as _svc from .._helpers import ( - run_command, check_tool, _install_shared_infra, ensure_constitution_from_template, diff --git a/src/specify_cli/commands/integration.py b/src/specify_cli/commands/integration.py index d928ece80d..a37af12131 100644 --- a/src/specify_cli/commands/integration.py +++ b/src/specify_cli/commands/integration.py @@ -10,7 +10,6 @@ from .._console import console from .._fs import save_init_options, load_init_options from .._helpers import ( - run_command, check_tool, _install_shared_infra, ensure_executable_scripts, get_speckit_version, _parse_integration_options, SCRIPT_TYPE_CHOICES, diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index c4f986d177..f28492c178 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -371,7 +371,7 @@ def test_no_git_graceful_degradation(self, tmp_path: Path): ) assert result.returncode == 0, result.stderr # pwsh may prefix warnings to stdout; find the JSON line - json_line = [l for l in result.stdout.splitlines() if l.strip().startswith("{")] + json_line = [line for line in result.stdout.splitlines() if line.strip().startswith("{")] assert json_line, f"No JSON in output: {result.stdout}" data = json.loads(json_line[-1]) assert "BRANCH_NAME" in data diff --git a/tests/integrations/test_integration_base_markdown.py b/tests/integrations/test_integration_base_markdown.py index 0b74a6f1a9..3e3257da65 100644 --- a/tests/integrations/test_integration_base_markdown.py +++ b/tests/integrations/test_integration_base_markdown.py @@ -267,10 +267,10 @@ def _expected_files(self, script_variant: str) -> list[str]: files.append(f"{cmd_dir}/speckit.{stem}.md") # Framework files - files.append(f".specify/integration.json") - files.append(f".specify/init-options.json") + files.append(".specify/integration.json") + files.append(".specify/init-options.json") files.append(f".specify/integrations/{self.KEY}.manifest.json") - files.append(f".specify/integrations/speckit.manifest.json") + files.append(".specify/integrations/speckit.manifest.json") if script_variant == "sh": for name in ["check-prerequisites.sh", "common.sh", "create-new-feature.sh", diff --git a/tests/integrations/test_integration_base_yaml.py b/tests/integrations/test_integration_base_yaml.py index 956c7a796f..a90fc48572 100644 --- a/tests/integrations/test_integration_base_yaml.py +++ b/tests/integrations/test_integration_base_yaml.py @@ -152,7 +152,7 @@ def test_yaml_is_valid(self, tmp_path): content = f.read_text(encoding="utf-8") # Strip trailing source comment before parsing lines = content.split("\n") - yaml_lines = [l for l in lines if not l.startswith("# Source:")] + yaml_lines = [line for line in lines if not line.startswith("# Source:")] try: parsed = yaml.safe_load("\n".join(yaml_lines)) except Exception as exc: @@ -183,7 +183,7 @@ def test_yaml_prompt_excludes_frontmatter(self, tmp_path, monkeypatch): content = cmd_files[0].read_text(encoding="utf-8") # Strip source comment for parsing lines = content.split("\n") - yaml_lines = [l for l in lines if not l.startswith("# Source:")] + yaml_lines = [line for line in lines if not line.startswith("# Source:")] parsed = yaml.safe_load("\n".join(yaml_lines)) assert "description:" not in parsed["prompt"] diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index b3236a66b7..28ba22fa7b 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -500,7 +500,7 @@ def test_hook_note_injected_in_skills_with_hooks(self, tmp_path): """Skills that have hook sections should get the normalization note.""" i = get_integration("claude") m = IntegrationManifest("claude", tmp_path) - created = i.setup(tmp_path, m, script_type="sh") + i.setup(tmp_path, m, script_type="sh") specify_skill = tmp_path / ".claude/skills/speckit-specify/SKILL.md" assert specify_skill.exists() content = specify_skill.read_text(encoding="utf-8") @@ -539,7 +539,7 @@ def test_hook_note_preserves_indentation(self, tmp_path): ) result = ClaudeIntegration._inject_hook_command_note(content) lines = result.splitlines() - note_line = [l for l in lines if "replace dots" in l][0] + note_line = [line for line in lines if "replace dots" in line][0] assert note_line.startswith(" "), "Note should preserve indentation" def test_post_process_injects_all_claude_flags(self): diff --git a/tests/test_asset_service.py b/tests/test_asset_service.py index a0cea74464..5c923de2de 100644 --- a/tests/test_asset_service.py +++ b/tests/test_asset_service.py @@ -28,6 +28,6 @@ def test_locate_bundled_preset_invalid_id_returns_none(): def test_backward_compat_module_functions(): # The old underscore functions must still work via __init__.py - from specify_cli import _locate_core_pack, _locate_bundled_extension + from specify_cli import _locate_core_pack result = _locate_core_pack() assert result is None or isinstance(result, Path) diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index 89e8b4a8b8..6e6a0e65d9 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -18,9 +18,7 @@ from pathlib import Path from specify_cli.extensions import ( - ExtensionManifest, ExtensionManager, - ExtensionError, ) @@ -220,7 +218,7 @@ def test_skills_created_when_ai_skills_active(self, skills_project, extension_di """Skills should be created when ai_skills is enabled.""" project_dir, skills_dir = skills_project manager = ExtensionManager(project_dir) - manifest = manager.install_from_directory( + manager.install_from_directory( extension_dir, "0.1.0", register_commands=False ) @@ -588,7 +586,7 @@ def test_command_without_frontmatter(self, skills_project, temp_dir): ) manager = ExtensionManager(project_dir) - manifest = manager.install_from_directory( + manager.install_from_directory( ext_dir, "0.1.0", register_commands=False ) @@ -607,7 +605,7 @@ def test_gemini_agent_skills(self, project_dir, temp_dir): ext_dir = _create_extension_dir(temp_dir, ext_id="test-ext") manager = ExtensionManager(project_dir) - manifest = manager.install_from_directory( + manager.install_from_directory( ext_dir, "0.1.0", register_commands=False ) @@ -623,10 +621,10 @@ def test_multiple_extensions_independent_skills(self, skills_project, temp_dir): ext_dir_b = _create_extension_dir(temp_dir, ext_id="ext-b") manager = ExtensionManager(project_dir) - manifest_a = manager.install_from_directory( + manager.install_from_directory( ext_dir_a, "0.1.0", register_commands=False ) - manifest_b = manager.install_from_directory( + manager.install_from_directory( ext_dir_b, "0.1.0", register_commands=False ) @@ -684,7 +682,7 @@ def test_malformed_frontmatter_handled(self, skills_project, temp_dir): manager = ExtensionManager(project_dir) # Should not raise - manifest = manager.install_from_directory( + manager.install_from_directory( ext_dir, "0.1.0", register_commands=False ) diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 60ed76dae4..78df3fedb8 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1846,7 +1846,7 @@ def test_unregister_skill_removes_parent_directory(self, project_dir, temp_dir): registrar = CommandRegistrar() from specify_cli.extensions import ExtensionManifest manifest = ExtensionManifest(ext_dir / "extension.yml") - registered = registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir) + registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir) skill_subdir = skills_dir / "speckit-cleanup-ext-run" assert skill_subdir.exists(), "Skill subdirectory should exist after registration" @@ -2614,7 +2614,8 @@ def fake_open(req, timeout=None): def test_download_extension_sends_auth_header(self, temp_dir, monkeypatch): """download_extension passes Authorization header via opener for GitHub URLs.""" from unittest.mock import patch, MagicMock - import zipfile, io + import zipfile + import io monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") catalog = self._make_catalog(temp_dir) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 1b9485ba45..763f20f518 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -1,6 +1,4 @@ import shutil -from pathlib import Path -from unittest.mock import patch from specify_cli._helpers import check_tool, run_command def test_check_tool_git_found(): diff --git a/tests/test_setup_tasks.py b/tests/test_setup_tasks.py index f2e10d8b0f..751e399182 100644 --- a/tests/test_setup_tasks.py +++ b/tests/test_setup_tasks.py @@ -123,7 +123,7 @@ def test_setup_tasks_bash_core_template_resolved(tasks_repo: Path) -> None: setup-tasks.sh --json should exit 0 and return an absolute, existing TASKS_TEMPLATE path pointing to the core template. """ - feat = _minimal_feature(tasks_repo) + _minimal_feature(tasks_repo) script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh" result = subprocess.run( @@ -150,7 +150,7 @@ def test_setup_tasks_bash_override_wins(tasks_repo: Path) -> None: When an override exists at .specify/templates/overrides/tasks-template.md, setup-tasks.sh --json must return the override path, not the core path. """ - feat = _minimal_feature(tasks_repo) + _minimal_feature(tasks_repo) # Create the override overrides_dir = tasks_repo / ".specify" / "templates" / "overrides" @@ -187,7 +187,7 @@ def test_setup_tasks_bash_extension_wins_over_core(tasks_repo: Path) -> None: When an extension template exists, setup-tasks.sh --json must resolve tasks-template.md from the extension before falling back to the core path. """ - feat = _minimal_feature(tasks_repo) + _minimal_feature(tasks_repo) # FIX: real extension layout is .specify/extensions//templates/.md extension_dir = ( @@ -225,7 +225,7 @@ def test_setup_tasks_bash_preset_wins_over_extension(tasks_repo: Path) -> None: When both preset and extension templates exist, setup-tasks.sh --json must resolve the preset path because presets outrank extensions. """ - feat = _minimal_feature(tasks_repo) + _minimal_feature(tasks_repo) # FIX: real extension layout is .specify/extensions//templates/.md extension_dir = ( @@ -269,7 +269,7 @@ def test_setup_tasks_bash_preset_priority_order(tasks_repo: Path) -> None: When two presets both provide tasks-template.md, the one listed first in .specify/presets/.registry wins. """ - feat = _minimal_feature(tasks_repo) + _minimal_feature(tasks_repo) # resolve_template reads .specify/presets/.registry as a JSON object with a # "presets" map where each entry has a numeric "priority" (lower = higher @@ -329,7 +329,7 @@ def test_setup_tasks_bash_missing_template_errors(tasks_repo: Path) -> None: When tasks-template.md is absent from all locations, setup-tasks.sh must exit non-zero and print a helpful ERROR message to stderr. """ - feat = _minimal_feature(tasks_repo) + _minimal_feature(tasks_repo) # Remove the core template so no template exists anywhere core = tasks_repo / ".specify" / "templates" / "tasks-template.md" @@ -429,7 +429,7 @@ def test_setup_tasks_ps_core_template_resolved(tasks_repo: Path) -> None: setup-tasks.ps1 -Json should exit 0 and return an absolute, existing TASKS_TEMPLATE path. """ - feat = _minimal_feature(tasks_repo) + _minimal_feature(tasks_repo) script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1" exe = "pwsh" if HAS_PWSH else _POWERSHELL @@ -457,7 +457,7 @@ def test_setup_tasks_ps_override_wins(tasks_repo: Path) -> None: When an override exists at .specify/templates/overrides/tasks-template.md, setup-tasks.ps1 -Json must return the override path, not the core path. """ - feat = _minimal_feature(tasks_repo) + _minimal_feature(tasks_repo) overrides_dir = tasks_repo / ".specify" / "templates" / "overrides" overrides_dir.mkdir(parents=True, exist_ok=True) @@ -493,7 +493,7 @@ def test_setup_tasks_ps_missing_template_errors(tasks_repo: Path) -> None: When tasks-template.md is absent from all locations, setup-tasks.ps1 must exit non-zero and write a helpful error to stderr. """ - feat = _minimal_feature(tasks_repo) + _minimal_feature(tasks_repo) core = tasks_repo / ".specify" / "templates" / "tasks-template.md" core.unlink() diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index c99f675081..695d87ee48 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -853,7 +853,7 @@ def test_dry_run_with_timestamp(self, git_repo: Path): assert re.match(r"^\d{8}-\d{6}-ts-feat$", branch), f"unexpected: {branch}" # Verify no side effects branches = subprocess.run( - ["git", "branch", "--list", f"*ts-feat*"], + ["git", "branch", "--list", "*ts-feat*"], cwd=git_repo, capture_output=True, text=True, diff --git a/tests/test_ui.py b/tests/test_ui.py index 5139f7a580..8a16f42a4d 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -1,4 +1,4 @@ -from specify_cli._ui import StepTracker, BannerGroup, show_banner, select_with_arrows +from specify_cli._ui import StepTracker, BannerGroup def test_step_tracker_add_and_complete(): t = StepTracker("test") From 522e7f5c18c7f4cbce3cbe838e2fb1fecb854791 Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Wed, 6 May 2026 12:38:43 +0800 Subject: [PATCH 27/31] fix: restore missing exports in __init__.py for test compatibility Restored several key constants and functions to the package root in that were removed during modularization. These exports are required by the existing test suite. Resolved names: - AI_ASSISTANT_ALIASES & AI_ASSISTANT_HELP (re-exported from ._helpers) - save_init_options, merge_json_files, & handle_vscode_settings (re-exported from ._fs) Fixed ImportErrors in: - tests/test_agent_config_consistency.py - tests/test_branch_numbering.py - tests/test_merge.py Verified that all 41 affected tests now pass. --- src/specify_cli/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 14348b324c..14a2f7694a 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -41,10 +41,13 @@ from ._assets import _asset_service as _svc from ._git import _git_service as _git_svc from ._version import _version_service as _ver_svc +from ._fs import save_init_options, merge_json_files, handle_vscode_settings from ._helpers import ( check_tool, get_speckit_version, AGENT_CONFIG, + AI_ASSISTANT_ALIASES, + AI_ASSISTANT_HELP, ) from .commands import init as _init_cmd from .commands.extension import extension_app From 7d414cfeead186f45971fd9e0c02e3608fabc398 Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Wed, 6 May 2026 14:57:16 +0800 Subject: [PATCH 28/31] 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 --- src/specify_cli/__init__.py | 69 +- src/specify_cli/_helpers.py | 101 +-- src/specify_cli/commands/extension.py | 24 +- src/specify_cli/commands/init.py | 19 +- src/specify_cli/commands/integration.py | 898 +++++++++++++++++++----- src/specify_cli/commands/preset.py | 24 +- src/specify_cli/commands/workflow.py | 34 +- src/specify_cli/integrations/catalog.py | 125 ++-- src/specify_cli/shared_infra.py | 8 +- 9 files changed, 940 insertions(+), 362 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 14a2f7694a..ff61ed69d7 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -27,6 +27,9 @@ """ import sys +import urllib +import urllib.request +import urllib.error from pathlib import Path from typing import Optional @@ -41,14 +44,74 @@ from ._assets import _asset_service as _svc from ._git import _git_service as _git_svc from ._version import _version_service as _ver_svc -from ._fs import save_init_options, merge_json_files, handle_vscode_settings +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 from ._helpers import ( check_tool, get_speckit_version, AGENT_CONFIG, - AI_ASSISTANT_ALIASES, - AI_ASSISTANT_HELP, + AI_ASSISTANT_ALIASES as AI_ASSISTANT_ALIASES, + AI_ASSISTANT_HELP as AI_ASSISTANT_HELP, + _get_skills_dir as _get_skills_dir, + _parse_integration_options as _parse_integration_options, ) +from ._ui import select_with_arrows as select_with_arrows + + +def _install_shared_infra( + project_path: Path, + script_type: str, + tracker=None, + force: bool = False, + invoke_separator: str = ".", +) -> bool: + """Install shared infrastructure files into *project_path*.""" + from .shared_infra import install_shared_infra + from ._assets import _asset_service as _svc + + def _get_version() -> str: + import importlib.metadata + try: + return importlib.metadata.version("specify-cli") + except Exception: + return "unknown" + + return install_shared_infra( + project_path, + script_type, + version=_get_version(), + core_pack=_svc.locate_core_pack(), + repo_root=Path(__file__).parent.parent.parent, + console=console, + force=force, + invoke_separator=invoke_separator, + ) + + +def _refresh_shared_templates( + project_path: Path, + invoke_separator: str, + force: bool = False, +) -> None: + """Refresh default-sensitive shared templates without touching scripts.""" + from .shared_infra import refresh_shared_templates + from ._assets import _asset_service as _svc + + def _get_version() -> str: + import importlib.metadata + try: + return importlib.metadata.version("specify-cli") + except Exception: + return "unknown" + + refresh_shared_templates( + project_path, + version=_get_version(), + core_pack=_svc.locate_core_pack(), + repo_root=Path(__file__).parent.parent.parent, + console=console, + invoke_separator=invoke_separator, + force=force, + ) from .commands import init as _init_cmd from .commands.extension import extension_app from .commands.integration import integration_app diff --git a/src/specify_cli/_helpers.py b/src/specify_cli/_helpers.py index 9597b38767..76e99025a4 100644 --- a/src/specify_cli/_helpers.py +++ b/src/specify_cli/_helpers.py @@ -202,27 +202,20 @@ def check_tool(tool: str, tracker: StepTracker = None) -> bool: def _install_shared_infra( project_path: Path, script_type: str, - tracker: StepTracker | None = None, + tracker=None, force: bool = False, invoke_separator: str = ".", ) -> bool: """Install shared infrastructure files into *project_path*. - Copies ``.specify/scripts/`` and ``.specify/templates/`` from the - bundled core_pack or source checkout. Tracks all installed files - in ``speckit.manifest.json``. - - Page templates are processed to resolve ``__SPECKIT_COMMAND___`` - placeholders using *invoke_separator* (``"."`` for markdown agents, - ``"-"`` for skills agents). - - When *force* is ``True``, existing files are overwritten with the - latest bundled versions. When ``False`` (default), only missing - files are added and existing ones are skipped. + Delegates to ``shared_infra.install_shared_infra`` which provides + symlink-safe writes and atomic template updates. Returns ``True`` on success. """ import importlib.metadata + from .shared_infra import install_shared_infra + from ._assets import _asset_service as _svc def _get_version() -> str: try: @@ -230,80 +223,16 @@ def _get_version() -> str: except Exception: return "unknown" - from .integrations.base import IntegrationBase - from .integrations.manifest import IntegrationManifest - from ._assets import _asset_service as _svc - - core = _svc.locate_core_pack() - manifest = IntegrationManifest("speckit", project_path, version=_get_version()) - - # Scripts - if core and (core / "scripts").is_dir(): - scripts_src = core / "scripts" - else: - repo_root = Path(__file__).parent.parent.parent - scripts_src = repo_root / "scripts" - - skipped_files: list[str] = [] - - if scripts_src.is_dir(): - dest_scripts = project_path / ".specify" / "scripts" - dest_scripts.mkdir(parents=True, exist_ok=True) - variant_dir = "bash" if script_type == "sh" else "powershell" - variant_src = scripts_src / variant_dir - if variant_src.is_dir(): - dest_variant = dest_scripts / variant_dir - dest_variant.mkdir(parents=True, exist_ok=True) - for src_path in variant_src.rglob("*"): - if src_path.is_file(): - rel_path = src_path.relative_to(variant_src) - dst_path = dest_variant / rel_path - if dst_path.exists() and not force: - skipped_files.append(str(dst_path.relative_to(project_path))) - else: - dst_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(src_path, dst_path) - rel = dst_path.relative_to(project_path).as_posix() - manifest.record_existing(rel) - - # Page templates (not command templates, not vscode-settings.json) - if core and (core / "templates").is_dir(): - templates_src = core / "templates" - else: - repo_root = Path(__file__).parent.parent.parent - templates_src = repo_root / "templates" - - if templates_src.is_dir(): - dest_templates = project_path / ".specify" / "templates" - dest_templates.mkdir(parents=True, exist_ok=True) - for f in templates_src.iterdir(): - if f.is_file() and f.name != "vscode-settings.json" and not f.name.startswith("."): - dst = dest_templates / f.name - if dst.exists() and not force: - skipped_files.append(str(dst.relative_to(project_path))) - else: - content = f.read_text(encoding="utf-8") - content = IntegrationBase.resolve_command_refs( - content, invoke_separator - ) - dst.write_text(content, encoding="utf-8") - rel = dst.relative_to(project_path).as_posix() - manifest.record_existing(rel) - - if skipped_files: - console.print( - f"[yellow]⚠[/yellow] {len(skipped_files)} shared infrastructure file(s) already exist and were not updated:" - ) - for f in skipped_files: - console.print(f" {f}") - console.print( - "To refresh shared infrastructure, run " - "[cyan]specify init --here --force[/cyan] or " - "[cyan]specify integration upgrade --force[/cyan]." - ) - - manifest.save() - return True + return install_shared_infra( + project_path, + script_type, + version=_get_version(), + core_pack=_svc.locate_core_pack(), + repo_root=Path(__file__).parent.parent.parent, + console=console, + force=force, + invoke_separator=invoke_separator, + ) def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = None) -> None: diff --git a/src/specify_cli/commands/extension.py b/src/specify_cli/commands/extension.py index 7d788e7ee1..f2506ec752 100644 --- a/src/specify_cli/commands/extension.py +++ b/src/specify_cli/commands/extension.py @@ -260,7 +260,7 @@ def extension_list( # Check if we're in a spec-kit project specify_dir = project_root / _SPECIFY_DIR - if not specify_dir.exists(): + if not specify_dir.is_dir(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") raise typer.Exit(1) @@ -300,7 +300,7 @@ def catalog_list() -> None: project_root = Path.cwd() specify_dir = project_root / _SPECIFY_DIR - if not specify_dir.exists(): + if not specify_dir.is_dir(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") raise typer.Exit(1) @@ -369,7 +369,7 @@ def catalog_add( project_root = Path.cwd() specify_dir = project_root / _SPECIFY_DIR - if not specify_dir.exists(): + if not specify_dir.is_dir(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") raise typer.Exit(1) @@ -432,7 +432,7 @@ def catalog_remove( project_root = Path.cwd() specify_dir = project_root / _SPECIFY_DIR - if not specify_dir.exists(): + if not specify_dir.is_dir(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") raise typer.Exit(1) @@ -481,7 +481,7 @@ def extension_add( # Check if we're in a spec-kit project specify_dir = project_root / _SPECIFY_DIR - if not specify_dir.exists(): + if not specify_dir.is_dir(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") raise typer.Exit(1) @@ -663,7 +663,7 @@ def extension_remove( # Check if we're in a spec-kit project specify_dir = project_root / _SPECIFY_DIR - if not specify_dir.exists(): + if not specify_dir.is_dir(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") raise typer.Exit(1) @@ -739,7 +739,7 @@ def extension_search( # Check if we're in a spec-kit project specify_dir = project_root / _SPECIFY_DIR - if not specify_dir.exists(): + if not specify_dir.is_dir(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") raise typer.Exit(1) @@ -823,7 +823,7 @@ def extension_info( # Check if we're in a spec-kit project specify_dir = project_root / _SPECIFY_DIR - if not specify_dir.exists(): + if not specify_dir.is_dir(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") raise typer.Exit(1) @@ -925,7 +925,7 @@ def extension_update( # Check if we're in a spec-kit project specify_dir = project_root / _SPECIFY_DIR - if not specify_dir.exists(): + if not specify_dir.is_dir(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") raise typer.Exit(1) @@ -1321,7 +1321,7 @@ def extension_enable( # Check if we're in a spec-kit project specify_dir = project_root / _SPECIFY_DIR - if not specify_dir.exists(): + if not specify_dir.is_dir(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") raise typer.Exit(1) @@ -1368,7 +1368,7 @@ def extension_disable( # Check if we're in a spec-kit project specify_dir = project_root / _SPECIFY_DIR - if not specify_dir.exists(): + if not specify_dir.is_dir(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") raise typer.Exit(1) @@ -1418,7 +1418,7 @@ def extension_set_priority( # Check if we're in a spec-kit project specify_dir = project_root / _SPECIFY_DIR - if not specify_dir.exists(): + if not specify_dir.is_dir(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") raise typer.Exit(1) diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index 67538e8e97..e02382ef14 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -349,6 +349,13 @@ def init( console.print(f"[cyan]Selected coding agent integration:[/cyan] {selected_ai}") console.print(f"[cyan]Selected script type:[/cyan] {selected_script}") + if not no_git: + console.print( + "\n[dim]Note: The git extension is currently enabled by default during `specify init`.\n" + "Starting with v0.10.0, this will require an explicit opt-in via\n" + "[bold]specify extension add git[/bold].[/dim]" + ) + tracker = StepTracker("Initialize Specify Project") sys._specify_tracker_active = True @@ -405,11 +412,19 @@ def init( ) manifest.save() - # Write .specify/integration.json + # Write .specify/integration.json (v1 schema) integration_json = project_path / ".specify" / "integration.json" integration_json.parent.mkdir(parents=True, exist_ok=True) + _int_key = resolved_integration.key + _int_sep = resolved_integration.effective_invoke_separator(integration_parsed_options) integration_json.write_text(json.dumps({ - "integration": resolved_integration.key, + "integration_state_schema": 1, + "integration": _int_key, + "default_integration": _int_key, + "installed_integrations": [_int_key], + "integration_settings": { + _int_key: {"invoke_separator": _int_sep}, + }, "version": get_speckit_version(), }, indent=2) + "\n", encoding="utf-8") diff --git a/src/specify_cli/commands/integration.py b/src/specify_cli/commands/integration.py index a37af12131..978a04b127 100644 --- a/src/specify_cli/commands/integration.py +++ b/src/specify_cli/commands/integration.py @@ -1,6 +1,8 @@ """specify integration * commands.""" -import os import json +import os +import sys +import yaml from pathlib import Path from typing import Any @@ -21,7 +23,29 @@ add_completion=False, ) +integration_catalog_app = typer.Typer( + name="catalog", + help="Manage integration catalogs", + add_completion=False, +) +integration_app.add_typer(integration_catalog_app, name="catalog") + INTEGRATION_JSON = ".specify/integration.json" +INTEGRATION_STATE_SCHEMA = 1 + + +def _call_refresh_shared_templates( + project_root: Path, + invoke_separator: str, + force: bool = False, +) -> None: + """Call _refresh_shared_templates via sys.modules so monkeypatching works.""" + _specify_cli = sys.modules.get("specify_cli") + if _specify_cli and hasattr(_specify_cli, "_refresh_shared_templates"): + _specify_cli._refresh_shared_templates(project_root, invoke_separator, force=force) + else: + from .. import _refresh_shared_templates + _refresh_shared_templates(project_root, invoke_separator, force=force) def _read_integration_json(project_root: Path) -> dict[str, Any]: @@ -48,17 +72,71 @@ def _read_integration_json(project_root: Path) -> dict[str, Any]: return data -def _write_integration_json( - project_root: Path, - integration_key: str, -) -> None: - """Write ``.specify/integration.json`` for *integration_key*.""" +def _read_integration_state(project_root: Path) -> dict[str, Any]: + """Read integration.json and migrate to v1 schema if needed. + + Validates schema version and exits with error if unsupported. + Returns empty dict when no integration is installed. + """ + from ..integrations import INTEGRATION_REGISTRY + + data = _read_integration_json(project_root) + if not data: + return {} + + schema = data.get("integration_state_schema") + + # Schema version guard + if isinstance(schema, int) and schema > INTEGRATION_STATE_SCHEMA: + console.print( + f"[red]Error:[/red] integration.json uses schema {schema}, " + f"but this CLI only supports schema {INTEGRATION_STATE_SCHEMA}." + ) + raise typer.Exit(1) + + # Legacy migration from v0 (no schema version field) + if schema is None: + integration_key = data.get("integration") or "" + settings: dict[str, Any] = {} + installed: list[str] = [] + if integration_key: + installed = [integration_key] + integration = INTEGRATION_REGISTRY.get(integration_key) + if integration: + settings[integration_key] = { + "invoke_separator": integration.effective_invoke_separator(None), + } + return { + "integration_state_schema": INTEGRATION_STATE_SCHEMA, + "integration": integration_key, + "default_integration": integration_key, + "installed_integrations": installed, + "integration_settings": settings, + "version": data.get("version", ""), + } + + return data + + +def _build_integration_state( + default_key: str, + installed: list[str], + settings: dict[str, Any], +) -> dict[str, Any]: + return { + "integration_state_schema": INTEGRATION_STATE_SCHEMA, + "integration": default_key, + "default_integration": default_key, + "installed_integrations": installed, + "integration_settings": settings, + "version": get_speckit_version(), + } + + +def _write_integration_state(project_root: Path, state: dict[str, Any]) -> None: dest = project_root / INTEGRATION_JSON dest.parent.mkdir(parents=True, exist_ok=True) - dest.write_text(json.dumps({ - "integration": integration_key, - "version": get_speckit_version(), - }, indent=2) + "\n", encoding="utf-8") + dest.write_text(json.dumps(state, indent=2) + "\n", encoding="utf-8") def _remove_integration_json(project_root: Path) -> None: @@ -111,6 +189,14 @@ def _update_init_options_for_integration( save_init_options(project_root, opts) +def _ensure_speckit_project(project_root: Path) -> None: + specify_dir = project_root / ".specify" + if not specify_dir.is_dir(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + @integration_app.command("list") def integration_list( catalog: bool = typer.Option(False, "--catalog", help="Browse full catalog (built-in + community)"), @@ -119,15 +205,11 @@ def integration_list( from ..integrations import INTEGRATION_REGISTRY project_root = Path.cwd() + _ensure_speckit_project(project_root) - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - - current = _read_integration_json(project_root) - installed_key = current.get("integration") + state = _read_integration_state(project_root) + installed_keys: list[str] = state.get("installed_integrations", []) + default_key: str = state.get("default_integration") or state.get("integration") or "" if catalog: from ..integrations.catalog import IntegrationCatalog, IntegrationCatalogError @@ -154,7 +236,7 @@ def integration_list( eid = entry["id"] cat_name = entry.get("_catalog_name", "") install_allowed = entry.get("_install_allowed", True) - if eid == installed_key: + if eid == default_key: status = "[green]installed[/green]" elif eid in INTEGRATION_REGISTRY: status = "built-in" @@ -174,10 +256,11 @@ def integration_list( return table = Table(title="Coding Agent Integrations") - table.add_column("Key", style="cyan") + table.add_column("Key") table.add_column("Name") table.add_column("Status") table.add_column("CLI Required") + table.add_column("Multi-install Safe") for key in sorted(INTEGRATION_REGISTRY.keys()): integration = INTEGRATION_REGISTRY[key] @@ -185,18 +268,24 @@ def integration_list( name = cfg.get("name", key) requires_cli = cfg.get("requires_cli", False) - if key == installed_key: + if key == default_key and key in installed_keys: + status = "[green]installed (default)[/green]" + elif key in installed_keys: status = "[green]installed[/green]" else: status = "" cli_req = "yes" if requires_cli else "no (IDE)" - table.add_row(key, name, status, cli_req) + multi_safe = "yes" if integration.multi_install_safe else "no" + table.add_row(key, name, status, cli_req, multi_safe) console.print(table) - if installed_key: - console.print(f"\n[dim]Current integration:[/dim] [cyan]{installed_key}[/cyan]") + if default_key: + console.print(f"\n[dim]Current integration:[/dim] [cyan]{default_key}[/cyan]") + if len(installed_keys) > 1: + others = [k for k in installed_keys if k != default_key] + console.print(f"[dim]Also installed:[/dim] {', '.join(others)}") else: console.print("\n[yellow]No integration currently installed.[/yellow]") console.print("Install one with: [cyan]specify integration install [/cyan]") @@ -206,19 +295,15 @@ def integration_list( def integration_install( key: str = typer.Argument(help="Integration key to install (e.g. claude, copilot)"), script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"), - integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'), + force: bool = typer.Option(False, "--force", help="Force install even alongside non-multi-safe integrations"), + integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the integration'), ): """Install an integration into an existing project.""" from ..integrations import INTEGRATION_REGISTRY, get_integration from ..integrations.manifest import IntegrationManifest project_root = Path.cwd() - - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) + _ensure_speckit_project(project_root) integration = get_integration(key) if integration is None: @@ -227,31 +312,48 @@ def integration_install( console.print(f"Available integrations: {available}") raise typer.Exit(1) - current = _read_integration_json(project_root) - installed_key = current.get("integration") + state = _read_integration_state(project_root) + installed_keys: list[str] = list(state.get("installed_integrations", [])) + default_key: str = state.get("default_integration") or state.get("integration") or "" + settings: dict[str, Any] = dict(state.get("integration_settings", {})) - if installed_key and installed_key == key: + # Already installed (same key) + if key in installed_keys: console.print(f"[yellow]Integration '{key}' is already installed.[/yellow]") - console.print("Run [cyan]specify integration uninstall[/cyan] first, then reinstall.") + console.print(f"Run [cyan]specify integration upgrade {key}[/cyan] to upgrade, or") + console.print(f" [cyan]specify integration uninstall {key}[/cyan] to remove it.") raise typer.Exit(0) - if installed_key: - console.print(f"[red]Error:[/red] Integration '{installed_key}' is already installed.") - console.print(f"Run [cyan]specify integration uninstall[/cyan] first, or use [cyan]specify integration switch {key}[/cyan].") - raise typer.Exit(1) + # Another integration is installed — check multi-install compatibility + if installed_keys: + all_existing_safe = all( + INTEGRATION_REGISTRY[k].multi_install_safe + for k in installed_keys + if k in INTEGRATION_REGISTRY + ) + new_is_safe = integration.multi_install_safe + + if not (all_existing_safe and new_is_safe): + if not force: + installed_str = ", ".join(installed_keys) + console.print(f"[red]Error:[/red] Integration cannot be installed alongside existing ones.") + console.print(f"Installed integrations: {installed_str}") + console.print(f"Default integration: {default_key}") + console.print( + "Only multi-install safe integrations can be installed alongside others." + ) + console.print("Use [cyan]--force[/cyan] to override.") + raise typer.Exit(1) selected_script = _resolve_script_type(project_root, script) - # Build parsed options from --integration-options so the integration - # can determine its effective invoke separator before shared infra - # is installed. + # Build parsed options parsed_options: dict[str, Any] | None = None if integration_options: parsed_options = _parse_integration_options(integration, integration_options) - # Ensure shared infrastructure is present (safe to run unconditionally; - # _install_shared_infra merges missing files without overwriting). - _install_shared_infra(project_root, selected_script, invoke_separator=integration.effective_invoke_separator(parsed_options)) + invoke_sep = integration.effective_invoke_separator(parsed_options) + _install_shared_infra(project_root, selected_script, invoke_separator=invoke_sep) if os.name != "nt": ensure_executable_scripts(project_root) @@ -267,17 +369,29 @@ def integration_install( raw_options=integration_options, ) manifest.save() - _write_integration_json(project_root, integration.key) - _update_init_options_for_integration(project_root, integration, script_type=selected_script) + + # Build new state + new_installed = installed_keys + [key] + # Populate settings for all installed integrations + for k in new_installed: + if k not in settings: + reg = INTEGRATION_REGISTRY.get(k) + if reg: + settings[k] = {"invoke_separator": reg.effective_invoke_separator(None)} + + new_default = default_key if default_key in new_installed else (new_installed[0] if new_installed else "") + new_state = _build_integration_state(new_default, new_installed, settings) + _write_integration_state(project_root, new_state) + + # Update init-options only if this is the default (first) integration + if not default_key: + _update_init_options_for_integration(project_root, integration, script_type=selected_script) except Exception as exc: - # Attempt rollback of any files written by setup try: integration.teardown(project_root, manifest, force=True) except Exception as rollback_err: - # Suppress so the original setup error remains the primary failure console.print(f"[yellow]Warning:[/yellow] Failed to roll back integration changes: {rollback_err}") - _remove_integration_json(project_root) console.print(f"[red]Error:[/red] Failed to install integration: {exc}") raise typer.Exit(1) @@ -285,34 +399,43 @@ def integration_install( console.print(f"\n[green]✓[/green] Integration '{name}' installed successfully") +# Keep 'add' as hidden alias for backward compatibility +@integration_app.command("add", hidden=True) +def integration_add( + key: str = typer.Argument(help="Integration key to add (e.g. claude, copilot)"), + script: str | None = typer.Option(None, "--script"), + force: bool = typer.Option(False, "--force"), + integration_options: str | None = typer.Option(None, "--integration-options"), +): + """Alias for 'install'.""" + return integration_install(key=key, script=script, force=force, integration_options=integration_options) + + @integration_app.command("uninstall") def integration_uninstall( - key: str = typer.Argument(None, help="Integration key to uninstall (default: current integration)"), + key: str = typer.Argument(None, help="Integration key to uninstall (default: current default integration)"), force: bool = typer.Option(False, "--force", help="Remove files even if modified"), ): """Uninstall an integration, safely preserving modified files.""" - from ..integrations import get_integration + from ..integrations import INTEGRATION_REGISTRY, get_integration from ..integrations.manifest import IntegrationManifest project_root = Path.cwd() + _ensure_speckit_project(project_root) - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - - current = _read_integration_json(project_root) - installed_key = current.get("integration") + state = _read_integration_state(project_root) + installed_keys: list[str] = list(state.get("installed_integrations", [])) + default_key: str = state.get("default_integration") or state.get("integration") or "" + settings: dict[str, Any] = dict(state.get("integration_settings", {})) if key is None: - if not installed_key: + if not default_key: console.print("[yellow]No integration is currently installed.[/yellow]") raise typer.Exit(0) - key = installed_key + key = default_key - if installed_key and installed_key != key: - console.print(f"[red]Error:[/red] Integration '{key}' is not the currently installed integration ('{installed_key}').") + if key not in installed_keys: + console.print(f"[red]Error:[/red] Integration '{key}' is not installed.") raise typer.Exit(1) integration = get_integration(key) @@ -320,15 +443,22 @@ def integration_uninstall( manifest_path = project_root / ".specify" / "integrations" / f"{key}.manifest.json" if not manifest_path.exists(): console.print(f"[yellow]No manifest found for integration '{key}'. Nothing to uninstall.[/yellow]") - _remove_integration_json(project_root) - # Clear integration-related keys from init-options.json - opts = load_init_options(project_root) - if opts.get("integration") == key or opts.get("ai") == key: - opts.pop("integration", None) - opts.pop("ai", None) - opts.pop("ai_skills", None) - opts.pop("context_file", None) - save_init_options(project_root, opts) + # Clean up state anyway + new_installed = [k for k in installed_keys if k != key] + settings.pop(key, None) + if not new_installed: + _remove_integration_json(project_root) + opts = load_init_options(project_root) + if opts.get("integration") == key or opts.get("ai") == key: + opts.pop("integration", None) + opts.pop("ai", None) + opts.pop("ai_skills", None) + opts.pop("context_file", None) + save_init_options(project_root, opts) + else: + new_default = default_key if default_key in new_installed else new_installed[0] + new_state = _build_integration_state(new_default, new_installed, settings) + _write_integration_state(project_root, new_state) raise typer.Exit(0) try: @@ -346,22 +476,42 @@ def integration_uninstall( removed, skipped = manifest.uninstall(project_root, force=force) - # Remove managed context section from the agent context file if integration: integration.remove_context_section(project_root) - _remove_integration_json(project_root) - - # Update init-options.json to clear the integration - opts = load_init_options(project_root) - if opts.get("integration") == key or opts.get("ai") == key: - opts.pop("integration", None) - opts.pop("ai", None) - opts.pop("ai_skills", None) - opts.pop("context_file", None) - save_init_options(project_root, opts) + # Compute new installed state + new_installed = [k for k in installed_keys if k != key] + settings.pop(key, None) name = (integration.config or {}).get("name", key) if integration else key + + if not new_installed: + _remove_integration_json(project_root) + opts = load_init_options(project_root) + if opts.get("integration") == key or opts.get("ai") == key: + opts.pop("integration", None) + opts.pop("ai", None) + opts.pop("ai_skills", None) + opts.pop("context_file", None) + save_init_options(project_root, opts) + else: + # Set new default if we removed the current default + if key == default_key: + new_default = new_installed[0] + new_default_integration = get_integration(new_default) + if new_default_integration: + new_sep = new_default_integration.effective_invoke_separator(None) + try: + _call_refresh_shared_templates(project_root, new_sep, force=False) + except Exception: + pass # Template refresh failure is non-fatal during uninstall + _update_init_options_for_integration(project_root, new_default_integration) + else: + new_default = default_key + + new_state = _build_integration_state(new_default, new_installed, settings) + _write_integration_state(project_root, new_state) + console.print(f"\n[green]✓[/green] Integration '{name}' uninstalled") if removed: console.print(f" Removed {len(removed)} file(s)") @@ -372,24 +522,64 @@ def integration_uninstall( console.print(f" {rel}") +@integration_app.command("use") +def integration_use( + target: str = typer.Argument(help="Integration key to set as default"), + force: bool = typer.Option(False, "--force", help="Force refresh of modified templates"), +): + """Set the default integration (must already be installed).""" + from ..integrations import INTEGRATION_REGISTRY, get_integration + + project_root = Path.cwd() + _ensure_speckit_project(project_root) + + target_integration = get_integration(target) + if target_integration is None: + console.print(f"[red]Error:[/red] Unknown integration '{target}'") + available = ", ".join(sorted(INTEGRATION_REGISTRY.keys())) + console.print(f"Available integrations: {available}") + raise typer.Exit(1) + + state = _read_integration_state(project_root) + installed_keys: list[str] = list(state.get("installed_integrations", [])) + default_key: str = state.get("default_integration") or state.get("integration") or "" + settings: dict[str, Any] = dict(state.get("integration_settings", {})) + + if target not in installed_keys: + console.print(f"[red]Error:[/red] Integration '{target}' is not installed.") + console.print(f"Install it first with: [cyan]specify integration install {target}[/cyan]") + raise typer.Exit(1) + + # Refresh templates atomically (before persisting state) + new_sep = target_integration.effective_invoke_separator(None) + try: + _call_refresh_shared_templates(project_root, new_sep, force=force) + except Exception as exc: + console.print(f"[red]Error:[/red] Failed to refresh shared templates: {exc}") + raise typer.Exit(1) + + # Persist state + new_state = _build_integration_state(target, installed_keys, settings) + _write_integration_state(project_root, new_state) + _update_init_options_for_integration(project_root, target_integration) + + name = (target_integration.config or {}).get("name", target) + console.print(f"\n[green]✓[/green] Default integration set to '{name}'") + + @integration_app.command("switch") def integration_switch( target: str = typer.Argument(help="Integration key to switch to"), - script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"), - force: bool = typer.Option(False, "--force", help="Force removal of modified files during uninstall"), - integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the target integration'), + script: str | None = typer.Option(None, "--script", help="Script type: sh or ps"), + force: bool = typer.Option(False, "--force", help="Force overwrite of modified files"), + integration_options: str | None = typer.Option(None, "--integration-options", help="Options for the target integration"), ): - """Switch from the current integration to a different one.""" + """Switch the active integration (installs if not present, sets default if installed).""" from ..integrations import INTEGRATION_REGISTRY, get_integration from ..integrations.manifest import IntegrationManifest project_root = Path.cwd() - - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) + _ensure_speckit_project(project_root) target_integration = get_integration(target) if target_integration is None: @@ -398,59 +588,105 @@ def integration_switch( console.print(f"Available integrations: {available}") raise typer.Exit(1) - current = _read_integration_json(project_root) - installed_key = current.get("integration") + state = _read_integration_state(project_root) + installed_keys: list[str] = list(state.get("installed_integrations", [])) + default_key: str = state.get("default_integration") or state.get("integration") or "" + settings: dict[str, Any] = dict(state.get("integration_settings", {})) - if installed_key == target: - console.print(f"[yellow]Integration '{target}' is already installed. Nothing to switch.[/yellow]") + # Case 1: Target is the current default + if target == default_key: + if not force: + console.print(f"[yellow]Integration '{target}' is already the default integration.[/yellow]") + raise typer.Exit(0) + # Force refresh of shared templates + new_sep = target_integration.effective_invoke_separator( + _parse_integration_options(target_integration, integration_options) if integration_options else None + ) + try: + _call_refresh_shared_templates(project_root, new_sep, force=True) + except Exception as exc: + console.print(f"[red]Error:[/red] Failed to refresh shared templates: {exc}") + raise typer.Exit(1) + console.print(f"[green]✓[/green] managed shared templates refreshed") raise typer.Exit(0) + # Case 2: Target is already installed (but not default) + if target in installed_keys: + if integration_options: + console.print( + f"[red]Error:[/red] --integration-options cannot be used when switching to an already-installed integration." + ) + raise typer.Exit(1) + # Act like "use" command + new_sep = target_integration.effective_invoke_separator(None) + try: + _call_refresh_shared_templates(project_root, new_sep, force=force) + except Exception as exc: + console.print(f"[red]Error:[/red] Failed to refresh shared templates: {exc}") + raise typer.Exit(1) + new_state = _build_integration_state(target, installed_keys, settings) + _write_integration_state(project_root, new_state) + _update_init_options_for_integration(project_root, target_integration) + name = (target_integration.config or {}).get("name", target) + console.print(f"\n[green]✓[/green] Switched to integration '{name}'") + return + + # Case 3: Full switch — uninstall current default, install new target selected_script = _resolve_script_type(project_root, script) - # Phase 1: Uninstall current integration (if any) - if installed_key: - current_integration = get_integration(installed_key) - manifest_path = project_root / ".specify" / "integrations" / f"{installed_key}.manifest.json" + # Phase 1: Uninstall current default (if any) + if default_key and default_key in installed_keys: + current_integration = get_integration(default_key) + manifest_path = project_root / ".specify" / "integrations" / f"{default_key}.manifest.json" - if current_integration and manifest_path.exists(): - console.print(f"Uninstalling current integration: [cyan]{installed_key}[/cyan]") + if manifest_path.exists(): try: - old_manifest = IntegrationManifest.load(installed_key, project_root) + old_manifest = IntegrationManifest.load(default_key, project_root) except (ValueError, FileNotFoundError) as exc: - console.print(f"[red]Error:[/red] Could not read integration manifest for '{installed_key}': {manifest_path}") + console.print(f"[red]Error:[/red] Could not read integration manifest for '{default_key}': {manifest_path}") console.print(f"[dim]{exc}[/dim]") console.print( f"To recover, delete the unreadable manifest at {manifest_path}, " - f"run [cyan]specify integration uninstall {installed_key}[/cyan], then retry." + f"run [cyan]specify integration uninstall {default_key}[/cyan], then retry." ) raise typer.Exit(1) + console.print(f"Uninstalling current integration: [cyan]{default_key}[/cyan]") removed, skipped = old_manifest.uninstall(project_root, force=force) - current_integration.remove_context_section(project_root) + if current_integration: + current_integration.remove_context_section(project_root) if removed: console.print(f" Removed {len(removed)} file(s)") if skipped: console.print(f" [yellow]⚠[/yellow] {len(skipped)} modified file(s) preserved") - elif not current_integration and manifest_path.exists(): - # Integration removed from registry but manifest exists — use manifest-only uninstall - console.print(f"Uninstalling unknown integration '{installed_key}' via manifest") - try: - old_manifest = IntegrationManifest.load(installed_key, project_root) - removed, skipped = old_manifest.uninstall(project_root, force=force) - if removed: - console.print(f" Removed {len(removed)} file(s)") - if skipped: - console.print(f" [yellow]⚠[/yellow] {len(skipped)} modified file(s) preserved") - except (ValueError, FileNotFoundError) as exc: - console.print(f"[yellow]Warning:[/yellow] Could not read manifest for '{installed_key}': {exc}") + elif current_integration is None and not manifest_path.exists(): + pass # No manifest and unknown integration — skip uninstall else: - console.print(f"[red]Error:[/red] Integration '{installed_key}' is installed but has no manifest.") + console.print(f"[red]Error:[/red] Integration '{default_key}' is installed but has no manifest.") console.print( - f"Run [cyan]specify integration uninstall {installed_key}[/cyan] to clear metadata, " + f"Run [cyan]specify integration uninstall {default_key}[/cyan] to clear metadata, " f"then retry [cyan]specify integration switch {target}[/cyan]." ) raise typer.Exit(1) - # Clear metadata so a failed Phase 2 doesn't leave stale references + # Update state: remove old default, determine fallback + new_installed_before_target = [k for k in installed_keys if k != default_key] + settings.pop(default_key, None) + + # Determine fallback default (for atomicity — if phase 2 fails) + fallback_default = new_installed_before_target[0] if new_installed_before_target else "" + if fallback_default: + # Write intermediate state with fallback as default + fallback_state = _build_integration_state(fallback_default, new_installed_before_target, settings) + _write_integration_state(project_root, fallback_state) + fallback_integration = get_integration(fallback_default) + if fallback_integration: + _update_init_options_for_integration(project_root, fallback_integration) + try: + fallback_sep = fallback_integration.effective_invoke_separator(None) + _call_refresh_shared_templates(project_root, fallback_sep, force=False) + except Exception: + pass # Non-fatal + else: _remove_integration_json(project_root) opts = load_init_options(project_root) opts.pop("integration", None) @@ -459,16 +695,13 @@ def integration_switch( opts.pop("context_file", None) save_init_options(project_root, opts) - # Build parsed options from --integration-options so the integration - # can determine its effective invoke separator before shared infra - # is installed. + # Build parsed options for target parsed_options: dict[str, Any] | None = None if integration_options: parsed_options = _parse_integration_options(target_integration, integration_options) - # Ensure shared infrastructure is present (safe to run unconditionally; - # _install_shared_infra merges missing files without overwriting). - _install_shared_infra(project_root, selected_script, invoke_separator=target_integration.effective_invoke_separator(parsed_options)) + invoke_sep = target_integration.effective_invoke_separator(parsed_options) + _install_shared_infra(project_root, selected_script, invoke_separator=invoke_sep) if os.name != "nt": ensure_executable_scripts(project_root) @@ -486,61 +719,86 @@ def integration_switch( raw_options=integration_options, ) manifest.save() - _write_integration_json(project_root, target_integration.key) + + # Build final state + final_installed = new_installed_before_target + [target] + settings[target] = {"invoke_separator": target_integration.effective_invoke_separator(parsed_options)} + final_state = _build_integration_state(target, final_installed, settings) + _write_integration_state(project_root, final_state) _update_init_options_for_integration(project_root, target_integration, script_type=selected_script) except Exception as exc: - # Attempt rollback of any files written by setup try: target_integration.teardown(project_root, manifest, force=True) except Exception as rollback_err: - # Suppress so the original setup error remains the primary failure console.print(f"[yellow]Warning:[/yellow] Failed to roll back integration '{target}': {rollback_err}") - _remove_integration_json(project_root) console.print(f"[red]Error:[/red] Failed to install integration '{target}': {exc}") raise typer.Exit(1) + # Migrate extension commands to new integration + try: + _migrate_extension_commands(project_root, default_key, target, parsed_options, selected_script) + except Exception: + pass # Extension migration failure is non-fatal for the switch itself + name = (target_integration.config or {}).get("name", target) console.print(f"\n[green]✓[/green] Switched to integration '{name}'") +def _migrate_extension_commands( + project_root: Path, + old_key: str, + new_key: str, + parsed_options: dict[str, Any] | None, + script_type: str, +) -> None: + """Migrate enabled extensions to the new integration's command format.""" + registry_path = project_root / ".specify" / "extensions" / ".registry" + if not registry_path.exists(): + return + + try: + from ..extensions import ExtensionManager + manager = ExtensionManager(project_root) + + # Remove old integration's commands and skills + if old_key: + manager.unregister_agent_artifacts(old_key) + + # Register extensions for new integration + if new_key: + manager.register_enabled_extensions_for_agent(new_key) + except Exception: + pass + + @integration_app.command("upgrade") def integration_upgrade( key: str | None = typer.Argument(None, help="Integration key to upgrade (default: current integration)"), force: bool = typer.Option(False, "--force", help="Force upgrade even if files are modified"), - script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"), + script: str | None = typer.Option(None, "--script", help="Script type: sh or ps"), integration_options: str | None = typer.Option(None, "--integration-options", help="Options for the integration"), ): - """Upgrade an integration by reinstalling with diff-aware file handling. - - Compares manifest hashes to detect locally modified files and - blocks the upgrade unless --force is used. - """ + """Upgrade an integration by reinstalling with diff-aware file handling.""" from ..integrations import get_integration from ..integrations.manifest import IntegrationManifest project_root = Path.cwd() + _ensure_speckit_project(project_root) - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - - current = _read_integration_json(project_root) - installed_key = current.get("integration") + state = _read_integration_state(project_root) + installed_keys: list[str] = list(state.get("installed_integrations", [])) + default_key: str = state.get("default_integration") or state.get("integration") or "" + settings: dict[str, Any] = dict(state.get("integration_settings", {})) if key is None: - if not installed_key: + if not default_key: console.print("[yellow]No integration is currently installed.[/yellow]") raise typer.Exit(0) - key = installed_key + key = default_key - if installed_key and installed_key != key: - console.print( - f"[red]Error:[/red] Integration '{key}' is not the currently installed integration ('{installed_key}')." - ) - console.print(f"Use [cyan]specify integration switch {key}[/cyan] instead.") + if key not in installed_keys: + console.print(f"[red]Error:[/red] Integration '{key}' is not installed.") raise typer.Exit(1) integration = get_integration(key) @@ -571,22 +829,39 @@ def integration_upgrade( selected_script = _resolve_script_type(project_root, script) - # Build parsed options from --integration-options so the integration - # can determine its effective invoke separator before shared infra - # is installed. parsed_options: dict[str, Any] | None = None if integration_options: parsed_options = _parse_integration_options(integration, integration_options) - # Ensure shared infrastructure is up to date; --force overwrites existing files. - _install_shared_infra(project_root, selected_script, force=force, invoke_separator=integration.effective_invoke_separator(parsed_options)) + invoke_sep = integration.effective_invoke_separator(parsed_options) + is_default = (key == default_key) + + # When upgrading a non-default integration, use the default's separator for + # shared infra so templates stay consistent with the active integration. + # Don't force-overwrite templates for non-default upgrades. + if is_default: + _install_shared_infra(project_root, selected_script, force=force, invoke_separator=invoke_sep) + else: + default_integration = None + from ..integrations import get_integration as _get_int + if default_key: + default_integration = _get_int(default_key) + default_sep = default_integration.effective_invoke_separator(None) if default_integration else invoke_sep + _install_shared_infra(project_root, selected_script, force=False, invoke_separator=default_sep) if os.name != "nt": ensure_executable_scripts(project_root) - # Phase 1: Install new files (overwrites existing; old-only files remain) + # Reinstall console.print(f"Upgrading integration: [cyan]{key}[/cyan]") new_manifest = IntegrationManifest(key, project_root, version=get_speckit_version()) + # Save state snapshots for rollback + int_json_path = project_root / INTEGRATION_JSON + init_options_path = project_root / ".specify" / "init-options.json" + before_state_text = int_json_path.read_text(encoding="utf-8") if int_json_path.exists() else None + before_options_text = init_options_path.read_text(encoding="utf-8") if init_options_path.exists() else None + before_manifest_text = manifest_path.read_text(encoding="utf-8") if manifest_path.exists() else None + try: integration.setup( project_root, @@ -596,16 +871,38 @@ def integration_upgrade( raw_options=integration_options, ) new_manifest.save() - _write_integration_json(project_root, key) - _update_init_options_for_integration(project_root, integration, script_type=selected_script) + + # Update settings for this integration + settings[key] = {"invoke_separator": invoke_sep} + new_state = _build_integration_state(default_key, installed_keys, settings) + + # If this is the default integration, refresh templates (atomically) + if is_default: + try: + _call_refresh_shared_templates(project_root, invoke_sep, force=force) + except Exception as exc: + # Rollback everything + if before_state_text is not None: + int_json_path.write_text(before_state_text, encoding="utf-8") + if before_options_text is not None: + init_options_path.write_text(before_options_text, encoding="utf-8") + if before_manifest_text is not None: + manifest_path.write_text(before_manifest_text, encoding="utf-8") + console.print(f"[red]Error:[/red] Failed to refresh shared templates: {exc}") + raise typer.Exit(1) + + _write_integration_state(project_root, new_state) + if is_default: + _update_init_options_for_integration(project_root, integration, script_type=selected_script) + + except typer.Exit: + raise except Exception as exc: - # Don't teardown — setup overwrites in-place, so teardown would - # delete files that were working before the upgrade. Just report. console.print(f"[red]Error:[/red] Failed to upgrade integration: {exc}") console.print("[yellow]The previous integration files may still be in place.[/yellow]") raise typer.Exit(1) - # Phase 2: Remove stale files from old manifest that are not in the new one + # Remove stale files from old manifest that are not in the new one old_files = old_manifest.files new_files = new_manifest.files stale_keys = set(old_files) - set(new_files) @@ -618,3 +915,274 @@ def integration_upgrade( name = (integration.config or {}).get("name", key) console.print(f"\n[green]✓[/green] Integration '{name}' upgraded successfully") + + +def _handle_catalog_error(exc: Exception, project_root: Path) -> None: + """Format and print catalog errors with helpful tips.""" + from ..integrations.catalog import IntegrationValidationError + + console.print(f"[red]Error:[/red] {exc}") + + env_url = os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip() + + if env_url: + console.print("\n[bold]Tips:[/bold]") + console.print(" • If using a custom catalog URL, ensure it is reachable and valid JSON.") + console.print(" • Check your SPECKIT_INTEGRATION_CATALOG_URL environment variable. unset it to use the configured catalog files (.specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml).") + elif isinstance(exc, IntegrationValidationError): + console.print("\n[dim]Tip: Check the configuration file path shown above (.specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml) for errors.[/dim]") + else: + console.print("\n[dim]Tip: The catalog may be temporarily unavailable. Try again when online.[/dim]") + + raise typer.Exit(1) + + +@integration_app.command("info") +def integration_info( + key: str = typer.Argument(..., help="Integration key to get info about"), +): + """Show detailed information about an integration.""" + from ..integrations import INTEGRATION_REGISTRY, get_integration + from ..integrations.catalog import IntegrationCatalog, IntegrationCatalogError + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.is_dir(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + _read_integration_json(project_root) + + integration = get_integration(key) + if integration: + cfg = integration.config or {} + console.print(f"\n[bold cyan]Integration: {cfg.get('name', key)}[/bold cyan]\n") + console.print(f" Key: {key}") + console.print(f" Folder: {cfg.get('folder', '')}") + console.print(f" Requires CLI: {'yes' if cfg.get('requires_cli') else 'no'}") + if cfg.get("install_url"): + console.print(f" Install URL: {cfg['install_url']}") + if integration.context_file: + console.print(f" Context File: {integration.context_file}") + console.print("\n [green]Built-in integration[/green]") + console.print() + return + + ic = IntegrationCatalog(project_root) + try: + entry = ic.get_integration_info(key) + except Exception as exc: + _handle_catalog_error(exc, project_root) + + if not entry: + console.print(f"[red]Error:[/red] Integration '{key}' not found") + raise typer.Exit(1) + + console.print(f"\n[bold cyan]Integration: {entry.get('name', key)}[/bold cyan]\n") + console.print(f" Key: {key}") + version = entry.get('version', '?') + if version != '?' and not version.startswith('v'): + version = f"v{version}" + console.print(f" Version: {version}") + console.print(f" Description: {entry.get('description', '')}") + if entry.get("author"): + console.print(f" Author: {entry['author']}") + if entry.get("tags"): + console.print(f" Tags: {', '.join(entry['tags'])}") + + if entry.get("_install_allowed", True): + console.print("\n [yellow]Available in catalog[/yellow]") + console.print(f" Install with: [cyan]specify integration install {key}[/cyan]") + else: + console.print("\n [dim]Not directly installable (community catalog)[/dim]") + + console.print() + + +@integration_app.command("search") +def integration_search( + query: str = typer.Argument(None, help="Search query"), + tag: str = typer.Option(None, "--tag", help="Filter by tag"), + author: str = typer.Option(None, "--author", help="Filter by author"), +): + """Search for integrations in the catalog.""" + from ..integrations.catalog import IntegrationCatalog, IntegrationCatalogError + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.is_dir(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + _read_integration_json(project_root) + + ic = IntegrationCatalog(project_root) + try: + results = ic.search(query=query, tag=tag, author=author) + except Exception as exc: + _handle_catalog_error(exc, project_root) + + if not results: + console.print("[yellow]No integrations found matching your criteria.[/yellow]") + console.print("\nTry broadening your search (e.g., [cyan]specify integration search [/cyan]) or check [cyan]specify integration catalog list[/cyan]") + return + + console.print(f"\n[bold cyan]Found {len(results)} integration(s):[/bold cyan]\n") + table = Table() + table.add_column("ID", style="cyan") + table.add_column("Name") + table.add_column("Version") + table.add_column("Description") + + has_discovery_only = False + for entry in sorted(results, key=lambda e: e["id"]): + v = entry.get("version", "") + if v and not v.startswith("v"): + v = f"v{v}" + + install_allowed = entry.get("_install_allowed", True) + name = entry.get("name", entry["id"]) + if not install_allowed: + name = f"{name} [yellow]*[/yellow]" + has_discovery_only = True + + table.add_row( + entry["id"], + name, + v, + entry.get("description", "")[:60] + "..." if len(entry.get("description", "")) > 60 else entry.get("description", ""), + ) + + console.print(table) + if has_discovery_only: + console.print("\n[yellow]*[/yellow] = Not directly installable (community catalog)") + console.print("Note: Only built-in integration IDs can be installed; community integrations are discovery-only.") + + console.print("\nView details with: [cyan]specify integration info [/cyan]") + console.print() + + +@integration_catalog_app.command("list") +def integration_catalog_list(): + """List all active integration catalogs.""" + from ..integrations.catalog import IntegrationCatalog + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.is_dir(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + _read_integration_json(project_root) + + ic = IntegrationCatalog(project_root) + + env_url = os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip() + if env_url: + console.print("[yellow]SPECKIT_INTEGRATION_CATALOG_URL is set[/yellow]") + console.print("[dim]This supersedes configured catalog files and is non-removable via CLI.[/dim]\n") + + try: + active = ic.get_active_catalogs() + except Exception as exc: + _handle_catalog_error(exc, project_root) + + console.print("[bold cyan]Integration Catalog Sources[/bold cyan]\n") + console.print("[bold cyan]Active catalog sources[/bold cyan]") + console.print("[bold cyan]Project catalog sources:[/bold cyan]\n") + + config_path = project_root / ".specify" / "integration-catalogs.yml" + has_config = config_path.exists() + + for i, entry in enumerate(active): + install_str = "[green]install allowed[/green]" if entry.install_allowed else "[yellow]discovery only[/yellow]" + prefix = f"[{i}] " if has_config and not env_url else "" + console.print(f" {prefix}[bold]{entry.name}[/bold] (priority {entry.priority})") + if entry.description: + console.print(f" {entry.description}") + console.print(f" URL: {entry.url}") + console.print(f" Install: {install_str}") + console.print() + + if env_url: + pass + elif has_config: + console.print(f"[dim]Config: {config_path.relative_to(project_root)}[/dim]") + else: + console.print("[dim]No project-level catalog sources configured. Using built-in defaults. (non-removable)[/dim]") + + if env_url or config_path.exists(): + is_default = ( + len(active) == 2 + and active[0].url == IntegrationCatalog.DEFAULT_CATALOG_URL + and active[1].url == IntegrationCatalog.COMMUNITY_CATALOG_URL + ) + if not is_default: + console.print("\n[yellow]Warning:[/yellow] Using a custom integration catalog.") + console.print("Only use catalogs from sources you trust.") + + +@integration_catalog_app.command("add") +def integration_catalog_add( + url: str = typer.Argument(help="Catalog URL (must use HTTPS)"), + name: str | None = typer.Option(None, "--name", help="Catalog name"), +): + """Add a catalog to .specify/integration-catalogs.yml.""" + from ..integrations.catalog import IntegrationCatalog, IntegrationCatalogError + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.is_dir(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + url = url.strip() + ic = IntegrationCatalog(project_root) + _read_integration_json(project_root) + try: + ic.add_catalog(url, name=name) + except IntegrationCatalogError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + console.print(f"Catalog source added: {url}") + console.print(f"Config saved to .specify/integration-catalogs.yml") + + +@integration_catalog_app.command("remove") +def integration_catalog_remove( + target: str = typer.Argument(help="Catalog name or index to remove (from 'catalog list')"), +): + """Remove a catalog from .specify/integration-catalogs.yml.""" + from ..integrations.catalog import IntegrationCatalog, IntegrationCatalogError + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.is_dir(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + ic = IntegrationCatalog(project_root) + _read_integration_json(project_root) + try: + # Try integer index first + try: + idx = int(target) + name = ic.remove_catalog(idx) + except ValueError: + name = ic.remove_catalog(target) + except IntegrationCatalogError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + console.print(f"Catalog source '{name}' removed") diff --git a/src/specify_cli/commands/preset.py b/src/specify_cli/commands/preset.py index 387c220e67..3b1477693b 100644 --- a/src/specify_cli/commands/preset.py +++ b/src/specify_cli/commands/preset.py @@ -35,7 +35,7 @@ def preset_list() -> None: project_root = Path.cwd() specify_dir = project_root / ".specify" - if not specify_dir.exists(): + if not specify_dir.is_dir(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") raise typer.Exit(1) @@ -81,7 +81,7 @@ def preset_add( project_root = Path.cwd() specify_dir = project_root / ".specify" - if not specify_dir.exists(): + if not specify_dir.is_dir(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") raise typer.Exit(1) @@ -202,7 +202,7 @@ def preset_remove( project_root = Path.cwd() specify_dir = project_root / ".specify" - if not specify_dir.exists(): + if not specify_dir.is_dir(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") raise typer.Exit(1) @@ -232,7 +232,7 @@ def preset_search( project_root = Path.cwd() specify_dir = project_root / ".specify" - if not specify_dir.exists(): + if not specify_dir.is_dir(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") raise typer.Exit(1) @@ -269,7 +269,7 @@ def preset_resolve( project_root = Path.cwd() specify_dir = project_root / ".specify" - if not specify_dir.exists(): + if not specify_dir.is_dir(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") raise typer.Exit(1) @@ -340,7 +340,7 @@ def preset_info( project_root = Path.cwd() specify_dir = project_root / ".specify" - if not specify_dir.exists(): + if not specify_dir.is_dir(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") raise typer.Exit(1) @@ -415,7 +415,7 @@ def preset_set_priority( # Check if we're in a spec-kit project specify_dir = project_root / ".specify" - if not specify_dir.exists(): + if not specify_dir.is_dir(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") raise typer.Exit(1) @@ -466,7 +466,7 @@ def preset_enable( # Check if we're in a spec-kit project specify_dir = project_root / ".specify" - if not specify_dir.exists(): + if not specify_dir.is_dir(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") raise typer.Exit(1) @@ -507,7 +507,7 @@ def preset_disable( # Check if we're in a spec-kit project specify_dir = project_root / ".specify" - if not specify_dir.exists(): + if not specify_dir.is_dir(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") raise typer.Exit(1) @@ -549,7 +549,7 @@ def preset_catalog_list() -> None: project_root = Path.cwd() specify_dir = project_root / ".specify" - if not specify_dir.exists(): + if not specify_dir.is_dir(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") raise typer.Exit(1) @@ -618,7 +618,7 @@ def preset_catalog_add( project_root = Path.cwd() specify_dir = project_root / ".specify" - if not specify_dir.exists(): + if not specify_dir.is_dir(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") raise typer.Exit(1) @@ -681,7 +681,7 @@ def preset_catalog_remove( project_root = Path.cwd() specify_dir = project_root / ".specify" - if not specify_dir.exists(): + if not specify_dir.is_dir(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") raise typer.Exit(1) diff --git a/src/specify_cli/commands/workflow.py b/src/specify_cli/commands/workflow.py index 5d14c6b615..ddce9d5180 100644 --- a/src/specify_cli/commands/workflow.py +++ b/src/specify_cli/commands/workflow.py @@ -41,8 +41,9 @@ def workflow_run( from ..workflows.engine import WorkflowEngine project_root = Path.cwd() - if not (project_root / _SPECIFY_DIR).exists(): + if not (project_root / _SPECIFY_DIR).is_dir(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") raise typer.Exit(1) engine = WorkflowEngine(project_root) engine.on_step_start = lambda sid, label: console.print(f" ▸ [{sid}] {label} …") @@ -108,8 +109,9 @@ def workflow_resume( from ..workflows.engine import WorkflowEngine project_root = Path.cwd() - if not (project_root / _SPECIFY_DIR).exists(): + if not (project_root / _SPECIFY_DIR).is_dir(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") raise typer.Exit(1) engine = WorkflowEngine(project_root) engine.on_step_start = lambda sid, label: console.print(f" ▸ [{sid}] {label} …") @@ -144,8 +146,9 @@ def workflow_status( from ..workflows.engine import WorkflowEngine project_root = Path.cwd() - if not (project_root / _SPECIFY_DIR).exists(): + if not (project_root / _SPECIFY_DIR).is_dir(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") raise typer.Exit(1) engine = WorkflowEngine(project_root) @@ -207,8 +210,9 @@ def workflow_list() -> None: project_root = Path.cwd() specify_dir = project_root / _SPECIFY_DIR - if not specify_dir.exists(): + if not specify_dir.is_dir(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") raise typer.Exit(1) registry = WorkflowRegistry(project_root) @@ -239,8 +243,9 @@ def workflow_add( project_root = Path.cwd() specify_dir = project_root / _SPECIFY_DIR - if not specify_dir.exists(): + if not specify_dir.is_dir(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") raise typer.Exit(1) registry = WorkflowRegistry(project_root) @@ -469,8 +474,9 @@ def workflow_remove( project_root = Path.cwd() specify_dir = project_root / _SPECIFY_DIR - if not specify_dir.exists(): + if not specify_dir.is_dir(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") raise typer.Exit(1) registry = WorkflowRegistry(project_root) @@ -497,8 +503,9 @@ def workflow_search( from ..workflows.catalog import WorkflowCatalog, WorkflowCatalogError project_root = Path.cwd() - if not (project_root / _SPECIFY_DIR).exists(): + if not (project_root / _SPECIFY_DIR).is_dir(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") raise typer.Exit(1) catalog = WorkflowCatalog(project_root) @@ -533,8 +540,9 @@ def workflow_info( from ..workflows.engine import WorkflowEngine project_root = Path.cwd() - if not (project_root / _SPECIFY_DIR).exists(): + if not (project_root / _SPECIFY_DIR).is_dir(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") raise typer.Exit(1) # Check installed first @@ -606,6 +614,10 @@ def workflow_catalog_list() -> None: from ..workflows.catalog import WorkflowCatalog, WorkflowCatalogError project_root = Path.cwd() + if not (project_root / _SPECIFY_DIR).is_dir(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) catalog = WorkflowCatalog(project_root) try: @@ -634,8 +646,9 @@ def workflow_catalog_add( project_root = Path.cwd() specify_dir = project_root / _SPECIFY_DIR - if not specify_dir.exists(): + if not specify_dir.is_dir(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") raise typer.Exit(1) catalog = WorkflowCatalog(project_root) @@ -657,8 +670,9 @@ def workflow_catalog_remove( project_root = Path.cwd() specify_dir = project_root / _SPECIFY_DIR - if not specify_dir.exists(): + if not specify_dir.is_dir(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") raise typer.Exit(1) catalog = WorkflowCatalog(project_root) diff --git a/src/specify_cli/integrations/catalog.py b/src/specify_cli/integrations/catalog.py index 1b449af682..9bfd4116ee 100644 --- a/src/specify_cli/integrations/catalog.py +++ b/src/specify_cli/integrations/catalog.py @@ -591,16 +591,15 @@ def add_catalog(self, url: str, name: Optional[str] = None) -> None: allow_unicode=True, ) - def remove_catalog(self, index: int) -> str: - """Remove a catalog source by 0-based index. + def remove_catalog(self, target: str | int) -> str: + """Remove a catalog source by name or 0-based index. - ``index`` is interpreted in the same display order shown by - ``integration catalog list`` (i.e. sorted ascending by priority, - with missing priority defaulting to ``yaml_index + 1``, matching - ``_load_catalog_config()``). This way, the index a user sees in - ``catalog list`` is the index they pass to ``catalog remove``, - even if the underlying YAML lists entries in a different order - from how they sort by priority. + If ``target`` is an integer or can be parsed as one, it is interpreted + as a 0-based index in the same display order shown by + ``integration catalog list``. + + If ``target`` is a string, it is matched against the ``name`` field of + entries in the project config. Returns the removed catalog's name. """ @@ -618,32 +617,18 @@ def remove_catalog(self, index: int) -> str: data = {} if not isinstance(data, dict): raise IntegrationValidationError( - f"Catalog config file {config_path} is corrupted " - "(expected a mapping)." + f"Catalog config file {config_path} is corrupted (expected a mapping)." ) catalogs = data.get("catalogs", []) if not isinstance(catalogs, list): raise IntegrationValidationError( - f"Catalog config {config_path} has invalid 'catalogs' value: " - "must be a list." + f"Catalog config {config_path} has invalid 'catalogs' value: must be a list." ) if not catalogs: - # An empty list is the kind of state that only happens if the - # user hand-edited the file; our own `remove_catalog` deletes - # the file when the last entry is popped. Surface a clear - # message instead of `out of range (0--1)`. - raise IntegrationValidationError( - "Catalog config contains no catalog entries." - ) + raise IntegrationValidationError("Catalog config contains no catalog entries.") - # Map displayed index -> raw YAML index using the same priority - # defaulting as ``_load_catalog_config``. We deliberately stay - # tolerant here (no new validation errors) because the goal is - # only to mirror the order shown by ``catalog list``; entries - # that ``_load_catalog_config`` would have rejected outright - # would have failed ``catalog list`` already. def _is_removable_catalog_entry(item: Any) -> bool: if not isinstance(item, dict): return False @@ -652,55 +637,61 @@ def _is_removable_catalog_entry(item: Any) -> bool: return False return bool(str(raw_url).strip()) - priority_pairs: List[Tuple[int, int]] = [] - for yaml_idx, item in enumerate(catalogs): - if not _is_removable_catalog_entry(item): - continue - - raw_priority = item.get("priority", yaml_idx + 1) - if isinstance(raw_priority, bool): - priority = yaml_idx + 1 - else: - try: - priority = int(raw_priority) - except (TypeError, ValueError): + # Resolve index + index: Optional[int] = None + try: + index = int(target) + except (ValueError, TypeError): + pass + + if index is not None: + # Map displayed index -> raw YAML index + priority_pairs: List[Tuple[int, int]] = [] + for yaml_idx, item in enumerate(catalogs): + if not _is_removable_catalog_entry(item): + continue + raw_priority = item.get("priority", yaml_idx + 1) + if isinstance(raw_priority, bool): priority = yaml_idx + 1 - priority_pairs.append((priority, yaml_idx)) - if not priority_pairs: - raise IntegrationValidationError( - "Catalog config contains no removable catalog entries." - ) - # Stable sort: ties keep their YAML order, matching list-view ordering. - priority_pairs.sort(key=lambda p: p[0]) - display_order: List[int] = [yaml_idx for _, yaml_idx in priority_pairs] + else: + try: + priority = int(raw_priority) + except (TypeError, ValueError): + priority = yaml_idx + 1 + priority_pairs.append((priority, yaml_idx)) - if index < 0 or index >= len(display_order): - raise IntegrationValidationError( - f"Catalog index {index} out of range (0-{len(display_order) - 1})." - ) + if not priority_pairs: + raise IntegrationValidationError("Catalog config contains no removable catalog entries.") + + priority_pairs.sort(key=lambda p: p[0]) + display_order: List[int] = [yaml_idx for _, yaml_idx in priority_pairs] + + if index < 0 or index >= len(display_order): + raise IntegrationValidationError( + f"Catalog index {index} out of range (0-{len(display_order) - 1})." + ) + target_yaml_idx = display_order[index] + else: + # Match by name + target_yaml_idx = None + for yaml_idx, item in enumerate(catalogs): + if not _is_removable_catalog_entry(item): + continue + if item.get("name") == target: + target_yaml_idx = yaml_idx + break + + if target_yaml_idx is None: + raise IntegrationValidationError(f"Catalog source '{target}' not found in configuration.") - target_yaml_idx = display_order[index] removed = catalogs.pop(target_yaml_idx) + removed_name = removed.get("name", str(target)) if any(_is_removable_catalog_entry(item) for item in catalogs): data["catalogs"] = catalogs with open(config_path, "w", encoding="utf-8") as f: - yaml.dump( - data, - f, - default_flow_style=False, - sort_keys=False, - allow_unicode=True, - ) + yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True) else: - # Removing the final entry: delete the config file rather than - # leaving behind an empty `catalogs:` list. `_load_catalog_config` - # treats an empty list as an error, so leaving the file would - # break every subsequent `integration` command until the user - # manually deletes `.specify/integration-catalogs.yml`. - # Deleting the file lets the project fall back to built-in - # defaults, which matches the behavior before any - # `catalog add` was ever run. try: config_path.unlink(missing_ok=True) except OSError as exc: @@ -708,7 +699,7 @@ def _is_removable_catalog_entry(item: Any) -> bool: f"Failed to delete catalog config {config_path}: {exc}" ) from exc - fallback_name = f"catalog-{index + 1}" + fallback_name = f"catalog-{target_yaml_idx + 1}" if isinstance(removed, dict): removed_name = removed.get("name") if removed_name is not None: diff --git a/src/specify_cli/shared_infra.py b/src/specify_cli/shared_infra.py index 1e8be7b282..6f6373d2b3 100644 --- a/src/specify_cli/shared_infra.py +++ b/src/specify_cli/shared_infra.py @@ -307,11 +307,9 @@ def install_shared_infra( ) for path in skipped_files: console.print(f" {path}") - console.print( - "To refresh shared infrastructure, run " - "[cyan]specify init --here --force[/cyan] or " - "[cyan]specify integration upgrade --force[/cyan]." - ) + console.print("To refresh shared infrastructure, run:") + console.print(" [cyan]specify init --here --force[/cyan]") + console.print(" [cyan]specify integration upgrade --force[/cyan]") manifest.save() return True From 8ace55a988b9f87c139d85b33ba7008e6bf4d614 Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Wed, 6 May 2026 15:08:31 +0800 Subject: [PATCH 29/31] fix(lint): resolve ruff errors in __init__ and integration command - Remove unused urllib.error import; mark urllib.request with noqa F401 (required for test monkeypatching of specify_cli.urllib.request.urlopen) - Add noqa E402 to late imports that must follow function definitions - Remove unused yaml import from integration.py - Remove unused INTEGRATION_REGISTRY imports in uninstall/info/search - Remove unused IntegrationCatalogError imports in info/search - Remove unused default_key variable in integration_use - Remove extraneous f-string prefixes on string literals without placeholders --- src/specify_cli/__init__.py | 15 ++++++++------- src/specify_cli/commands/integration.py | 18 ++++++++---------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index ff61ed69d7..58bf79afa4 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -28,8 +28,7 @@ import sys import urllib -import urllib.request -import urllib.error +import urllib.request # noqa: F401 from pathlib import Path from typing import Optional @@ -112,11 +111,13 @@ def _get_version() -> str: invoke_separator=invoke_separator, force=force, ) -from .commands import init as _init_cmd -from .commands.extension import extension_app -from .commands.integration import integration_app -from .commands.preset import preset_app -from .commands.workflow import workflow_app + + +from .commands import init as _init_cmd # noqa: E402 +from .commands.extension import extension_app # noqa: E402 +from .commands.integration import integration_app # noqa: E402 +from .commands.preset import preset_app # noqa: E402 +from .commands.workflow import workflow_app # noqa: E402 app = typer.Typer( name="specify", diff --git a/src/specify_cli/commands/integration.py b/src/specify_cli/commands/integration.py index 978a04b127..be18035510 100644 --- a/src/specify_cli/commands/integration.py +++ b/src/specify_cli/commands/integration.py @@ -2,7 +2,6 @@ import json import os import sys -import yaml from pathlib import Path from typing import Any @@ -336,7 +335,7 @@ def integration_install( if not (all_existing_safe and new_is_safe): if not force: installed_str = ", ".join(installed_keys) - console.print(f"[red]Error:[/red] Integration cannot be installed alongside existing ones.") + console.print("[red]Error:[/red] Integration cannot be installed alongside existing ones.") console.print(f"Installed integrations: {installed_str}") console.print(f"Default integration: {default_key}") console.print( @@ -417,7 +416,7 @@ def integration_uninstall( force: bool = typer.Option(False, "--force", help="Remove files even if modified"), ): """Uninstall an integration, safely preserving modified files.""" - from ..integrations import INTEGRATION_REGISTRY, get_integration + from ..integrations import get_integration from ..integrations.manifest import IntegrationManifest project_root = Path.cwd() @@ -542,7 +541,6 @@ def integration_use( state = _read_integration_state(project_root) installed_keys: list[str] = list(state.get("installed_integrations", [])) - default_key: str = state.get("default_integration") or state.get("integration") or "" settings: dict[str, Any] = dict(state.get("integration_settings", {})) if target not in installed_keys: @@ -607,14 +605,14 @@ def integration_switch( except Exception as exc: console.print(f"[red]Error:[/red] Failed to refresh shared templates: {exc}") raise typer.Exit(1) - console.print(f"[green]✓[/green] managed shared templates refreshed") + console.print("[green]✓[/green] managed shared templates refreshed") raise typer.Exit(0) # Case 2: Target is already installed (but not default) if target in installed_keys: if integration_options: console.print( - f"[red]Error:[/red] --integration-options cannot be used when switching to an already-installed integration." + "[red]Error:[/red] --integration-options cannot be used when switching to an already-installed integration." ) raise typer.Exit(1) # Act like "use" command @@ -942,8 +940,8 @@ def integration_info( key: str = typer.Argument(..., help="Integration key to get info about"), ): """Show detailed information about an integration.""" - from ..integrations import INTEGRATION_REGISTRY, get_integration - from ..integrations.catalog import IntegrationCatalog, IntegrationCatalogError + from ..integrations import get_integration + from ..integrations.catalog import IntegrationCatalog project_root = Path.cwd() @@ -1008,7 +1006,7 @@ def integration_search( author: str = typer.Option(None, "--author", help="Filter by author"), ): """Search for integrations in the catalog.""" - from ..integrations.catalog import IntegrationCatalog, IntegrationCatalogError + from ..integrations.catalog import IntegrationCatalog project_root = Path.cwd() @@ -1154,7 +1152,7 @@ def integration_catalog_add( raise typer.Exit(1) console.print(f"Catalog source added: {url}") - console.print(f"Config saved to .specify/integration-catalogs.yml") + console.print("Config saved to .specify/integration-catalogs.yml") @integration_catalog_app.command("remove") From fb7c045bf2786534080b1044aab7fe237f98e686 Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Wed, 6 May 2026 15:13:58 +0800 Subject: [PATCH 30/31] fix(windows): use as_posix() for all user-facing path output Path objects on Windows use backslashes, causing test assertions that check for forward-slash paths to fail. Call .as_posix() on every relative_to() result before printing to ensure consistent POSIX-style separators across platforms. --- src/specify_cli/commands/extension.py | 4 ++-- src/specify_cli/commands/integration.py | 4 ++-- src/specify_cli/commands/preset.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/specify_cli/commands/extension.py b/src/specify_cli/commands/extension.py index f2506ec752..2b7eebdcbf 100644 --- a/src/specify_cli/commands/extension.py +++ b/src/specify_cli/commands/extension.py @@ -337,7 +337,7 @@ def catalog_list() -> None: except ValidationError: proj_loaded = False if proj_loaded: - console.print(f"[dim]Config: {config_path.relative_to(project_root)}[/dim]") + console.print(f"[dim]Config: {config_path.relative_to(project_root).as_posix()}[/dim]") else: try: user_loaded = user_config_path.exists() and catalog._load_catalog_config(user_config_path) is not None @@ -421,7 +421,7 @@ def catalog_add( console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})") console.print(f" URL: {url}") console.print(f" Priority: {priority}") - console.print(f"\nConfig saved to {config_path.relative_to(project_root)}") + console.print(f"\nConfig saved to {config_path.relative_to(project_root).as_posix()}") @catalog_app.command("remove") diff --git a/src/specify_cli/commands/integration.py b/src/specify_cli/commands/integration.py index be18035510..2f1213c81d 100644 --- a/src/specify_cli/commands/integration.py +++ b/src/specify_cli/commands/integration.py @@ -518,7 +518,7 @@ def integration_uninstall( console.print(f"\n[yellow]⚠[/yellow] {len(skipped)} modified file(s) were preserved:") for path in skipped: rel = path.relative_to(project_root) if path.is_absolute() else path - console.print(f" {rel}") + console.print(f" {rel.as_posix()}") @integration_app.command("use") @@ -1111,7 +1111,7 @@ def integration_catalog_list(): if env_url: pass elif has_config: - console.print(f"[dim]Config: {config_path.relative_to(project_root)}[/dim]") + console.print(f"[dim]Config: {config_path.relative_to(project_root).as_posix()}[/dim]") else: console.print("[dim]No project-level catalog sources configured. Using built-in defaults. (non-removable)[/dim]") diff --git a/src/specify_cli/commands/preset.py b/src/specify_cli/commands/preset.py index 3b1477693b..949fbdaac4 100644 --- a/src/specify_cli/commands/preset.py +++ b/src/specify_cli/commands/preset.py @@ -586,7 +586,7 @@ def preset_catalog_list() -> None: except PresetValidationError: proj_loaded = False if proj_loaded: - console.print(f"[dim]Config: {config_path.relative_to(project_root)}[/dim]") + console.print(f"[dim]Config: {config_path.relative_to(project_root).as_posix()}[/dim]") else: try: user_loaded = user_config_path.exists() and catalog._load_catalog_config(user_config_path) is not None @@ -670,7 +670,7 @@ def preset_catalog_add( console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})") console.print(f" URL: {url}") console.print(f" Priority: {priority}") - console.print(f"\nConfig saved to {config_path.relative_to(project_root)}") + console.print(f"\nConfig saved to {config_path.relative_to(project_root).as_posix()}") @preset_catalog_app.command("remove") From 1055d2dac50625dc01a5a8ee23f7a10ed6ceeb29 Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Wed, 6 May 2026 15:31:22 +0800 Subject: [PATCH 31/31] fix(windows): harden path output to avoid relative_to() mismatch on Windows The previous fix used relative_to().as_posix(), which silently fails when project_root and the resolved absolute path have different representations (e.g. short vs. long paths, symlinks) on Windows. Replace all user-facing path output with more robust alternatives: - Catalog 'Config saved to' messages: hardcode the known POSIX-relative path string directly (.specify/preset-catalogs.yml etc.) instead of computing it from Path objects at runtime - Catalog 'Config:' dim hints: same hardcoded approach - Uninstall skipped-file listing: use os.path.relpath() + replace(os.sep, '/') which handles resolved vs. unresolved path mismatches on all platforms --- src/specify_cli/commands/extension.py | 4 ++-- src/specify_cli/commands/integration.py | 6 +++--- src/specify_cli/commands/preset.py | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/specify_cli/commands/extension.py b/src/specify_cli/commands/extension.py index 2b7eebdcbf..7c75a31da6 100644 --- a/src/specify_cli/commands/extension.py +++ b/src/specify_cli/commands/extension.py @@ -337,7 +337,7 @@ def catalog_list() -> None: except ValidationError: proj_loaded = False if proj_loaded: - console.print(f"[dim]Config: {config_path.relative_to(project_root).as_posix()}[/dim]") + console.print(f"[dim]Config: {_SPECIFY_DIR}/{_EXTENSION_CATALOGS_FILE}[/dim]") else: try: user_loaded = user_config_path.exists() and catalog._load_catalog_config(user_config_path) is not None @@ -421,7 +421,7 @@ def catalog_add( console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})") console.print(f" URL: {url}") console.print(f" Priority: {priority}") - console.print(f"\nConfig saved to {config_path.relative_to(project_root).as_posix()}") + console.print("\nConfig saved to .specify/extension-catalogs.yml") @catalog_app.command("remove") diff --git a/src/specify_cli/commands/integration.py b/src/specify_cli/commands/integration.py index 2f1213c81d..20a3bca93f 100644 --- a/src/specify_cli/commands/integration.py +++ b/src/specify_cli/commands/integration.py @@ -517,8 +517,8 @@ def integration_uninstall( if skipped: console.print(f"\n[yellow]⚠[/yellow] {len(skipped)} modified file(s) were preserved:") for path in skipped: - rel = path.relative_to(project_root) if path.is_absolute() else path - console.print(f" {rel.as_posix()}") + rel_str = os.path.relpath(str(path), str(project_root)).replace(os.sep, "/") + console.print(f" {rel_str}") @integration_app.command("use") @@ -1111,7 +1111,7 @@ def integration_catalog_list(): if env_url: pass elif has_config: - console.print(f"[dim]Config: {config_path.relative_to(project_root).as_posix()}[/dim]") + console.print("[dim]Config: .specify/integration-catalogs.yml[/dim]") else: console.print("[dim]No project-level catalog sources configured. Using built-in defaults. (non-removable)[/dim]") diff --git a/src/specify_cli/commands/preset.py b/src/specify_cli/commands/preset.py index 949fbdaac4..fed570520b 100644 --- a/src/specify_cli/commands/preset.py +++ b/src/specify_cli/commands/preset.py @@ -586,7 +586,7 @@ def preset_catalog_list() -> None: except PresetValidationError: proj_loaded = False if proj_loaded: - console.print(f"[dim]Config: {config_path.relative_to(project_root).as_posix()}[/dim]") + console.print("[dim]Config: .specify/preset-catalogs.yml[/dim]") else: try: user_loaded = user_config_path.exists() and catalog._load_catalog_config(user_config_path) is not None @@ -670,7 +670,7 @@ def preset_catalog_add( console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})") console.print(f" URL: {url}") console.print(f" Priority: {priority}") - console.print(f"\nConfig saved to {config_path.relative_to(project_root).as_posix()}") + console.print("\nConfig saved to .specify/preset-catalogs.yml") @preset_catalog_app.command("remove")