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: