From bbd4f3485974a362eff3587852ef50a8d1caa4f1 Mon Sep 17 00:00:00 2001 From: Markus Date: Wed, 29 Apr 2026 15:27:54 +0200 Subject: [PATCH 01/16] fix(workflows): auto-detect project integration instead of hardcoding copilot (#2406) The workflow engine hardcoded 'copilot' as the default integration input, ignoring the project's configured integration in .specify/integration.json. This caused workflows to fail when a project was initialized with a different integration (e.g. opencode) but the copilot CLI happened to be installed. Changes: - Change workflow.yml integration default from 'copilot' to 'auto' - Add _resolve_integration_auto() to WorkflowEngine that reads .specify/integration.json and falls back to 'copilot' if absent - Post-process resolved inputs to replace 'auto' with detected value - Add 4 regression tests for auto-detection scenarios Fixes #2406 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 6 ++ src/specify_cli/workflows/engine.py | 28 ++++++++ tests/test_workflows.py | 104 ++++++++++++++++++++++++++++ workflows/speckit/workflow.yml | 4 +- 4 files changed, 140 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8202610ff3..6932dfc7c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## [Unreleased] + +### Fixed + +- fix(workflows): auto-detect project integration instead of hardcoding copilot default (#2406) + ## [0.8.2] - 2026-04-28 ### Changed diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py index d6a73bbeb0..865ae39e4b 100644 --- a/src/specify_cli/workflows/engine.py +++ b/src/specify_cli/workflows/engine.py @@ -719,8 +719,36 @@ def _resolve_inputs( elif input_def.get("required", False): msg = f"Required input {name!r} not provided." raise ValueError(msg) + + # Auto-detect integration from project config when set to "auto" + if resolved.get("integration") == "auto": + resolved["integration"] = self._resolve_integration_auto() + return resolved + _INTEGRATION_JSON = ".specify/integration.json" + _AUTO_FALLBACK = "copilot" + + def _resolve_integration_auto(self) -> str: + """Read the project integration from ``.specify/integration.json``. + + Returns the stored integration key, or ``"copilot"`` when the + file is missing, unreadable, or does not contain a valid key. + This method is intentionally decoupled from the CLI layer + (no ``typer.Exit`` / ``console.print``) so the engine remains + independently testable. + """ + path = self.project_root / self._INTEGRATION_JSON + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return self._AUTO_FALLBACK + if isinstance(data, dict): + value = data.get("integration") + if isinstance(value, str) and value: + return value + return self._AUTO_FALLBACK + @staticmethod def _coerce_input( name: str, value: Any, input_def: dict[str, Any] diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 4c042fc7d5..8742358254 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -1843,3 +1843,107 @@ def test_switch_workflow(self, project_dir): assert state.status == RunStatus.COMPLETED assert "do-plan" in state.step_results assert "do-specify" not in state.step_results + + +class TestIntegrationAutoDetect: + """Test auto-detection of project integration from .specify/integration.json. + + When workflow inputs specify ``default: "auto"`` for the integration + input, the engine should resolve it by reading the project's + ``.specify/integration.json`` instead of hardcoding ``"copilot"``. + + Regression tests for https://github.com/github/spec-kit/issues/2406. + """ + + @staticmethod + def _make_workflow_yaml(default_integration: str = "auto") -> str: + return f""" +schema_version: "1.0" +workflow: + id: "auto-test" + name: "Auto Test" + version: "1.0.0" +inputs: + spec: + type: string + default: "build login" + integration: + type: string + default: "{default_integration}" +steps: + - id: specify + command: speckit.specify + integration: "{{{{ inputs.integration }}}}" + input: + args: "{{{{ inputs.spec }}}}" +""" + + def test_resolve_inputs_auto_reads_integration_json(self, project_dir): + """'auto' default resolves to the integration in .specify/integration.json.""" + from unittest.mock import patch + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + from specify_cli.workflows.base import StepStatus + + # Write integration.json with opencode + int_json = project_dir / ".specify" / "integration.json" + int_json.write_text(json.dumps({"integration": "opencode"}), encoding="utf-8") + + definition = WorkflowDefinition.from_string(self._make_workflow_yaml()) + engine = WorkflowEngine(project_dir) + + with patch("specify_cli.workflows.steps.command.shutil.which", return_value=None): + state = engine.execute(definition) + + # The resolved integration should be "opencode", not "auto" or "copilot" + step_output = state.step_results["specify"]["output"] + assert step_output["integration"] == "opencode" + + def test_resolve_inputs_auto_no_json_falls_back_to_copilot(self, project_dir): + """When no integration.json exists, 'auto' falls back to 'copilot'.""" + from unittest.mock import patch + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + + # No integration.json exists in the project_dir fixture + definition = WorkflowDefinition.from_string(self._make_workflow_yaml()) + engine = WorkflowEngine(project_dir) + + with patch("specify_cli.workflows.steps.command.shutil.which", return_value=None): + state = engine.execute(definition) + + step_output = state.step_results["specify"]["output"] + assert step_output["integration"] == "copilot" + + def test_resolve_inputs_explicit_override_ignores_auto(self, project_dir): + """Explicit --input integration=gemini takes precedence over auto.""" + from unittest.mock import patch + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + + # Write integration.json with opencode (should be ignored) + int_json = project_dir / ".specify" / "integration.json" + int_json.write_text(json.dumps({"integration": "opencode"}), encoding="utf-8") + + definition = WorkflowDefinition.from_string(self._make_workflow_yaml()) + engine = WorkflowEngine(project_dir) + + with patch("specify_cli.workflows.steps.command.shutil.which", return_value=None): + state = engine.execute(definition, {"integration": "gemini"}) + + step_output = state.step_results["specify"]["output"] + assert step_output["integration"] == "gemini" + + def test_resolve_inputs_auto_with_empty_json(self, project_dir): + """When integration.json has no 'integration' key, fall back to 'copilot'.""" + from unittest.mock import patch + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + + int_json = project_dir / ".specify" / "integration.json" + int_json.write_text(json.dumps({"version": "1.0"}), encoding="utf-8") + + definition = WorkflowDefinition.from_string(self._make_workflow_yaml()) + engine = WorkflowEngine(project_dir) + + with patch("specify_cli.workflows.steps.command.shutil.which", return_value=None): + state = engine.execute(definition) + + step_output = state.step_results["specify"]["output"] + assert step_output["integration"] == "copilot" diff --git a/workflows/speckit/workflow.yml b/workflows/speckit/workflow.yml index bf18451029..ca127ce1b1 100644 --- a/workflows/speckit/workflow.yml +++ b/workflows/speckit/workflow.yml @@ -18,8 +18,8 @@ inputs: prompt: "Describe what you want to build" integration: type: string - default: "copilot" - prompt: "Integration to use (e.g. claude, copilot, gemini)" + default: "auto" + prompt: "Integration to use (e.g. claude, copilot, gemini, or 'auto' to detect from project config)" scope: type: string default: "full" From 903ce05da928f1064ba694c407a879b4016d9c20 Mon Sep 17 00:00:00 2001 From: Markus Date: Wed, 29 Apr 2026 16:12:41 +0200 Subject: [PATCH 02/16] fix(workflows): tolerate non-UTF8 integration.json in auto-detect (#2408) Made-with: Cursor --- src/specify_cli/workflows/engine.py | 2 +- tests/test_workflows.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py index 865ae39e4b..e712fce836 100644 --- a/src/specify_cli/workflows/engine.py +++ b/src/specify_cli/workflows/engine.py @@ -741,7 +741,7 @@ def _resolve_integration_auto(self) -> str: path = self.project_root / self._INTEGRATION_JSON try: data = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): + except (OSError, UnicodeDecodeError, json.JSONDecodeError): return self._AUTO_FALLBACK if isinstance(data, dict): value = data.get("integration") diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 8742358254..df6d7980b1 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -1882,7 +1882,6 @@ def test_resolve_inputs_auto_reads_integration_json(self, project_dir): """'auto' default resolves to the integration in .specify/integration.json.""" from unittest.mock import patch from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition - from specify_cli.workflows.base import StepStatus # Write integration.json with opencode int_json = project_dir / ".specify" / "integration.json" From 563cf4685ac766803a8e45cc71fba8f9f83bbc60 Mon Sep 17 00:00:00 2001 From: Markus Date: Wed, 29 Apr 2026 18:45:44 +0200 Subject: [PATCH 03/16] fix(workflows): address PR #2408 review findings - Add tests for JSONDecodeError and OSError fallback paths in _resolve_integration_auto() - Centralize INTEGRATION_JSON constant in workflows/constants.py (zero-dependency module) - Change changelog reference to (Fixes #2406) for auto-close semantics - Remove static requires.integrations.any from speckit workflow (incomplete with 'auto') - Add opencode to integration prompt examples --- CHANGELOG.md | 2 +- src/specify_cli/__init__.py | 2035 ++++++++++++++++++------ src/specify_cli/workflows/constants.py | 7 + src/specify_cli/workflows/engine.py | 72 +- tests/test_workflows.py | 300 +++- workflows/speckit/workflow.yml | 6 +- 6 files changed, 1796 insertions(+), 626 deletions(-) create mode 100644 src/specify_cli/workflows/constants.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6932dfc7c8..aee8df38e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ ### Fixed -- fix(workflows): auto-detect project integration instead of hardcoding copilot default (#2406) +- fix(workflows): auto-detect project integration instead of hardcoding copilot default (Fixes #2406) ## [0.8.2] - 2026-04-28 diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index f5e117beef..1ef603e0d0 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -57,17 +57,22 @@ # For cross-platform keyboard input import readchar +from .workflows.constants import INTEGRATION_JSON + 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 + 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 = { @@ -77,6 +82,7 @@ def _build_agent_config() -> dict[str, dict[str, Any]]: # 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.""" @@ -96,9 +102,11 @@ def _build_ai_assistant_help() -> str: if len(alias_phrases) == 1: aliases_text = alias_phrases[0] else: - aliases_text = ', '.join(alias_phrases[:-1]) + ' and ' + alias_phrases[-1] + aliases_text = ", ".join(alias_phrases[:-1]) + " and " + alias_phrases[-1] return base_help + " Use " + aliases_text + "." + + AI_ASSISTANT_HELP = _build_ai_assistant_help() @@ -131,10 +139,13 @@ def _build_ai_deprecation_warning( f"Use [bold]{replacement}[/bold] instead." ) + 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" +CLAUDE_NPM_LOCAL_PATH = ( + Path.home() / ".claude" / "local" / "node_modules" / ".bin" / "claude" +) BANNER = """ ███████╗██████╗ ███████╗ ██████╗██╗███████╗██╗ ██╗ @@ -146,14 +157,23 @@ def _build_ai_deprecation_warning( """ 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.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): @@ -161,7 +181,9 @@ def attach_refresh(self, 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.steps.append( + {"key": key, "label": label, "status": "pending", "detail": ""} + ) self._maybe_refresh() def start(self, key: str, detail: str = ""): @@ -185,7 +207,9 @@ def _update(self, key: str, status: str, detail: str): self._maybe_refresh() return - self.steps.append({"key": key, "label": key, "status": status, "detail": detail}) + self.steps.append( + {"key": key, "label": key, "status": status, "detail": detail} + ) self._maybe_refresh() def _maybe_refresh(self): @@ -218,7 +242,9 @@ def render(self): if status == "pending": # Entire line light gray (pending) if detail_text: - line = f"{symbol} [bright_black]{label} ({detail_text})[/bright_black]" + line = ( + f"{symbol} [bright_black]{label} ({detail_text})[/bright_black]" + ) else: line = f"{symbol} [bright_black]{label}[/bright_black]" else: @@ -231,27 +257,31 @@ def render(self): 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' + return "up" if key == readchar.key.DOWN or key == readchar.key.CTRL_N: - return 'down' + return "down" if key == readchar.key.ENTER: - return 'enter' + return "enter" if key == readchar.key.ESC: - return 'escape' + 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: + +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. @@ -284,31 +314,38 @@ def create_selection_panel(): 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]") + 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) + 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: + with Live( + create_selection_panel(), + console=console, + transient=True, + auto_refresh=False, + ) as live: while True: try: key = get_key() - if key == 'up': + if key == "up": selected_index = (selected_index - 1) % len(option_keys) - elif key == 'down': + elif key == "down": selected_index = (selected_index + 1) % len(option_keys) - elif key == 'enter': + elif key == "enter": selected_key = option_keys[selected_index] break - elif key == 'escape': + elif key == "escape": console.print("\n[yellow]Selection cancelled[/yellow]") raise typer.Exit(1) @@ -326,8 +363,10 @@ def run_selection_loop(): return selected_key + console = Console(highlight=False) + class BannerGroup(TyperGroup): """Custom group that shows banner before help.""" @@ -345,9 +384,10 @@ def format_help(self, ctx, formatter): cls=BannerGroup, ) + def show_banner(): """Display the ASCII art banner.""" - banner_lines = BANNER.strip().split('\n') + banner_lines = BANNER.strip().split("\n") colors = ["bright_blue", "blue", "cyan", "bright_cyan", "white", "bright_white"] styled_banner = Text() @@ -359,27 +399,50 @@ def show_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()}") raise typer.Exit() + @app.callback() def callback( ctx: typer.Context, - version: bool = typer.Option(False, "--version", "-V", callback=_version_callback, is_eager=True, help="Show version and exit."), + version: bool = typer.Option( + False, + "--version", + "-V", + callback=_version_callback, + is_eager=True, + help="Show version and exit.", + ), ): """Show banner when no subcommand is provided.""" - if ctx.invoked_subcommand is None and "--help" not in sys.argv and "-h" not in sys.argv: + if ( + ctx.invoked_subcommand is None + and "--help" not in sys.argv + and "-h" not in sys.argv + ): show_banner() - console.print(Align.center("[dim]Run 'specify --help' for usage information[/dim]")) + 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]: + +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) + 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) @@ -388,11 +451,12 @@ def run_command(cmd: list[str], check_return: bool = True, capture: bool = False 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: + 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. @@ -452,7 +516,9 @@ def is_git_repo(path: Path = None) -> bool: return False -def init_git_repo(project_path: Path, quiet: bool = False) -> tuple[bool, Optional[str]]: +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() @@ -461,7 +527,12 @@ def init_git_repo(project_path: Path, quiet: bool = False) -> tuple[bool, Option 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) + subprocess.run( + ["git", "commit", "-m", "Initial commit from Specify template"], + check=True, + capture_output=True, + text=True, + ) if not quiet: console.print("[green]✓[/green] Git repository initialized") return True, None @@ -478,12 +549,15 @@ 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: +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}") @@ -493,8 +567,8 @@ def atomic_write_json(target_file: Path, payload: dict[str, Any]) -> None: temp_path: Optional[Path] = None try: with tempfile.NamedTemporaryFile( - mode='w', - encoding='utf-8', + mode="w", + encoding="utf-8", dir=target_file.parent, prefix=f"{target_file.name}.", suffix=".tmp", @@ -502,7 +576,7 @@ def atomic_write_json(target_file: Path, payload: dict[str, Any]) -> None: ) as f: temp_path = Path(f.name) json.dump(payload, f, indent=4) - f.write('\n') + f.write("\n") if target_file.exists(): try: @@ -510,7 +584,9 @@ def atomic_write_json(target_file: Path, payload: dict[str, Any]) -> None: 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) + os.chown( + temp_path, existing_stat.st_uid, existing_stat.st_gid + ) except PermissionError: # Best-effort owner/group preservation without requiring elevated privileges. pass @@ -525,16 +601,21 @@ def atomic_write_json(target_file: Path, payload: dict[str, Any]) -> None: raise try: - with open(sub_item, 'r', encoding='utf-8') as f: + 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) + 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") + log( + "Note: comments/trailing commas are normalized when rewritten", + "yellow", + ) else: log("Skipped merge (preserved existing settings)", "yellow") else: @@ -547,7 +628,9 @@ def atomic_write_json(target_file: Path, payload: dict[str, Any]) -> None: shutil.copy2(sub_item, dest_file) -def merge_json_files(existing_path: Path, new_content: Any, verbose: bool = False) -> Optional[dict[str, Any]]: +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: @@ -570,7 +653,7 @@ def merge_json_files(existing_path: Path, new_content: Any, verbose: bool = Fals if exists: try: - with open(existing_path, 'r', encoding='utf-8') as f: + 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) @@ -579,14 +662,18 @@ def merge_json_files(existing_path: Path, new_content: Any, verbose: bool = Fals 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]") + 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]") + console.print( + f"[yellow]Warning: Template content for {existing_path.name} is not a dictionary. Preserving existing settings.[/yellow]" + ) return None if not exists: @@ -595,10 +682,14 @@ def merge_json_files(existing_path: Path, new_content: Any, verbose: bool = Fals # 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]") + 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]: + 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(): @@ -626,6 +717,7 @@ def deep_merge_polite(base: dict[str, Any], update: dict[str, Any]) -> dict[str, return merged + def _locate_core_pack() -> Path | None: """Return the filesystem path to the bundled core_pack directory, or None. @@ -650,7 +742,8 @@ def _locate_bundled_extension(extension_id: str) -> Path | None: source-checkout ``extensions//`` directory. """ import re as _re - if not _re.match(r'^[a-z0-9-]+$', extension_id): + + if not _re.match(r"^[a-z0-9-]+$", extension_id): return None core = _locate_core_pack() @@ -675,7 +768,8 @@ def _locate_bundled_workflow(workflow_id: str) -> Path | None: source-checkout ``workflows//`` directory. """ import re as _re - if not _re.match(r'^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$', workflow_id): + + if not _re.match(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$", workflow_id): return None core = _locate_core_pack() @@ -700,7 +794,8 @@ def _locate_bundled_preset(preset_id: str) -> Path | None: source-checkout ``presets//`` directory. """ import re as _re - if not _re.match(r'^[a-z0-9-]+$', preset_id): + + if not _re.match(r"^[a-z0-9-]+$", preset_id): return None core = _locate_core_pack() @@ -745,7 +840,9 @@ def _install_shared_infra( from .integrations.manifest import IntegrationManifest core = _locate_core_pack() - manifest = IntegrationManifest("speckit", project_path, version=get_speckit_version()) + manifest = IntegrationManifest( + "speckit", project_path, version=get_speckit_version() + ) # Scripts if core and (core / "scripts").is_dir(): @@ -787,7 +884,11 @@ def _install_shared_infra( 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("."): + 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))) @@ -816,7 +917,9 @@ def _install_shared_infra( return True -def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = None) -> None: +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 @@ -857,21 +960,30 @@ def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = 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 "") + 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]") + 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: + +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" + template_constitution = ( + project_path / ".specify" / "templates" / "constitution-template.md" + ) # If constitution already exists in memory, preserve it if memory_constitution.exists(): @@ -901,7 +1013,9 @@ def ensure_constitution_from_template(project_path: Path, tracker: StepTracker | tracker.add("constitution", "Constitution setup") tracker.error("constitution", str(e)) else: - console.print(f"[yellow]Warning: Could not initialize constitution: {e}[/yellow]") + console.print( + f"[yellow]Warning: Could not initialize constitution: {e}[/yellow]" + ) INIT_OPTIONS_FILE = ".specify/init-options.json" @@ -963,23 +1077,84 @@ 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)"), + 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")'), + 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. @@ -1027,15 +1202,27 @@ def init( # 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())}") + 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/\"") + 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: @@ -1048,6 +1235,7 @@ def init( # 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: @@ -1059,7 +1247,9 @@ def init( 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))}") + 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, @@ -1071,6 +1261,7 @@ def init( 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; " @@ -1099,21 +1290,29 @@ def init( 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") + 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") + 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") + 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))}") + 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 @@ -1124,10 +1323,16 @@ def init( 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]") + 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]") + console.print( + "[cyan]--force supplied: skipping confirmation and proceeding with merge[/cyan]" + ) else: response = typer.confirm("Do you want to continue?") if not response: @@ -1138,14 +1343,22 @@ def init( 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.") + 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]") + 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" @@ -1153,7 +1366,7 @@ def init( "Use [bold]--force[/bold] to merge into the existing directory.", title="[red]Directory Conflict[/red]", border_style="red", - padding=(1, 2) + padding=(1, 2), ) console.print() console.print(error_panel) @@ -1161,16 +1374,16 @@ def init( 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())}") + 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" + ai_choices, "Choose your coding agent integration:", "copilot" ) # Auto-promote interactively selected agents to the integration path @@ -1185,8 +1398,12 @@ def init( # 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]') + 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() @@ -1207,7 +1424,9 @@ def init( 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]") + console.print( + "[yellow]Git not found - will skip repository initialization[/yellow]" + ) if not ignore_agent_tools: agent_config = AGENT_CONFIG.get(selected_ai) @@ -1221,7 +1440,7 @@ def init( "Tip: Use [cyan]--ignore-agent-tools[/cyan] to skip this check", title="[red]Agent Detection Error[/red]", border_style="red", - padding=(1, 2) + padding=(1, 2), ) console.print() console.print(error_panel) @@ -1229,14 +1448,20 @@ def init( 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())}") + 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) + selected_script = select_with_arrows( + SCRIPT_TYPE_CHOICES, + "Choose script type (or press Enter)", + default_script, + ) else: selected_script = default_script @@ -1266,11 +1491,14 @@ def init( ]: tracker.add(key, label) - with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live: + 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() @@ -1287,12 +1515,15 @@ def init( # 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) + extra = _parse_integration_options( + resolved_integration, integration_options + ) if extra: integration_parsed_options.update(extra) resolved_integration.setup( - project_path, manifest, + project_path, + manifest, parsed_options=integration_parsed_options or None, script_type=selected_script, raw_options=integration_options, @@ -1302,16 +1533,34 @@ def init( # 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") + 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)) + 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)) + _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) @@ -1331,7 +1580,7 @@ def init( git_has_error = True # Sanitize multi-line error_msg to single line for tracker if error_msg: - sanitized = error_msg.replace('\n', ' ').strip() + sanitized = error_msg.replace("\n", " ").strip() git_messages.append(f"init failed: {sanitized[:120]}") else: git_messages.append("init failed") @@ -1340,6 +1589,7 @@ def init( # Step 2: Install bundled git extension try: from .extensions import ExtensionManager + bundled_path = _locate_bundled_extension("git") if bundled_path: manager = ExtensionManager(project_path) @@ -1355,7 +1605,7 @@ def init( git_messages.append("bundled extension not found") except Exception as ext_err: git_has_error = True - sanitized_ext = str(ext_err).replace('\n', ' ').strip() + sanitized_ext = str(ext_err).replace("\n", " ").strip() git_messages.append( f"extension install failed: {sanitized_ext[:120]}" ) @@ -1373,29 +1623,36 @@ def init( 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", - }) + 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() + sanitized_wf = str(wf_err).replace("\n", " ").strip() tracker.error("workflow", f"install failed: {sanitized_wf[:120]}") # Fix permissions after all installs (scripts + extensions) @@ -1418,7 +1675,10 @@ def init( # 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): + + if isinstance(resolved_integration, _SkillsPersist) or getattr( + resolved_integration, "_skills_mode", False + ): init_opts["ai_skills"] = True save_init_options(project_path, init_opts) @@ -1426,6 +1686,7 @@ def init( if preset: try: from .presets import PresetManager, PresetCatalog, PresetError + preset_manager = PresetManager(project_path) speckit_ver = get_speckit_version() @@ -1436,14 +1697,21 @@ def init( else: bundled_path = _locate_bundled_preset(preset) if bundled_path: - preset_manager.install_from_directory(bundled_path, speckit_ver) + 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"): + 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." @@ -1456,9 +1724,13 @@ def init( zip_path = None try: zip_path = preset_catalog.download_pack(preset) - preset_manager.install_from_zip(zip_path, speckit_ver) + 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}") + 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 @@ -1468,14 +1740,20 @@ def init( # 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}") + 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")) + console.print( + Panel( + f"Initialization failed: {e}", title="Failure", border_style="red" + ) + ) if debug: _env_pairs = [ ("Python", sys.version.split()[0]), @@ -1483,8 +1761,17 @@ def init( ("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")) + 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) @@ -1497,14 +1784,16 @@ def init( # 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"] + 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) + padding=(1, 2), ) console.print() console.print(security_notice) @@ -1521,7 +1810,9 @@ def init( steps_lines = [] if not here: - steps_lines.append(f"1. Go to the project folder: [cyan]cd {project_name}[/cyan]") + 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!") @@ -1530,26 +1821,47 @@ def init( # 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) + + _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) + 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) + 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 + 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]") + 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]") + 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]") + 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" @@ -1566,13 +1878,25 @@ def _display_cmd(name: str) -> str: 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_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)) + steps_panel = Panel( + "\n".join(steps_lines), title="Next Steps", border_style="cyan", padding=(1, 2) + ) console.print() console.print(steps_panel) @@ -1586,13 +1910,21 @@ def _display_cmd(name: str) -> str: "", 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')}[/])" + 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)) + 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) + @app.command() def check(): """Check that all required tools are installed.""" @@ -1637,6 +1969,7 @@ def check(): if not any(agent_results.values()): console.print("[dim]Tip: Install a coding agent for the best experience[/dim]") + @app.command() def version(): """Display version and system information.""" @@ -1661,12 +1994,13 @@ def version(): info_table, title="[bold cyan]Specify CLI Information[/bold cyan]", border_style="cyan", - padding=(1, 2) + padding=(1, 2), ) console.print(panel) console.print() + def _get_installed_version() -> str: """Return the installed specify-cli distribution version or 'unknown'. @@ -1689,6 +2023,7 @@ def _get_installed_version() -> str: except tuple(metadata_errors): return "unknown" + def _normalize_tag(tag: str) -> str: """Strip exactly one leading 'v' from a release tag. @@ -1698,6 +2033,7 @@ def _normalize_tag(tag: str) -> str: """ return tag[1:] if tag.startswith("v") else tag + def _is_newer(latest: str, current: str) -> bool: """Return True iff `latest` is strictly greater than `current` under PEP 440. @@ -1759,6 +2095,7 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]: ) app.add_typer(self_app, name="self") + @self_app.command("check") def self_check() -> None: """Check whether a newer specify-cli release is available. Read-only. @@ -1779,7 +2116,9 @@ def self_check() -> None: # never contains a URL, headers, response body, or traceback. assert failure_reason is not None console.print(f"Installed: {installed}") - console.print(f"[yellow]Could not check latest release:[/yellow] {failure_reason}") + console.print( + f"[yellow]Could not check latest release:[/yellow] {failure_reason}" + ) return latest_normalized = _normalize_tag(tag) @@ -1795,7 +2134,9 @@ def self_check() -> None: return if _is_newer(latest_normalized, installed): - console.print(f"[green]Update available:[/green] {installed} → {latest_normalized}") + console.print( + f"[green]Update available:[/green] {installed} → {latest_normalized}" + ) console.print("\nTo upgrade:") console.print(" uv tool install specify-cli --force \\") console.print(f" --from git+https://github.com/github/spec-kit.git@{tag}") @@ -1821,7 +2162,9 @@ def self_upgrade() -> None: and to get a copy-pasteable reinstall command. """ console.print("specify self upgrade is not implemented yet.") - console.print("Run 'specify self check' to see whether a newer release is available.") + console.print( + "Run 'specify self check' to see whether a newer release is available." + ) console.print("Actual self-upgrade is planned as follow-up work.") @@ -1859,12 +2202,14 @@ def self_upgrade() -> None: 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: @@ -1894,9 +2239,6 @@ def get_speckit_version() -> str: integration_app.add_typer(integration_catalog_app, name="catalog") -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 @@ -1911,11 +2253,15 @@ def _read_integration_json(project_root: Path) -> dict[str, Any]: 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"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"[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 @@ -1928,10 +2274,17 @@ def _write_integration_json( """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") + 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: @@ -1966,7 +2319,9 @@ def _resolve_script_type(project_root: Path, script_type: str | None) -> str: @integration_app.command("list") def integration_list( - catalog: bool = typer.Option(False, "--catalog", help="Browse full catalog (built-in + community)"), + catalog: bool = typer.Option( + False, "--catalog", help="Browse full catalog (built-in + community)" + ), ): """List available integrations and installed status.""" from .integrations import INTEGRATION_REGISTRY @@ -1975,7 +2330,9 @@ def integration_list( 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( + "[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) @@ -2052,14 +2409,24 @@ def integration_list( 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]") + 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")'), + 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 @@ -2069,7 +2436,9 @@ def integration_install( 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( + "[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) @@ -2085,12 +2454,18 @@ def integration_install( 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.") + 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].") + 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) @@ -2104,7 +2479,11 @@ def integration_install( # 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)) + _install_shared_infra( + project_root, + selected_script, + invoke_separator=integration.effective_invoke_separator(parsed_options), + ) if os.name != "nt": ensure_executable_scripts(project_root) @@ -2114,14 +2493,17 @@ def integration_install( try: integration.setup( - project_root, manifest, + 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) + _update_init_options_for_integration( + project_root, integration, script_type=selected_script + ) except Exception as e: # Attempt rollback of any files written by setup @@ -2129,7 +2511,9 @@ def integration_install( 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}") + 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) @@ -2138,12 +2522,15 @@ def integration_install( console.print(f"\n[green]✓[/green] Integration '{name}' installed successfully") -def _parse_integration_options(integration: Any, raw_options: str) -> dict[str, Any] | None: +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()) @@ -2153,7 +2540,9 @@ def _parse_integration_options(integration: Any, raw_options: str) -> dict[str, while i < len(tokens): token = tokens[i] if not token.startswith("-"): - console.print(f"[red]Error:[/red] Unexpected integration option value '{token}'.") + console.print( + f"[red]Error:[/red] Unexpected integration option value '{token}'." + ) if allowed: console.print(f"Allowed options: {allowed}") raise typer.Exit(1) @@ -2171,7 +2560,9 @@ def _parse_integration_options(integration: Any, raw_options: str) -> dict[str, 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.") + 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 @@ -2194,13 +2585,16 @@ def _update_init_options_for_integration( ) -> 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): + if isinstance(integration, SkillsIntegration) or getattr( + integration, "_skills_mode", False + ): opts["ai_skills"] = True else: opts.pop("ai_skills", None) @@ -2209,7 +2603,9 @@ def _update_init_options_for_integration( @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 integration)" + ), force: bool = typer.Option(False, "--force", help="Remove files even if modified"), ): """Uninstall an integration, safely preserving modified files.""" @@ -2220,7 +2616,9 @@ def integration_uninstall( 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( + "[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) @@ -2234,14 +2632,18 @@ def integration_uninstall( 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"[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]") + 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) @@ -2256,7 +2658,9 @@ def integration_uninstall( 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"[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 " @@ -2288,7 +2692,9 @@ def integration_uninstall( 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:") + 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}") @@ -2297,9 +2703,17 @@ def integration_uninstall( @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 (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 @@ -2309,7 +2723,9 @@ def integration_switch( 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( + "[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) @@ -2324,7 +2740,9 @@ def integration_switch( installed_key = current.get("integration") if installed_key == target: - console.print(f"[yellow]Integration '{target}' is already installed. Nothing to switch.[/yellow]") + 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) @@ -2332,14 +2750,23 @@ def integration_switch( # 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" + 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]") + 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"[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}, " @@ -2351,21 +2778,31 @@ def integration_switch( if removed: console.print(f" Removed {len(removed)} file(s)") if skipped: - console.print(f" [yellow]⚠[/yellow] {len(skipped)} modified file(s) preserved") + 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") + 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") + 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}") + 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"[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]." @@ -2386,11 +2823,17 @@ def integration_switch( # is installed. parsed_options: dict[str, Any] | None = None if integration_options: - parsed_options = _parse_integration_options(target_integration, 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)) + _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) @@ -2402,14 +2845,17 @@ def integration_switch( try: target_integration.setup( - project_root, manifest, + 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) + _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 @@ -2417,9 +2863,13 @@ def integration_switch( 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}") + 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}': {e}" + ) raise typer.Exit(1) name = (target_integration.config or {}).get("name", target) @@ -2428,10 +2878,20 @@ def integration_switch( @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"), + 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. @@ -2445,7 +2905,9 @@ def integration_upgrade( 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( + "[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) @@ -2472,23 +2934,33 @@ def integration_upgrade( 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.") + 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}") + 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:") + 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.") + 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) @@ -2501,7 +2973,12 @@ def integration_upgrade( 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)) + _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) @@ -2519,12 +2996,16 @@ def integration_upgrade( ) new_manifest.save() _write_integration_json(project_root, key) - _update_init_options_for_integration(project_root, integration, script_type=selected_script) + _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]") + 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 @@ -2536,7 +3017,9 @@ def integration_upgrade( 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") + 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") @@ -2557,7 +3040,9 @@ def _require_specify_project() -> Path: """Return the current project root if it is a spec-kit project, else exit.""" project_root = Path.cwd() if not (project_root / ".specify").exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + 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) return project_root @@ -2600,7 +3085,9 @@ def integration_search( "(.specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml)." ) else: - console.print("\nTip: The catalog may be temporarily unavailable. Try again later.") + console.print( + "\nTip: The catalog may be temporarily unavailable. Try again later." + ) raise typer.Exit(1) if not results: @@ -2641,7 +3128,9 @@ def integration_search( 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}") + 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 " @@ -2699,7 +3188,9 @@ def integration_info( 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]" + 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"): @@ -2724,7 +3215,9 @@ def integration_info( return if catalog_error: - console.print(f"[red]Error:[/red] Could not query integration catalog: {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 " @@ -2737,7 +3230,9 @@ def integration_info( "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.") + 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") @@ -2759,7 +3254,11 @@ def integration_catalog_list(): 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() + 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) @@ -2772,7 +3271,9 @@ def integration_catalog_list(): 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") + 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") @@ -2833,7 +3334,9 @@ def integration_catalog_add( @integration_catalog_app.command("remove") def integration_catalog_remove( - index: int = typer.Argument(..., help="Catalog index to remove (from 'catalog list')"), + 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 @@ -2862,7 +3365,9 @@ def preset_list(): 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( + "[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) @@ -2877,9 +3382,15 @@ def preset_list(): 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}") + 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"]) @@ -2892,8 +3403,14 @@ def preset_list(): 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)"), + 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 ( @@ -2908,13 +3425,17 @@ def preset_add( 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( + "[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)") + console.print( + "[red]Error:[/red] Priority must be a positive integer (1 or higher)" + ) raise typer.Exit(1) manager = PresetManager(project_root) @@ -2928,16 +3449,25 @@ def preset_add( 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})") + 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.") + 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]...") @@ -2956,27 +3486,36 @@ def preset_add( manifest = manager.install_from_zip(zip_path, speckit_version, priority) - console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {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})") + 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") + 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." @@ -2990,21 +3529,33 @@ def preset_add( 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.") + 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]...") + 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})") + 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(): + 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") + console.print( + "[red]Error:[/red] Specify a preset ID, --from URL, or --dev path" + ) raise typer.Exit(1) except PresetCompatibilityError as e: @@ -3029,7 +3580,9 @@ def preset_remove( 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( + "[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) @@ -3059,7 +3612,9 @@ def preset_search( 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( + "[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) @@ -3077,7 +3632,9 @@ def preset_search( 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" [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"]) @@ -3087,7 +3644,9 @@ def preset_search( @preset_app.command("resolve") def preset_resolve( - template_name: str = typer.Argument(..., help="Template name to resolve (e.g., spec-template)"), + 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 @@ -3096,7 +3655,9 @@ def preset_resolve( 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( + "[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) @@ -3110,9 +3671,8 @@ def preset_resolve( 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) + has_composition = layers[0]["strategy"] != "replace" and any( + layer["strategy"] != "replace" for layer in layers ) if has_composition: # Verify composition is actually possible @@ -3122,9 +3682,13 @@ def preset_resolve( 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]") + 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( + " [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). @@ -3136,14 +3700,16 @@ def preset_resolve( break # Show only contributing layers (base and above) if effective_base_idx is not None: - contributing = layers[:effective_base_idx + 1] + 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']}") + 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) @@ -3152,7 +3718,9 @@ def preset_resolve( 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]") + console.print( + " [dim]No template with this name exists in the resolution stack[/dim]" + ) @preset_app.command("info") @@ -3167,7 +3735,9 @@ def preset_info( 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( + "[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) @@ -3186,7 +3756,9 @@ def preset_info( 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', '')}") + 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}") @@ -3196,7 +3768,9 @@ def preset_info( 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) + priority = normalize_priority( + pack_metadata.get("priority") if isinstance(pack_metadata, dict) else None + ) console.print(f" [dim]Priority:[/dim] {priority}") console.print() return @@ -3209,10 +3783,14 @@ def preset_info( pack_info = None if not pack_info: - console.print(f"[red]Error:[/red] Preset '{preset_id}' not found (not installed and not in catalog)") + 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"\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', '')}") @@ -3242,13 +3820,17 @@ def preset_set_priority( # 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( + "[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)") + console.print( + "[red]Error:[/red] Priority must be a positive integer (1 or higher)" + ) raise typer.Exit(1) manager = PresetManager(project_root) @@ -3261,15 +3843,20 @@ def preset_set_priority( # 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)") + 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]") + console.print( + f"[yellow]Preset '{preset_id}' already has priority {priority}[/yellow]" + ) raise typer.Exit(0) old_priority = normalize_priority(raw_priority) @@ -3277,8 +3864,12 @@ def preset_set_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]") + 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") @@ -3293,7 +3884,9 @@ def preset_enable( # 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( + "[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) @@ -3307,7 +3900,9 @@ def preset_enable( # 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)") + console.print( + f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)" + ) raise typer.Exit(1) if metadata.get("enabled", True): @@ -3319,7 +3914,9 @@ def preset_enable( 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]") + console.print( + "[dim]Note: Previously registered commands/skills remain active.[/dim]" + ) @preset_app.command("disable") @@ -3334,7 +3931,9 @@ def preset_disable( # 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( + "[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) @@ -3348,7 +3947,9 @@ def preset_disable( # 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)") + 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): @@ -3360,7 +3961,9 @@ def preset_disable( 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( + "[dim]Note: Previously registered commands/skills remain active until preset removal.[/dim]" + ) console.print(f"To re-enable: specify preset enable {preset_id}") @@ -3376,7 +3979,9 @@ def preset_catalog_list(): 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( + "[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) @@ -3405,17 +4010,25 @@ def preset_catalog_list(): 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]") + 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 + 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 + 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: @@ -3431,12 +4044,17 @@ def preset_catalog_list(): 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)"), + priority: int = typer.Option( + 10, "--priority", help="Priority (lower = higher priority)" + ), install_allowed: bool = typer.Option( - False, "--install-allowed/--no-install-allowed", + 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"), + description: str = typer.Option( + "", "--description", help="Description of the catalog" + ), ): """Add a catalog to .specify/preset-catalogs.yml.""" from .presets import PresetCatalog, PresetValidationError @@ -3445,7 +4063,9 @@ def preset_catalog_add( 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( + "[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) @@ -3471,29 +4091,44 @@ def preset_catalog_add( catalogs = config.get("catalogs", []) if not isinstance(catalogs, list): - console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a 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.") + 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, - }) + 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") + 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"\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)}") @@ -3508,13 +4143,17 @@ def preset_catalog_remove( 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( + "[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.") + console.print( + "[red]Error:[/red] No preset catalog config found. Nothing to remove." + ) raise typer.Exit(1) try: @@ -3525,7 +4164,9 @@ def preset_catalog_remove( catalogs = config.get("catalogs", []) if not isinstance(catalogs, list): - console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a 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] @@ -3535,11 +4176,18 @@ def preset_catalog_remove( 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") + 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]") + console.print( + "\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]" + ) # ===== Extension Commands ===== @@ -3573,7 +4221,9 @@ def _resolve_installed_extension( 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()] + name_matches = [ + ext for ext in installed_extensions if ext["name"].lower() == argument.lower() + ] if len(name_matches) == 1: # Unique display-name match @@ -3589,7 +4239,9 @@ def _resolve_installed_extension( 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", ""))) + 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]") @@ -3631,7 +4283,9 @@ def _resolve_catalog_extension( # 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()] + name_matches = [ + ext for ext in search_results if ext["name"].lower() == argument.lower() + ] if len(name_matches) == 1: return (name_matches[0], None) @@ -3655,7 +4309,9 @@ def _resolve_catalog_extension( ) console.print(table) console.print("\nPlease rerun using the extension ID:") - console.print(f" [bold]specify extension {command_name} [/bold]") + console.print( + f" [bold]specify extension {command_name} [/bold]" + ) raise typer.Exit(1) # Not found @@ -3667,8 +4323,12 @@ def _resolve_catalog_extension( @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"), + 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 @@ -3678,7 +4338,9 @@ def extension_list( # 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( + "[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) @@ -3698,10 +4360,14 @@ def extension_list( 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" [{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( + 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: @@ -3718,7 +4384,9 @@ def catalog_list(): 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( + "[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) @@ -3747,17 +4415,25 @@ def catalog_list(): 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]") + 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 + 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 + 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: @@ -3773,12 +4449,17 @@ def catalog_list(): 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)"), + priority: int = typer.Option( + 10, "--priority", help="Priority (lower = higher priority)" + ), install_allowed: bool = typer.Option( - False, "--install-allowed/--no-install-allowed", + 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"), + description: str = typer.Option( + "", "--description", help="Description of the catalog" + ), ): """Add a catalog to .specify/extension-catalogs.yml.""" from .extensions import ExtensionCatalog, ValidationError @@ -3787,7 +4468,9 @@ def catalog_add( 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( + "[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) @@ -3813,29 +4496,44 @@ def catalog_add( catalogs = config.get("catalogs", []) if not isinstance(catalogs, list): - console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a 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.") + 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, - }) + 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") + 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"\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)}") @@ -3850,7 +4548,9 @@ def catalog_remove( 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( + "[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) @@ -3867,7 +4567,9 @@ def catalog_remove( catalogs = config.get("catalogs", []) if not isinstance(catalogs, list): - console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a 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] @@ -3877,35 +4579,59 @@ def catalog_remove( 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") + 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]") + 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)"), + 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 + 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( + "[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)") + console.print( + "[red]Error:[/red] Priority must be a positive integer (1 or higher)" + ) raise typer.Exit(1) manager = ExtensionManager(project_root) @@ -3917,14 +4643,20 @@ def extension_add( # 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}") + 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}") + 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) + manifest = manager.install_from_directory( + source_path, speckit_version, priority=priority + ) elif from_url: # Install from URL (ZIP file) @@ -3936,7 +4668,9 @@ def extension_add( 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): + 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) @@ -3947,7 +4681,9 @@ 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" / "extensions" / ".cache" / "downloads" + ) download_dir.mkdir(parents=True, exist_ok=True) zip_path = download_dir / f"{extension}-url-download.zip" @@ -3957,9 +4693,13 @@ def extension_add( zip_path.write_bytes(zip_data) # Install from downloaded ZIP - manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority) + 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}") + console.print( + f"[red]Error:[/red] Failed to download from {from_url}: {e}" + ) raise typer.Exit(1) finally: # Clean up downloaded ZIP @@ -3970,28 +4710,38 @@ def extension_add( # 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) + 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") + 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}") + 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( + 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'] + 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) + 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 @@ -4021,13 +4771,17 @@ def extension_add( 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')}...") + 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) + manifest = manager.install_from_zip( + zip_path, speckit_version, priority=priority + ) finally: # Clean up downloaded ZIP if zip_path.exists(): @@ -4051,7 +4805,9 @@ def extension_add( 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( + 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}/") @@ -4070,7 +4826,9 @@ def extension_add( @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"), + 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.""" @@ -4081,7 +4839,9 @@ def extension_remove( # 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( + "[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) @@ -4089,7 +4849,9 @@ def extension_remove( # Resolve extension ID from argument (handles ambiguous names) installed = manager.list_installed() - extension_id, display_name = _resolve_installed_extension(extension, installed, "remove") + 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) @@ -4100,7 +4862,9 @@ def extension_remove( # 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 + 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)), @@ -4114,7 +4878,9 @@ def extension_remove( # 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") + 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}/") @@ -4131,11 +4897,17 @@ def extension_remove( success = manager.remove(extension_id, keep_config=keep_config) if success: - console.print(f"\n[green]✓[/green] Extension '{display_name}' removed successfully") + 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}/") + 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"\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") @@ -4147,7 +4919,9 @@ 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"), + verified: bool = typer.Option( + False, "--verified", help="Show only verified extensions" + ), ): """Search for available extensions in catalog.""" from .extensions import ExtensionCatalog, ExtensionError @@ -4157,7 +4931,9 @@ def extension_search( # 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( + "[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) @@ -4165,7 +4941,9 @@ def extension_search( try: console.print("🔍 Searching extension catalog...") - results = catalog.search(query=query, tag=tag, author=author, verified_only=verified) + 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]") @@ -4181,13 +4959,15 @@ def extension_search( 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"[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']) + if ext.get("tags"): + tags_str = ", ".join(ext["tags"]) console.print(f" [dim]Tags:[/dim] {tags_str}") # Source catalog @@ -4197,26 +4977,32 @@ def extension_search( 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]") + console.print( + f" [dim]Catalog:[/dim] {catalog_name} [yellow](discovery only — not installable)[/yellow]" + ) # Stats stats = [] - if ext.get('downloads') is not None: + if ext.get("downloads") is not None: stats.append(f"Downloads: {ext['downloads']:,}") - if ext.get('stars') is not None: + 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'): + 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']}") + 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"\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 " @@ -4225,7 +5011,9 @@ def extension_search( except ExtensionError as e: console.print(f"\n[red]Error:[/red] {e}") - console.print("\nTip: The catalog may be temporarily unavailable. Try again later.") + console.print( + "\nTip: The catalog may be temporarily unavailable. Try again later." + ) raise typer.Exit(1) @@ -4241,7 +5029,9 @@ def extension_info( # 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( + "[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) @@ -4301,20 +5091,28 @@ def extension_info( # 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]") + 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( + "[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) + 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( + 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") @@ -4328,7 +5126,9 @@ def _print_extension_info(ext_info: dict, manager): # 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"\n[bold]{ext_info['name']}[/bold] (v{ext_info['version']}){verified_badge}" + ) console.print(f"ID: {ext_info['id']}") console.print() @@ -4344,44 +5144,46 @@ def _print_extension_info(ext_info: dict, manager): 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( + f"[dim]Source catalog:[/dim] {ext_info['_catalog_name']}{install_note}" + ) console.print() # Requirements - if ext_info.get('requires'): + if ext_info.get("requires"): console.print("[bold]Requirements:[/bold]") - reqs = ext_info['requires'] - if reqs.get('speckit_version'): + 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)" + 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'): + if ext_info.get("provides"): console.print("[bold]Provides:[/bold]") - provides = ext_info['provides'] - if provides.get('commands'): + provides = ext_info["provides"] + if provides.get("commands"): console.print(f" • Commands: {provides['commands']}") - if provides.get('hooks'): + if provides.get("hooks"): console.print(f" • Hooks: {provides['hooks']}") console.print() # Tags - if ext_info.get('tags'): - tags_str = ", ".join(ext_info['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: + if ext_info.get("downloads") is not None: stats.append(f"Downloads: {ext_info['downloads']:,}") - if ext_info.get('stars') is not None: + 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)}") @@ -4389,23 +5191,25 @@ def _print_extension_info(ext_info: dict, manager): # Links console.print("[bold]Links:[/bold]") - if ext_info.get('repository'): + if ext_info.get("repository"): console.print(f" • Repository: {ext_info['repository']}") - if ext_info.get('homepage'): + if ext_info.get("homepage"): console.print(f" • Homepage: {ext_info['homepage']}") - if ext_info.get('documentation'): + if ext_info.get("documentation"): console.print(f" • Documentation: {ext_info['documentation']}") - if ext_info.get('changelog'): + 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']) + 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) + 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: @@ -4423,7 +5227,9 @@ def _print_extension_info(ext_info: dict, manager): @extension_app.command("update") def extension_update( - extension: str = typer.Argument(None, help="Extension ID or name to update (or all)"), + extension: str = typer.Argument( + None, help="Extension ID or name to update (or all)" + ), ): """Update extension(s) to latest version.""" from .extensions import ( @@ -4443,7 +5249,9 @@ def extension_update( # 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( + "[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) @@ -4456,7 +5264,9 @@ def extension_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") + extension_id, _ = _resolve_installed_extension( + extension, installed, "update" + ) extensions_to_update = [extension_id] else: # Update all extensions @@ -4473,8 +5283,14 @@ def extension_update( 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)") + 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"]) @@ -4492,7 +5308,9 @@ def extension_update( # 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)") + console.print( + f"⚠ {ext_id}: Updates not allowed from '{ext_info.get('_catalog_name', 'catalog')}' (skipping)" + ) continue try: @@ -4507,7 +5325,9 @@ def extension_update( updates_available.append( { "id": ext_id, - "name": ext_info.get("name", ext_id), # Display name for status messages + "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"), @@ -4553,7 +5373,9 @@ def extension_update( # Store backup state backup_registry_entry = None - backup_hooks = None # None means no hooks key in config; {} means hooks key existed + backup_hooks = ( + None # None means no hooks key in config; {} means hooks key existed + ) backed_up_command_files = {} try: @@ -4579,7 +5401,10 @@ def extension_update( # 3. Backup command files for all agents from .agents import CommandRegistrar as _AgentReg - registered_commands = backup_registry_entry.get("registered_commands", {}) + + 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 @@ -4587,22 +5412,43 @@ def extension_update( 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']}" + 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 = ( + 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) + 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" + 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) + 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) + 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) @@ -4611,7 +5457,9 @@ def extension_update( 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] + ext_hooks = [ + h for h in hook_list if h.get("extension") == extension_id + ] if ext_hooks: backup_hooks[hook_name] = ext_hooks @@ -4622,6 +5470,7 @@ def extension_update( # 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() @@ -4632,13 +5481,19 @@ def extension_update( 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] + 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'") + raise ValueError( + "Downloaded extension archive is missing 'extension.yml'" + ) zip_extension_id = manifest_data.get("extension", {}).get("id") if zip_extension_id != extension_id: @@ -4657,14 +5512,20 @@ def extension_update( 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) + 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): + 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): + 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" ) @@ -4672,11 +5533,15 @@ def extension_update( # Preserve the original installation timestamp if "installed_at" in backup_registry_entry: - new_metadata["installed_at"] = backup_registry_entry["installed_at"] + 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"]) + 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): @@ -4731,10 +5596,14 @@ def extension_update( # (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): + 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", {}) + 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 @@ -4742,16 +5611,33 @@ def extension_update( 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']}" + 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: + 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 = ( + 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 @@ -4780,7 +5666,8 @@ def extension_update( for hook_name, hooks_list in config["hooks"].items(): original_len = len(hooks_list) config["hooks"][hook_name] = [ - h for h in hooks_list + h + for h in hooks_list if h.get("extension") != extension_id ] if len(config["hooks"][hook_name]) != original_len: @@ -4812,9 +5699,13 @@ def extension_update( # Summary console.print() if updated_extensions: - console.print(f"[green]✓[/green] Successfully updated {len(updated_extensions)} extension(s)") + 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):") + 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) @@ -4839,7 +5730,9 @@ def extension_enable( # 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( + "[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) @@ -4848,12 +5741,16 @@ def extension_enable( # Resolve extension ID from argument (handles ambiguous names) installed = manager.list_installed() - extension_id, display_name = _resolve_installed_extension(extension, installed, "enable") + 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)") + console.print( + f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)" + ) raise typer.Exit(1) if metadata.get("enabled", True): @@ -4886,7 +5783,9 @@ def extension_disable( # 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( + "[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) @@ -4895,16 +5794,22 @@ def extension_disable( # Resolve extension ID from argument (handles ambiguous names) installed = manager.list_installed() - extension_id, display_name = _resolve_installed_extension(extension, installed, "disable") + 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)") + 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]") + console.print( + f"[yellow]Extension '{display_name}' is already disabled[/yellow]" + ) raise typer.Exit(0) manager.registry.update(extension_id, {"enabled": False}) @@ -4936,33 +5841,44 @@ def extension_set_priority( # 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( + "[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)") + 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") + 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)") + 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]") + console.print( + f"[yellow]Extension '{display_name}' already has priority {priority}[/yellow]" + ) raise typer.Exit(0) old_priority = normalize_priority(raw_priority) @@ -4970,8 +5886,12 @@ def extension_set_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]") + 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 ===== @@ -5003,10 +5923,14 @@ def workflow_run( project_root = Path.cwd() if not (project_root / ".specify").exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + 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" \u25b8 [{sid}] {label} \u2026") + engine.on_step_start = lambda sid, label: console.print( + f" \u25b8 [{sid}] {label} \u2026" + ) try: definition = engine.load_workflow(source) @@ -5030,12 +5954,16 @@ def workflow_run( 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)") + 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"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})" + ) console.print(f"[dim]Version: {definition.version}[/dim]\n") try: @@ -5058,7 +5986,9 @@ def workflow_run( 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]") + console.print( + f"\nResume with: [cyan]specify workflow resume {state.run_id}[/cyan]" + ) @workflow_app.command("resume") @@ -5070,10 +6000,14 @@ def workflow_resume( project_root = Path.cwd() if not (project_root / ".specify").exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + 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" \u25b8 [{sid}] {label} \u2026") + engine.on_step_start = lambda sid, label: console.print( + f" \u25b8 [{sid}] {label} \u2026" + ) try: state = engine.resume(run_id) @@ -5099,20 +6033,25 @@ def workflow_resume( @workflow_app.command("status") def workflow_status( - run_id: str | None = typer.Argument(None, help="Run ID to inspect (shows all if omitted)"), + 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 = Path.cwd() if not (project_root / ".specify").exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + 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}") @@ -5141,7 +6080,9 @@ def workflow_status( 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") + sc = {"completed": "green", "failed": "red", "paused": "yellow"}.get( + s, "white" + ) console.print(f" [{sc}]●[/{sc}] {step_id}: {s}") else: runs = engine.list_runs() @@ -5152,7 +6093,12 @@ def workflow_status( 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") + 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', '?')} " @@ -5169,7 +6115,9 @@ def workflow_list(): 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( + "[red]Error:[/red] Not a spec-kit project (no .specify/ directory)" + ) raise typer.Exit(1) registry = WorkflowRegistry(project_root) @@ -5183,7 +6131,9 @@ def workflow_list(): 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', '?')}") + 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}") @@ -5195,13 +6145,19 @@ 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.catalog import ( + WorkflowCatalog, + WorkflowRegistry, + WorkflowCatalogError, + ) from .workflows.engine import WorkflowDefinition 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( + "[red]Error:[/red] Not a spec-kit project (no .specify/ directory)" + ) raise typer.Exit(1) registry = WorkflowRegistry(project_root) @@ -5215,10 +6171,13 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None: 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'") + 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:") @@ -5229,14 +6188,20 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None: 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") + 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://"): @@ -5253,11 +6218,16 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None: 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.") + 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() @@ -5270,8 +6240,12 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None: 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}") + 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()) @@ -5314,13 +6288,17 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None: 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( + 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") + 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) @@ -5338,7 +6316,9 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None: 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): + 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." @@ -5370,9 +6350,12 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None: 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 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}" @@ -5382,8 +6365,11 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None: 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}") + console.print( + f"[red]Error:[/red] Failed to install workflow '{source}' from catalog: {exc}" + ) raise typer.Exit(1) # Validate the downloaded workflow before registering @@ -5391,14 +6377,17 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None: 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: @@ -5408,6 +6397,7 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None: # 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}) " @@ -5416,15 +6406,20 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None: ) 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") + 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") @@ -5437,7 +6432,9 @@ def workflow_remove( 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( + "[red]Error:[/red] Not a spec-kit project (no .specify/ directory)" + ) raise typer.Exit(1) registry = WorkflowRegistry(project_root) @@ -5450,6 +6447,7 @@ def workflow_remove( workflow_dir = project_root / ".specify" / "workflows" / workflow_id if workflow_dir.exists(): import shutil + shutil.rmtree(workflow_dir) registry.remove(workflow_id) @@ -5466,7 +6464,9 @@ def workflow_search( project_root = Path.cwd() if not (project_root / ".specify").exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print( + "[red]Error:[/red] Not a spec-kit project (no .specify/ directory)" + ) raise typer.Exit(1) catalog = WorkflowCatalog(project_root) @@ -5482,7 +6482,9 @@ def workflow_search( 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', '?')}") + 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}") @@ -5497,12 +6499,18 @@ 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.catalog import ( + WorkflowCatalog, + WorkflowRegistry, + WorkflowCatalogError, + ) from .workflows.engine import WorkflowEngine project_root = Path.cwd() if not (project_root / ".specify").exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print( + "[red]Error:[/red] Not a spec-kit project (no .specify/ directory)" + ) raise typer.Exit(1) # Check installed first @@ -5553,7 +6561,9 @@ def workflow_info( info = None if info: - console.print(f"\n[bold cyan]{info.get('name', workflow_id)}[/bold cyan] ({workflow_id})") + 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']}") @@ -5581,7 +6591,11 @@ def workflow_catalog_list(): 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]" + 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"): @@ -5600,7 +6614,9 @@ def workflow_catalog_add( 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( + "[red]Error:[/red] Not a spec-kit project (no .specify/ directory)" + ) raise typer.Exit(1) catalog = WorkflowCatalog(project_root) @@ -5615,7 +6631,9 @@ def workflow_catalog_add( @workflow_catalog_app.command("remove") def workflow_catalog_remove( - index: int = typer.Argument(..., help="Catalog index to remove (from 'catalog list')"), + 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 @@ -5623,7 +6641,9 @@ def workflow_catalog_remove( 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( + "[red]Error:[/red] Not a spec-kit project (no .specify/ directory)" + ) raise typer.Exit(1) catalog = WorkflowCatalog(project_root) @@ -5639,5 +6659,6 @@ def workflow_catalog_remove( def main(): app() + if __name__ == "__main__": main() diff --git a/src/specify_cli/workflows/constants.py b/src/specify_cli/workflows/constants.py new file mode 100644 index 0000000000..25df156c18 --- /dev/null +++ b/src/specify_cli/workflows/constants.py @@ -0,0 +1,7 @@ +"""Shared constants for the workflow engine and CLI layer. + +This module is dependency-free (no typer, no rich) so it can be imported +by both the engine and the CLI without circular-import or side-effect concerns. +""" + +INTEGRATION_JSON = ".specify/integration.json" diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py index e712fce836..e7f6795566 100644 --- a/src/specify_cli/workflows/engine.py +++ b/src/specify_cli/workflows/engine.py @@ -20,6 +20,7 @@ import yaml from .base import RunStatus, StepContext, StepResult, StepStatus +from .constants import INTEGRATION_JSON # -- Workflow Definition -------------------------------------------------- @@ -82,15 +83,25 @@ def from_string(cls, content: str) -> WorkflowDefinition: # ID format: lowercase alphanumeric with hyphens _ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$") + # Valid step types (matching STEP_REGISTRY keys) def _get_valid_step_types() -> set[str]: """Return valid step types from the registry, with a built-in fallback.""" from . import STEP_REGISTRY + if STEP_REGISTRY: return set(STEP_REGISTRY.keys()) return { - "command", "shell", "prompt", "gate", "if", - "switch", "while", "do-while", "fan-out", "fan-in", + "command", + "shell", + "prompt", + "gate", + "if", + "switch", + "while", + "do-while", + "fan-out", + "fan-in", } @@ -104,8 +115,7 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]: # -- Schema version --------------------------------------------------- if definition.schema_version not in ("1.0", "1"): errors.append( - f"Unsupported schema_version {definition.schema_version!r}. " - f"Expected '1.0'." + f"Unsupported schema_version {definition.schema_version!r}. Expected '1.0'." ) # -- Top-level fields ------------------------------------------------- @@ -187,9 +197,7 @@ def _validate_steps( # Determine step type step_type = step_config.get("type", "command") if step_type not in _get_valid_step_types(): - errors.append( - f"Step {step_id!r} has invalid type {step_type!r}." - ) + errors.append(f"Step {step_id!r} has invalid type {step_type!r}.") continue # Delegate to step-specific validation @@ -238,7 +246,7 @@ def __init__( project_root: Path | None = None, ) -> None: self.run_id = run_id or str(uuid.uuid4())[:8] - if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_-]*$', self.run_id): + if not re.match(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$", self.run_id): msg = f"Invalid run_id {self.run_id!r}: must be alphanumeric with hyphens/underscores only." raise ValueError(msg) self.workflow_id = workflow_id @@ -361,11 +369,7 @@ def load_workflow(self, source: str | Path) -> WorkflowDefinition: # Try as an installed workflow ID installed_path = ( - self.project_root - / ".specify" - / "workflows" - / str(source) - / "workflow.yml" + self.project_root / ".specify" / "workflows" / str(source) / "workflow.yml" ) if installed_path.exists(): return WorkflowDefinition.from_yaml(installed_path) @@ -413,6 +417,7 @@ def execute( run_dir.mkdir(parents=True, exist_ok=True) workflow_copy = run_dir / "workflow.yml" import yaml + with open(workflow_copy, "w", encoding="utf-8") as f: yaml.safe_dump(definition.data, f, sort_keys=False) @@ -491,7 +496,10 @@ def resume(self, run_id: str) -> RunState: try: self._execute_steps( - remaining_steps, context, state, STEP_REGISTRY, + remaining_steps, + context, + state, + STEP_REGISTRY, step_offset=step_offset, ) except KeyboardInterrupt: @@ -565,8 +573,7 @@ def _execute_steps( or context.default_model, "options": result.output.get("options") or step_config.get("options", {}), - "input": result.output.get("input") - or step_config.get("input", {}), + "input": result.output.get("input") or step_config.get("input", {}), "output": result.output, "status": result.status.value, } @@ -618,7 +625,10 @@ def _execute_steps( # enhancement. if result.next_steps: self._execute_steps( - result.next_steps, context, state, registry, + result.next_steps, + context, + state, + registry, step_offset=-1, ) if state.status in ( @@ -644,10 +654,15 @@ def _execute_steps( for ns in result.next_steps: ns_copy = dict(ns) if "id" in ns_copy: - ns_copy["id"] = f"{step_id}:{ns_copy['id']}:{_loop_iter + 1}" + ns_copy["id"] = ( + f"{step_id}:{ns_copy['id']}:{_loop_iter + 1}" + ) iter_steps.append(ns_copy) self._execute_steps( - iter_steps, context, state, registry, + iter_steps, + context, + state, + registry, step_offset=-1, ) if state.status in ( @@ -670,7 +685,10 @@ def _execute_steps( base_id = item_step.get("id", "item") item_step["id"] = f"{step_id}:{base_id}:{item_idx}" self._execute_steps( - [item_step], context, state, registry, + [item_step], + context, + state, + registry, step_offset=-1, ) # Collect per-item result for fan-in @@ -711,9 +729,7 @@ def _resolve_inputs( if not isinstance(input_def, dict): continue if name in provided: - resolved[name] = self._coerce_input( - name, provided[name], input_def - ) + resolved[name] = self._coerce_input(name, provided[name], input_def) elif "default" in input_def: resolved[name] = input_def["default"] elif input_def.get("required", False): @@ -726,7 +742,6 @@ def _resolve_inputs( return resolved - _INTEGRATION_JSON = ".specify/integration.json" _AUTO_FALLBACK = "copilot" def _resolve_integration_auto(self) -> str: @@ -738,7 +753,7 @@ def _resolve_integration_auto(self) -> str: (no ``typer.Exit`` / ``console.print``) so the engine remains independently testable. """ - path = self.project_root / self._INTEGRATION_JSON + path = self.project_root / INTEGRATION_JSON try: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, UnicodeDecodeError, json.JSONDecodeError): @@ -750,9 +765,7 @@ def _resolve_integration_auto(self) -> str: return self._AUTO_FALLBACK @staticmethod - def _coerce_input( - name: str, value: Any, input_def: dict[str, Any] - ) -> Any: + def _coerce_input(name: str, value: Any, input_def: dict[str, Any]) -> Any: """Coerce a provided input value to the declared type.""" input_type = input_def.get("type", "string") enum_values = input_def.get("enum") @@ -777,8 +790,7 @@ def _coerce_input( if enum_values is not None and value not in enum_values: msg = ( - f"Input {name!r} value {value!r} not in allowed " - f"values: {enum_values}." + f"Input {name!r} value {value!r} not in allowed values: {enum_values}." ) raise ValueError(msg) diff --git a/tests/test_workflows.py b/tests/test_workflows.py index df6d7980b1..7965dd7449 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -25,6 +25,7 @@ # Fixtures # --------------------------------------------------------------------------- + @pytest.fixture def temp_dir(): """Create a temporary directory for tests.""" @@ -86,6 +87,7 @@ def sample_workflow_file(project_dir, sample_workflow_yaml): # ===== Step Registry Tests ===== + class TestStepRegistry: """Test STEP_REGISTRY and auto-discovery.""" @@ -98,8 +100,16 @@ def test_all_step_types_registered(self): from specify_cli.workflows import STEP_REGISTRY expected = { - "command", "shell", "prompt", "gate", "if", "switch", - "while", "do-while", "fan-out", "fan-in", + "command", + "shell", + "prompt", + "gate", + "if", + "switch", + "while", + "do-while", + "fan-out", + "fan-in", } assert expected.issubset(set(STEP_REGISTRY.keys())) @@ -128,6 +138,7 @@ def test_register_step_empty_key_raises(self): class EmptyStep(StepBase): type_key = "" + def execute(self, config, context): return StepResult() @@ -137,6 +148,7 @@ def execute(self, config, context): # ===== Base Classes Tests ===== + class TestBaseClasses: """Test StepBase, StepContext, StepResult.""" @@ -194,6 +206,7 @@ def test_run_status_values(self): # ===== Expression Engine Tests ===== + class TestExpressions: """Test sandboxed expression evaluator.""" @@ -208,9 +221,7 @@ def test_step_output_reference(self): from specify_cli.workflows.expressions import evaluate_expression from specify_cli.workflows.base import StepContext - ctx = StepContext( - steps={"specify": {"output": {"file": "spec.md"}}} - ) + ctx = StepContext(steps={"specify": {"output": {"file": "spec.md"}}}) assert evaluate_expression("{{ steps.specify.output.file }}", ctx) == "spec.md" def test_string_interpolation(self): @@ -233,9 +244,7 @@ def test_comparison_not_equals(self): from specify_cli.workflows.expressions import evaluate_expression from specify_cli.workflows.base import StepContext - ctx = StepContext( - steps={"run-tests": {"output": {"exit_code": 1}}} - ) + ctx = StepContext(steps={"run-tests": {"output": {"exit_code": 1}}}) result = evaluate_expression("{{ steps.run-tests.output.exit_code != 0 }}", ctx) assert result is True @@ -243,11 +252,13 @@ def test_numeric_comparison(self): from specify_cli.workflows.expressions import evaluate_expression from specify_cli.workflows.base import StepContext - ctx = StepContext( - steps={"plan": {"output": {"task_count": 7}}} + ctx = StepContext(steps={"plan": {"output": {"task_count": 7}}}) + assert ( + evaluate_expression("{{ steps.plan.output.task_count > 5 }}", ctx) is True + ) + assert ( + evaluate_expression("{{ steps.plan.output.task_count < 5 }}", ctx) is False ) - assert evaluate_expression("{{ steps.plan.output.task_count > 5 }}", ctx) is True - assert evaluate_expression("{{ steps.plan.output.task_count < 5 }}", ctx) is False def test_boolean_and(self): from specify_cli.workflows.expressions import evaluate_expression @@ -268,7 +279,10 @@ def test_filter_default(self): from specify_cli.workflows.base import StepContext ctx = StepContext() - assert evaluate_expression("{{ inputs.missing | default('fallback') }}", ctx) == "fallback" + assert ( + evaluate_expression("{{ inputs.missing | default('fallback') }}", ctx) + == "fallback" + ) def test_filter_join(self): from specify_cli.workflows.expressions import evaluate_expression @@ -327,7 +341,9 @@ def test_list_indexing(self): from specify_cli.workflows.base import StepContext ctx = StepContext( - steps={"tasks": {"output": {"task_list": [{"file": "a.md"}, {"file": "b.md"}]}}} + steps={ + "tasks": {"output": {"task_list": [{"file": "a.md"}, {"file": "b.md"}]}} + } ) result = evaluate_expression("{{ steps.tasks.output.task_list[0].file }}", ctx) assert result == "a.md" @@ -335,11 +351,13 @@ def test_list_indexing(self): # ===== Integration Dispatch Tests ===== + class TestBuildExecArgs: """Test build_exec_args for CLI-based integrations.""" def test_claude_exec_args(self): from specify_cli.integrations.claude import ClaudeIntegration + impl = ClaudeIntegration() args = impl.build_exec_args("do stuff", model="sonnet-4") assert args[0] == "claude" @@ -351,6 +369,7 @@ def test_claude_exec_args(self): def test_gemini_exec_args(self): from specify_cli.integrations.gemini import GeminiIntegration + impl = GeminiIntegration() args = impl.build_exec_args("do stuff", model="gemini-2.5-pro") assert args[0] == "gemini" @@ -360,6 +379,7 @@ def test_gemini_exec_args(self): def test_codex_exec_args(self): from specify_cli.integrations.codex import CodexIntegration + impl = CodexIntegration() args = impl.build_exec_args("do stuff") assert args[0] == "codex" @@ -371,6 +391,7 @@ def test_copilot_exec_args(self, monkeypatch): monkeypatch.delenv("SPECKIT_COPILOT_ALLOW_ALL_TOOLS", raising=False) monkeypatch.delenv("SPECKIT_ALLOW_ALL_TOOLS", raising=False) from specify_cli.integrations.copilot import CopilotIntegration + impl = CopilotIntegration() args = impl.build_exec_args("do stuff", model="claude-sonnet-4-20250514") assert args[0] == "copilot" @@ -382,6 +403,7 @@ def test_copilot_new_env_var_disables_yolo(self, monkeypatch): monkeypatch.setenv("SPECKIT_COPILOT_ALLOW_ALL_TOOLS", "0") monkeypatch.delenv("SPECKIT_ALLOW_ALL_TOOLS", raising=False) from specify_cli.integrations.copilot import CopilotIntegration + impl = CopilotIntegration() args = impl.build_exec_args("do stuff") assert "--yolo" not in args @@ -391,6 +413,7 @@ def test_copilot_deprecated_env_var_still_honoured(self, monkeypatch): monkeypatch.setenv("SPECKIT_ALLOW_ALL_TOOLS", "0") import warnings from specify_cli.integrations.copilot import CopilotIntegration + impl = CopilotIntegration() with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") @@ -406,23 +429,27 @@ def test_copilot_new_env_var_takes_precedence(self, monkeypatch): monkeypatch.setenv("SPECKIT_COPILOT_ALLOW_ALL_TOOLS", "1") monkeypatch.setenv("SPECKIT_ALLOW_ALL_TOOLS", "0") from specify_cli.integrations.copilot import CopilotIntegration + impl = CopilotIntegration() args = impl.build_exec_args("do stuff") assert "--yolo" in args def test_ide_only_returns_none(self): from specify_cli.integrations.windsurf import WindsurfIntegration + impl = WindsurfIntegration() assert impl.build_exec_args("test") is None def test_no_model_omits_flag(self): from specify_cli.integrations.claude import ClaudeIntegration + impl = ClaudeIntegration() args = impl.build_exec_args("do stuff", model=None) assert "--model" not in args def test_no_json_omits_flag(self): from specify_cli.integrations.claude import ClaudeIntegration + impl = ClaudeIntegration() args = impl.build_exec_args("do stuff", output_json=False) assert "--output-format" not in args @@ -430,6 +457,7 @@ def test_no_json_omits_flag(self): # ===== Step Type Tests ===== + class TestCommandStep: """Test the command step type.""" @@ -448,7 +476,9 @@ def test_execute_basic(self): "command": "speckit.specify", "input": {"args": "{{ inputs.name }}"}, } - with patch("specify_cli.workflows.steps.command.shutil.which", return_value=None): + with patch( + "specify_cli.workflows.steps.command.shutil.which", return_value=None + ): result = step.execute(config, ctx) assert result.status == StepStatus.FAILED assert result.output["command"] == "speckit.specify" @@ -525,7 +555,9 @@ def test_dispatch_not_attempted_without_cli(self): "command": "speckit.specify", "input": {"args": "{{ inputs.name }}"}, } - with patch("specify_cli.workflows.steps.command.shutil.which", return_value=None): + with patch( + "specify_cli.workflows.steps.command.shutil.which", return_value=None + ): result = step.execute(config, ctx) assert result.status == StepStatus.FAILED assert result.output["dispatched"] is False @@ -554,8 +586,13 @@ def test_dispatch_with_mock_cli(self, tmp_path, monkeypatch): mock_result.stdout = '{"result": "done"}' mock_result.stderr = "" - with patch("specify_cli.workflows.steps.command.shutil.which", return_value="/usr/local/bin/claude"), \ - patch("subprocess.run", return_value=mock_result) as mock_run: + with ( + patch( + "specify_cli.workflows.steps.command.shutil.which", + return_value="/usr/local/bin/claude", + ), + patch("subprocess.run", return_value=mock_result) as mock_run, + ): result = step.execute(config, ctx) assert result.status == StepStatus.COMPLETED @@ -591,8 +628,13 @@ def test_dispatch_failure_returns_failed_status(self, tmp_path): mock_result.stdout = "" mock_result.stderr = "API error" - with patch("specify_cli.workflows.steps.command.shutil.which", return_value="/usr/local/bin/claude"), \ - patch("subprocess.run", return_value=mock_result): + with ( + patch( + "specify_cli.workflows.steps.command.shutil.which", + return_value="/usr/local/bin/claude", + ), + patch("subprocess.run", return_value=mock_result), + ): result = step.execute(config, ctx) assert result.status == StepStatus.FAILED @@ -618,7 +660,9 @@ def test_execute_basic(self): "type": "prompt", "prompt": "Review {{ inputs.file }} for security issues", } - with patch("specify_cli.workflows.steps.prompt.shutil.which", return_value=None): + with patch( + "specify_cli.workflows.steps.prompt.shutil.which", return_value=None + ): result = step.execute(config, ctx) assert result.status == StepStatus.FAILED assert result.output["prompt"] == "Review auth.py for security issues" @@ -676,8 +720,13 @@ def test_dispatch_with_mock_cli(self, tmp_path): mock_result.stdout = "Here is the explanation" mock_result.stderr = "" - with patch("specify_cli.workflows.steps.prompt.shutil.which", return_value="/usr/local/bin/claude"), \ - patch("subprocess.run", return_value=mock_result): + with ( + patch( + "specify_cli.workflows.steps.prompt.shutil.which", + return_value="/usr/local/bin/claude", + ), + patch("subprocess.run", return_value=mock_result), + ): result = step.execute(config, ctx) assert result.status == StepStatus.COMPLETED @@ -765,11 +814,13 @@ def test_validate_invalid_on_reject(self): from specify_cli.workflows.steps.gate import GateStep step = GateStep() - errors = step.validate({ - "id": "test", - "message": "Review", - "on_reject": "invalid", - }) + errors = step.validate( + { + "id": "test", + "message": "Review", + "on_reject": "invalid", + } + ) assert any("on_reject" in e for e in errors) @@ -825,9 +876,7 @@ def test_execute_matches_case(self): from specify_cli.workflows.base import StepContext step = SwitchStep() - ctx = StepContext( - steps={"review": {"output": {"choice": "approve"}}} - ) + ctx = StepContext(steps={"review": {"output": {"choice": "approve"}}}) config = { "id": "route", "expression": "{{ steps.review.output.choice }}", @@ -846,9 +895,7 @@ def test_execute_falls_to_default(self): from specify_cli.workflows.base import StepContext step = SwitchStep() - ctx = StepContext( - steps={"review": {"output": {"choice": "unknown"}}} - ) + ctx = StepContext(steps={"review": {"output": {"choice": "unknown"}}}) config = { "id": "route", "expression": "{{ steps.review.output.choice }}", @@ -866,9 +913,7 @@ def test_execute_no_default_no_match(self): from specify_cli.workflows.base import StepContext step = SwitchStep() - ctx = StepContext( - steps={"review": {"output": {"choice": "other"}}} - ) + ctx = StepContext(steps={"review": {"output": {"choice": "other"}}}) config = { "id": "route", "expression": "{{ steps.review.output.choice }}", @@ -891,12 +936,14 @@ def test_validate_invalid_cases_and_default(self): from specify_cli.workflows.steps.switch import SwitchStep step = SwitchStep() - errors = step.validate({ - "id": "test", - "expression": "{{ x }}", - "cases": {"a": "not-a-list"}, - "default": "also-bad", - }) + errors = step.validate( + { + "id": "test", + "expression": "{{ x }}", + "cases": {"a": "not-a-list"}, + "default": "also-bad", + } + ) assert any("case 'a' must be a list" in e for e in errors) assert any("'default' must be a list" in e for e in errors) @@ -909,9 +956,7 @@ def test_execute_condition_true(self): from specify_cli.workflows.base import StepContext step = WhileStep() - ctx = StepContext( - steps={"run-tests": {"output": {"exit_code": 1}}} - ) + ctx = StepContext(steps={"run-tests": {"output": {"exit_code": 1}}}) config = { "id": "retry", "condition": "{{ steps.run-tests.output.exit_code != 0 }}", @@ -927,9 +972,7 @@ def test_execute_condition_false(self): from specify_cli.workflows.base import StepContext step = WhileStep() - ctx = StepContext( - steps={"run-tests": {"output": {"exit_code": 0}}} - ) + ctx = StepContext(steps={"run-tests": {"output": {"exit_code": 0}}}) config = { "id": "retry", "condition": "{{ steps.run-tests.output.exit_code != 0 }}", @@ -952,7 +995,9 @@ def test_validate_invalid_max_iterations(self): from specify_cli.workflows.steps.while_loop import WhileStep step = WhileStep() - errors = step.validate({"id": "test", "condition": "{{ true }}", "max_iterations": 0, "steps": []}) + errors = step.validate( + {"id": "test", "condition": "{{ true }}", "max_iterations": 0, "steps": []} + ) assert any("must be an integer >= 1" in e for e in errors) @@ -1021,12 +1066,14 @@ def test_validate_steps_not_list(self): from specify_cli.workflows.steps.do_while import DoWhileStep step = DoWhileStep() - errors = step.validate({ - "id": "test", - "condition": "{{ true }}", - "max_iterations": 3, - "steps": "not-a-list", - }) + errors = step.validate( + { + "id": "test", + "condition": "{{ true }}", + "max_iterations": 3, + "steps": "not-a-list", + } + ) assert any("'steps' must be a list" in e for e in errors) @@ -1039,10 +1086,16 @@ def test_execute_with_items(self): step = FanOutStep() ctx = StepContext( - steps={"tasks": {"output": {"task_list": [ - {"file": "a.md"}, - {"file": "b.md"}, - ]}}} + steps={ + "tasks": { + "output": { + "task_list": [ + {"file": "a.md"}, + {"file": "b.md"}, + ] + } + } + } ) config = { "id": "parallel", @@ -1081,11 +1134,13 @@ def test_validate_step_not_mapping(self): from specify_cli.workflows.steps.fan_out import FanOutStep step = FanOutStep() - errors = step.validate({ - "id": "test", - "items": "{{ x }}", - "step": "not-a-dict", - }) + errors = step.validate( + { + "id": "test", + "items": "{{ x }}", + "step": "not-a-dict", + } + ) assert any("'step' must be a mapping" in e for e in errors) @@ -1098,9 +1153,7 @@ def test_execute_collects_results(self): step = FanInStep() ctx = StepContext( - steps={ - "parallel": {"output": {"item_count": 2, "status": "done"}} - } + steps={"parallel": {"output": {"item_count": 2, "status": "done"}}} ) config = { "id": "collect", @@ -1163,6 +1216,7 @@ def test_validate_wait_for_not_list(self): # ===== Workflow Definition Tests ===== + class TestWorkflowDefinition: """Test WorkflowDefinition loading and parsing.""" @@ -1199,6 +1253,7 @@ def test_inputs_parsed(self, sample_workflow_yaml): # ===== Workflow Validation Tests ===== + class TestWorkflowValidation: """Test workflow validation.""" @@ -1326,6 +1381,7 @@ def test_invalid_input_type(self): # ===== Workflow Engine Tests ===== + class TestWorkflowEngine: """Test WorkflowEngine execution.""" @@ -1374,7 +1430,9 @@ def test_execute_simple_workflow(self, project_dir): """ definition = WorkflowDefinition.from_string(yaml_str) engine = WorkflowEngine(project_dir) - with patch("specify_cli.workflows.steps.command.shutil.which", return_value=None): + with patch( + "specify_cli.workflows.steps.command.shutil.which", return_value=None + ): state = engine.execute(definition, {"name": "login"}) assert state.status == RunStatus.FAILED @@ -1498,6 +1556,7 @@ def test_execute_missing_required_input(self, project_dir): # ===== State Persistence Tests ===== + class TestRunState: """Test RunState persistence and loading.""" @@ -1585,6 +1644,7 @@ def test_list_after_execution(self, project_dir): # ===== Workflow Registry Tests ===== + class TestWorkflowRegistry: """Test WorkflowRegistry operations.""" @@ -1642,6 +1702,7 @@ def test_persistence(self, project_dir): # ===== Workflow Catalog Tests ===== + class TestWorkflowCatalog: """Test WorkflowCatalog catalog resolution.""" @@ -1657,7 +1718,9 @@ def test_default_catalogs(self, project_dir): def test_env_var_override(self, project_dir, monkeypatch): from specify_cli.workflows.catalog import WorkflowCatalog - monkeypatch.setenv("SPECKIT_WORKFLOW_CATALOG_URL", "https://example.com/catalog.json") + monkeypatch.setenv( + "SPECKIT_WORKFLOW_CATALOG_URL", "https://example.com/catalog.json" + ) catalog = WorkflowCatalog(project_dir) entries = catalog.get_active_catalogs() assert len(entries) == 1 @@ -1668,14 +1731,20 @@ def test_project_level_config(self, project_dir): from specify_cli.workflows.catalog import WorkflowCatalog config_path = project_dir / ".specify" / "workflow-catalogs.yml" - config_path.write_text(yaml.dump({ - "catalogs": [{ - "name": "custom", - "url": "https://example.com/wf-catalog.json", - "priority": 1, - "install_allowed": True, - }] - })) + config_path.write_text( + yaml.dump( + { + "catalogs": [ + { + "name": "custom", + "url": "https://example.com/wf-catalog.json", + "priority": 1, + "install_allowed": True, + } + ] + } + ) + ) catalog = WorkflowCatalog(project_dir) entries = catalog.get_active_catalogs() @@ -1683,7 +1752,10 @@ def test_project_level_config(self, project_dir): assert entries[0].name == "custom" def test_validate_url_http_rejected(self, project_dir): - from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError + from specify_cli.workflows.catalog import ( + WorkflowCatalog, + WorkflowValidationError, + ) catalog = WorkflowCatalog(project_dir) with pytest.raises(WorkflowValidationError, match="HTTPS"): @@ -1709,7 +1781,10 @@ def test_add_catalog(self, project_dir): assert data["catalogs"][0]["url"] == "https://example.com/new-catalog.json" def test_add_catalog_duplicate_rejected(self, project_dir): - from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError + from specify_cli.workflows.catalog import ( + WorkflowCatalog, + WorkflowValidationError, + ) catalog = WorkflowCatalog(project_dir) catalog.add_catalog("https://example.com/catalog.json") @@ -1732,7 +1807,10 @@ def test_remove_catalog(self, project_dir): assert len(data["catalogs"]) == 1 def test_remove_catalog_invalid_index(self, project_dir): - from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError + from specify_cli.workflows.catalog import ( + WorkflowCatalog, + WorkflowValidationError, + ) catalog = WorkflowCatalog(project_dir) catalog.add_catalog("https://example.com/c1.json") @@ -1752,6 +1830,7 @@ def test_get_catalog_configs(self, project_dir): # ===== Integration Test ===== + class TestWorkflowIntegration: """End-to-end workflow execution tests.""" @@ -1890,7 +1969,9 @@ def test_resolve_inputs_auto_reads_integration_json(self, project_dir): definition = WorkflowDefinition.from_string(self._make_workflow_yaml()) engine = WorkflowEngine(project_dir) - with patch("specify_cli.workflows.steps.command.shutil.which", return_value=None): + with patch( + "specify_cli.workflows.steps.command.shutil.which", return_value=None + ): state = engine.execute(definition) # The resolved integration should be "opencode", not "auto" or "copilot" @@ -1906,7 +1987,9 @@ def test_resolve_inputs_auto_no_json_falls_back_to_copilot(self, project_dir): definition = WorkflowDefinition.from_string(self._make_workflow_yaml()) engine = WorkflowEngine(project_dir) - with patch("specify_cli.workflows.steps.command.shutil.which", return_value=None): + with patch( + "specify_cli.workflows.steps.command.shutil.which", return_value=None + ): state = engine.execute(definition) step_output = state.step_results["specify"]["output"] @@ -1924,7 +2007,9 @@ def test_resolve_inputs_explicit_override_ignores_auto(self, project_dir): definition = WorkflowDefinition.from_string(self._make_workflow_yaml()) engine = WorkflowEngine(project_dir) - with patch("specify_cli.workflows.steps.command.shutil.which", return_value=None): + with patch( + "specify_cli.workflows.steps.command.shutil.which", return_value=None + ): state = engine.execute(definition, {"integration": "gemini"}) step_output = state.step_results["specify"]["output"] @@ -1941,7 +2026,52 @@ def test_resolve_inputs_auto_with_empty_json(self, project_dir): definition = WorkflowDefinition.from_string(self._make_workflow_yaml()) engine = WorkflowEngine(project_dir) - with patch("specify_cli.workflows.steps.command.shutil.which", return_value=None): + with patch( + "specify_cli.workflows.steps.command.shutil.which", return_value=None + ): + state = engine.execute(definition) + + step_output = state.step_results["specify"]["output"] + assert step_output["integration"] == "copilot" + + def test_resolve_inputs_auto_invalid_json_falls_back_to_copilot(self, project_dir): + """When integration.json contains invalid JSON, 'auto' falls back to 'copilot'.""" + from unittest.mock import patch + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + + int_json = project_dir / ".specify" / "integration.json" + int_json.parent.mkdir(parents=True, exist_ok=True) + int_json.write_text("{invalid json content", encoding="utf-8") + + definition = WorkflowDefinition.from_string(self._make_workflow_yaml()) + engine = WorkflowEngine(project_dir) + + with patch( + "specify_cli.workflows.steps.command.shutil.which", return_value=None + ): + state = engine.execute(definition) + + step_output = state.step_results["specify"]["output"] + assert step_output["integration"] == "copilot" + + def test_resolve_inputs_auto_oserror_falls_back_to_copilot(self, project_dir): + """When integration.json is unreadable (OSError), 'auto' falls back to 'copilot'.""" + from unittest.mock import patch + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + + int_json = project_dir / ".specify" / "integration.json" + int_json.parent.mkdir(parents=True, exist_ok=True) + int_json.write_text(json.dumps({"integration": "opencode"}), encoding="utf-8") + + definition = WorkflowDefinition.from_string(self._make_workflow_yaml()) + engine = WorkflowEngine(project_dir) + + with ( + patch( + "specify_cli.workflows.steps.command.shutil.which", return_value=None + ), + patch.object(Path, "read_text", side_effect=OSError("Permission denied")), + ): state = engine.execute(definition) step_output = state.step_results["specify"]["output"] diff --git a/workflows/speckit/workflow.yml b/workflows/speckit/workflow.yml index ca127ce1b1..c783884e65 100644 --- a/workflows/speckit/workflow.yml +++ b/workflows/speckit/workflow.yml @@ -8,8 +8,8 @@ workflow: requires: speckit_version: ">=0.7.2" - integrations: - any: ["copilot", "claude", "gemini"] + # integrations.any removed — "auto" resolves dynamically from the + # project's integration.json; a static allowlist would be incomplete. inputs: spec: @@ -19,7 +19,7 @@ inputs: integration: type: string default: "auto" - prompt: "Integration to use (e.g. claude, copilot, gemini, or 'auto' to detect from project config)" + prompt: "Integration to use (e.g. claude, copilot, gemini, opencode, or 'auto' to detect from project config)" scope: type: string default: "full" From 2322fdba1813919d7ee558481c6aeab73abd0a2b Mon Sep 17 00:00:00 2001 From: Markus Date: Thu, 30 Apr 2026 07:12:37 +0200 Subject: [PATCH 04/16] fix(workflows): address remaining PR #2408 review findings - engine.py: strip whitespace from integration value in _resolve_integration_auto(); whitespace-only strings like ' ' now fall back to 'copilot' instead of being returned as-is - __init__.py: define INTEGRATION_JSON locally instead of importing from workflows.constants; avoids executing the workflows package __init__ (which registers all step types) during CLI startup for commands that don't use workflows - tests: add test for whitespace-only integration value fallback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/__init__.py | 4 +++- src/specify_cli/workflows/engine.py | 6 ++++-- tests/test_workflows.py | 19 +++++++++++++++++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index ab5e4124c2..bb0496ed20 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -57,7 +57,9 @@ # For cross-platform keyboard input import readchar -from .workflows.constants import INTEGRATION_JSON +# Keep this local so importing specify_cli does not import the workflows package +# (whose package initialization registers steps and imports all workflow modules). +INTEGRATION_JSON = ".specify/integration.json" GITHUB_API_LATEST = "https://api.github.com/repos/github/spec-kit/releases/latest" diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py index e7f6795566..5f1f4ca8ba 100644 --- a/src/specify_cli/workflows/engine.py +++ b/src/specify_cli/workflows/engine.py @@ -760,8 +760,10 @@ def _resolve_integration_auto(self) -> str: return self._AUTO_FALLBACK if isinstance(data, dict): value = data.get("integration") - if isinstance(value, str) and value: - return value + if isinstance(value, str): + normalized_value = value.strip() + if normalized_value: + return normalized_value return self._AUTO_FALLBACK @staticmethod diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 7965dd7449..04b866bf7e 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -2076,3 +2076,22 @@ def test_resolve_inputs_auto_oserror_falls_back_to_copilot(self, project_dir): step_output = state.step_results["specify"]["output"] assert step_output["integration"] == "copilot" + + def test_resolve_inputs_auto_whitespace_only_falls_back_to_copilot(self, project_dir): + """When integration.json has a whitespace-only value, 'auto' falls back to 'copilot'.""" + from unittest.mock import patch + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + + int_json = project_dir / ".specify" / "integration.json" + int_json.write_text(json.dumps({"integration": " "}), encoding="utf-8") + + definition = WorkflowDefinition.from_string(self._make_workflow_yaml()) + engine = WorkflowEngine(project_dir) + + with patch( + "specify_cli.workflows.steps.command.shutil.which", return_value=None + ): + state = engine.execute(definition) + + step_output = state.step_results["specify"]["output"] + assert step_output["integration"] == "copilot" From 46760cafdeaebad1587e1a69191fa8114fcf5888 Mon Sep 17 00:00:00 2001 From: Markus Date: Thu, 30 Apr 2026 08:27:46 +0200 Subject: [PATCH 05/16] refactor: move INTEGRATION_JSON to top-level constants module Move INTEGRATION_JSON from specify_cli.workflows.constants to specify_cli.constants to avoid importing the workflows package when the main CLI module loads. Update imports in __init__.py and workflows/engine.py accordingly, and remove the now-empty workflows/constants.py. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/__init__.py | 4 +--- src/specify_cli/constants.py | 7 +++++++ src/specify_cli/workflows/constants.py | 7 ------- src/specify_cli/workflows/engine.py | 2 +- 4 files changed, 9 insertions(+), 11 deletions(-) create mode 100644 src/specify_cli/constants.py delete mode 100644 src/specify_cli/workflows/constants.py diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index bb0496ed20..000b98b232 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -57,9 +57,7 @@ # For cross-platform keyboard input import readchar -# Keep this local so importing specify_cli does not import the workflows package -# (whose package initialization registers steps and imports all workflow modules). -INTEGRATION_JSON = ".specify/integration.json" +from .constants import INTEGRATION_JSON GITHUB_API_LATEST = "https://api.github.com/repos/github/spec-kit/releases/latest" diff --git a/src/specify_cli/constants.py b/src/specify_cli/constants.py new file mode 100644 index 0000000000..7e1d4ebd73 --- /dev/null +++ b/src/specify_cli/constants.py @@ -0,0 +1,7 @@ +"""Top-level constants shared by the CLI and the workflow engine. + +This module is dependency-free (no typer, no rich, no workflow imports), so it +can be imported from anywhere inside the package without triggering side effects. +""" + +INTEGRATION_JSON = ".specify/integration.json" diff --git a/src/specify_cli/workflows/constants.py b/src/specify_cli/workflows/constants.py deleted file mode 100644 index 25df156c18..0000000000 --- a/src/specify_cli/workflows/constants.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Shared constants for the workflow engine and CLI layer. - -This module is dependency-free (no typer, no rich) so it can be imported -by both the engine and the CLI without circular-import or side-effect concerns. -""" - -INTEGRATION_JSON = ".specify/integration.json" diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py index 5f1f4ca8ba..eb3b9633f9 100644 --- a/src/specify_cli/workflows/engine.py +++ b/src/specify_cli/workflows/engine.py @@ -20,7 +20,7 @@ import yaml from .base import RunStatus, StepContext, StepResult, StepStatus -from .constants import INTEGRATION_JSON +from specify_cli.constants import INTEGRATION_JSON # -- Workflow Definition -------------------------------------------------- From 22880ea2954e85ad1671933fbafa3bf9c96ef7f7 Mon Sep 17 00:00:00 2001 From: Markus Wondrak Date: Thu, 30 Apr 2026 08:33:34 +0200 Subject: [PATCH 06/16] Update src/specify_cli/__init__.py Remove hardcoded string. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/specify_cli/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 000b98b232..ef22dbfe0f 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1531,7 +1531,7 @@ def init( manifest.save() # Write .specify/integration.json - integration_json = project_path / ".specify" / "integration.json" + integration_json = project_path / INTEGRATION_JSON integration_json.parent.mkdir(parents=True, exist_ok=True) integration_json.write_text( json.dumps( From 609eadef9a30544be9ea36055b7b126d75e08204 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:45:26 +0000 Subject: [PATCH 07/16] Initial plan From 9dc4b0b8c6047d647089ee805caca5807656b3c7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:53:51 +0000 Subject: [PATCH 08/16] fix(workflows): minimal-invasive auto-detect integration from project config - Add _INTEGRATION_JSON local constant in engine.py - Replace post-processing _resolve_integration_auto() with inline _resolve_default() + _load_project_integration() methods - Revert all Black style-only changes to engine.py, __init__.py - Delete unnecessary constants.py - Add TestIntegrationAutoDetect with 6 tests (no changes to existing tests) - Update workflow.yml: default 'auto', remove static integrations.any list Agent-Logs-Url: https://github.com/markuswondrak/spec-kit/sessions/4cb75741-9272-4a13-a90b-cc6cad486967 Co-authored-by: markuswondrak <245696895+markuswondrak@users.noreply.github.com> --- src/specify_cli/__init__.py | 2038 +++++++-------------------- src/specify_cli/constants.py | 7 - src/specify_cli/workflows/engine.py | 120 +- tests/test_workflows.py | 282 ++-- workflows/speckit/workflow.yml | 2 - 5 files changed, 656 insertions(+), 1793 deletions(-) delete mode 100644 src/specify_cli/constants.py diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index ef22dbfe0f..8039f79983 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -57,22 +57,17 @@ # For cross-platform keyboard input import readchar -from .constants import INTEGRATION_JSON - 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 - 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 = { @@ -82,7 +77,6 @@ def _build_agent_config() -> dict[str, dict[str, Any]]: # 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.""" @@ -102,11 +96,9 @@ def _build_ai_assistant_help() -> str: if len(alias_phrases) == 1: aliases_text = alias_phrases[0] else: - aliases_text = ", ".join(alias_phrases[:-1]) + " and " + alias_phrases[-1] + aliases_text = ', '.join(alias_phrases[:-1]) + ' and ' + alias_phrases[-1] return base_help + " Use " + aliases_text + "." - - AI_ASSISTANT_HELP = _build_ai_assistant_help() @@ -139,13 +131,10 @@ def _build_ai_deprecation_warning( f"Use [bold]{replacement}[/bold] instead." ) - 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" -) +CLAUDE_NPM_LOCAL_PATH = Path.home() / ".claude" / "local" / "node_modules" / ".bin" / "claude" BANNER = """ ███████╗██████╗ ███████╗ ██████╗██╗███████╗██╗ ██╗ @@ -157,23 +146,14 @@ def _build_ai_deprecation_warning( """ 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.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): @@ -181,9 +161,7 @@ def attach_refresh(self, 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.steps.append({"key": key, "label": label, "status": "pending", "detail": ""}) self._maybe_refresh() def start(self, key: str, detail: str = ""): @@ -207,9 +185,7 @@ def _update(self, key: str, status: str, detail: str): self._maybe_refresh() return - self.steps.append( - {"key": key, "label": key, "status": status, "detail": detail} - ) + self.steps.append({"key": key, "label": key, "status": status, "detail": detail}) self._maybe_refresh() def _maybe_refresh(self): @@ -242,9 +218,7 @@ def render(self): if status == "pending": # Entire line light gray (pending) if detail_text: - line = ( - f"{symbol} [bright_black]{label} ({detail_text})[/bright_black]" - ) + line = f"{symbol} [bright_black]{label} ({detail_text})[/bright_black]" else: line = f"{symbol} [bright_black]{label}[/bright_black]" else: @@ -257,31 +231,27 @@ def render(self): 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" + return 'up' if key == readchar.key.DOWN or key == readchar.key.CTRL_N: - return "down" + return 'down' if key == readchar.key.ENTER: - return "enter" + return 'enter' if key == readchar.key.ESC: - return "escape" + 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: +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. @@ -314,38 +284,31 @@ def create_selection_panel(): 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]" - ) + 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), + 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: + with Live(create_selection_panel(), console=console, transient=True, auto_refresh=False) as live: while True: try: key = get_key() - if key == "up": + if key == 'up': selected_index = (selected_index - 1) % len(option_keys) - elif key == "down": + elif key == 'down': selected_index = (selected_index + 1) % len(option_keys) - elif key == "enter": + elif key == 'enter': selected_key = option_keys[selected_index] break - elif key == "escape": + elif key == 'escape': console.print("\n[yellow]Selection cancelled[/yellow]") raise typer.Exit(1) @@ -363,10 +326,8 @@ def run_selection_loop(): return selected_key - console = Console(highlight=False) - class BannerGroup(TyperGroup): """Custom group that shows banner before help.""" @@ -384,10 +345,9 @@ def format_help(self, ctx, formatter): cls=BannerGroup, ) - def show_banner(): """Display the ASCII art banner.""" - banner_lines = BANNER.strip().split("\n") + banner_lines = BANNER.strip().split('\n') colors = ["bright_blue", "blue", "cyan", "bright_cyan", "white", "bright_white"] styled_banner = Text() @@ -399,50 +359,27 @@ def show_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()}") raise typer.Exit() - @app.callback() def callback( ctx: typer.Context, - version: bool = typer.Option( - False, - "--version", - "-V", - callback=_version_callback, - is_eager=True, - help="Show version and exit.", - ), + version: bool = typer.Option(False, "--version", "-V", callback=_version_callback, is_eager=True, help="Show version and exit."), ): """Show banner when no subcommand is provided.""" - if ( - ctx.invoked_subcommand is None - and "--help" not in sys.argv - and "-h" not in sys.argv - ): + if ctx.invoked_subcommand is None and "--help" not in sys.argv and "-h" not in sys.argv: show_banner() - console.print( - Align.center("[dim]Run 'specify --help' for usage information[/dim]") - ) + 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]: +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 - ) + 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) @@ -451,12 +388,11 @@ def run_command( 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: + 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. @@ -516,9 +452,7 @@ def is_git_repo(path: Path = None) -> bool: return False -def init_git_repo( - project_path: Path, quiet: bool = False -) -> tuple[bool, Optional[str]]: +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() @@ -527,12 +461,7 @@ def init_git_repo( 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, - ) + subprocess.run(["git", "commit", "-m", "Initial commit from Specify template"], check=True, capture_output=True, text=True) if not quiet: console.print("[green]✓[/green] Git repository initialized") return True, None @@ -549,15 +478,12 @@ def init_git_repo( os.chdir(original_cwd) -def handle_vscode_settings( - sub_item, dest_file, rel_path, verbose=False, tracker=None -) -> None: +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}") @@ -567,8 +493,8 @@ def atomic_write_json(target_file: Path, payload: dict[str, Any]) -> None: temp_path: Optional[Path] = None try: with tempfile.NamedTemporaryFile( - mode="w", - encoding="utf-8", + mode='w', + encoding='utf-8', dir=target_file.parent, prefix=f"{target_file.name}.", suffix=".tmp", @@ -576,7 +502,7 @@ def atomic_write_json(target_file: Path, payload: dict[str, Any]) -> None: ) as f: temp_path = Path(f.name) json.dump(payload, f, indent=4) - f.write("\n") + f.write('\n') if target_file.exists(): try: @@ -584,9 +510,7 @@ def atomic_write_json(target_file: Path, payload: dict[str, Any]) -> None: 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 - ) + os.chown(temp_path, existing_stat.st_uid, existing_stat.st_gid) except PermissionError: # Best-effort owner/group preservation without requiring elevated privileges. pass @@ -601,21 +525,16 @@ def atomic_write_json(target_file: Path, payload: dict[str, Any]) -> None: raise try: - with open(sub_item, "r", encoding="utf-8") as f: + 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 - ) + 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", - ) + log("Note: comments/trailing commas are normalized when rewritten", "yellow") else: log("Skipped merge (preserved existing settings)", "yellow") else: @@ -628,9 +547,7 @@ def atomic_write_json(target_file: Path, payload: dict[str, Any]) -> None: shutil.copy2(sub_item, dest_file) -def merge_json_files( - existing_path: Path, new_content: Any, verbose: bool = False -) -> Optional[dict[str, Any]]: +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: @@ -653,7 +570,7 @@ def merge_json_files( if exists: try: - with open(existing_path, "r", encoding="utf-8") as f: + 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) @@ -662,18 +579,14 @@ def merge_json_files( 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]" - ) + 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]" - ) + console.print(f"[yellow]Warning: Template content for {existing_path.name} is not a dictionary. Preserving existing settings.[/yellow]") return None if not exists: @@ -682,14 +595,10 @@ def merge_json_files( # 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]" - ) + 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]: + 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(): @@ -717,7 +626,6 @@ def deep_merge_polite( return merged - def _locate_core_pack() -> Path | None: """Return the filesystem path to the bundled core_pack directory, or None. @@ -742,8 +650,7 @@ def _locate_bundled_extension(extension_id: str) -> Path | None: source-checkout ``extensions//`` directory. """ import re as _re - - if not _re.match(r"^[a-z0-9-]+$", extension_id): + if not _re.match(r'^[a-z0-9-]+$', extension_id): return None core = _locate_core_pack() @@ -768,8 +675,7 @@ def _locate_bundled_workflow(workflow_id: str) -> Path | None: source-checkout ``workflows//`` directory. """ import re as _re - - if not _re.match(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$", workflow_id): + if not _re.match(r'^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$', workflow_id): return None core = _locate_core_pack() @@ -794,8 +700,7 @@ def _locate_bundled_preset(preset_id: str) -> Path | None: source-checkout ``presets//`` directory. """ import re as _re - - if not _re.match(r"^[a-z0-9-]+$", preset_id): + if not _re.match(r'^[a-z0-9-]+$', preset_id): return None core = _locate_core_pack() @@ -840,9 +745,7 @@ def _install_shared_infra( from .integrations.manifest import IntegrationManifest core = _locate_core_pack() - manifest = IntegrationManifest( - "speckit", project_path, version=get_speckit_version() - ) + manifest = IntegrationManifest("speckit", project_path, version=get_speckit_version()) # Scripts if core and (core / "scripts").is_dir(): @@ -884,11 +787,7 @@ def _install_shared_infra( 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(".") - ): + 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))) @@ -917,9 +816,7 @@ def _install_shared_infra( return True -def ensure_executable_scripts( - project_path: Path, tracker: StepTracker | None = None -) -> None: +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 @@ -960,30 +857,21 @@ def ensure_executable_scripts( 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 "" - ) + 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]" - ) + 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: +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" - ) + template_constitution = project_path / ".specify" / "templates" / "constitution-template.md" # If constitution already exists in memory, preserve it if memory_constitution.exists(): @@ -1013,9 +901,7 @@ def ensure_constitution_from_template( tracker.add("constitution", "Constitution setup") tracker.error("constitution", str(e)) else: - console.print( - f"[yellow]Warning: Could not initialize constitution: {e}[/yellow]" - ) + console.print(f"[yellow]Warning: Could not initialize constitution: {e}[/yellow]") INIT_OPTIONS_FILE = ".specify/init-options.json" @@ -1077,84 +963,23 @@ 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)", - ), + 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")', - ), + 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. @@ -1202,27 +1027,15 @@ def init( # 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())}" - ) + 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/"' - ) + 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: @@ -1235,7 +1048,6 @@ def init( # 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: @@ -1247,9 +1059,7 @@ def init( 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))}" - ) + 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, @@ -1261,7 +1071,6 @@ def init( 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; " @@ -1290,29 +1099,21 @@ def init( 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" - ) + 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" - ) + 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" - ) + 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))}" - ) + 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 @@ -1323,16 +1124,10 @@ def init( 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]" - ) + 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]" - ) + console.print("[cyan]--force supplied: skipping confirmation and proceeding with merge[/cyan]") else: response = typer.confirm("Do you want to continue?") if not response: @@ -1343,22 +1138,14 @@ def init( 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." - ) + 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]" - ) + 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" @@ -1366,7 +1153,7 @@ def init( "Use [bold]--force[/bold] to merge into the existing directory.", title="[red]Directory Conflict[/red]", border_style="red", - padding=(1, 2), + padding=(1, 2) ) console.print() console.print(error_panel) @@ -1374,16 +1161,16 @@ def init( 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())}" - ) + 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" + ai_choices, + "Choose your coding agent integration:", + "copilot" ) # Auto-promote interactively selected agents to the integration path @@ -1398,12 +1185,8 @@ def init( # 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]' - ) + 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() @@ -1424,9 +1207,7 @@ def init( 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]" - ) + console.print("[yellow]Git not found - will skip repository initialization[/yellow]") if not ignore_agent_tools: agent_config = AGENT_CONFIG.get(selected_ai) @@ -1440,7 +1221,7 @@ def init( "Tip: Use [cyan]--ignore-agent-tools[/cyan] to skip this check", title="[red]Agent Detection Error[/red]", border_style="red", - padding=(1, 2), + padding=(1, 2) ) console.print() console.print(error_panel) @@ -1448,20 +1229,14 @@ def init( 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())}" - ) + 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, - ) + selected_script = select_with_arrows(SCRIPT_TYPE_CHOICES, "Choose script type (or press Enter)", default_script) else: selected_script = default_script @@ -1491,14 +1266,11 @@ def init( ]: tracker.add(key, label) - with Live( - tracker.render(), console=console, refresh_per_second=8, transient=True - ) as live: + 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() @@ -1515,15 +1287,12 @@ def init( # 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 - ) + extra = _parse_integration_options(resolved_integration, integration_options) if extra: integration_parsed_options.update(extra) resolved_integration.setup( - project_path, - manifest, + project_path, manifest, parsed_options=integration_parsed_options or None, script_type=selected_script, raw_options=integration_options, @@ -1531,36 +1300,18 @@ def init( manifest.save() # Write .specify/integration.json - integration_json = project_path / 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", - ) + 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), - ) + 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 - ), - ) + _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) @@ -1580,7 +1331,7 @@ def init( git_has_error = True # Sanitize multi-line error_msg to single line for tracker if error_msg: - sanitized = error_msg.replace("\n", " ").strip() + sanitized = error_msg.replace('\n', ' ').strip() git_messages.append(f"init failed: {sanitized[:120]}") else: git_messages.append("init failed") @@ -1589,7 +1340,6 @@ def init( # Step 2: Install bundled git extension try: from .extensions import ExtensionManager - bundled_path = _locate_bundled_extension("git") if bundled_path: manager = ExtensionManager(project_path) @@ -1605,7 +1355,7 @@ def init( git_messages.append("bundled extension not found") except Exception as ext_err: git_has_error = True - sanitized_ext = str(ext_err).replace("\n", " ").strip() + sanitized_ext = str(ext_err).replace('\n', ' ').strip() git_messages.append( f"extension install failed: {sanitized_ext[:120]}" ) @@ -1623,36 +1373,29 @@ def init( 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", - }, - ) + 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() + sanitized_wf = str(wf_err).replace('\n', ' ').strip() tracker.error("workflow", f"install failed: {sanitized_wf[:120]}") # Fix permissions after all installs (scripts + extensions) @@ -1675,10 +1418,7 @@ def init( # 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 - ): + if isinstance(resolved_integration, _SkillsPersist) or getattr(resolved_integration, "_skills_mode", False): init_opts["ai_skills"] = True save_init_options(project_path, init_opts) @@ -1686,7 +1426,6 @@ def init( if preset: try: from .presets import PresetManager, PresetCatalog, PresetError - preset_manager = PresetManager(project_path) speckit_ver = get_speckit_version() @@ -1697,21 +1436,14 @@ def init( else: bundled_path = _locate_bundled_preset(preset) if bundled_path: - preset_manager.install_from_directory( - bundled_path, speckit_ver - ) + 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" - ): + 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." @@ -1724,13 +1456,9 @@ def init( zip_path = None try: zip_path = preset_catalog.download_pack(preset) - preset_manager.install_from_zip( - zip_path, speckit_ver - ) + 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}" - ) + 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 @@ -1740,20 +1468,14 @@ def init( # 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}" - ) + 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" - ) - ) + console.print(Panel(f"Initialization failed: {e}", title="Failure", border_style="red")) if debug: _env_pairs = [ ("Python", sys.version.split()[0]), @@ -1761,17 +1483,8 @@ def init( ("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", - ) - ) + 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) @@ -1784,16 +1497,14 @@ def init( # 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"] - ) + 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), + padding=(1, 2) ) console.print() console.print(security_notice) @@ -1810,9 +1521,7 @@ def init( steps_lines = [] if not here: - steps_lines.append( - f"1. Go to the project folder: [cyan]cd {project_name}[/cyan]" - ) + 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!") @@ -1821,49 +1530,27 @@ def init( # 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 - ) + _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 - ) + 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 - ) + 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 - ) + 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]" - ) + 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]" - ) + 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]" - ) + 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]") @@ -1883,25 +1570,13 @@ def _display_cmd(name: str) -> str: 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_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) - ) + steps_panel = Panel("\n".join(steps_lines), title="Next Steps", border_style="cyan", padding=(1,2)) console.print() console.print(steps_panel) @@ -1915,21 +1590,13 @@ def _display_cmd(name: str) -> str: "", 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')}[/])", + 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), - ) + 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) - @app.command() def check(): """Check that all required tools are installed.""" @@ -1974,7 +1641,6 @@ def check(): if not any(agent_results.values()): console.print("[dim]Tip: Install a coding agent for the best experience[/dim]") - @app.command() def version(): """Display version and system information.""" @@ -1999,13 +1665,12 @@ def version(): info_table, title="[bold cyan]Specify CLI Information[/bold cyan]", border_style="cyan", - padding=(1, 2), + padding=(1, 2) ) console.print(panel) console.print() - def _get_installed_version() -> str: """Return the installed specify-cli distribution version or 'unknown'. @@ -2028,7 +1693,6 @@ def _get_installed_version() -> str: except tuple(metadata_errors): return "unknown" - def _normalize_tag(tag: str) -> str: """Strip exactly one leading 'v' from a release tag. @@ -2038,7 +1702,6 @@ def _normalize_tag(tag: str) -> str: """ return tag[1:] if tag.startswith("v") else tag - def _is_newer(latest: str, current: str) -> bool: """Return True iff `latest` is strictly greater than `current` under PEP 440. @@ -2100,7 +1763,6 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]: ) app.add_typer(self_app, name="self") - @self_app.command("check") def self_check() -> None: """Check whether a newer specify-cli release is available. Read-only. @@ -2121,9 +1783,7 @@ def self_check() -> None: # never contains a URL, headers, response body, or traceback. assert failure_reason is not None console.print(f"Installed: {installed}") - console.print( - f"[yellow]Could not check latest release:[/yellow] {failure_reason}" - ) + console.print(f"[yellow]Could not check latest release:[/yellow] {failure_reason}") return latest_normalized = _normalize_tag(tag) @@ -2139,9 +1799,7 @@ def self_check() -> None: return if _is_newer(latest_normalized, installed): - console.print( - f"[green]Update available:[/green] {installed} → {latest_normalized}" - ) + console.print(f"[green]Update available:[/green] {installed} → {latest_normalized}") console.print("\nTo upgrade:") console.print(" uv tool install specify-cli --force \\") console.print(f" --from git+https://github.com/github/spec-kit.git@{tag}") @@ -2167,9 +1825,7 @@ def self_upgrade() -> None: and to get a copy-pasteable reinstall command. """ console.print("specify self upgrade is not implemented yet.") - console.print( - "Run 'specify self check' to see whether a newer release is available." - ) + console.print("Run 'specify self check' to see whether a newer release is available.") console.print("Actual self-upgrade is planned as follow-up work.") @@ -2207,14 +1863,12 @@ def self_upgrade() -> None: 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: @@ -2244,6 +1898,9 @@ def get_speckit_version() -> str: integration_app.add_typer(integration_catalog_app, name="catalog") +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 @@ -2258,15 +1915,11 @@ def _read_integration_json(project_root: Path) -> dict[str, Any]: 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"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"[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 @@ -2279,17 +1932,10 @@ def _write_integration_json( """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", - ) + 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: @@ -2324,9 +1970,7 @@ def _resolve_script_type(project_root: Path, script_type: str | None) -> str: @integration_app.command("list") def integration_list( - catalog: bool = typer.Option( - False, "--catalog", help="Browse full catalog (built-in + community)" - ), + catalog: bool = typer.Option(False, "--catalog", help="Browse full catalog (built-in + community)"), ): """List available integrations and installed status.""" from .integrations import INTEGRATION_REGISTRY @@ -2335,9 +1979,7 @@ def integration_list( 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("[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) @@ -2414,24 +2056,14 @@ def integration_list( 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]" - ) + 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")', - ), + 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 @@ -2441,9 +2073,7 @@ def integration_install( 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("[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) @@ -2459,18 +2089,12 @@ def integration_install( 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." - ) + 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]." - ) + 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) @@ -2484,11 +2108,7 @@ def integration_install( # 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), - ) + _install_shared_infra(project_root, selected_script, invoke_separator=integration.effective_invoke_separator(parsed_options)) if os.name != "nt": ensure_executable_scripts(project_root) @@ -2498,17 +2118,14 @@ def integration_install( try: integration.setup( - project_root, - manifest, + 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 - ) + _update_init_options_for_integration(project_root, integration, script_type=selected_script) except Exception as e: # Attempt rollback of any files written by setup @@ -2516,9 +2133,7 @@ def integration_install( 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}" - ) + 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) @@ -2527,15 +2142,12 @@ def integration_install( console.print(f"\n[green]✓[/green] Integration '{name}' installed successfully") -def _parse_integration_options( - integration: Any, raw_options: str -) -> dict[str, Any] | None: +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()) @@ -2545,9 +2157,7 @@ def _parse_integration_options( while i < len(tokens): token = tokens[i] if not token.startswith("-"): - console.print( - f"[red]Error:[/red] Unexpected integration option value '{token}'." - ) + console.print(f"[red]Error:[/red] Unexpected integration option value '{token}'.") if allowed: console.print(f"Allowed options: {allowed}") raise typer.Exit(1) @@ -2565,9 +2175,7 @@ def _parse_integration_options( 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." - ) + 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 @@ -2590,16 +2198,13 @@ def _update_init_options_for_integration( ) -> 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 - ): + if isinstance(integration, SkillsIntegration) or getattr(integration, "_skills_mode", False): opts["ai_skills"] = True else: opts.pop("ai_skills", None) @@ -2608,9 +2213,7 @@ def _update_init_options_for_integration( @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 integration)"), force: bool = typer.Option(False, "--force", help="Remove files even if modified"), ): """Uninstall an integration, safely preserving modified files.""" @@ -2621,9 +2224,7 @@ def integration_uninstall( 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("[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) @@ -2637,18 +2238,14 @@ def integration_uninstall( 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"[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]" - ) + 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) @@ -2663,9 +2260,7 @@ def integration_uninstall( 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"[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 " @@ -2697,9 +2292,7 @@ def integration_uninstall( 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:" - ) + 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}") @@ -2708,17 +2301,9 @@ def integration_uninstall( @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 (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 @@ -2728,9 +2313,7 @@ def integration_switch( 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("[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) @@ -2745,9 +2328,7 @@ def integration_switch( installed_key = current.get("integration") if installed_key == target: - console.print( - f"[yellow]Integration '{target}' is already installed. Nothing to switch.[/yellow]" - ) + 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) @@ -2755,23 +2336,14 @@ def integration_switch( # 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" - ) + 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]" - ) + 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"[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}, " @@ -2783,31 +2355,21 @@ def integration_switch( if removed: console.print(f" Removed {len(removed)} file(s)") if skipped: - console.print( - f" [yellow]⚠[/yellow] {len(skipped)} modified file(s) preserved" - ) + 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" - ) + 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" - ) + 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}" - ) + 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"[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]." @@ -2828,17 +2390,11 @@ def integration_switch( # is installed. parsed_options: dict[str, Any] | None = None if integration_options: - parsed_options = _parse_integration_options( - target_integration, 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), - ) + _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) @@ -2850,17 +2406,14 @@ def integration_switch( try: target_integration.setup( - project_root, - manifest, + 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 - ) + _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 @@ -2868,13 +2421,9 @@ def integration_switch( 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}" - ) + 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}': {e}") raise typer.Exit(1) name = (target_integration.config or {}).get("name", target) @@ -2883,20 +2432,10 @@ def integration_switch( @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" - ), + 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. @@ -2910,9 +2449,7 @@ def integration_upgrade( 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("[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) @@ -2939,33 +2476,23 @@ def integration_upgrade( 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." - ) + 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}" - ) + 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:" - ) + 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." - ) + 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) @@ -2978,12 +2505,7 @@ def integration_upgrade( 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), - ) + _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) @@ -3001,16 +2523,12 @@ def integration_upgrade( ) new_manifest.save() _write_integration_json(project_root, key) - _update_init_options_for_integration( - project_root, integration, script_type=selected_script - ) + _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]" - ) + 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 @@ -3022,9 +2540,7 @@ def integration_upgrade( 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" - ) + 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") @@ -3045,9 +2561,7 @@ def _require_specify_project() -> Path: """Return the current project root if it is a spec-kit project, else exit.""" project_root = Path.cwd() if not (project_root / ".specify").exists(): - console.print( - "[red]Error:[/red] Not a spec-kit project (no .specify/ directory)" - ) + 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) return project_root @@ -3090,9 +2604,7 @@ def integration_search( "(.specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml)." ) else: - console.print( - "\nTip: The catalog may be temporarily unavailable. Try again later." - ) + console.print("\nTip: The catalog may be temporarily unavailable. Try again later.") raise typer.Exit(1) if not results: @@ -3133,9 +2645,7 @@ def integration_search( 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}" - ) + 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 " @@ -3193,9 +2703,7 @@ def integration_info( 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]" - ) + 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"): @@ -3220,9 +2728,7 @@ def integration_info( return if catalog_error: - console.print( - f"[red]Error:[/red] Could not query integration catalog: {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 " @@ -3235,9 +2741,7 @@ def integration_info( "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." - ) + 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") @@ -3259,11 +2763,7 @@ def integration_catalog_list(): 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() - ) + 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) @@ -3276,9 +2776,7 @@ def integration_catalog_list(): 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" - ) + 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") @@ -3339,9 +2837,7 @@ def integration_catalog_add( @integration_catalog_app.command("remove") def integration_catalog_remove( - index: int = typer.Argument( - ..., help="Catalog index to remove (from 'catalog list')" - ), + 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 @@ -3370,9 +2866,7 @@ def preset_list(): 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("[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) @@ -3387,15 +2881,9 @@ def preset_list(): 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}" - ) + 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"]) @@ -3408,14 +2896,8 @@ def preset_list(): 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)", - ), + 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 ( @@ -3430,17 +2912,13 @@ def preset_add( 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("[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)" - ) + console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)") raise typer.Exit(1) manager = PresetManager(project_root) @@ -3454,25 +2932,16 @@ def preset_add( 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})" - ) + 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." - ) + 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]...") @@ -3491,36 +2960,27 @@ def preset_add( manifest = manager.install_from_zip(zip_path, speckit_version, priority) - console.print( - f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {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})" - ) + 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" - ) + 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." @@ -3534,33 +2994,21 @@ def preset_add( 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." - ) + 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]..." - ) + 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})" - ) + 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(): + 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" - ) + console.print("[red]Error:[/red] Specify a preset ID, --from URL, or --dev path") raise typer.Exit(1) except PresetCompatibilityError as e: @@ -3585,9 +3033,7 @@ def preset_remove( 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("[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) @@ -3617,9 +3063,7 @@ def preset_search( 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("[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) @@ -3637,9 +3081,7 @@ def preset_search( 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" [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"]) @@ -3649,9 +3091,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)" - ), + 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 @@ -3660,9 +3100,7 @@ def preset_resolve( 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("[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) @@ -3676,8 +3114,9 @@ def preset_resolve( 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 + has_composition = ( + layers[0]["strategy"] != "replace" + and any(layer["strategy"] != "replace" for layer in layers) ) if has_composition: # Verify composition is actually possible @@ -3687,13 +3126,9 @@ def preset_resolve( 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]" - ) + 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(" [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). @@ -3705,16 +3140,14 @@ def preset_resolve( break # Show only contributing layers (base and above) if effective_base_idx is not None: - contributing = layers[: effective_base_idx + 1] + 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']}" - ) + 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) @@ -3723,9 +3156,7 @@ def preset_resolve( 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]" - ) + console.print(" [dim]No template with this name exists in the resolution stack[/dim]") @preset_app.command("info") @@ -3740,9 +3171,7 @@ def preset_info( 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("[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) @@ -3761,9 +3190,7 @@ def preset_info( 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', '')}" - ) + 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}") @@ -3773,9 +3200,7 @@ def preset_info( 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 - ) + priority = normalize_priority(pack_metadata.get("priority") if isinstance(pack_metadata, dict) else None) console.print(f" [dim]Priority:[/dim] {priority}") console.print() return @@ -3788,14 +3213,10 @@ def preset_info( pack_info = None if not pack_info: - console.print( - f"[red]Error:[/red] Preset '{preset_id}' not found (not installed and not in catalog)" - ) + 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"\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', '')}") @@ -3825,17 +3246,13 @@ def preset_set_priority( # 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("[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)" - ) + console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)") raise typer.Exit(1) manager = PresetManager(project_root) @@ -3848,20 +3265,15 @@ def preset_set_priority( # 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)" - ) + 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]" - ) + console.print(f"[yellow]Preset '{preset_id}' already has priority {priority}[/yellow]") raise typer.Exit(0) old_priority = normalize_priority(raw_priority) @@ -3869,12 +3281,8 @@ def preset_set_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]" - ) + 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") @@ -3889,9 +3297,7 @@ def preset_enable( # 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("[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) @@ -3905,9 +3311,7 @@ def preset_enable( # 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)" - ) + console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)") raise typer.Exit(1) if metadata.get("enabled", True): @@ -3919,9 +3323,7 @@ def preset_enable( 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]" - ) + console.print("[dim]Note: Previously registered commands/skills remain active.[/dim]") @preset_app.command("disable") @@ -3936,9 +3338,7 @@ def preset_disable( # 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("[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) @@ -3952,9 +3352,7 @@ def preset_disable( # 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)" - ) + 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): @@ -3966,9 +3364,7 @@ def preset_disable( 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("[dim]Note: Previously registered commands/skills remain active until preset removal.[/dim]") console.print(f"To re-enable: specify preset enable {preset_id}") @@ -3984,9 +3380,7 @@ def preset_catalog_list(): 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("[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) @@ -4015,25 +3409,17 @@ def preset_catalog_list(): 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]" - ) + 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 - ) + 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 - ) + 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: @@ -4049,17 +3435,12 @@ def preset_catalog_list(): 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)" - ), + priority: int = typer.Option(10, "--priority", help="Priority (lower = higher priority)"), install_allowed: bool = typer.Option( - False, - "--install-allowed/--no-install-allowed", + 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" - ), + description: str = typer.Option("", "--description", help="Description of the catalog"), ): """Add a catalog to .specify/preset-catalogs.yml.""" from .presets import PresetCatalog, PresetValidationError @@ -4068,9 +3449,7 @@ def preset_catalog_add( 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("[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) @@ -4096,44 +3475,29 @@ def preset_catalog_add( catalogs = config.get("catalogs", []) if not isinstance(catalogs, list): - console.print( - "[red]Error:[/red] Invalid catalog config: 'catalogs' must be a 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." - ) + 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, - } - ) + 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", - ) + 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"\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)}") @@ -4148,17 +3512,13 @@ def preset_catalog_remove( 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("[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." - ) + console.print("[red]Error:[/red] No preset catalog config found. Nothing to remove.") raise typer.Exit(1) try: @@ -4169,9 +3529,7 @@ def preset_catalog_remove( catalogs = config.get("catalogs", []) if not isinstance(catalogs, list): - console.print( - "[red]Error:[/red] Invalid catalog config: 'catalogs' must be a 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] @@ -4181,18 +3539,11 @@ def preset_catalog_remove( 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", - ) + 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]" - ) + console.print("\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]") # ===== Extension Commands ===== @@ -4226,9 +3577,7 @@ def _resolve_installed_extension( 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() - ] + name_matches = [ext for ext in installed_extensions if ext["name"].lower() == argument.lower()] if len(name_matches) == 1: # Unique display-name match @@ -4244,9 +3593,7 @@ def _resolve_installed_extension( 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", "")) - ) + 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]") @@ -4288,9 +3635,7 @@ def _resolve_catalog_extension( # 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() - ] + name_matches = [ext for ext in search_results if ext["name"].lower() == argument.lower()] if len(name_matches) == 1: return (name_matches[0], None) @@ -4314,9 +3659,7 @@ def _resolve_catalog_extension( ) console.print(table) console.print("\nPlease rerun using the extension ID:") - console.print( - f" [bold]specify extension {command_name} [/bold]" - ) + console.print(f" [bold]specify extension {command_name} [/bold]") raise typer.Exit(1) # Not found @@ -4328,12 +3671,8 @@ def _resolve_catalog_extension( @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" - ), + 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 @@ -4343,9 +3682,7 @@ def extension_list( # 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("[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) @@ -4365,14 +3702,10 @@ def extension_list( 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" [{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(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: @@ -4389,9 +3722,7 @@ def catalog_list(): 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("[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) @@ -4420,25 +3751,17 @@ def catalog_list(): 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]" - ) + 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 - ) + 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 - ) + 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: @@ -4454,17 +3777,12 @@ def catalog_list(): 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)" - ), + priority: int = typer.Option(10, "--priority", help="Priority (lower = higher priority)"), install_allowed: bool = typer.Option( - False, - "--install-allowed/--no-install-allowed", + 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" - ), + description: str = typer.Option("", "--description", help="Description of the catalog"), ): """Add a catalog to .specify/extension-catalogs.yml.""" from .extensions import ExtensionCatalog, ValidationError @@ -4473,9 +3791,7 @@ def catalog_add( 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("[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) @@ -4501,44 +3817,29 @@ def catalog_add( catalogs = config.get("catalogs", []) if not isinstance(catalogs, list): - console.print( - "[red]Error:[/red] Invalid catalog config: 'catalogs' must be a 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." - ) + 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, - } - ) + 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", - ) + 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"\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)}") @@ -4553,9 +3854,7 @@ def catalog_remove( 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("[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) @@ -4572,9 +3871,7 @@ def catalog_remove( catalogs = config.get("catalogs", []) if not isinstance(catalogs, list): - console.print( - "[red]Error:[/red] Invalid catalog config: 'catalogs' must be a 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] @@ -4584,59 +3881,35 @@ def catalog_remove( 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", - ) + 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]" - ) + 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)", - ), + 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, - ) + 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("[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)" - ) + console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)") raise typer.Exit(1) manager = ExtensionManager(project_root) @@ -4648,20 +3921,14 @@ def extension_add( # 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}" - ) + 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}" - ) + 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 - ) + manifest = manager.install_from_directory(source_path, speckit_version, priority=priority) elif from_url: # Install from URL (ZIP file) @@ -4673,9 +3940,7 @@ def extension_add( 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 - ): + 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) @@ -4686,9 +3951,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" / "extensions" / ".cache" / "downloads" download_dir.mkdir(parents=True, exist_ok=True) zip_path = download_dir / f"{extension}-url-download.zip" @@ -4698,13 +3961,9 @@ def extension_add( zip_path.write_bytes(zip_data) # Install from downloaded ZIP - manifest = manager.install_from_zip( - zip_path, speckit_version, priority=priority - ) + 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}" - ) + console.print(f"[red]Error:[/red] Failed to download from {from_url}: {e}") raise typer.Exit(1) finally: # Clean up downloaded ZIP @@ -4715,38 +3974,28 @@ def extension_add( # 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 - ) + 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" - ) + 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}" - ) + 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(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"] + 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 - ) + 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 @@ -4776,17 +4025,13 @@ def extension_add( 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')}..." - ) + 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 - ) + manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority) finally: # Clean up downloaded ZIP if zip_path.exists(): @@ -4810,9 +4055,7 @@ def extension_add( 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(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}/") @@ -4831,9 +4074,7 @@ def extension_add( @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" - ), + 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.""" @@ -4844,9 +4085,7 @@ def extension_remove( # 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("[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) @@ -4854,9 +4093,7 @@ def extension_remove( # Resolve extension ID from argument (handles ambiguous names) installed = manager.list_installed() - extension_id, display_name = _resolve_installed_extension( - extension, installed, "remove" - ) + 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) @@ -4867,9 +4104,7 @@ def extension_remove( # 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 - ) + 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)), @@ -4883,9 +4118,7 @@ def extension_remove( # 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" - ) + 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}/") @@ -4902,17 +4135,11 @@ def extension_remove( success = manager.remove(extension_id, keep_config=keep_config) if success: - console.print( - f"\n[green]✓[/green] Extension '{display_name}' removed successfully" - ) + 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}/" - ) + 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"\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") @@ -4924,9 +4151,7 @@ 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" - ), + verified: bool = typer.Option(False, "--verified", help="Show only verified extensions"), ): """Search for available extensions in catalog.""" from .extensions import ExtensionCatalog, ExtensionError @@ -4936,9 +4161,7 @@ def extension_search( # 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("[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) @@ -4946,9 +4169,7 @@ def extension_search( try: console.print("🔍 Searching extension catalog...") - results = catalog.search( - query=query, tag=tag, author=author, verified_only=verified - ) + 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]") @@ -4964,15 +4185,13 @@ def extension_search( 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"[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"]) + if ext.get('tags'): + tags_str = ", ".join(ext['tags']) console.print(f" [dim]Tags:[/dim] {tags_str}") # Source catalog @@ -4982,32 +4201,26 @@ def extension_search( 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]" - ) + console.print(f" [dim]Catalog:[/dim] {catalog_name} [yellow](discovery only — not installable)[/yellow]") # Stats stats = [] - if ext.get("downloads") is not None: + if ext.get('downloads') is not None: stats.append(f"Downloads: {ext['downloads']:,}") - if ext.get("stars") is not None: + 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"): + 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']}" - ) + 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"\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 " @@ -5016,9 +4229,7 @@ def extension_search( except ExtensionError as e: console.print(f"\n[red]Error:[/red] {e}") - console.print( - "\nTip: The catalog may be temporarily unavailable. Try again later." - ) + console.print("\nTip: The catalog may be temporarily unavailable. Try again later.") raise typer.Exit(1) @@ -5034,9 +4245,7 @@ def extension_info( # 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("[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) @@ -5096,28 +4305,20 @@ def extension_info( # 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]" - ) + 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("[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 - ) + 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(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") @@ -5131,9 +4332,7 @@ def _print_extension_info(ext_info: dict, manager): # 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"\n[bold]{ext_info['name']}[/bold] (v{ext_info['version']}){verified_badge}") console.print(f"ID: {ext_info['id']}") console.print() @@ -5149,46 +4348,44 @@ def _print_extension_info(ext_info: dict, manager): 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(f"[dim]Source catalog:[/dim] {ext_info['_catalog_name']}{install_note}") console.print() # Requirements - if ext_info.get("requires"): + if ext_info.get('requires'): console.print("[bold]Requirements:[/bold]") - reqs = ext_info["requires"] - if reqs.get("speckit_version"): + 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)" + 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"): + if ext_info.get('provides'): console.print("[bold]Provides:[/bold]") - provides = ext_info["provides"] - if provides.get("commands"): + provides = ext_info['provides'] + if provides.get('commands'): console.print(f" • Commands: {provides['commands']}") - if provides.get("hooks"): + if provides.get('hooks'): console.print(f" • Hooks: {provides['hooks']}") console.print() # Tags - if ext_info.get("tags"): - tags_str = ", ".join(ext_info["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: + if ext_info.get('downloads') is not None: stats.append(f"Downloads: {ext_info['downloads']:,}") - if ext_info.get("stars") is not None: + 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)}") @@ -5196,25 +4393,23 @@ def _print_extension_info(ext_info: dict, manager): # Links console.print("[bold]Links:[/bold]") - if ext_info.get("repository"): + if ext_info.get('repository'): console.print(f" • Repository: {ext_info['repository']}") - if ext_info.get("homepage"): + if ext_info.get('homepage'): console.print(f" • Homepage: {ext_info['homepage']}") - if ext_info.get("documentation"): + if ext_info.get('documentation'): console.print(f" • Documentation: {ext_info['documentation']}") - if ext_info.get("changelog"): + 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"]) + 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 - ) + 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: @@ -5232,9 +4427,7 @@ def _print_extension_info(ext_info: dict, manager): @extension_app.command("update") def extension_update( - extension: str = typer.Argument( - None, help="Extension ID or name to update (or all)" - ), + extension: str = typer.Argument(None, help="Extension ID or name to update (or all)"), ): """Update extension(s) to latest version.""" from .extensions import ( @@ -5254,9 +4447,7 @@ def extension_update( # 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("[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) @@ -5269,9 +4460,7 @@ def extension_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" - ) + extension_id, _ = _resolve_installed_extension(extension, installed, "update") extensions_to_update = [extension_id] else: # Update all extensions @@ -5288,14 +4477,8 @@ def extension_update( 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)" - ) + 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"]) @@ -5313,9 +4496,7 @@ def extension_update( # 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)" - ) + console.print(f"⚠ {ext_id}: Updates not allowed from '{ext_info.get('_catalog_name', 'catalog')}' (skipping)") continue try: @@ -5330,9 +4511,7 @@ def extension_update( updates_available.append( { "id": ext_id, - "name": ext_info.get( - "name", ext_id - ), # Display name for status messages + "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"), @@ -5378,9 +4557,7 @@ def extension_update( # Store backup state backup_registry_entry = None - backup_hooks = ( - None # None means no hooks key in config; {} means hooks key existed - ) + backup_hooks = None # None means no hooks key in config; {} means hooks key existed backed_up_command_files = {} try: @@ -5406,10 +4583,7 @@ def extension_update( # 3. Backup command files for all agents from .agents import CommandRegistrar as _AgentReg - - registered_commands = backup_registry_entry.get( - "registered_commands", {} - ) + 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 @@ -5417,43 +4591,22 @@ def extension_update( 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']}" - ) + 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 = 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 - ) + 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" - ) + 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 - ) + 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 - ) + 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) @@ -5462,9 +4615,7 @@ def extension_update( 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 - ] + ext_hooks = [h for h in hook_list if h.get("extension") == extension_id] if ext_hooks: backup_hooks[hook_name] = ext_hooks @@ -5475,7 +4626,6 @@ def extension_update( # 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() @@ -5486,19 +4636,13 @@ def extension_update( 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 - ] + 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'" - ) + raise ValueError("Downloaded extension archive is missing 'extension.yml'") zip_extension_id = manifest_data.get("extension", {}).get("id") if zip_extension_id != extension_id: @@ -5517,20 +4661,14 @@ def extension_update( 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 - ) + 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 - ): + 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 - ): + 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" ) @@ -5538,15 +4676,11 @@ def extension_update( # Preserve the original installation timestamp if "installed_at" in backup_registry_entry: - new_metadata["installed_at"] = backup_registry_entry[ - "installed_at" - ] + 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"] - ) + 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): @@ -5601,14 +4735,10 @@ def extension_update( # (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 - ): + 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", {} - ) + 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 @@ -5616,33 +4746,16 @@ def extension_update( 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']}" - ) + 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 - ): + 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 = 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 @@ -5671,8 +4784,7 @@ def extension_update( for hook_name, hooks_list in config["hooks"].items(): original_len = len(hooks_list) config["hooks"][hook_name] = [ - h - for h in hooks_list + h for h in hooks_list if h.get("extension") != extension_id ] if len(config["hooks"][hook_name]) != original_len: @@ -5704,13 +4816,9 @@ def extension_update( # Summary console.print() if updated_extensions: - console.print( - f"[green]✓[/green] Successfully updated {len(updated_extensions)} extension(s)" - ) + 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):" - ) + 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) @@ -5735,9 +4843,7 @@ def extension_enable( # 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("[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) @@ -5746,16 +4852,12 @@ def extension_enable( # Resolve extension ID from argument (handles ambiguous names) installed = manager.list_installed() - extension_id, display_name = _resolve_installed_extension( - extension, installed, "enable" - ) + 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)" - ) + console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)") raise typer.Exit(1) if metadata.get("enabled", True): @@ -5788,9 +4890,7 @@ def extension_disable( # 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("[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) @@ -5799,22 +4899,16 @@ def extension_disable( # Resolve extension ID from argument (handles ambiguous names) installed = manager.list_installed() - extension_id, display_name = _resolve_installed_extension( - extension, installed, "disable" - ) + 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)" - ) + 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]" - ) + console.print(f"[yellow]Extension '{display_name}' is already disabled[/yellow]") raise typer.Exit(0) manager.registry.update(extension_id, {"enabled": False}) @@ -5846,44 +4940,33 @@ def extension_set_priority( # 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("[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)" - ) + 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" - ) + 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)" - ) + 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]" - ) + console.print(f"[yellow]Extension '{display_name}' already has priority {priority}[/yellow]") raise typer.Exit(0) old_priority = normalize_priority(raw_priority) @@ -5891,12 +4974,8 @@ def extension_set_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]" - ) + 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 ===== @@ -5928,14 +5007,10 @@ def workflow_run( project_root = Path.cwd() if not (project_root / ".specify").exists(): - console.print( - "[red]Error:[/red] Not a spec-kit project (no .specify/ directory)" - ) + 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" \u25b8 [{sid}] {label} \u2026" - ) + engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026") try: definition = engine.load_workflow(source) @@ -5959,16 +5034,12 @@ def workflow_run( 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)" - ) + 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"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})") console.print(f"[dim]Version: {definition.version}[/dim]\n") try: @@ -5991,9 +5062,7 @@ def workflow_run( 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]" - ) + console.print(f"\nResume with: [cyan]specify workflow resume {state.run_id}[/cyan]") @workflow_app.command("resume") @@ -6005,14 +5074,10 @@ def workflow_resume( project_root = Path.cwd() if not (project_root / ".specify").exists(): - console.print( - "[red]Error:[/red] Not a spec-kit project (no .specify/ directory)" - ) + 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" \u25b8 [{sid}] {label} \u2026" - ) + engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026") try: state = engine.resume(run_id) @@ -6038,25 +5103,20 @@ def workflow_resume( @workflow_app.command("status") def workflow_status( - run_id: str | None = typer.Argument( - None, help="Run ID to inspect (shows all if omitted)" - ), + 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 = Path.cwd() if not (project_root / ".specify").exists(): - console.print( - "[red]Error:[/red] Not a spec-kit project (no .specify/ directory)" - ) + 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}") @@ -6085,9 +5145,7 @@ def workflow_status( 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" - ) + sc = {"completed": "green", "failed": "red", "paused": "yellow"}.get(s, "white") console.print(f" [{sc}]●[/{sc}] {step_id}: {s}") else: runs = engine.list_runs() @@ -6098,12 +5156,7 @@ def workflow_status( 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") + 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', '?')} " @@ -6120,9 +5173,7 @@ def workflow_list(): 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("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") raise typer.Exit(1) registry = WorkflowRegistry(project_root) @@ -6136,9 +5187,7 @@ def workflow_list(): 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', '?')}" - ) + 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}") @@ -6150,19 +5199,13 @@ 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.catalog import WorkflowCatalog, WorkflowRegistry, WorkflowCatalogError from .workflows.engine import WorkflowDefinition 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("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") raise typer.Exit(1) registry = WorkflowRegistry(project_root) @@ -6176,13 +5219,10 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None: 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'" - ) + 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:") @@ -6193,20 +5233,14 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None: 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" - ) + 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://"): @@ -6223,16 +5257,11 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None: 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." - ) + 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() @@ -6245,12 +5274,8 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None: 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}" - ) + 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()) @@ -6293,17 +5318,13 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None: 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(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" - ) + 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) @@ -6321,9 +5342,7 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None: 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 - ): + 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." @@ -6355,12 +5374,9 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None: 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 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}" @@ -6370,11 +5386,8 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None: 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}" - ) + console.print(f"[red]Error:[/red] Failed to install workflow '{source}' from catalog: {exc}") raise typer.Exit(1) # Validate the downloaded workflow before registering @@ -6382,17 +5395,14 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None: 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: @@ -6402,7 +5412,6 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None: # 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}) " @@ -6411,20 +5420,15 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None: ) 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" - ) + 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") @@ -6437,9 +5441,7 @@ def workflow_remove( 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("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") raise typer.Exit(1) registry = WorkflowRegistry(project_root) @@ -6452,7 +5454,6 @@ def workflow_remove( workflow_dir = project_root / ".specify" / "workflows" / workflow_id if workflow_dir.exists(): import shutil - shutil.rmtree(workflow_dir) registry.remove(workflow_id) @@ -6469,9 +5470,7 @@ def workflow_search( project_root = Path.cwd() if not (project_root / ".specify").exists(): - console.print( - "[red]Error:[/red] Not a spec-kit project (no .specify/ directory)" - ) + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") raise typer.Exit(1) catalog = WorkflowCatalog(project_root) @@ -6487,9 +5486,7 @@ def workflow_search( 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', '?')}" - ) + 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}") @@ -6504,18 +5501,12 @@ 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.catalog import WorkflowCatalog, WorkflowRegistry, WorkflowCatalogError from .workflows.engine import WorkflowEngine project_root = Path.cwd() if not (project_root / ".specify").exists(): - console.print( - "[red]Error:[/red] Not a spec-kit project (no .specify/ directory)" - ) + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") raise typer.Exit(1) # Check installed first @@ -6566,9 +5557,7 @@ def workflow_info( info = None if info: - console.print( - f"\n[bold cyan]{info.get('name', workflow_id)}[/bold cyan] ({workflow_id})" - ) + 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']}") @@ -6596,11 +5585,7 @@ def workflow_catalog_list(): 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]" - ) + 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"): @@ -6619,9 +5604,7 @@ def workflow_catalog_add( 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("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") raise typer.Exit(1) catalog = WorkflowCatalog(project_root) @@ -6636,9 +5619,7 @@ def workflow_catalog_add( @workflow_catalog_app.command("remove") def workflow_catalog_remove( - index: int = typer.Argument( - ..., help="Catalog index to remove (from 'catalog list')" - ), + 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 @@ -6646,9 +5627,7 @@ def workflow_catalog_remove( 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("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") raise typer.Exit(1) catalog = WorkflowCatalog(project_root) @@ -6664,6 +5643,5 @@ def workflow_catalog_remove( def main(): app() - if __name__ == "__main__": main() diff --git a/src/specify_cli/constants.py b/src/specify_cli/constants.py deleted file mode 100644 index 7e1d4ebd73..0000000000 --- a/src/specify_cli/constants.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Top-level constants shared by the CLI and the workflow engine. - -This module is dependency-free (no typer, no rich, no workflow imports), so it -can be imported from anywhere inside the package without triggering side effects. -""" - -INTEGRATION_JSON = ".specify/integration.json" diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py index eb3b9633f9..5463297fcd 100644 --- a/src/specify_cli/workflows/engine.py +++ b/src/specify_cli/workflows/engine.py @@ -20,7 +20,6 @@ import yaml from .base import RunStatus, StepContext, StepResult, StepStatus -from specify_cli.constants import INTEGRATION_JSON # -- Workflow Definition -------------------------------------------------- @@ -83,25 +82,17 @@ def from_string(cls, content: str) -> WorkflowDefinition: # ID format: lowercase alphanumeric with hyphens _ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$") +_INTEGRATION_JSON = ".specify/integration.json" # Valid step types (matching STEP_REGISTRY keys) def _get_valid_step_types() -> set[str]: """Return valid step types from the registry, with a built-in fallback.""" from . import STEP_REGISTRY - if STEP_REGISTRY: return set(STEP_REGISTRY.keys()) return { - "command", - "shell", - "prompt", - "gate", - "if", - "switch", - "while", - "do-while", - "fan-out", - "fan-in", + "command", "shell", "prompt", "gate", "if", + "switch", "while", "do-while", "fan-out", "fan-in", } @@ -115,7 +106,8 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]: # -- Schema version --------------------------------------------------- if definition.schema_version not in ("1.0", "1"): errors.append( - f"Unsupported schema_version {definition.schema_version!r}. Expected '1.0'." + f"Unsupported schema_version {definition.schema_version!r}. " + f"Expected '1.0'." ) # -- Top-level fields ------------------------------------------------- @@ -197,7 +189,9 @@ def _validate_steps( # Determine step type step_type = step_config.get("type", "command") if step_type not in _get_valid_step_types(): - errors.append(f"Step {step_id!r} has invalid type {step_type!r}.") + errors.append( + f"Step {step_id!r} has invalid type {step_type!r}." + ) continue # Delegate to step-specific validation @@ -246,7 +240,7 @@ def __init__( project_root: Path | None = None, ) -> None: self.run_id = run_id or str(uuid.uuid4())[:8] - if not re.match(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$", self.run_id): + if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_-]*$', self.run_id): msg = f"Invalid run_id {self.run_id!r}: must be alphanumeric with hyphens/underscores only." raise ValueError(msg) self.workflow_id = workflow_id @@ -369,7 +363,11 @@ def load_workflow(self, source: str | Path) -> WorkflowDefinition: # Try as an installed workflow ID installed_path = ( - self.project_root / ".specify" / "workflows" / str(source) / "workflow.yml" + self.project_root + / ".specify" + / "workflows" + / str(source) + / "workflow.yml" ) if installed_path.exists(): return WorkflowDefinition.from_yaml(installed_path) @@ -417,7 +415,6 @@ def execute( run_dir.mkdir(parents=True, exist_ok=True) workflow_copy = run_dir / "workflow.yml" import yaml - with open(workflow_copy, "w", encoding="utf-8") as f: yaml.safe_dump(definition.data, f, sort_keys=False) @@ -496,10 +493,7 @@ def resume(self, run_id: str) -> RunState: try: self._execute_steps( - remaining_steps, - context, - state, - STEP_REGISTRY, + remaining_steps, context, state, STEP_REGISTRY, step_offset=step_offset, ) except KeyboardInterrupt: @@ -573,7 +567,8 @@ def _execute_steps( or context.default_model, "options": result.output.get("options") or step_config.get("options", {}), - "input": result.output.get("input") or step_config.get("input", {}), + "input": result.output.get("input") + or step_config.get("input", {}), "output": result.output, "status": result.status.value, } @@ -625,10 +620,7 @@ def _execute_steps( # enhancement. if result.next_steps: self._execute_steps( - result.next_steps, - context, - state, - registry, + result.next_steps, context, state, registry, step_offset=-1, ) if state.status in ( @@ -654,15 +646,10 @@ def _execute_steps( for ns in result.next_steps: ns_copy = dict(ns) if "id" in ns_copy: - ns_copy["id"] = ( - f"{step_id}:{ns_copy['id']}:{_loop_iter + 1}" - ) + ns_copy["id"] = f"{step_id}:{ns_copy['id']}:{_loop_iter + 1}" iter_steps.append(ns_copy) self._execute_steps( - iter_steps, - context, - state, - registry, + iter_steps, context, state, registry, step_offset=-1, ) if state.status in ( @@ -685,10 +672,7 @@ def _execute_steps( base_id = item_step.get("id", "item") item_step["id"] = f"{step_id}:{base_id}:{item_idx}" self._execute_steps( - [item_step], - context, - state, - registry, + [item_step], context, state, registry, step_offset=-1, ) # Collect per-item result for fan-in @@ -729,45 +713,54 @@ def _resolve_inputs( if not isinstance(input_def, dict): continue if name in provided: - resolved[name] = self._coerce_input(name, provided[name], input_def) + resolved[name] = self._coerce_input( + name, provided[name], input_def + ) elif "default" in input_def: - resolved[name] = input_def["default"] + resolved[name] = self._resolve_default(name, input_def["default"]) elif input_def.get("required", False): msg = f"Required input {name!r} not provided." raise ValueError(msg) - - # Auto-detect integration from project config when set to "auto" - if resolved.get("integration") == "auto": - resolved["integration"] = self._resolve_integration_auto() - return resolved - _AUTO_FALLBACK = "copilot" - - def _resolve_integration_auto(self) -> str: - """Read the project integration from ``.specify/integration.json``. + def _resolve_default(self, name: str, default: Any) -> Any: + """Resolve special default sentinels against project state. - Returns the stored integration key, or ``"copilot"`` when the - file is missing, unreadable, or does not contain a valid key. - This method is intentionally decoupled from the CLI layer - (no ``typer.Exit`` / ``console.print``) so the engine remains - independently testable. + For the ``integration`` input, ``"auto"`` resolves to the integration + recorded in ``.specify/integration.json`` so workflows dispatch to the + AI the project was actually initialized with. """ - path = self.project_root / INTEGRATION_JSON + if name == "integration" and default == "auto": + resolved = self._load_project_integration() + if resolved is not None: + return resolved + return default + + def _load_project_integration(self) -> str | None: + """Read the active integration key from ``.specify/integration.json``. + + Returns the stored integration string, or ``"copilot"`` when the file is + missing, unreadable, or does not contain a valid non-empty key. + The ``"copilot"`` fallback preserves backwards compatibility for projects + that predate the introduction of ``.specify/integration.json``. + """ + path = self.project_root / _INTEGRATION_JSON + if not path.is_file(): + return "copilot" try: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, UnicodeDecodeError, json.JSONDecodeError): - return self._AUTO_FALLBACK + return "copilot" if isinstance(data, dict): - value = data.get("integration") - if isinstance(value, str): - normalized_value = value.strip() - if normalized_value: - return normalized_value - return self._AUTO_FALLBACK + value = data.get("integration", "").strip() + if value: + return value + return "copilot" @staticmethod - def _coerce_input(name: str, value: Any, input_def: dict[str, Any]) -> Any: + def _coerce_input( + name: str, value: Any, input_def: dict[str, Any] + ) -> Any: """Coerce a provided input value to the declared type.""" input_type = input_def.get("type", "string") enum_values = input_def.get("enum") @@ -792,7 +785,8 @@ def _coerce_input(name: str, value: Any, input_def: dict[str, Any]) -> Any: if enum_values is not None and value not in enum_values: msg = ( - f"Input {name!r} value {value!r} not in allowed values: {enum_values}." + f"Input {name!r} value {value!r} not in allowed " + f"values: {enum_values}." ) raise ValueError(msg) diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 04b866bf7e..53ada20139 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -25,7 +25,6 @@ # Fixtures # --------------------------------------------------------------------------- - @pytest.fixture def temp_dir(): """Create a temporary directory for tests.""" @@ -87,7 +86,6 @@ def sample_workflow_file(project_dir, sample_workflow_yaml): # ===== Step Registry Tests ===== - class TestStepRegistry: """Test STEP_REGISTRY and auto-discovery.""" @@ -100,16 +98,8 @@ def test_all_step_types_registered(self): from specify_cli.workflows import STEP_REGISTRY expected = { - "command", - "shell", - "prompt", - "gate", - "if", - "switch", - "while", - "do-while", - "fan-out", - "fan-in", + "command", "shell", "prompt", "gate", "if", "switch", + "while", "do-while", "fan-out", "fan-in", } assert expected.issubset(set(STEP_REGISTRY.keys())) @@ -138,7 +128,6 @@ def test_register_step_empty_key_raises(self): class EmptyStep(StepBase): type_key = "" - def execute(self, config, context): return StepResult() @@ -148,7 +137,6 @@ def execute(self, config, context): # ===== Base Classes Tests ===== - class TestBaseClasses: """Test StepBase, StepContext, StepResult.""" @@ -206,7 +194,6 @@ def test_run_status_values(self): # ===== Expression Engine Tests ===== - class TestExpressions: """Test sandboxed expression evaluator.""" @@ -221,7 +208,9 @@ def test_step_output_reference(self): from specify_cli.workflows.expressions import evaluate_expression from specify_cli.workflows.base import StepContext - ctx = StepContext(steps={"specify": {"output": {"file": "spec.md"}}}) + ctx = StepContext( + steps={"specify": {"output": {"file": "spec.md"}}} + ) assert evaluate_expression("{{ steps.specify.output.file }}", ctx) == "spec.md" def test_string_interpolation(self): @@ -244,7 +233,9 @@ def test_comparison_not_equals(self): from specify_cli.workflows.expressions import evaluate_expression from specify_cli.workflows.base import StepContext - ctx = StepContext(steps={"run-tests": {"output": {"exit_code": 1}}}) + ctx = StepContext( + steps={"run-tests": {"output": {"exit_code": 1}}} + ) result = evaluate_expression("{{ steps.run-tests.output.exit_code != 0 }}", ctx) assert result is True @@ -252,13 +243,11 @@ def test_numeric_comparison(self): from specify_cli.workflows.expressions import evaluate_expression from specify_cli.workflows.base import StepContext - ctx = StepContext(steps={"plan": {"output": {"task_count": 7}}}) - assert ( - evaluate_expression("{{ steps.plan.output.task_count > 5 }}", ctx) is True - ) - assert ( - evaluate_expression("{{ steps.plan.output.task_count < 5 }}", ctx) is False + ctx = StepContext( + steps={"plan": {"output": {"task_count": 7}}} ) + assert evaluate_expression("{{ steps.plan.output.task_count > 5 }}", ctx) is True + assert evaluate_expression("{{ steps.plan.output.task_count < 5 }}", ctx) is False def test_boolean_and(self): from specify_cli.workflows.expressions import evaluate_expression @@ -279,10 +268,7 @@ def test_filter_default(self): from specify_cli.workflows.base import StepContext ctx = StepContext() - assert ( - evaluate_expression("{{ inputs.missing | default('fallback') }}", ctx) - == "fallback" - ) + assert evaluate_expression("{{ inputs.missing | default('fallback') }}", ctx) == "fallback" def test_filter_join(self): from specify_cli.workflows.expressions import evaluate_expression @@ -341,9 +327,7 @@ def test_list_indexing(self): from specify_cli.workflows.base import StepContext ctx = StepContext( - steps={ - "tasks": {"output": {"task_list": [{"file": "a.md"}, {"file": "b.md"}]}} - } + steps={"tasks": {"output": {"task_list": [{"file": "a.md"}, {"file": "b.md"}]}}} ) result = evaluate_expression("{{ steps.tasks.output.task_list[0].file }}", ctx) assert result == "a.md" @@ -351,13 +335,11 @@ def test_list_indexing(self): # ===== Integration Dispatch Tests ===== - class TestBuildExecArgs: """Test build_exec_args for CLI-based integrations.""" def test_claude_exec_args(self): from specify_cli.integrations.claude import ClaudeIntegration - impl = ClaudeIntegration() args = impl.build_exec_args("do stuff", model="sonnet-4") assert args[0] == "claude" @@ -369,7 +351,6 @@ def test_claude_exec_args(self): def test_gemini_exec_args(self): from specify_cli.integrations.gemini import GeminiIntegration - impl = GeminiIntegration() args = impl.build_exec_args("do stuff", model="gemini-2.5-pro") assert args[0] == "gemini" @@ -379,7 +360,6 @@ def test_gemini_exec_args(self): def test_codex_exec_args(self): from specify_cli.integrations.codex import CodexIntegration - impl = CodexIntegration() args = impl.build_exec_args("do stuff") assert args[0] == "codex" @@ -391,7 +371,6 @@ def test_copilot_exec_args(self, monkeypatch): monkeypatch.delenv("SPECKIT_COPILOT_ALLOW_ALL_TOOLS", raising=False) monkeypatch.delenv("SPECKIT_ALLOW_ALL_TOOLS", raising=False) from specify_cli.integrations.copilot import CopilotIntegration - impl = CopilotIntegration() args = impl.build_exec_args("do stuff", model="claude-sonnet-4-20250514") assert args[0] == "copilot" @@ -403,7 +382,6 @@ def test_copilot_new_env_var_disables_yolo(self, monkeypatch): monkeypatch.setenv("SPECKIT_COPILOT_ALLOW_ALL_TOOLS", "0") monkeypatch.delenv("SPECKIT_ALLOW_ALL_TOOLS", raising=False) from specify_cli.integrations.copilot import CopilotIntegration - impl = CopilotIntegration() args = impl.build_exec_args("do stuff") assert "--yolo" not in args @@ -413,7 +391,6 @@ def test_copilot_deprecated_env_var_still_honoured(self, monkeypatch): monkeypatch.setenv("SPECKIT_ALLOW_ALL_TOOLS", "0") import warnings from specify_cli.integrations.copilot import CopilotIntegration - impl = CopilotIntegration() with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") @@ -429,27 +406,23 @@ def test_copilot_new_env_var_takes_precedence(self, monkeypatch): monkeypatch.setenv("SPECKIT_COPILOT_ALLOW_ALL_TOOLS", "1") monkeypatch.setenv("SPECKIT_ALLOW_ALL_TOOLS", "0") from specify_cli.integrations.copilot import CopilotIntegration - impl = CopilotIntegration() args = impl.build_exec_args("do stuff") assert "--yolo" in args def test_ide_only_returns_none(self): from specify_cli.integrations.windsurf import WindsurfIntegration - impl = WindsurfIntegration() assert impl.build_exec_args("test") is None def test_no_model_omits_flag(self): from specify_cli.integrations.claude import ClaudeIntegration - impl = ClaudeIntegration() args = impl.build_exec_args("do stuff", model=None) assert "--model" not in args def test_no_json_omits_flag(self): from specify_cli.integrations.claude import ClaudeIntegration - impl = ClaudeIntegration() args = impl.build_exec_args("do stuff", output_json=False) assert "--output-format" not in args @@ -457,7 +430,6 @@ def test_no_json_omits_flag(self): # ===== Step Type Tests ===== - class TestCommandStep: """Test the command step type.""" @@ -476,9 +448,7 @@ def test_execute_basic(self): "command": "speckit.specify", "input": {"args": "{{ inputs.name }}"}, } - with patch( - "specify_cli.workflows.steps.command.shutil.which", return_value=None - ): + with patch("specify_cli.workflows.steps.command.shutil.which", return_value=None): result = step.execute(config, ctx) assert result.status == StepStatus.FAILED assert result.output["command"] == "speckit.specify" @@ -555,9 +525,7 @@ def test_dispatch_not_attempted_without_cli(self): "command": "speckit.specify", "input": {"args": "{{ inputs.name }}"}, } - with patch( - "specify_cli.workflows.steps.command.shutil.which", return_value=None - ): + with patch("specify_cli.workflows.steps.command.shutil.which", return_value=None): result = step.execute(config, ctx) assert result.status == StepStatus.FAILED assert result.output["dispatched"] is False @@ -586,13 +554,8 @@ def test_dispatch_with_mock_cli(self, tmp_path, monkeypatch): mock_result.stdout = '{"result": "done"}' mock_result.stderr = "" - with ( - patch( - "specify_cli.workflows.steps.command.shutil.which", - return_value="/usr/local/bin/claude", - ), - patch("subprocess.run", return_value=mock_result) as mock_run, - ): + with patch("specify_cli.workflows.steps.command.shutil.which", return_value="/usr/local/bin/claude"), \ + patch("subprocess.run", return_value=mock_result) as mock_run: result = step.execute(config, ctx) assert result.status == StepStatus.COMPLETED @@ -628,13 +591,8 @@ def test_dispatch_failure_returns_failed_status(self, tmp_path): mock_result.stdout = "" mock_result.stderr = "API error" - with ( - patch( - "specify_cli.workflows.steps.command.shutil.which", - return_value="/usr/local/bin/claude", - ), - patch("subprocess.run", return_value=mock_result), - ): + with patch("specify_cli.workflows.steps.command.shutil.which", return_value="/usr/local/bin/claude"), \ + patch("subprocess.run", return_value=mock_result): result = step.execute(config, ctx) assert result.status == StepStatus.FAILED @@ -660,9 +618,7 @@ def test_execute_basic(self): "type": "prompt", "prompt": "Review {{ inputs.file }} for security issues", } - with patch( - "specify_cli.workflows.steps.prompt.shutil.which", return_value=None - ): + with patch("specify_cli.workflows.steps.prompt.shutil.which", return_value=None): result = step.execute(config, ctx) assert result.status == StepStatus.FAILED assert result.output["prompt"] == "Review auth.py for security issues" @@ -720,13 +676,8 @@ def test_dispatch_with_mock_cli(self, tmp_path): mock_result.stdout = "Here is the explanation" mock_result.stderr = "" - with ( - patch( - "specify_cli.workflows.steps.prompt.shutil.which", - return_value="/usr/local/bin/claude", - ), - patch("subprocess.run", return_value=mock_result), - ): + with patch("specify_cli.workflows.steps.prompt.shutil.which", return_value="/usr/local/bin/claude"), \ + patch("subprocess.run", return_value=mock_result): result = step.execute(config, ctx) assert result.status == StepStatus.COMPLETED @@ -814,13 +765,11 @@ def test_validate_invalid_on_reject(self): from specify_cli.workflows.steps.gate import GateStep step = GateStep() - errors = step.validate( - { - "id": "test", - "message": "Review", - "on_reject": "invalid", - } - ) + errors = step.validate({ + "id": "test", + "message": "Review", + "on_reject": "invalid", + }) assert any("on_reject" in e for e in errors) @@ -876,7 +825,9 @@ def test_execute_matches_case(self): from specify_cli.workflows.base import StepContext step = SwitchStep() - ctx = StepContext(steps={"review": {"output": {"choice": "approve"}}}) + ctx = StepContext( + steps={"review": {"output": {"choice": "approve"}}} + ) config = { "id": "route", "expression": "{{ steps.review.output.choice }}", @@ -895,7 +846,9 @@ def test_execute_falls_to_default(self): from specify_cli.workflows.base import StepContext step = SwitchStep() - ctx = StepContext(steps={"review": {"output": {"choice": "unknown"}}}) + ctx = StepContext( + steps={"review": {"output": {"choice": "unknown"}}} + ) config = { "id": "route", "expression": "{{ steps.review.output.choice }}", @@ -913,7 +866,9 @@ def test_execute_no_default_no_match(self): from specify_cli.workflows.base import StepContext step = SwitchStep() - ctx = StepContext(steps={"review": {"output": {"choice": "other"}}}) + ctx = StepContext( + steps={"review": {"output": {"choice": "other"}}} + ) config = { "id": "route", "expression": "{{ steps.review.output.choice }}", @@ -936,14 +891,12 @@ def test_validate_invalid_cases_and_default(self): from specify_cli.workflows.steps.switch import SwitchStep step = SwitchStep() - errors = step.validate( - { - "id": "test", - "expression": "{{ x }}", - "cases": {"a": "not-a-list"}, - "default": "also-bad", - } - ) + errors = step.validate({ + "id": "test", + "expression": "{{ x }}", + "cases": {"a": "not-a-list"}, + "default": "also-bad", + }) assert any("case 'a' must be a list" in e for e in errors) assert any("'default' must be a list" in e for e in errors) @@ -956,7 +909,9 @@ def test_execute_condition_true(self): from specify_cli.workflows.base import StepContext step = WhileStep() - ctx = StepContext(steps={"run-tests": {"output": {"exit_code": 1}}}) + ctx = StepContext( + steps={"run-tests": {"output": {"exit_code": 1}}} + ) config = { "id": "retry", "condition": "{{ steps.run-tests.output.exit_code != 0 }}", @@ -972,7 +927,9 @@ def test_execute_condition_false(self): from specify_cli.workflows.base import StepContext step = WhileStep() - ctx = StepContext(steps={"run-tests": {"output": {"exit_code": 0}}}) + ctx = StepContext( + steps={"run-tests": {"output": {"exit_code": 0}}} + ) config = { "id": "retry", "condition": "{{ steps.run-tests.output.exit_code != 0 }}", @@ -995,9 +952,7 @@ def test_validate_invalid_max_iterations(self): from specify_cli.workflows.steps.while_loop import WhileStep step = WhileStep() - errors = step.validate( - {"id": "test", "condition": "{{ true }}", "max_iterations": 0, "steps": []} - ) + errors = step.validate({"id": "test", "condition": "{{ true }}", "max_iterations": 0, "steps": []}) assert any("must be an integer >= 1" in e for e in errors) @@ -1066,14 +1021,12 @@ def test_validate_steps_not_list(self): from specify_cli.workflows.steps.do_while import DoWhileStep step = DoWhileStep() - errors = step.validate( - { - "id": "test", - "condition": "{{ true }}", - "max_iterations": 3, - "steps": "not-a-list", - } - ) + errors = step.validate({ + "id": "test", + "condition": "{{ true }}", + "max_iterations": 3, + "steps": "not-a-list", + }) assert any("'steps' must be a list" in e for e in errors) @@ -1086,16 +1039,10 @@ def test_execute_with_items(self): step = FanOutStep() ctx = StepContext( - steps={ - "tasks": { - "output": { - "task_list": [ - {"file": "a.md"}, - {"file": "b.md"}, - ] - } - } - } + steps={"tasks": {"output": {"task_list": [ + {"file": "a.md"}, + {"file": "b.md"}, + ]}}} ) config = { "id": "parallel", @@ -1134,13 +1081,11 @@ def test_validate_step_not_mapping(self): from specify_cli.workflows.steps.fan_out import FanOutStep step = FanOutStep() - errors = step.validate( - { - "id": "test", - "items": "{{ x }}", - "step": "not-a-dict", - } - ) + errors = step.validate({ + "id": "test", + "items": "{{ x }}", + "step": "not-a-dict", + }) assert any("'step' must be a mapping" in e for e in errors) @@ -1153,7 +1098,9 @@ def test_execute_collects_results(self): step = FanInStep() ctx = StepContext( - steps={"parallel": {"output": {"item_count": 2, "status": "done"}}} + steps={ + "parallel": {"output": {"item_count": 2, "status": "done"}} + } ) config = { "id": "collect", @@ -1216,7 +1163,6 @@ def test_validate_wait_for_not_list(self): # ===== Workflow Definition Tests ===== - class TestWorkflowDefinition: """Test WorkflowDefinition loading and parsing.""" @@ -1253,7 +1199,6 @@ def test_inputs_parsed(self, sample_workflow_yaml): # ===== Workflow Validation Tests ===== - class TestWorkflowValidation: """Test workflow validation.""" @@ -1381,7 +1326,6 @@ def test_invalid_input_type(self): # ===== Workflow Engine Tests ===== - class TestWorkflowEngine: """Test WorkflowEngine execution.""" @@ -1430,9 +1374,7 @@ def test_execute_simple_workflow(self, project_dir): """ definition = WorkflowDefinition.from_string(yaml_str) engine = WorkflowEngine(project_dir) - with patch( - "specify_cli.workflows.steps.command.shutil.which", return_value=None - ): + with patch("specify_cli.workflows.steps.command.shutil.which", return_value=None): state = engine.execute(definition, {"name": "login"}) assert state.status == RunStatus.FAILED @@ -1556,7 +1498,6 @@ def test_execute_missing_required_input(self, project_dir): # ===== State Persistence Tests ===== - class TestRunState: """Test RunState persistence and loading.""" @@ -1644,7 +1585,6 @@ def test_list_after_execution(self, project_dir): # ===== Workflow Registry Tests ===== - class TestWorkflowRegistry: """Test WorkflowRegistry operations.""" @@ -1702,7 +1642,6 @@ def test_persistence(self, project_dir): # ===== Workflow Catalog Tests ===== - class TestWorkflowCatalog: """Test WorkflowCatalog catalog resolution.""" @@ -1718,9 +1657,7 @@ def test_default_catalogs(self, project_dir): def test_env_var_override(self, project_dir, monkeypatch): from specify_cli.workflows.catalog import WorkflowCatalog - monkeypatch.setenv( - "SPECKIT_WORKFLOW_CATALOG_URL", "https://example.com/catalog.json" - ) + monkeypatch.setenv("SPECKIT_WORKFLOW_CATALOG_URL", "https://example.com/catalog.json") catalog = WorkflowCatalog(project_dir) entries = catalog.get_active_catalogs() assert len(entries) == 1 @@ -1731,20 +1668,14 @@ def test_project_level_config(self, project_dir): from specify_cli.workflows.catalog import WorkflowCatalog config_path = project_dir / ".specify" / "workflow-catalogs.yml" - config_path.write_text( - yaml.dump( - { - "catalogs": [ - { - "name": "custom", - "url": "https://example.com/wf-catalog.json", - "priority": 1, - "install_allowed": True, - } - ] - } - ) - ) + config_path.write_text(yaml.dump({ + "catalogs": [{ + "name": "custom", + "url": "https://example.com/wf-catalog.json", + "priority": 1, + "install_allowed": True, + }] + })) catalog = WorkflowCatalog(project_dir) entries = catalog.get_active_catalogs() @@ -1752,10 +1683,7 @@ def test_project_level_config(self, project_dir): assert entries[0].name == "custom" def test_validate_url_http_rejected(self, project_dir): - from specify_cli.workflows.catalog import ( - WorkflowCatalog, - WorkflowValidationError, - ) + from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError catalog = WorkflowCatalog(project_dir) with pytest.raises(WorkflowValidationError, match="HTTPS"): @@ -1781,10 +1709,7 @@ def test_add_catalog(self, project_dir): assert data["catalogs"][0]["url"] == "https://example.com/new-catalog.json" def test_add_catalog_duplicate_rejected(self, project_dir): - from specify_cli.workflows.catalog import ( - WorkflowCatalog, - WorkflowValidationError, - ) + from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError catalog = WorkflowCatalog(project_dir) catalog.add_catalog("https://example.com/catalog.json") @@ -1807,10 +1732,7 @@ def test_remove_catalog(self, project_dir): assert len(data["catalogs"]) == 1 def test_remove_catalog_invalid_index(self, project_dir): - from specify_cli.workflows.catalog import ( - WorkflowCatalog, - WorkflowValidationError, - ) + from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError catalog = WorkflowCatalog(project_dir) catalog.add_catalog("https://example.com/c1.json") @@ -1830,7 +1752,6 @@ def test_get_catalog_configs(self, project_dir): # ===== Integration Test ===== - class TestWorkflowIntegration: """End-to-end workflow execution tests.""" @@ -1924,6 +1845,10 @@ def test_switch_workflow(self, project_dir): assert "do-specify" not in state.step_results +# --------------------------------------------------------------------------- +# Integration auto-detection tests +# --------------------------------------------------------------------------- + class TestIntegrationAutoDetect: """Test auto-detection of project integration from .specify/integration.json. @@ -1957,12 +1882,11 @@ def _make_workflow_yaml(default_integration: str = "auto") -> str: args: "{{{{ inputs.spec }}}}" """ - def test_resolve_inputs_auto_reads_integration_json(self, project_dir): + def test_integration_auto_default_uses_project_integration(self, project_dir): """'auto' default resolves to the integration in .specify/integration.json.""" from unittest.mock import patch from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition - # Write integration.json with opencode int_json = project_dir / ".specify" / "integration.json" int_json.write_text(json.dumps({"integration": "opencode"}), encoding="utf-8") @@ -1974,16 +1898,14 @@ def test_resolve_inputs_auto_reads_integration_json(self, project_dir): ): state = engine.execute(definition) - # The resolved integration should be "opencode", not "auto" or "copilot" step_output = state.step_results["specify"]["output"] assert step_output["integration"] == "opencode" - def test_resolve_inputs_auto_no_json_falls_back_to_copilot(self, project_dir): + def test_integration_auto_default_falls_back_to_copilot_when_no_json(self, project_dir): """When no integration.json exists, 'auto' falls back to 'copilot'.""" from unittest.mock import patch from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition - # No integration.json exists in the project_dir fixture definition = WorkflowDefinition.from_string(self._make_workflow_yaml()) engine = WorkflowEngine(project_dir) @@ -1995,12 +1917,11 @@ def test_resolve_inputs_auto_no_json_falls_back_to_copilot(self, project_dir): step_output = state.step_results["specify"]["output"] assert step_output["integration"] == "copilot" - def test_resolve_inputs_explicit_override_ignores_auto(self, project_dir): + def test_integration_explicit_input_overrides_auto(self, project_dir): """Explicit --input integration=gemini takes precedence over auto.""" from unittest.mock import patch from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition - # Write integration.json with opencode (should be ignored) int_json = project_dir / ".specify" / "integration.json" int_json.write_text(json.dumps({"integration": "opencode"}), encoding="utf-8") @@ -2015,32 +1936,12 @@ def test_resolve_inputs_explicit_override_ignores_auto(self, project_dir): step_output = state.step_results["specify"]["output"] assert step_output["integration"] == "gemini" - def test_resolve_inputs_auto_with_empty_json(self, project_dir): - """When integration.json has no 'integration' key, fall back to 'copilot'.""" - from unittest.mock import patch - from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition - - int_json = project_dir / ".specify" / "integration.json" - int_json.write_text(json.dumps({"version": "1.0"}), encoding="utf-8") - - definition = WorkflowDefinition.from_string(self._make_workflow_yaml()) - engine = WorkflowEngine(project_dir) - - with patch( - "specify_cli.workflows.steps.command.shutil.which", return_value=None - ): - state = engine.execute(definition) - - step_output = state.step_results["specify"]["output"] - assert step_output["integration"] == "copilot" - - def test_resolve_inputs_auto_invalid_json_falls_back_to_copilot(self, project_dir): + def test_integration_auto_ignores_malformed_integration_json(self, project_dir): """When integration.json contains invalid JSON, 'auto' falls back to 'copilot'.""" from unittest.mock import patch from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition int_json = project_dir / ".specify" / "integration.json" - int_json.parent.mkdir(parents=True, exist_ok=True) int_json.write_text("{invalid json content", encoding="utf-8") definition = WorkflowDefinition.from_string(self._make_workflow_yaml()) @@ -2054,13 +1955,12 @@ def test_resolve_inputs_auto_invalid_json_falls_back_to_copilot(self, project_di step_output = state.step_results["specify"]["output"] assert step_output["integration"] == "copilot" - def test_resolve_inputs_auto_oserror_falls_back_to_copilot(self, project_dir): + def test_integration_auto_falls_back_on_oserror(self, project_dir): """When integration.json is unreadable (OSError), 'auto' falls back to 'copilot'.""" from unittest.mock import patch from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition int_json = project_dir / ".specify" / "integration.json" - int_json.parent.mkdir(parents=True, exist_ok=True) int_json.write_text(json.dumps({"integration": "opencode"}), encoding="utf-8") definition = WorkflowDefinition.from_string(self._make_workflow_yaml()) @@ -2077,7 +1977,7 @@ def test_resolve_inputs_auto_oserror_falls_back_to_copilot(self, project_dir): step_output = state.step_results["specify"]["output"] assert step_output["integration"] == "copilot" - def test_resolve_inputs_auto_whitespace_only_falls_back_to_copilot(self, project_dir): + def test_integration_auto_ignores_whitespace_only_value(self, project_dir): """When integration.json has a whitespace-only value, 'auto' falls back to 'copilot'.""" from unittest.mock import patch from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition diff --git a/workflows/speckit/workflow.yml b/workflows/speckit/workflow.yml index c783884e65..127ff72090 100644 --- a/workflows/speckit/workflow.yml +++ b/workflows/speckit/workflow.yml @@ -8,8 +8,6 @@ workflow: requires: speckit_version: ">=0.7.2" - # integrations.any removed — "auto" resolves dynamically from the - # project's integration.json; a static allowlist would be incomplete. inputs: spec: From a811a78dcd275cff8baa25b0ecbb451d64a237c9 Mon Sep 17 00:00:00 2001 From: Markus Date: Thu, 30 Apr 2026 20:16:38 +0200 Subject: [PATCH 09/16] fix(workflows): resolve explicit integration auto input Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/workflows/engine.py | 9 +++++---- tests/test_workflows.py | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py index 5463297fcd..7f82cb165f 100644 --- a/src/specify_cli/workflows/engine.py +++ b/src/specify_cli/workflows/engine.py @@ -721,6 +721,9 @@ def _resolve_inputs( elif input_def.get("required", False): msg = f"Required input {name!r} not provided." raise ValueError(msg) + + if resolved.get("integration") == "auto": + resolved["integration"] = self._load_project_integration() return resolved def _resolve_default(self, name: str, default: Any) -> Any: @@ -731,12 +734,10 @@ def _resolve_default(self, name: str, default: Any) -> Any: AI the project was actually initialized with. """ if name == "integration" and default == "auto": - resolved = self._load_project_integration() - if resolved is not None: - return resolved + return self._load_project_integration() return default - def _load_project_integration(self) -> str | None: + def _load_project_integration(self) -> str: """Read the active integration key from ``.specify/integration.json``. Returns the stored integration string, or ``"copilot"`` when the file is diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 53ada20139..d0939d5eb3 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -1936,6 +1936,25 @@ def test_integration_explicit_input_overrides_auto(self, project_dir): step_output = state.step_results["specify"]["output"] assert step_output["integration"] == "gemini" + def test_integration_explicit_auto_uses_project_integration(self, project_dir): + """Explicit --input integration=auto resolves from integration.json.""" + from unittest.mock import patch + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + + int_json = project_dir / ".specify" / "integration.json" + int_json.write_text(json.dumps({"integration": "opencode"}), encoding="utf-8") + + definition = WorkflowDefinition.from_string(self._make_workflow_yaml()) + engine = WorkflowEngine(project_dir) + + with patch( + "specify_cli.workflows.steps.command.shutil.which", return_value=None + ): + state = engine.execute(definition, {"integration": "auto"}) + + step_output = state.step_results["specify"]["output"] + assert step_output["integration"] == "opencode" + def test_integration_auto_ignores_malformed_integration_json(self, project_dir): """When integration.json contains invalid JSON, 'auto' falls back to 'copilot'.""" from unittest.mock import patch From 98afd4abccf608c8a88cef8cdb2fd82e573df8a2 Mon Sep 17 00:00:00 2001 From: Markus Wondrak Date: Thu, 30 Apr 2026 21:11:22 +0200 Subject: [PATCH 10/16] Update src/specify_cli/workflows/engine.py Use suggested type-safe strip call Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/specify_cli/workflows/engine.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py index 7f82cb165f..b2e11af674 100644 --- a/src/specify_cli/workflows/engine.py +++ b/src/specify_cli/workflows/engine.py @@ -753,9 +753,11 @@ def _load_project_integration(self) -> str: except (OSError, UnicodeDecodeError, json.JSONDecodeError): return "copilot" if isinstance(data, dict): - value = data.get("integration", "").strip() - if value: - return value + value = data.get("integration") + if isinstance(value, str): + value = value.strip() + if value and value != "auto": + return value return "copilot" @staticmethod From d9f932568a17988193f00e27dea1ad96215d7b29 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 16:54:19 +0000 Subject: [PATCH 11/16] fix(workflows): centralize path constants, add init-options fallback, auto-detect integration Agent-Logs-Url: https://github.com/markuswondrak/spec-kit/sessions/54bfb375-ba13-4531-b9b0-4140ea6b0edc Co-authored-by: markuswondrak <245696895+markuswondrak@users.noreply.github.com> --- src/specify_cli/__init__.py | 4 +- src/specify_cli/paths.py | 9 ++ src/specify_cli/workflows/engine.py | 61 +++++++++- tests/test_workflows.py | 179 ++++++++++++++++++++++++++++ workflows/speckit/workflow.yml | 6 +- 5 files changed, 252 insertions(+), 7 deletions(-) create mode 100644 src/specify_cli/paths.py diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 8039f79983..5bdcc95b3f 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -904,7 +904,7 @@ 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" +from specify_cli.paths import INIT_OPTIONS_FILE def save_init_options(project_path: Path, options: dict[str, Any]) -> None: @@ -1898,7 +1898,7 @@ def get_speckit_version() -> str: integration_app.add_typer(integration_catalog_app, name="catalog") -INTEGRATION_JSON = ".specify/integration.json" +from specify_cli.paths import INTEGRATION_JSON def _read_integration_json(project_root: Path) -> dict[str, Any]: diff --git a/src/specify_cli/paths.py b/src/specify_cli/paths.py new file mode 100644 index 0000000000..d87201e470 --- /dev/null +++ b/src/specify_cli/paths.py @@ -0,0 +1,9 @@ +"""Shared path constants for specify_cli. + +This module is intentionally dependency-free (no typer, no rich, no workflows) +so it can be safely imported from anywhere in the package without side effects. +""" + +SPECIFY_DIR = ".specify" +INTEGRATION_JSON = ".specify/integration.json" +INIT_OPTIONS_FILE = ".specify/init-options.json" diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py index d6a73bbeb0..cbe559fcab 100644 --- a/src/specify_cli/workflows/engine.py +++ b/src/specify_cli/workflows/engine.py @@ -20,6 +20,8 @@ import yaml from .base import RunStatus, StepContext, StepResult, StepStatus +from specify_cli.paths import INTEGRATION_JSON as _INTEGRATION_JSON +from specify_cli.paths import INIT_OPTIONS_FILE as _INIT_OPTIONS_FILE # -- Workflow Definition -------------------------------------------------- @@ -715,12 +717,69 @@ def _resolve_inputs( name, provided[name], input_def ) elif "default" in input_def: - resolved[name] = input_def["default"] + resolved[name] = self._resolve_default(name, input_def["default"]) elif input_def.get("required", False): msg = f"Required input {name!r} not provided." raise ValueError(msg) + # Also resolve "auto" sentinel when explicitly supplied by the caller + if resolved.get("integration") == "auto": + resolved["integration"] = self._resolve_default("integration", "auto") return resolved + def _resolve_default(self, name: str, default: Any) -> Any: + """Resolve special default sentinels against project state. + + For the ``integration`` input, ``"auto"`` resolves to the integration + recorded in project metadata so workflows dispatch to the AI the + project was actually initialized with. + """ + if name == "integration" and default == "auto": + return self._load_project_integration() + return default + + def _load_project_integration(self) -> str: + """Read the active integration key from project metadata. + + The primary source is ``.specify/integration.json``. If that file is + missing or invalid, fall back to ``.specify/init-options.json`` for + older projects or partially migrated state, checking ``integration`` + first and then ``ai``. Returns ``"copilot"`` only when neither source + contains a valid non-empty integration key. + """ + + def _read_integration(path: Path, *keys: str) -> str | None: + if not path.is_file(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + return None + if not isinstance(data, dict): + return None + for key in keys: + value = data.get(key) + if isinstance(value, str): + value = value.strip() + if value and value != "auto": # skip "auto" to avoid circular resolution + return value + return None + + integration = _read_integration( + self.project_root / _INTEGRATION_JSON, "integration" + ) + if integration is not None: + return integration + + integration = _read_integration( + self.project_root / _INIT_OPTIONS_FILE, + "integration", + "ai", + ) + if integration is not None: + return integration + + return "copilot" + @staticmethod def _coerce_input( name: str, value: Any, input_def: dict[str, Any] diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 4c042fc7d5..dcb6f72f78 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -1843,3 +1843,182 @@ def test_switch_workflow(self, project_dir): assert state.status == RunStatus.COMPLETED assert "do-plan" in state.step_results assert "do-specify" not in state.step_results + + +# ===== Integration Auto-Detect Tests ===== + + +class TestIntegrationAutoDetect: + """Tests for _resolve_default / _load_project_integration auto-detection.""" + + def test_integration_auto_default_uses_project_integration(self, project_dir): + """'auto' default resolves to the value in .specify/integration.json.""" + from specify_cli.workflows.engine import WorkflowEngine + + (project_dir / ".specify" / "integration.json").write_text( + '{"integration": "opencode"}', encoding="utf-8" + ) + engine = WorkflowEngine(project_dir) + yaml_str = """ +schema_version: "1.0" +workflow: + id: "auto-test" + name: "Auto Test" + version: "1.0.0" +inputs: + integration: + type: string + default: "auto" +steps: + - id: echo + type: shell + run: "echo {{ inputs.integration }}" +""" + from specify_cli.workflows.engine import WorkflowDefinition + definition = WorkflowDefinition.from_string(yaml_str) + resolved = engine._resolve_inputs(definition, {}) + assert resolved["integration"] == "opencode" + + def test_integration_auto_default_falls_back_to_copilot_when_no_json(self, project_dir): + """'auto' falls back to 'copilot' when integration.json is absent.""" + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + + engine = WorkflowEngine(project_dir) + yaml_str = """ +schema_version: "1.0" +workflow: + id: "fallback-test" + name: "Fallback Test" + version: "1.0.0" +inputs: + integration: + type: string + default: "auto" +steps: + - id: echo + type: shell + run: "echo {{ inputs.integration }}" +""" + definition = WorkflowDefinition.from_string(yaml_str) + resolved = engine._resolve_inputs(definition, {}) + assert resolved["integration"] == "copilot" + + def test_integration_explicit_input_overrides_auto(self, project_dir): + """Explicitly provided --input integration=X overrides 'auto' detection.""" + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + + (project_dir / ".specify" / "integration.json").write_text( + '{"integration": "opencode"}', encoding="utf-8" + ) + engine = WorkflowEngine(project_dir) + yaml_str = """ +schema_version: "1.0" +workflow: + id: "explicit-test" + name: "Explicit Test" + version: "1.0.0" +inputs: + integration: + type: string + default: "auto" +steps: + - id: echo + type: shell + run: "echo {{ inputs.integration }}" +""" + definition = WorkflowDefinition.from_string(yaml_str) + resolved = engine._resolve_inputs(definition, {"integration": "claude"}) + assert resolved["integration"] == "claude" + + def test_integration_explicit_auto_input_also_resolves(self, project_dir): + """Explicitly passing --input integration=auto also triggers auto-detection.""" + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + + (project_dir / ".specify" / "integration.json").write_text( + '{"integration": "gemini"}', encoding="utf-8" + ) + engine = WorkflowEngine(project_dir) + yaml_str = """ +schema_version: "1.0" +workflow: + id: "explicit-auto-test" + name: "Explicit Auto Test" + version: "1.0.0" +inputs: + integration: + type: string + default: "auto" +steps: + - id: echo + type: shell + run: "echo {{ inputs.integration }}" +""" + definition = WorkflowDefinition.from_string(yaml_str) + resolved = engine._resolve_inputs(definition, {"integration": "auto"}) + assert resolved["integration"] == "gemini" + + def test_integration_auto_ignores_malformed_integration_json(self, project_dir): + """Malformed integration.json falls back to 'copilot'.""" + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + + (project_dir / ".specify" / "integration.json").write_text( + "not valid json", encoding="utf-8" + ) + engine = WorkflowEngine(project_dir) + assert engine._load_project_integration() == "copilot" + + def test_integration_auto_falls_back_on_oserror(self, project_dir): + """OSError reading integration.json falls back to 'copilot'.""" + from unittest.mock import patch + from specify_cli.workflows.engine import WorkflowEngine + + engine = WorkflowEngine(project_dir) + with patch("pathlib.Path.read_text", side_effect=OSError("permission denied")): + # Create a file so is_file() returns True + (project_dir / ".specify" / "integration.json").write_text( + '{"integration": "claude"}', encoding="utf-8" + ) + assert engine._load_project_integration() == "copilot" + + def test_integration_auto_ignores_whitespace_only_value(self, project_dir): + """Whitespace-only integration value falls back to 'copilot'.""" + from specify_cli.workflows.engine import WorkflowEngine + + (project_dir / ".specify" / "integration.json").write_text( + '{"integration": " "}', encoding="utf-8" + ) + engine = WorkflowEngine(project_dir) + assert engine._load_project_integration() == "copilot" + + def test_integration_auto_falls_back_to_init_options_json(self, project_dir): + """Falls back to init-options.json when integration.json is absent.""" + from specify_cli.workflows.engine import WorkflowEngine + + (project_dir / ".specify" / "init-options.json").write_text( + '{"integration": "claude"}', encoding="utf-8" + ) + engine = WorkflowEngine(project_dir) + assert engine._load_project_integration() == "claude" + + def test_integration_auto_init_options_ai_key_fallback(self, project_dir): + """Uses 'ai' key from init-options.json when 'integration' key absent.""" + from specify_cli.workflows.engine import WorkflowEngine + + (project_dir / ".specify" / "init-options.json").write_text( + '{"ai": "opencode"}', encoding="utf-8" + ) + engine = WorkflowEngine(project_dir) + assert engine._load_project_integration() == "opencode" + + def test_integration_auto_integration_json_takes_priority(self, project_dir): + """integration.json takes priority over init-options.json.""" + from specify_cli.workflows.engine import WorkflowEngine + + (project_dir / ".specify" / "integration.json").write_text( + '{"integration": "gemini"}', encoding="utf-8" + ) + (project_dir / ".specify" / "init-options.json").write_text( + '{"integration": "claude"}', encoding="utf-8" + ) + engine = WorkflowEngine(project_dir) + assert engine._load_project_integration() == "gemini" diff --git a/workflows/speckit/workflow.yml b/workflows/speckit/workflow.yml index bf18451029..127ff72090 100644 --- a/workflows/speckit/workflow.yml +++ b/workflows/speckit/workflow.yml @@ -8,8 +8,6 @@ workflow: requires: speckit_version: ">=0.7.2" - integrations: - any: ["copilot", "claude", "gemini"] inputs: spec: @@ -18,8 +16,8 @@ inputs: prompt: "Describe what you want to build" integration: type: string - default: "copilot" - prompt: "Integration to use (e.g. claude, copilot, gemini)" + default: "auto" + prompt: "Integration to use (e.g. claude, copilot, gemini, opencode, or 'auto' to detect from project config)" scope: type: string default: "full" From f2fa165a4db02424bed99d83d972bce6f6b630cd Mon Sep 17 00:00:00 2001 From: Markus Date: Fri, 1 May 2026 20:39:12 +0200 Subject: [PATCH 12/16] chore: create commit per repository rules Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> From bb8db4ce16371b428b952bd63f816f34ed0fab36 Mon Sep 17 00:00:00 2001 From: Markus Date: Fri, 1 May 2026 20:41:37 +0200 Subject: [PATCH 13/16] fix(paths): use SPECIFY_DIR for .specify file paths Use SPECIFY_DIR-derived constants for integration.json and init-options.json. Co-authored-by: Cursor --- src/specify_cli/__init__.py | 11 ++--------- src/specify_cli/paths.py | 4 ++-- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 5bdcc95b3f..1142bef10e 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -43,6 +43,7 @@ from packaging.version import InvalidVersion, Version from typing import Any, Optional +from specify_cli.paths import INIT_OPTIONS_FILE, INTEGRATION_JSON import typer from rich.console import Console @@ -903,10 +904,6 @@ def ensure_constitution_from_template(project_path: Path, tracker: StepTracker | else: console.print(f"[yellow]Warning: Could not initialize constitution: {e}[/yellow]") - -from specify_cli.paths import INIT_OPTIONS_FILE - - def save_init_options(project_path: Path, options: dict[str, Any]) -> None: """Persist the CLI options used during ``specify init``. @@ -1300,7 +1297,7 @@ def init( manifest.save() # Write .specify/integration.json - integration_json = project_path / ".specify" / "integration.json" + integration_json = project_path / INTEGRATION_JSON integration_json.parent.mkdir(parents=True, exist_ok=True) integration_json.write_text(json.dumps({ "integration": resolved_integration.key, @@ -1897,10 +1894,6 @@ def get_speckit_version() -> str: ) integration_app.add_typer(integration_catalog_app, name="catalog") - -from specify_cli.paths import INTEGRATION_JSON - - def _read_integration_json(project_root: Path) -> dict[str, Any]: """Load ``.specify/integration.json``. Returns ``{}`` when missing.""" path = project_root / INTEGRATION_JSON diff --git a/src/specify_cli/paths.py b/src/specify_cli/paths.py index d87201e470..0f8d1bc1b8 100644 --- a/src/specify_cli/paths.py +++ b/src/specify_cli/paths.py @@ -5,5 +5,5 @@ """ SPECIFY_DIR = ".specify" -INTEGRATION_JSON = ".specify/integration.json" -INIT_OPTIONS_FILE = ".specify/init-options.json" +INTEGRATION_JSON = f"{SPECIFY_DIR}/integration.json" +INIT_OPTIONS_FILE = f"{SPECIFY_DIR}/init-options.json" From d76ac5c27c1f3f7ca55fb1de105376478c354f4a Mon Sep 17 00:00:00 2001 From: Markus Date: Sun, 3 May 2026 09:30:21 +0200 Subject: [PATCH 14/16] fix(lint): remove duplicate INTEGRATION_JSON import that triggered ruff F811 --- src/specify_cli/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index e5dac630ed..0e17a84fea 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -43,7 +43,7 @@ from packaging.version import InvalidVersion, Version from typing import Any, Optional -from specify_cli.paths import INIT_OPTIONS_FILE, INTEGRATION_JSON +from specify_cli.paths import INIT_OPTIONS_FILE import typer from rich.console import Console From 48c622399b22a03c1f273ca6d9e538069e712483 Mon Sep 17 00:00:00 2001 From: Markus Wondrak Date: Sun, 3 May 2026 16:43:48 +0200 Subject: [PATCH 15/16] Sync public workflow reference Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- workflows/speckit/workflow.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workflows/speckit/workflow.yml b/workflows/speckit/workflow.yml index 127ff72090..a24b27cb24 100644 --- a/workflows/speckit/workflow.yml +++ b/workflows/speckit/workflow.yml @@ -17,7 +17,7 @@ inputs: integration: type: string default: "auto" - prompt: "Integration to use (e.g. claude, copilot, gemini, opencode, or 'auto' to detect from project config)" + prompt: "Integration to use, or 'auto' to detect from project config" scope: type: string default: "full" From d05a622af7bdb0a4bca2086b2b27815c9d0ea615 Mon Sep 17 00:00:00 2001 From: Markus Date: Sun, 3 May 2026 20:08:58 +0200 Subject: [PATCH 16/16] fix(workflows): drop duplicate INTEGRATION_JSON and unused import (PR #2408) Co-authored-by: Cursor --- src/specify_cli/paths.py | 1 - src/specify_cli/workflows/engine.py | 2 +- tests/test_workflows.py | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/paths.py b/src/specify_cli/paths.py index 0f8d1bc1b8..1dc2924033 100644 --- a/src/specify_cli/paths.py +++ b/src/specify_cli/paths.py @@ -5,5 +5,4 @@ """ SPECIFY_DIR = ".specify" -INTEGRATION_JSON = f"{SPECIFY_DIR}/integration.json" INIT_OPTIONS_FILE = f"{SPECIFY_DIR}/init-options.json" diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py index cbe559fcab..2ddbea6400 100644 --- a/src/specify_cli/workflows/engine.py +++ b/src/specify_cli/workflows/engine.py @@ -20,7 +20,7 @@ import yaml from .base import RunStatus, StepContext, StepResult, StepStatus -from specify_cli.paths import INTEGRATION_JSON as _INTEGRATION_JSON +from specify_cli.integration_state import INTEGRATION_JSON as _INTEGRATION_JSON from specify_cli.paths import INIT_OPTIONS_FILE as _INIT_OPTIONS_FILE diff --git a/tests/test_workflows.py b/tests/test_workflows.py index dcb6f72f78..9b8f6641fc 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -1959,7 +1959,7 @@ def test_integration_explicit_auto_input_also_resolves(self, project_dir): def test_integration_auto_ignores_malformed_integration_json(self, project_dir): """Malformed integration.json falls back to 'copilot'.""" - from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + from specify_cli.workflows.engine import WorkflowEngine (project_dir / ".specify" / "integration.json").write_text( "not valid json", encoding="utf-8"