repo_description: 💫 Toolkit to help you get started with Spec-Driven Development
repo_topics: ai copilot development engineering prd spec spec-driven
code: #!/usr/bin/env python3
  # /// script
  # requires-python = ">=3.11"
  # dependencies = [
  #     "typer",
  #     "rich",
  #     "platformdirs",
  #     "readchar",
  #     "json5",
  # ]
  # ///
  """
  Specify CLI - Setup tool for Specify projects
  
  Usage:
      uvx specify-cli.py init <project-name>
      uvx specify-cli.py init .
      uvx specify-cli.py init --here
  
  Or install globally:
      uv tool install --from specify-cli.py specify-cli
      specify init <project-name>
      specify init .
      specify init --here
  """
  
  import os
  import subprocess
  import sys
  import zipfile
  import tempfile
  import shutil
  import json
  import json5
  import stat
  import shlex
  import yaml
  from pathlib import Path
  from typing import Any, Optional
  
  import typer
  from rich.console import Console
  from rich.panel import Panel
  from rich.text import Text
  from rich.live import Live
  from rich.align import Align
  from rich.table import Table
  from rich.tree import Tree
  from typer.core import TyperGroup
  
  # For cross-platform keyboard input
  import readchar
  
  def _build_agent_config() -> dict[str, dict[str, Any]]:
      """Derive AGENT_CONFIG from INTEGRATION_REGISTRY."""
      from .integrations import INTEGRATION_REGISTRY
      config: dict[str, dict[str, Any]] = {}
      for key, integration in INTEGRATION_REGISTRY.items():
          if integration.config:
              config[key] = dict(integration.config)
      return config
  
  AGENT_CONFIG = _build_agent_config()
  
  AI_ASSISTANT_ALIASES = {
      "kiro": "kiro-cli",
  }
  
  # Agents that use TOML command format (others use Markdown)
  _TOML_AGENTS = frozenset({"gemini", "tabnine"})
  
  def _build_ai_assistant_help() -> str:
      """Build the --ai help text from AGENT_CONFIG so it stays in sync with runtime config."""
  
      non_generic_agents = sorted(agent for agent in AGENT_CONFIG if agent != "generic")
      base_help = (
          f"AI assistant to use: {', '.join(non_generic_agents)}, "
          "or generic (requires --ai-commands-dir)."
      )
  
      if not AI_ASSISTANT_ALIASES:
          return base_help
  
      alias_phrases = []
      for alias, target in sorted(AI_ASSISTANT_ALIASES.items()):
          alias_phrases.append(f"'{alias}' as an alias for '{target}'")
  
      if len(alias_phrases) == 1:
          aliases_text = alias_phrases[0]
      else:
          aliases_text = ', '.join(alias_phrases[:-1]) + ' and ' + alias_phrases[-1]
  
      return base_help + " Use " + aliases_text + "."
  AI_ASSISTANT_HELP = _build_ai_assistant_help()
  
  
  def _build_integration_equivalent(
      integration_key: str,
      ai_commands_dir: str | None = None,
  ) -> str:
      """Build the modern --integration equivalent for legacy --ai usage."""
  
      parts = [f"--integration {integration_key}"]
      if integration_key == "generic" and ai_commands_dir:
          parts.append(
              f'--integration-options="--commands-dir {shlex.quote(ai_commands_dir)}"'
          )
      return " ".join(parts)
  
  
  def _build_ai_deprecation_warning(
      integration_key: str,
      ai_commands_dir: str | None = None,
  ) -> str:
      """Build the legacy --ai deprecation warning message."""
  
      replacement = _build_integration_equivalent(
          integration_key,
          ai_commands_dir=ai_commands_dir,
      )
      return (
          "[bold]--ai[/bold] is deprecated and will no longer be available in version 1.0.0 or later.\n\n"
          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"
  
  BANNER = """
  ███████╗██████╗ ███████╗ ██████╗██╗███████╗██╗   ██╗
  ██╔════╝██╔══██╗██╔════╝██╔════╝██║██╔════╝╚██╗ ██╔╝
  ███████╗██████╔╝█████╗  ██║     ██║█████╗   ╚████╔╝
  ╚════██║██╔═══╝ ██╔══╝  ██║     ██║██╔══╝    ╚██╔╝
  ███████║██║     ███████╗╚██████╗██║██║        ██║
  ╚══════╝╚═╝     ╚══════╝ ╚═════╝╚═╝╚═╝        ╚═╝
  """
  
  TAGLINE = "GitHub Spec Kit - Spec-Driven Development Toolkit"
  class StepTracker:
      """Track and render hierarchical steps without emojis, similar to Claude Code tree output.
      Supports live auto-refresh via an attached refresh callback.
      """
      def __init__(self, title: str):
          self.title = title
          self.steps = []  # list of dicts: {key, label, status, detail}
          self.status_order = {"pending": 0, "running": 1, "done": 2, "error": 3, "skipped": 4}
          self._refresh_cb = None  # callable to trigger UI refresh
  
      def attach_refresh(self, cb):
          self._refresh_cb = cb
  
      def add(self, key: str, label: str):
          if key not in [s["key"] for s in self.steps]:
              self.steps.append({"key": key, "label": label, "status": "pending", "detail": ""})
              self._maybe_refresh()
  
      def start(self, key: str, detail: str = ""):
          self._update(key, status="running", detail=detail)
  
      def complete(self, key: str, detail: str = ""):
          self._update(key, status="done", detail=detail)
  
      def error(self, key: str, detail: str = ""):
          self._update(key, status="error", detail=detail)
  
      def skip(self, key: str, detail: str = ""):
          self._update(key, status="skipped", detail=detail)
  
      def _update(self, key: str, status: str, detail: str):
          for s in self.steps:
              if s["key"] == key:
                  s["status"] = status
                  if detail:
                      s["detail"] = detail
                  self._maybe_refresh()
                  return
  
          self.steps.append({"key": key, "label": key, "status": status, "detail": detail})
          self._maybe_refresh()
  
      def _maybe_refresh(self):
          if self._refresh_cb:
              try:
                  self._refresh_cb()
              except Exception:
                  pass
  
      def render(self):
          tree = Tree(f"[cyan]{self.title}[/cyan]", guide_style="grey50")
          for step in self.steps:
              label = step["label"]
              detail_text = step["detail"].strip() if step["detail"] else ""
  
              status = step["status"]
              if status == "done":
                  symbol = "[green]●[/green]"
              elif status == "pending":
                  symbol = "[green dim]○[/green dim]"
              elif status == "running":
                  symbol = "[cyan]○[/cyan]"
              elif status == "error":
                  symbol = "[red]●[/red]"
              elif status == "skipped":
                  symbol = "[yellow]○[/yellow]"
              else:
                  symbol = " "
  
              if status == "pending":
                  # Entire line light gray (pending)
                  if detail_text:
                      line = f"{symbol} [bright_black]{label} ({detail_text})[/bright_black]"
                  else:
                      line = f"{symbol} [bright_black]{label}[/bright_black]"
              else:
                  # Label white, detail (if any) light gray in parentheses
                  if detail_text:
                      line = f"{symbol} [white]{label}[/white] [bright_black]({detail_text})[/bright_black]"
                  else:
                      line = f"{symbol} [white]{label}[/white]"
  
              tree.add(line)
          return tree
  
  def get_key():
      """Get a single keypress in a cross-platform way using readchar."""
      key = readchar.readkey()
  
      if key == readchar.key.UP or key == readchar.key.CTRL_P:
          return 'up'
      if key == readchar.key.DOWN or key == readchar.key.CTRL_N:
          return 'down'
  
      if key == readchar.key.ENTER:
          return 'enter'
  
      if key == readchar.key.ESC:
          return 'escape'
  
      if key == readchar.key.CTRL_C:
          raise KeyboardInterrupt
  
      return key
  
  def select_with_arrows(options: dict, prompt_text: str = "Select an option", default_key: str = None) -> str:
      """
      Interactive selection using arrow keys with Rich Live display.
  
      Args:
          options: Dict with keys as option keys and values as descriptions
          prompt_text: Text to show above the options
          default_key: Default option key to start with
  
      Returns:
          Selected option key
      """
      option_keys = list(options.keys())
      if default_key and default_key in option_keys:
          selected_index = option_keys.index(default_key)
      else:
          selected_index = 0
  
      selected_key = None
  
      def create_selection_panel():
          """Create the selection panel with current selection highlighted."""
          table = Table.grid(padding=(0, 2))
          table.add_column(style="cyan", justify="left", width=3)
          table.add_column(style="white", justify="left")
  
          for i, key in enumerate(option_keys):
              if i == selected_index:
                  table.add_row("▶", f"[cyan]{key}[/cyan] [dim]({options[key]})[/dim]")
              else:
                  table.add_row(" ", f"[cyan]{key}[/cyan] [dim]({options[key]})[/dim]")
  
          table.add_row("", "")
          table.add_row("", "[dim]Use ↑/↓ to navigate, Enter to select, Esc to cancel[/dim]")
  
          return Panel(
              table,
              title=f"[bold]{prompt_text}[/bold]",
              border_style="cyan",
              padding=(1, 2)
          )
  
      console.print()
  
      def run_selection_loop():
          nonlocal selected_key, selected_index
          with Live(create_selection_panel(), console=console, transient=True, auto_refresh=False) as live:
              while True:
                  try:
                      key = get_key()
                      if key == 'up':
                          selected_index = (selected_index - 1) % len(option_keys)
                      elif key == 'down':
                          selected_index = (selected_index + 1) % len(option_keys)
                      elif key == 'enter':
                          selected_key = option_keys[selected_index]
                          break
                      elif key == 'escape':
                          console.print("\n[yellow]Selection cancelled[/yellow]")
                          raise typer.Exit(1)
  
                      live.update(create_selection_panel(), refresh=True)
  
                  except KeyboardInterrupt:
                      console.print("\n[yellow]Selection cancelled[/yellow]")
                      raise typer.Exit(1)
  
      run_selection_loop()
  
      if selected_key is None:
          console.print("\n[red]Selection failed.[/red]")
          raise typer.Exit(1)
  
      return selected_key
  
  console = Console()
  
  class BannerGroup(TyperGroup):
      """Custom group that shows banner before help."""
  
      def format_help(self, ctx, formatter):
          # Show banner before help
          show_banner()
          super().format_help(ctx, formatter)
  
  
  app = typer.Typer(
      name="specify",
      help="Setup tool for Specify spec-driven development projects",
      add_completion=False,
      invoke_without_command=True,
      cls=BannerGroup,
  )
  
  def show_banner():
      """Display the ASCII art banner."""
      banner_lines = BANNER.strip().split('\n')
      colors = ["bright_blue", "blue", "cyan", "bright_cyan", "white", "bright_white"]
  
      styled_banner = Text()
      for i, line in enumerate(banner_lines):
          color = colors[i % len(colors)]
          styled_banner.append(line + "\n", style=color)
  
      console.print(Align.center(styled_banner))
      console.print(Align.center(Text(TAGLINE, style="italic bright_yellow")))
      console.print()
  
  def _version_callback(value: bool):
      if value:
          console.print(f"specify {get_speckit_version()}")
          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."),
  ):
      """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:
          show_banner()
          console.print(Align.center("[dim]Run 'specify --help' for usage information[/dim]"))
          console.print()
  
  def run_command(cmd: list[str], check_return: bool = True, capture: bool = False, shell: bool = False) -> Optional[str]:
      """Run a shell command and optionally capture output."""
      try:
          if capture:
              result = subprocess.run(cmd, check=check_return, capture_output=True, text=True, shell=shell)
              return result.stdout.strip()
          else:
              subprocess.run(cmd, check=check_return, shell=shell)
              return None
      except subprocess.CalledProcessError as e:
          if check_return:
              console.print(f"[red]Error running command:[/red] {' '.join(cmd)}")
              console.print(f"[red]Exit code:[/red] {e.returncode}")
              if hasattr(e, 'stderr') and e.stderr:
                  console.print(f"[red]Error output:[/red] {e.stderr}")
              raise
          return None
  
  def check_tool(tool: str, tracker: StepTracker = None) -> bool:
      """Check if a tool is installed. Optionally update tracker.
  
      Args:
          tool: Name of the tool to check
          tracker: Optional StepTracker to update with results
  
      Returns:
          True if tool is found, False otherwise
      """
      # Special handling for Claude CLI local installs
      # See: https://github.com/github/spec-kit/issues/123
      # See: https://github.com/github/spec-kit/issues/550
      # Claude Code can be installed in two local paths:
      #   1. ~/.claude/local/claude          (after `claude migrate-installer`)
      #   2. ~/.claude/local/node_modules/.bin/claude  (npm-local install, e.g. via nvm)
      # Neither path may be on the system PATH, so we check them explicitly.
      if tool == "claude":
          if CLAUDE_LOCAL_PATH.is_file() or CLAUDE_NPM_LOCAL_PATH.is_file():
              if tracker:
                  tracker.complete(tool, "available")
              return True
  
      if tool == "kiro-cli":
          # Kiro currently supports both executable names. Prefer kiro-cli and
          # accept kiro as a compatibility fallback.
          found = shutil.which("kiro-cli") is not None or shutil.which("kiro") is not None
      else:
          found = shutil.which(tool) is not None
  
      if tracker:
          if found:
              tracker.complete(tool, "available")
          else:
              tracker.error(tool, "not found")
  
      return found
  
  
  def is_git_repo(path: Path = None) -> bool:
      """Check if the specified path is inside a git repository."""
      if path is None:
          path = Path.cwd()
  
      if not path.is_dir():
          return False
  
      try:
          subprocess.run(
              ["git", "rev-parse", "--is-inside-work-tree"],
              check=True,
              capture_output=True,
              cwd=path,
          )
          return True
      except (subprocess.CalledProcessError, FileNotFoundError):
          return False
  
  
  def init_git_repo(project_path: Path, quiet: bool = False) -> tuple[bool, Optional[str]]:
      """Initialize a git repository in the specified path."""
      try:
          original_cwd = Path.cwd()
          os.chdir(project_path)
          if not quiet:
              console.print("[cyan]Initializing git repository...[/cyan]")
          subprocess.run(["git", "init"], check=True, capture_output=True, text=True)
          subprocess.run(["git", "add", "."], check=True, capture_output=True, text=True)
          subprocess.run(["git", "commit", "-m", "Initial commit from Specify template"], check=True, capture_output=True, text=True)
          if not quiet:
              console.print("[green]✓[/green] Git repository initialized")
          return True, None
      except subprocess.CalledProcessError as e:
          error_msg = f"Command: {' '.join(e.cmd)}\nExit code: {e.returncode}"
          if e.stderr:
              error_msg += f"\nError: {e.stderr.strip()}"
          elif e.stdout:
              error_msg += f"\nOutput: {e.stdout.strip()}"
          if not quiet:
              console.print(f"[red]Error initializing git repository:[/red] {e}")
          return False, error_msg
      finally:
          os.chdir(original_cwd)
  
  
  def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker=None) -> None:
      """Handle merging or copying of .vscode/settings.json files.
  
      Note: when merge produces changes, rewritten output is normalized JSON and
      existing JSONC comments/trailing commas are not preserved.
      """
      def log(message, color="green"):
          if verbose and not tracker:
              console.print(f"[{color}]{message}[/] {rel_path}")
  
      def atomic_write_json(target_file: Path, payload: dict[str, Any]) -> None:
          """Atomically write JSON while preserving existing mode bits when possible."""
          temp_path: Optional[Path] = None
          try:
              with tempfile.NamedTemporaryFile(
                  mode='w',
                  encoding='utf-8',
                  dir=target_file.parent,
                  prefix=f"{target_file.name}.",
                  suffix=".tmp",
                  delete=False,
              ) as f:
                  temp_path = Path(f.name)
                  json.dump(payload, f, indent=4)
                  f.write('\n')
  
              if target_file.exists():
                  try:
                      existing_stat = target_file.stat()
                      os.chmod(temp_path, stat.S_IMODE(existing_stat.st_mode))
                      if hasattr(os, "chown"):
                          try:
                              os.chown(temp_path, existing_stat.st_uid, existing_stat.st_gid)
                          except PermissionError:
                              # Best-effort owner/group preservation without requiring elevated privileges.
                              pass
                  except OSError:
                      # Best-effort metadata preservation; data safety is prioritized.
                      pass
  
              os.replace(temp_path, target_file)
          except Exception:
              if temp_path and temp_path.exists():
                  temp_path.unlink()
              raise
  
      try:
          with open(sub_item, 'r', encoding='utf-8') as f:
              # json5 natively supports comments and trailing commas (JSONC)
              new_settings = json5.load(f)
  
          if dest_file.exists():
              merged = merge_json_files(dest_file, new_settings, verbose=verbose and not tracker)
              if merged is not None:
                  atomic_write_json(dest_file, merged)
                  log("Merged:", "green")
                  log("Note: comments/trailing commas are normalized when rewritten", "yellow")
              else:
                  log("Skipped merge (preserved existing settings)", "yellow")
          else:
              shutil.copy2(sub_item, dest_file)
              log("Copied (no existing settings.json):", "blue")
  
      except Exception as e:
          log(f"Warning: Could not merge settings: {e}", "yellow")
          if not dest_file.exists():
              shutil.copy2(sub_item, dest_file)
  
  
  def merge_json_files(existing_path: Path, new_content: Any, verbose: bool = False) -> Optional[dict[str, Any]]:
      """Merge new JSON content into existing JSON file.
  
      Performs a polite deep merge where:
      - New keys are added
      - Existing keys are preserved (not overwritten) unless both values are dictionaries
      - Nested dictionaries are merged recursively only when both sides are dictionaries
      - Lists and other values are preserved from base if they exist
  
      Args:
          existing_path: Path to existing JSON file
          new_content: New JSON content to merge in
          verbose: Whether to print merge details
  
      Returns:
          Merged JSON content as dict, or None if the existing file should be left untouched.
      """
      # Load existing content first to have a safe fallback
      existing_content = None
      exists = existing_path.exists()
  
      if exists:
          try:
              with open(existing_path, 'r', encoding='utf-8') as f:
                  # Handle comments (JSONC) natively with json5
                  # Note: json5 handles BOM automatically
                  existing_content = json5.load(f)
          except FileNotFoundError:
              # Handle race condition where file is deleted after exists() check
              exists = False
          except Exception as e:
              if verbose:
                  console.print(f"[yellow]Warning: Could not read or parse existing JSON in {existing_path.name} ({e}).[/yellow]")
              # Skip merge to preserve existing file if unparseable or inaccessible (e.g. PermissionError)
              return None
  
      # Validate template content
      if not isinstance(new_content, dict):
          if verbose:
              console.print(f"[yellow]Warning: Template content for {existing_path.name} is not a dictionary. Preserving existing settings.[/yellow]")
          return None
  
      if not exists:
          return new_content
  
      # If existing content parsed but is not a dict, skip merge to avoid data loss
      if not isinstance(existing_content, dict):
          if verbose:
              console.print(f"[yellow]Warning: Existing JSON in {existing_path.name} is not an object. Skipping merge to avoid data loss.[/yellow]")
          return None
  
      def deep_merge_polite(base: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]:
          """Recursively merge update dict into base dict, preserving base values."""
          result = base.copy()
          for key, value in update.items():
              if key not in result:
                  # Add new key
                  result[key] = value
              elif isinstance(result[key], dict) and isinstance(value, dict):
                  # Recursively merge nested dictionaries
                  result[key] = deep_merge_polite(result[key], value)
              else:
                  # Key already exists and values are not both dicts; preserve existing value.
                  # This ensures user settings aren't overwritten by template defaults.
                  pass
          return result
  
      merged = deep_merge_polite(existing_content, new_content)
  
      # Detect if anything actually changed. If not, return None so the caller
      # can skip rewriting the file (preserving user's comments/formatting).
      if merged == existing_content:
          return None
  
      if verbose:
          console.print(f"[cyan]Merged JSON file:[/cyan] {existing_path.name}")
  
      return merged
  
  def _locate_core_pack() -> Path | None:
      """Return the filesystem path to the bundled core_pack directory, or None.
  
      Only present in wheel installs: hatchling's force-include copies
      templates/, scripts/ etc. into specify_cli/core_pack/ at build time.
  
      Source-checkout and editable installs do NOT have this directory.
      Callers that need to work in both environments must check the repo-root
      trees (templates/, scripts/) as a fallback when this returns None.
      """
      # Wheel install: core_pack is a sibling directory of this file
      candidate = Path(__file__).parent / "core_pack"
      if candidate.is_dir():
          return candidate
      return None
  
  
  def _locate_bundled_extension(extension_id: str) -> Path | None:
      """Return the path to a bundled extension, or None.
  
      Checks the wheel's core_pack first, then falls back to the
      source-checkout ``extensions/<id>/`` directory.
      """
      import re as _re
      if not _re.match(r'^[a-z0-9-]+$', extension_id):
          return None
  
      core = _locate_core_pack()
      if core is not None:
          candidate = core / "extensions" / extension_id
          if (candidate / "extension.yml").is_file():
              return candidate
  
      # Source-checkout / editable install: look relative to repo root
      repo_root = Path(__file__).parent.parent.parent
      candidate = repo_root / "extensions" / extension_id
      if (candidate / "extension.yml").is_file():
          return candidate
  
      return None
  
  
  def _locate_bundled_workflow(workflow_id: str) -> Path | None:
      """Return the path to a bundled workflow directory, or None.
  
      Checks the wheel's core_pack first, then falls back to the
      source-checkout ``workflows/<id>/`` directory.
      """
      import re as _re
      if not _re.match(r'^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$', workflow_id):
          return None
  
      core = _locate_core_pack()
      if core is not None:
          candidate = core / "workflows" / workflow_id
          if (candidate / "workflow.yml").is_file():
              return candidate
  
      # Source-checkout / editable install: look relative to repo root
      repo_root = Path(__file__).parent.parent.parent
      candidate = repo_root / "workflows" / workflow_id
      if (candidate / "workflow.yml").is_file():
          return candidate
  
      return None
  
  
  def _locate_bundled_preset(preset_id: str) -> Path | None:
      """Return the path to a bundled preset, or None.
  
      Checks the wheel's core_pack first, then falls back to the
      source-checkout ``presets/<id>/`` directory.
      """
      import re as _re
      if not _re.match(r'^[a-z0-9-]+$', preset_id):
          return None
  
      core = _locate_core_pack()
      if core is not None:
          candidate = core / "presets" / preset_id
          if (candidate / "preset.yml").is_file():
              return candidate
  
      # Source-checkout / editable install: look relative to repo root
      repo_root = Path(__file__).parent.parent.parent
      candidate = repo_root / "presets" / preset_id
      if (candidate / "preset.yml").is_file():
          return candidate
  
      return None
  
  
  def _install_shared_infra(
      project_path: Path,
      script_type: str,
      tracker: StepTracker | None = None,
  ) -> bool:
      """Install shared infrastructure files into *project_path*.
  
      Copies ``.specify/scripts/`` and ``.specify/templates/`` from the
      bundled core_pack or source checkout.  Tracks all installed files
      in ``speckit.manifest.json``.
      Returns ``True`` on success.
      """
      from .integrations.manifest import IntegrationManifest
  
      core = _locate_core_pack()
      manifest = IntegrationManifest("speckit", project_path, version=get_speckit_version())
  
      # Scripts
      if core and (core / "scripts").is_dir():
          scripts_src = core / "scripts"
      else:
          repo_root = Path(__file__).parent.parent.parent
          scripts_src = repo_root / "scripts"
  
      skipped_files: list[str] = []
  
      if scripts_src.is_dir():
          dest_scripts = project_path / ".specify" / "scripts"
          dest_scripts.mkdir(parents=True, exist_ok=True)
          variant_dir = "bash" if script_type == "sh" else "powershell"
          variant_src = scripts_src / variant_dir
          if variant_src.is_dir():
              dest_variant = dest_scripts / variant_dir
              dest_variant.mkdir(parents=True, exist_ok=True)
              # Merge without overwriting — only add files that don't exist yet
              for src_path in variant_src.rglob("*"):
                  if src_path.is_file():
                      rel_path = src_path.relative_to(variant_src)
                      dst_path = dest_variant / rel_path
                      if dst_path.exists():
                          skipped_files.append(str(dst_path.relative_to(project_path)))
                      else:
                          dst_path.parent.mkdir(parents=True, exist_ok=True)
                          shutil.copy2(src_path, dst_path)
                          rel = dst_path.relative_to(project_path).as_posix()
                          manifest.record_existing(rel)
  
      # Page templates (not command templates, not vscode-settings.json)
      if core and (core / "templates").is_dir():
          templates_src = core / "templates"
      else:
          repo_root = Path(__file__).parent.parent.parent
          templates_src = repo_root / "templates"
  
      if templates_src.is_dir():
          dest_templates = project_path / ".specify" / "templates"
          dest_templates.mkdir(parents=True, exist_ok=True)
          for f in templates_src.iterdir():
              if f.is_file() and f.name != "vscode-settings.json" and not f.name.startswith("."):
                  dst = dest_templates / f.name
                  if dst.exists():
                      skipped_files.append(str(dst.relative_to(project_path)))
                  else:
                      shutil.copy2(f, dst)
                      rel = dst.relative_to(project_path).as_posix()
                      manifest.record_existing(rel)
  
      if skipped_files:
          import logging
          logging.getLogger(__name__).warning(
              "The following shared files already exist and were not overwritten:\n%s",
              "\n".join(f"  {f}" for f in skipped_files),
          )
  
      manifest.save()
      return True
  
  
  def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = None) -> None:
      """Ensure POSIX .sh scripts under .specify/scripts and .specify/extensions (recursively) have execute bits (no-op on Windows)."""
      if os.name == "nt":
          return  # Windows: skip silently
      scan_roots = [
          project_path / ".specify" / "scripts",
          project_path / ".specify" / "extensions",
      ]
      failures: list[str] = []
      updated = 0
      for scripts_root in scan_roots:
          if not scripts_root.is_dir():
              continue
          for script in scripts_root.rglob("*.sh"):
              try:
                  if script.is_symlink() or not script.is_file():
                      continue
                  try:
                      with script.open("rb") as f:
                          if f.read(2) != b"#!":
                              continue
                  except Exception:
                      continue
                  st = script.stat()
                  mode = st.st_mode
                  if mode & 0o111:
                      continue
                  new_mode = mode
                  if mode & 0o400:
                      new_mode |= 0o100
                  if mode & 0o040:
                      new_mode |= 0o010
                  if mode & 0o004:
                      new_mode |= 0o001
                  if not (new_mode & 0o100):
                      new_mode |= 0o100
                  os.chmod(script, new_mode)
                  updated += 1
              except Exception as e:
                  failures.append(f"{script.relative_to(project_path)}: {e}")
      if tracker:
          detail = f"{updated} updated" + (f", {len(failures)} failed" if failures else "")
          tracker.add("chmod", "Set script permissions recursively")
          (tracker.error if failures else tracker.complete)("chmod", detail)
      else:
          if updated:
              console.print(f"[cyan]Updated execute permissions on {updated} script(s) recursively[/cyan]")
          if failures:
              console.print("[yellow]Some scripts could not be updated:[/yellow]")
              for f in failures:
                  console.print(f"  - {f}")
  
  def ensure_constitution_from_template(project_path: Path, tracker: StepTracker | None = None) -> None:
      """Copy constitution template to memory if it doesn't exist (preserves existing constitution on reinitialization)."""
      memory_constitution = project_path / ".specify" / "memory" / "constitution.md"
      template_constitution = project_path / ".specify" / "templates" / "constitution-template.md"
  
      # If constitution already exists in memory, preserve it
      if memory_constitution.exists():
          if tracker:
              tracker.add("constitution", "Constitution setup")
              tracker.skip("constitution", "existing file preserved")
          return
  
      # If template doesn't exist, something went wrong with extraction
      if not template_constitution.exists():
          if tracker:
              tracker.add("constitution", "Constitution setup")
              tracker.error("constitution", "template not found")
          return
  
      # Copy template to memory directory
      try:
          memory_constitution.parent.mkdir(parents=True, exist_ok=True)
          shutil.copy2(template_constitution, memory_constitution)
          if tracker:
              tracker.add("constitution", "Constitution setup")
              tracker.complete("constitution", "copied from template")
          else:
              console.print("[cyan]Initialized constitution from template[/cyan]")
      except Exception as e:
          if tracker:
              tracker.add("constitution", "Constitution setup")
              tracker.error("constitution", str(e))
          else:
              console.print(f"[yellow]Warning: Could not initialize constitution: {e}[/yellow]")
  
  
  INIT_OPTIONS_FILE = ".specify/init-options.json"
  
  
  def save_init_options(project_path: Path, options: dict[str, Any]) -> None:
      """Persist the CLI options used during ``specify init``.
  
      Writes a small JSON file to ``.specify/init-options.json`` so that
      later operations (e.g. preset install) can adapt their behaviour
      without scanning the filesystem.
      """
      dest = project_path / INIT_OPTIONS_FILE
      dest.parent.mkdir(parents=True, exist_ok=True)
      dest.write_text(json.dumps(options, indent=2, sort_keys=True))
  
  
  def load_init_options(project_path: Path) -> dict[str, Any]:
      """Load the init options previously saved by ``specify init``.
  
      Returns an empty dict if the file does not exist or cannot be parsed.
      """
      path = project_path / INIT_OPTIONS_FILE
      if not path.exists():
          return {}
      try:
          return json.loads(path.read_text())
      except (json.JSONDecodeError, OSError):
          return {}
  
  
  def _get_skills_dir(project_path: Path, selected_ai: str) -> Path:
      """Resolve the agent-specific skills directory.
  
      Returns ``project_path / <agent_folder> / "skills"``, falling back
      to ``project_path / ".agents/skills"`` for unknown agents.
      """
      agent_config = AGENT_CONFIG.get(selected_ai, {})
      agent_folder = agent_config.get("folder", "")
      if agent_folder:
          return project_path / agent_folder.rstrip("/") / "skills"
      return project_path / ".agents" / "skills"
  
  
  # Constants kept for backward compatibility with presets and extensions.
  DEFAULT_SKILLS_DIR = ".agents/skills"
  NATIVE_SKILLS_AGENTS = {"codex", "kimi"}
  SKILL_DESCRIPTIONS = {
      "specify": "Create or update feature specifications from natural language descriptions.",
      "plan": "Generate technical implementation plans from feature specifications.",
      "tasks": "Break down implementation plans into actionable task lists.",
      "implement": "Execute all tasks from the task breakdown to build the feature.",
      "analyze": "Perform cross-artifact consistency analysis across spec.md, plan.md, and tasks.md.",
      "clarify": "Structured clarification workflow for underspecified requirements.",
      "constitution": "Create or update project governing principles and development guidelines.",
      "checklist": "Generate custom quality checklists for validating requirements completeness and clarity.",
      "taskstoissues": "Convert tasks from tasks.md into GitHub issues.",
  }
  
  
  @app.command()
  def init(
      project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"),
      ai_assistant: str = typer.Option(None, "--ai", help=AI_ASSISTANT_HELP),
      ai_commands_dir: str = typer.Option(None, "--ai-commands-dir", help="Directory for agent command files (required with --ai generic, e.g. .myagent/commands/)"),
      script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"),
      ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for AI agent tools like Claude Code"),
      no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization"),
      here: bool = typer.Option(False, "--here", help="Initialize project in the current directory instead of creating a new one"),
      force: bool = typer.Option(False, "--force", help="Force merge/overwrite when using --here (skip confirmation)"),
      skip_tls: bool = typer.Option(False, "--skip-tls", help="Deprecated (no-op). Previously: skip SSL/TLS verification.", hidden=True),
      debug: bool = typer.Option(False, "--debug", help="Deprecated (no-op). Previously: show verbose diagnostic output.", hidden=True),
      github_token: str = typer.Option(None, "--github-token", help="Deprecated (no-op). Previously: GitHub token for API requests.", hidden=True),
      ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"),
      offline: bool = typer.Option(False, "--offline", help="Deprecated (no-op). All scaffolding now uses bundled assets.", hidden=True),
      preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"),
      branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, …, 1000, … — expands past 999 automatically) or 'timestamp' (YYYYMMDD-HHMMSS)"),
      integration: str = typer.Option(None, "--integration", help="Use the new integration system (e.g. --integration copilot). Mutually exclusive with --ai."),
      integration_options: str = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'),
  ):
      """
      Initialize a new Specify project.
  
      By default, project files are downloaded from the latest GitHub release.
      Use --offline to scaffold from assets bundled inside the specify-cli
      package instead (no internet access required, ideal for air-gapped or
      enterprise environments).
  
      NOTE: Starting with v0.6.0, bundled assets will be used by default and
      the --offline flag will be removed. The GitHub download path will be
      retired because bundled assets eliminate the need for network access,
      avoid proxy/firewall issues, and guarantee that templates always match
      the installed CLI version.
  
      This command will:
      1. Check that required tools are installed (git is optional)
      2. Let you choose your AI assistant
      3. Download template from GitHub (or use bundled assets with --offline)
      4. Initialize a fresh git repository (if not --no-git and no existing repo)
      5. Optionally set up AI assistant commands
  
      Examples:
          specify init my-project
          specify init my-project --ai claude
          specify init my-project --ai copilot --no-git
          specify init --ignore-agent-tools my-project
          specify init . --ai claude         # Initialize in current directory
          specify init .                     # Initialize in current directory (interactive AI selection)
          specify init --here --ai claude    # Alternative syntax for current directory
          specify init --here --ai codex --ai-skills
          specify init --here --ai codebuddy
          specify init --here --ai vibe      # Initialize with Mistral Vibe support
          specify init --here
          specify init --here --force  # Skip confirmation when current directory not empty
          specify init my-project --ai claude   # Claude installs skills by default
          specify init --here --ai gemini --ai-skills
          specify init my-project --ai generic --ai-commands-dir .myagent/commands/  # Unsupported agent
          specify init my-project --offline  # Use bundled assets (no network access)
          specify init my-project --ai claude --preset healthcare-compliance  # With preset
      """
  
      show_banner()
      ai_deprecation_warning: str | None = None
  
      # Detect when option values are likely misinterpreted flags (parameter ordering issue)
      if ai_assistant and ai_assistant.startswith("--"):
          console.print(f"[red]Error:[/red] Invalid value for --ai: '{ai_assistant}'")
          console.print("[yellow]Hint:[/yellow] Did you forget to provide a value for --ai?")
          console.print("[yellow]Example:[/yellow] specify init --ai 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 --ai generic --ai-commands-dir .myagent/commands/")
          raise typer.Exit(1)
  
      if ai_assistant:
          ai_assistant = AI_ASSISTANT_ALIASES.get(ai_assistant, ai_assistant)
  
      # --integration and --ai are mutually exclusive
      if integration and ai_assistant:
          console.print("[red]Error:[/red] --integration and --ai are mutually exclusive")
          raise typer.Exit(1)
  
      # Resolve the integration — either from --integration or --ai
      from .integrations import INTEGRATION_REGISTRY, get_integration
      if integration:
          resolved_integration = get_integration(integration)
          if not resolved_integration:
              console.print(f"[red]Error:[/red] Unknown integration: '{integration}'")
              available = ", ".join(sorted(INTEGRATION_REGISTRY))
              console.print(f"[yellow]Available integrations:[/yellow] {available}")
              raise typer.Exit(1)
          ai_assistant = integration
      elif ai_assistant:
          resolved_integration = get_integration(ai_assistant)
          if not resolved_integration:
              console.print(f"[red]Error:[/red] Unknown agent '{ai_assistant}'. Choose from: {', '.join(sorted(INTEGRATION_REGISTRY))}")
              raise typer.Exit(1)
          ai_deprecation_warning = _build_ai_deprecation_warning(
              resolved_integration.key,
              ai_commands_dir=ai_commands_dir,
          )
  
      # Deprecation warnings for --ai-skills and --ai-commands-dir (only when
      # an integration has been resolved from --ai or --integration)
      if ai_assistant or integration:
          if ai_skills:
              from .integrations.base import SkillsIntegration as _SkillsCheck
              if isinstance(resolved_integration, _SkillsCheck):
                  console.print(
                      "[dim]Note: --ai-skills is not needed; "
                      "skills are the default for this integration.[/dim]"
                  )
              else:
                  console.print(
                      "[dim]Note: --ai-skills has no effect with "
                      f"{resolved_integration.key}; this integration uses commands, not skills.[/dim]"
                  )
          if ai_commands_dir and resolved_integration.key != "generic":
              console.print(
                  "[dim]Note: --ai-commands-dir is deprecated; "
                  'use [bold]--integration generic --integration-options="--commands-dir <dir>"[/bold] instead.[/dim]'
              )
  
      if project_name == ".":
          here = True
          project_name = None  # Clear project_name to use existing validation logic
  
      if here and project_name:
          console.print("[red]Error:[/red] Cannot specify both project name and --here flag")
          raise typer.Exit(1)
  
      if not here and not project_name:
          console.print("[red]Error:[/red] Must specify either a project name, use '.' for current directory, or use --here flag")
          raise typer.Exit(1)
  
      if ai_skills and not ai_assistant:
          console.print("[red]Error:[/red] --ai-skills requires --ai to be specified")
          console.print("[yellow]Usage:[/yellow] specify init <project> --ai <agent> --ai-skills")
          raise typer.Exit(1)
  
      BRANCH_NUMBERING_CHOICES = {"sequential", "timestamp"}
      if branch_numbering and branch_numbering not in BRANCH_NUMBERING_CHOICES:
          console.print(f"[red]Error:[/red] Invalid --branch-numbering value '{branch_numbering}'. Choose from: {', '.join(sorted(BRANCH_NUMBERING_CHOICES))}")
          raise typer.Exit(1)
  
      dir_existed_before = False
      if here:
          project_name = Path.cwd().name
          project_path = Path.cwd()
          dir_existed_before = True
  
          existing_items = list(project_path.iterdir())
          if existing_items:
              console.print(f"[yellow]Warning:[/yellow] Current directory is not empty ({len(existing_items)} items)")
              console.print("[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]")
              if force:
                  console.print("[cyan]--force supplied: skipping confirmation and proceeding with merge[/cyan]")
              else:
                  response = typer.confirm("Do you want to continue?")
                  if not response:
                      console.print("[yellow]Operation cancelled[/yellow]")
                      raise typer.Exit(0)
      else:
          project_path = Path(project_name).resolve()
          dir_existed_before = project_path.exists()
          if project_path.exists():
              if not project_path.is_dir():
                  console.print(f"[red]Error:[/red] '{project_name}' exists but is not a directory.")
                  raise typer.Exit(1)
              existing_items = list(project_path.iterdir())
              if force:
                  if existing_items:
                      console.print(f"[yellow]Warning:[/yellow] Directory '{project_name}' is not empty ({len(existing_items)} items)")
                      console.print("[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]")
                  console.print(f"[cyan]--force supplied: merging into existing directory '[cyan]{project_name}[/cyan]'[/cyan]")
              else:
                  error_panel = Panel(
                      f"Directory already exists: '[cyan]{project_name}[/cyan]'\n"
                      "Please choose a different project name or remove the existing directory.\n"
                      "Use [bold]--force[/bold] to merge into the existing directory.",
                      title="[red]Directory Conflict[/red]",
                      border_style="red",
                      padding=(1, 2)
                  )
                  console.print()
                  console.print(error_panel)
                  raise typer.Exit(1)
  
      if ai_assistant:
          if ai_assistant not in AGENT_CONFIG:
              console.print(f"[red]Error:[/red] Invalid AI assistant '{ai_assistant}'. Choose from: {', '.join(AGENT_CONFIG.keys())}")
              raise typer.Exit(1)
          selected_ai = ai_assistant
      else:
          # Create options dict for selection (agent_key: display_name)
          ai_choices = {key: config["name"] for key, config in AGENT_CONFIG.items()}
          selected_ai = select_with_arrows(
              ai_choices,
              "Choose your AI assistant:",
              "copilot"
          )
  
      # Auto-promote interactively selected agents to the integration path
      if not ai_assistant:
          resolved_integration = get_integration(selected_ai)
          if not resolved_integration:
              console.print(f"[red]Error:[/red] Unknown agent '{selected_ai}'")
              raise typer.Exit(1)
  
      # Validate --ai-commands-dir usage.
      # Skip validation when --integration-options is provided — the integration
      # will validate its own options in setup().
      if selected_ai == "generic" and not integration_options:
          if not ai_commands_dir:
              console.print("[red]Error:[/red] --ai-commands-dir is required when using --ai generic or --integration generic")
              console.print('[dim]Example: specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/"[/dim]')
              raise typer.Exit(1)
  
      current_dir = Path.cwd()
  
      setup_lines = [
          "[cyan]Specify Project Setup[/cyan]",
          "",
          f"{'Project':<15} [green]{project_path.name}[/green]",
          f"{'Working Path':<15} [dim]{current_dir}[/dim]",
      ]
  
      if not here:
          setup_lines.append(f"{'Target Path':<15} [dim]{project_path}[/dim]")
  
      console.print(Panel("\n".join(setup_lines), border_style="cyan", padding=(1, 2)))
  
      should_init_git = False
      if not no_git:
          should_init_git = check_tool("git")
          if not should_init_git:
              console.print("[yellow]Git not found - will skip repository initialization[/yellow]")
  
      if not ignore_agent_tools:
          agent_config = AGENT_CONFIG.get(selected_ai)
          if agent_config and agent_config["requires_cli"]:
              install_url = agent_config["install_url"]
              if not check_tool(selected_ai):
                  error_panel = Panel(
                      f"[cyan]{selected_ai}[/cyan] not found\n"
                      f"Install from: [cyan]{install_url}[/cyan]\n"
                      f"{agent_config['name']} is required to continue with this project type.\n\n"
                      "Tip: Use [cyan]--ignore-agent-tools[/cyan] to skip this check",
                      title="[red]Agent Detection Error[/red]",
                      border_style="red",
                      padding=(1, 2)
                  )
                  console.print()
                  console.print(error_panel)
                  raise typer.Exit(1)
  
      if script_type:
          if script_type not in SCRIPT_TYPE_CHOICES:
              console.print(f"[red]Error:[/red] Invalid script type '{script_type}'. Choose from: {', '.join(SCRIPT_TYPE_CHOICES.keys())}")
              raise typer.Exit(1)
          selected_script = script_type
      else:
          default_script = "ps" if os.name == "nt" else "sh"
  
          if sys.stdin.isatty():
              selected_script = select_with_arrows(SCRIPT_TYPE_CHOICES, "Choose script type (or press Enter)", default_script)
          else:
              selected_script = default_script
  
      console.print(f"[cyan]Selected AI assistant:[/cyan] {selected_ai}")
      console.print(f"[cyan]Selected script type:[/cyan] {selected_script}")
  
      tracker = StepTracker("Initialize Specify Project")
  
      sys._specify_tracker_active = True
  
      tracker.add("precheck", "Check required tools")
      tracker.complete("precheck", "ok")
      tracker.add("ai-select", "Select AI assistant")
      tracker.complete("ai-select", f"{selected_ai}")
      tracker.add("script-select", "Select script type")
      tracker.complete("script-select", selected_script)
  
      tracker.add("integration", "Install integration")
      tracker.add("shared-infra", "Install shared infrastructure")
  
      for key, label in [
          ("chmod", "Ensure scripts executable"),
          ("constitution", "Constitution setup"),
          ("git", "Install git extension"),
          ("workflow", "Install bundled workflow"),
          ("final", "Finalize"),
      ]:
          tracker.add(key, label)
  
      with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live:
          tracker.attach_refresh(lambda: live.update(tracker.render()))
          try:
              # Integration-based scaffolding
              from .integrations.manifest import IntegrationManifest
              tracker.start("integration")
              manifest = IntegrationManifest(
                  resolved_integration.key, project_path, version=get_speckit_version()
              )
  
              # Forward all legacy CLI flags to the integration as parsed_options.
              # Integrations receive every option and decide what to use;
              # irrelevant keys are simply ignored by the integration's setup().
              integration_parsed_options: dict[str, Any] = {}
              if ai_commands_dir:
                  integration_parsed_options["commands_dir"] = ai_commands_dir
              if ai_skills:
                  integration_parsed_options["skills"] = True
  
              resolved_integration.setup(
                  project_path, manifest,
                  parsed_options=integration_parsed_options or None,
                  script_type=selected_script,
                  raw_options=integration_options,
              )
              manifest.save()
  
              # Write .specify/integration.json
              integration_json = project_path / ".specify" / "integration.json"
              integration_json.parent.mkdir(parents=True, exist_ok=True)
              integration_json.write_text(json.dumps({
                  "integration": resolved_integration.key,
                  "version": get_speckit_version(),
              }, indent=2) + "\n", encoding="utf-8")
  
              tracker.complete("integration", resolved_integration.config.get("name", resolved_integration.key))
  
              # Install shared infrastructure (scripts, templates)
              tracker.start("shared-infra")
              _install_shared_infra(project_path, selected_script, tracker=tracker)
              tracker.complete("shared-infra", f"scripts ({selected_script}) + templates")
  
              ensure_constitution_from_template(project_path, tracker=tracker)
  
              if not no_git:
                  tracker.start("git")
                  git_messages = []
                  git_has_error = False
                  # Step 1: Initialize git repo if needed
                  if is_git_repo(project_path):
                      git_messages.append("existing repo detected")
                  elif should_init_git:
                      success, error_msg = init_git_repo(project_path, quiet=True)
                      if success:
                          git_messages.append("initialized")
                      else:
                          git_has_error = True
                          # Sanitize multi-line error_msg to single line for tracker
                          if error_msg:
                              sanitized = error_msg.replace('\n', ' ').strip()
                              git_messages.append(f"init failed: {sanitized[:120]}")
                          else:
                              git_messages.append("init failed")
                  else:
                      git_messages.append("git not available")
                  # Step 2: Install bundled git extension
                  try:
                      from .extensions import ExtensionManager
                      bundled_path = _locate_bundled_extension("git")
                      if bundled_path:
                          manager = ExtensionManager(project_path)
                          if manager.registry.is_installed("git"):
                              git_messages.append("extension already installed")
                          else:
                              manager.install_from_directory(
                                  bundled_path, get_speckit_version()
                              )
                              git_messages.append("extension installed")
                      else:
                          git_has_error = True
                          git_messages.append("bundled extension not found")
                  except Exception as ext_err:
                      git_has_error = True
                      sanitized_ext = str(ext_err).replace('\n', ' ').strip()
                      git_messages.append(
                          f"extension install failed: {sanitized_ext[:120]}"
                      )
                  summary = "; ".join(git_messages)
                  if git_has_error:
                      tracker.error("git", summary)
                  else:
                      tracker.complete("git", summary)
              else:
                  tracker.skip("git", "--no-git flag")
  
              # Install bundled speckit workflow
              try:
                  bundled_wf = _locate_bundled_workflow("speckit")
                  if bundled_wf:
                      from .workflows.catalog import WorkflowRegistry
                      from .workflows.engine import WorkflowDefinition
                      wf_registry = WorkflowRegistry(project_path)
                      if wf_registry.is_installed("speckit"):
                          tracker.complete("workflow", "already installed")
                      else:
                          import shutil as _shutil
                          dest_wf = project_path / ".specify" / "workflows" / "speckit"
                          dest_wf.mkdir(parents=True, exist_ok=True)
                          _shutil.copy2(
                              bundled_wf / "workflow.yml",
                              dest_wf / "workflow.yml",
                          )
                          definition = WorkflowDefinition.from_yaml(dest_wf / "workflow.yml")
                          wf_registry.add("speckit", {
                              "name": definition.name,
                              "version": definition.version,
                              "description": definition.description,
                              "source": "bundled",
                          })
                          tracker.complete("workflow", "speckit installed")
                  else:
                      tracker.skip("workflow", "bundled workflow not found")
              except Exception as wf_err:
                  sanitized_wf = str(wf_err).replace('\n', ' ').strip()
                  tracker.error("workflow", f"install failed: {sanitized_wf[:120]}")
  
              # Fix permissions after all installs (scripts + extensions)
              ensure_executable_scripts(project_path, tracker=tracker)
  
              # Persist the CLI options so later operations (e.g. preset add)
              # can adapt their behaviour without re-scanning the filesystem.
              # Must be saved BEFORE preset install so _get_skills_dir() works.
              init_opts = {
                  "ai": selected_ai,
                  "integration": resolved_integration.key,
                  "branch_numbering": branch_numbering or "sequential",
                  "context_file": resolved_integration.context_file,
                  "here": here,
                  "script": selected_script,
                  "speckit_version": get_speckit_version(),
              }
              # Ensure ai_skills is set for SkillsIntegration so downstream
              # tools (extensions, presets) emit SKILL.md overrides correctly.
              from .integrations.base import SkillsIntegration as _SkillsPersist
              if isinstance(resolved_integration, _SkillsPersist):
                  init_opts["ai_skills"] = True
              save_init_options(project_path, init_opts)
  
              # Install preset if specified
              if preset:
                  try:
                      from .presets import PresetManager, PresetCatalog, PresetError
                      preset_manager = PresetManager(project_path)
                      speckit_ver = get_speckit_version()
  
                      # Try local directory first, then bundled, then catalog
                      local_path = Path(preset).resolve()
                      if local_path.is_dir() and (local_path / "preset.yml").exists():
                          preset_manager.install_from_directory(local_path, speckit_ver)
                      else:
                          bundled_path = _locate_bundled_preset(preset)
                          if bundled_path:
                              preset_manager.install_from_directory(bundled_path, speckit_ver)
                          else:
                              preset_catalog = PresetCatalog(project_path)
                              pack_info = preset_catalog.get_pack_info(preset)
                              if not pack_info:
                                  console.print(f"[yellow]Warning:[/yellow] Preset '{preset}' not found in catalog. Skipping.")
                              elif pack_info.get("bundled") and not pack_info.get("download_url"):
                                  from .extensions import REINSTALL_COMMAND
                                  console.print(
                                      f"[yellow]Warning:[/yellow] Preset '{preset}' is bundled with spec-kit "
                                      f"but could not be found in the installed package."
                                  )
                                  console.print(
                                      "This usually means the spec-kit installation is incomplete or corrupted."
                                  )
                                  console.print(f"Try reinstalling: {REINSTALL_COMMAND}")
                              else:
                                  zip_path = None
                                  try:
                                      zip_path = preset_catalog.download_pack(preset)
                                      preset_manager.install_from_zip(zip_path, speckit_ver)
                                  except PresetError as preset_err:
                                      console.print(f"[yellow]Warning:[/yellow] Failed to install preset '{preset}': {preset_err}")
                                  finally:
                                      if zip_path is not None:
                                          # Clean up downloaded ZIP to avoid cache accumulation
                                          try:
                                              zip_path.unlink(missing_ok=True)
                                          except OSError:
                                              # Best-effort cleanup; failure to delete is non-fatal
                                              pass
                  except Exception as preset_err:
                      console.print(f"[yellow]Warning:[/yellow] Failed to install preset: {preset_err}")
  
              tracker.complete("final", "project ready")
          except (typer.Exit, SystemExit):
              raise
          except Exception as e:
              tracker.error("final", str(e))
              console.print(Panel(f"Initialization failed: {e}", title="Failure", border_style="red"))
              if debug:
                  _env_pairs = [
                      ("Python", sys.version.split()[0]),
                      ("Platform", sys.platform),
                      ("CWD", str(Path.cwd())),
                  ]
                  _label_width = max(len(k) for k, _ in _env_pairs)
                  env_lines = [f"{k.ljust(_label_width)} → [bright_black]{v}[/bright_black]" for k, v in _env_pairs]
                  console.print(Panel("\n".join(env_lines), title="Debug Environment", border_style="magenta"))
              if not here and project_path.exists() and not dir_existed_before:
                  shutil.rmtree(project_path)
              raise typer.Exit(1)
          finally:
              pass
  
      console.print(tracker.render())
      console.print("\n[bold green]Project ready.[/bold green]")
  
      # Agent folder security notice
      agent_config = AGENT_CONFIG.get(selected_ai)
      if agent_config:
          agent_folder = ai_commands_dir if selected_ai == "generic" else agent_config["folder"]
          if agent_folder:
              security_notice = Panel(
                  f"Some agents may store credentials, auth tokens, or other identifying and private artifacts in the agent folder within your project.\n"
                  f"Consider adding [cyan]{agent_folder}[/cyan] (or parts of it) to [cyan].gitignore[/cyan] to prevent accidental credential leakage.",
                  title="[yellow]Agent Folder Security[/yellow]",
                  border_style="yellow",
                  padding=(1, 2)
              )
              console.print()
              console.print(security_notice)
  
      if ai_deprecation_warning:
          deprecation_notice = Panel(
              ai_deprecation_warning,
              title="[bold red]Deprecation Warning[/bold red]",
              border_style="red",
              padding=(1, 2),
          )
          console.print()
          console.print(deprecation_notice)
  
      steps_lines = []
      if not here:
          steps_lines.append(f"1. Go to the project folder: [cyan]cd {project_name}[/cyan]")
          step_num = 2
      else:
          steps_lines.append("1. You're already in the project directory!")
          step_num = 2
  
      # Determine skill display mode for the next-steps panel.
      # Skills integrations (codex, kimi, agy, trae, cursor-agent) should show skill invocation syntax.
      from .integrations.base import SkillsIntegration as _SkillsInt
      _is_skills_integration = isinstance(resolved_integration, _SkillsInt)
  
      codex_skill_mode = selected_ai == "codex" and (ai_skills or _is_skills_integration)
      claude_skill_mode = selected_ai == "claude" and (ai_skills or _is_skills_integration)
      kimi_skill_mode = selected_ai == "kimi"
      agy_skill_mode = selected_ai == "agy" and _is_skills_integration
      trae_skill_mode = selected_ai == "trae"
      cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _is_skills_integration)
      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
  
      if codex_skill_mode and not ai_skills:
          # Integration path installed skills; show the helpful notice
          steps_lines.append(f"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]")
          step_num += 1
      if claude_skill_mode and not ai_skills:
          steps_lines.append(f"{step_num}. Start Claude in this project directory; spec-kit skills were installed to [cyan].claude/skills[/cyan]")
          step_num += 1
      if cursor_agent_skill_mode and not ai_skills:
          steps_lines.append(f"{step_num}. Start Cursor Agent in this project directory; spec-kit skills were installed to [cyan].cursor/skills[/cyan]")
          step_num += 1
      usage_label = "skills" if native_skill_mode else "slash commands"
  
      def _display_cmd(name: str) -> str:
          if codex_skill_mode or agy_skill_mode or trae_skill_mode:
              return f"$speckit-{name}"
          if claude_skill_mode:
              return f"/speckit-{name}"
          if kimi_skill_mode:
              return f"/skill:speckit-{name}"
          if cursor_agent_skill_mode:
              return f"/speckit-{name}"
          return f"/speckit.{name}"
  
      steps_lines.append(f"{step_num}. Start using {usage_label} with your AI agent:")
  
      steps_lines.append(f"   {step_num}.1 [cyan]{_display_cmd('constitution')}[/] - Establish project principles")
      steps_lines.append(f"   {step_num}.2 [cyan]{_display_cmd('specify')}[/] - Create baseline specification")
      steps_lines.append(f"   {step_num}.3 [cyan]{_display_cmd('plan')}[/] - Create implementation plan")
      steps_lines.append(f"   {step_num}.4 [cyan]{_display_cmd('tasks')}[/] - Generate actionable tasks")
      steps_lines.append(f"   {step_num}.5 [cyan]{_display_cmd('implement')}[/] - Execute implementation")
  
      steps_panel = Panel("\n".join(steps_lines), title="Next Steps", border_style="cyan", padding=(1,2))
      console.print()
      console.print(steps_panel)
  
      enhancement_intro = (
          "Optional skills that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]"
          if native_skill_mode
          else "Optional commands that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]"
      )
      enhancement_lines = [
          enhancement_intro,
          "",
          f"○ [cyan]{_display_cmd('clarify')}[/] [bright_black](optional)[/bright_black] - Ask structured questions to de-risk ambiguous areas before planning (run before [cyan]{_display_cmd('plan')}[/] if used)",
          f"○ [cyan]{_display_cmd('analyze')}[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]{_display_cmd('tasks')}[/], before [cyan]{_display_cmd('implement')}[/])",
          f"○ [cyan]{_display_cmd('checklist')}[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]{_display_cmd('plan')}[/])"
      ]
      enhancements_title = "Enhancement Skills" if native_skill_mode else "Enhancement Commands"
      enhancements_panel = Panel("\n".join(enhancement_lines), title=enhancements_title, border_style="cyan", padding=(1,2))
      console.print()
      console.print(enhancements_panel)
  
  @app.command()
  def check():
      """Check that all required tools are installed."""
      show_banner()
      console.print("[bold]Checking for installed tools...[/bold]\n")
  
      tracker = StepTracker("Check Available Tools")
  
      tracker.add("git", "Git version control")
      git_ok = check_tool("git", tracker=tracker)
  
      agent_results = {}
      for agent_key, agent_config in AGENT_CONFIG.items():
          if agent_key == "generic":
              continue  # Generic is not a real agent to check
          agent_name = agent_config["name"]
          requires_cli = agent_config["requires_cli"]
  
          tracker.add(agent_key, agent_name)
  
          if requires_cli:
              agent_results[agent_key] = check_tool(agent_key, tracker=tracker)
          else:
              # IDE-based agent - skip CLI check and mark as optional
              tracker.skip(agent_key, "IDE-based, no CLI check")
              agent_results[agent_key] = False  # Don't count IDE agents as "found"
  
      # Check VS Code variants (not in agent config)
      tracker.add("code", "Visual Studio Code")
      check_tool("code", tracker=tracker)
  
      tracker.add("code-insiders", "Visual Studio Code Insiders")
      check_tool("code-insiders", tracker=tracker)
  
      console.print(tracker.render())
  
      console.print("\n[bold green]Specify CLI is ready to use![/bold green]")
  
      if not git_ok:
          console.print("[dim]Tip: Install git for repository management[/dim]")
  
      if not any(agent_results.values()):
          console.print("[dim]Tip: Install an AI assistant for the best experience[/dim]")
  
  @app.command()
  def version():
      """Display version and system information."""
      import platform
      import importlib.metadata
  
      show_banner()
  
      # Get CLI version from package metadata
      cli_version = "unknown"
      try:
          cli_version = importlib.metadata.version("specify-cli")
      except Exception:
          # Fallback: try reading from pyproject.toml if running from source
          try:
              import tomllib
              pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml"
              if pyproject_path.exists():
                  with open(pyproject_path, "rb") as f:
                      data = tomllib.load(f)
                      cli_version = data.get("project", {}).get("version", "unknown")
          except Exception:
              pass
  
      info_table = Table(show_header=False, box=None, padding=(0, 2))
      info_table.add_column("Key", style="cyan", justify="right")
      info_table.add_column("Value", style="white")
  
      info_table.add_row("CLI Version", cli_version)
      info_table.add_row("", "")
      info_table.add_row("Python", platform.python_version())
      info_table.add_row("Platform", platform.system())
      info_table.add_row("Architecture", platform.machine())
      info_table.add_row("OS Version", platform.version())
  
      panel = Panel(
          info_table,
          title="[bold cyan]Specify CLI Information[/bold cyan]",
          border_style="cyan",
          padding=(1, 2)
      )
  
      console.print(panel)
      console.print()
  
  
  # ===== Extension Commands =====
  
  extension_app = typer.Typer(
      name="extension",
      help="Manage spec-kit extensions",
      add_completion=False,
  )
  app.add_typer(extension_app, name="extension")
  
  catalog_app = typer.Typer(
      name="catalog",
      help="Manage extension catalogs",
      add_completion=False,
  )
  extension_app.add_typer(catalog_app, name="catalog")
  
  preset_app = typer.Typer(
      name="preset",
      help="Manage spec-kit presets",
      add_completion=False,
  )
  app.add_typer(preset_app, name="preset")
  
  preset_catalog_app = typer.Typer(
      name="catalog",
      help="Manage preset catalogs",
      add_completion=False,
  )
  preset_app.add_typer(preset_catalog_app, name="catalog")
  
  
  def get_speckit_version() -> str:
      """Get current spec-kit version."""
      import importlib.metadata
      try:
          return importlib.metadata.version("specify-cli")
      except Exception:
          # Fallback: try reading from pyproject.toml
          try:
              import tomllib
              pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml"
              if pyproject_path.exists():
                  with open(pyproject_path, "rb") as f:
                      data = tomllib.load(f)
                      return data.get("project", {}).get("version", "unknown")
          except Exception:
              # Intentionally ignore any errors while reading/parsing pyproject.toml.
              # If this lookup fails for any reason, we fall back to returning "unknown" below.
              pass
      return "unknown"
  
  
  # ===== Integration Commands =====
  
  integration_app = typer.Typer(
      name="integration",
      help="Manage AI agent integrations",
      add_completion=False,
  )
  app.add_typer(integration_app, name="integration")
  
  
  INTEGRATION_JSON = ".specify/integration.json"
  
  
  def _read_integration_json(project_root: Path) -> dict[str, Any]:
      """Load ``.specify/integration.json``.  Returns ``{}`` when missing."""
      path = project_root / INTEGRATION_JSON
      if not path.exists():
          return {}
      try:
          data = json.loads(path.read_text(encoding="utf-8"))
      except json.JSONDecodeError as exc:
          console.print(f"[red]Error:[/red] {path} contains invalid JSON.")
          console.print(f"Please fix or delete {INTEGRATION_JSON} and retry.")
          console.print(f"[dim]Details:[/dim] {exc}")
          raise typer.Exit(1)
      except OSError as exc:
          console.print(f"[red]Error:[/red] Could not read {path}.")
          console.print(f"Please fix file permissions or delete {INTEGRATION_JSON} and retry.")
          console.print(f"[dim]Details:[/dim] {exc}")
          raise typer.Exit(1)
      if not isinstance(data, dict):
          console.print(f"[red]Error:[/red] {path} must contain a JSON object, got {type(data).__name__}.")
          console.print(f"Please fix or delete {INTEGRATION_JSON} and retry.")
          raise typer.Exit(1)
      return data
  
  
  def _write_integration_json(
      project_root: Path,
      integration_key: str,
  ) -> None:
      """Write ``.specify/integration.json`` for *integration_key*."""
      dest = project_root / INTEGRATION_JSON
      dest.parent.mkdir(parents=True, exist_ok=True)
      dest.write_text(json.dumps({
          "integration": integration_key,
          "version": get_speckit_version(),
      }, indent=2) + "\n", encoding="utf-8")
  
  
  def _remove_integration_json(project_root: Path) -> None:
      """Remove ``.specify/integration.json`` if it exists."""
      path = project_root / INTEGRATION_JSON
      if path.exists():
          path.unlink()
  
  
  def _normalize_script_type(script_type: str, source: str) -> str:
      """Normalize and validate a script type from CLI/config sources."""
      normalized = script_type.strip().lower()
      if normalized in SCRIPT_TYPE_CHOICES:
          return normalized
      console.print(
          f"[red]Error:[/red] Invalid script type {script_type!r} from {source}. "
          f"Expected one of: {', '.join(sorted(SCRIPT_TYPE_CHOICES.keys()))}."
      )
      raise typer.Exit(1)
  
  
  def _resolve_script_type(project_root: Path, script_type: str | None) -> str:
      """Resolve the script type from the CLI flag or init-options.json."""
      if script_type:
          return _normalize_script_type(script_type, "--script")
      opts = load_init_options(project_root)
      saved = opts.get("script")
      if isinstance(saved, str) and saved.strip():
          return _normalize_script_type(saved, ".specify/init-options.json")
      return "ps" if os.name == "nt" else "sh"
  
  
  @integration_app.command("list")
  def integration_list(
      catalog: bool = typer.Option(False, "--catalog", help="Browse full catalog (built-in + community)"),
  ):
      """List available integrations and installed status."""
      from .integrations import INTEGRATION_REGISTRY
  
      project_root = Path.cwd()
  
      specify_dir = project_root / ".specify"
      if not specify_dir.exists():
          console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
          console.print("Run this command from a spec-kit project root")
          raise typer.Exit(1)
  
      current = _read_integration_json(project_root)
      installed_key = current.get("integration")
  
      if catalog:
          from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError
  
          ic = IntegrationCatalog(project_root)
          try:
              entries = ic.search()
          except IntegrationCatalogError as exc:
              console.print(f"[red]Error:[/red] {exc}")
              raise typer.Exit(1)
  
          if not entries:
              console.print("[yellow]No integrations found in catalog.[/yellow]")
              return
  
          table = Table(title="Integration Catalog")
          table.add_column("ID", style="cyan")
          table.add_column("Name")
          table.add_column("Version")
          table.add_column("Source")
          table.add_column("Status")
  
          for entry in sorted(entries, key=lambda e: e["id"]):
              eid = entry["id"]
              cat_name = entry.get("_catalog_name", "")
              install_allowed = entry.get("_install_allowed", True)
              if eid == installed_key:
                  status = "[green]installed[/green]"
              elif eid in INTEGRATION_REGISTRY:
                  status = "built-in"
              elif install_allowed is False:
                  status = "discovery-only"
              else:
                  status = ""
              table.add_row(
                  eid,
                  entry.get("name", eid),
                  entry.get("version", ""),
                  cat_name,
                  status,
              )
  
          console.print(table)
          return
  
      table = Table(title="AI Agent Integrations")
      table.add_column("Key", style="cyan")
      table.add_column("Name")
      table.add_column("Status")
      table.add_column("CLI Required")
  
      for key in sorted(INTEGRATION_REGISTRY.keys()):
          integration = INTEGRATION_REGISTRY[key]
          cfg = integration.config or {}
          name = cfg.get("name", key)
          requires_cli = cfg.get("requires_cli", False)
  
          if key == installed_key:
              status = "[green]installed[/green]"
          else:
              status = ""
  
          cli_req = "yes" if requires_cli else "no (IDE)"
          table.add_row(key, name, status, cli_req)
  
      console.print(table)
  
      if installed_key:
          console.print(f"\n[dim]Current integration:[/dim] [cyan]{installed_key}[/cyan]")
      else:
          console.print("\n[yellow]No integration currently installed.[/yellow]")
          console.print("Install one with: [cyan]specify integration install <key>[/cyan]")
  
  
  @integration_app.command("install")
  def integration_install(
      key: str = typer.Argument(help="Integration key to install (e.g. claude, copilot)"),
      script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
      integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'),
  ):
      """Install an integration into an existing project."""
      from .integrations import INTEGRATION_REGISTRY, get_integration
      from .integrations.manifest import IntegrationManifest
  
      project_root = Path.cwd()
  
      specify_dir = project_root / ".specify"
      if not specify_dir.exists():
          console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
          console.print("Run this command from a spec-kit project root")
          raise typer.Exit(1)
  
      integration = get_integration(key)
      if integration is None:
          console.print(f"[red]Error:[/red] Unknown integration '{key}'")
          available = ", ".join(sorted(INTEGRATION_REGISTRY.keys()))
          console.print(f"Available integrations: {available}")
          raise typer.Exit(1)
  
      current = _read_integration_json(project_root)
      installed_key = current.get("integration")
  
      if installed_key and installed_key == key:
          console.print(f"[yellow]Integration '{key}' is already installed.[/yellow]")
          console.print("Run [cyan]specify integration uninstall[/cyan] first, then reinstall.")
          raise typer.Exit(0)
  
      if installed_key:
          console.print(f"[red]Error:[/red] Integration '{installed_key}' is already installed.")
          console.print(f"Run [cyan]specify integration uninstall[/cyan] first, or use [cyan]specify integration switch {key}[/cyan].")
          raise typer.Exit(1)
  
      selected_script = _resolve_script_type(project_root, script)
  
      # Ensure shared infrastructure is present (safe to run unconditionally;
      # _install_shared_infra merges missing files without overwriting).
      _install_shared_infra(project_root, selected_script)
      if os.name != "nt":
          ensure_executable_scripts(project_root)
  
      manifest = IntegrationManifest(
          integration.key, project_root, version=get_speckit_version()
      )
  
      # Build parsed options from --integration-options
      parsed_options: dict[str, Any] | None = None
      if integration_options:
          parsed_options = _parse_integration_options(integration, integration_options)
  
      try:
          integration.setup(
              project_root, manifest,
              parsed_options=parsed_options,
              script_type=selected_script,
              raw_options=integration_options,
          )
          manifest.save()
          _write_integration_json(project_root, integration.key)
          _update_init_options_for_integration(project_root, integration, script_type=selected_script)
  
      except Exception as e:
          # Attempt rollback of any files written by setup
          try:
              integration.teardown(project_root, manifest, force=True)
          except Exception as rollback_err:
              # Suppress so the original setup error remains the primary failure
              console.print(f"[yellow]Warning:[/yellow] Failed to roll back integration changes: {rollback_err}")
          _remove_integration_json(project_root)
          console.print(f"[red]Error:[/red] Failed to install integration: {e}")
          raise typer.Exit(1)
  
      name = (integration.config or {}).get("name", key)
      console.print(f"\n[green]✓[/green] Integration '{name}' installed successfully")
  
  
  def _parse_integration_options(integration: Any, raw_options: str) -> dict[str, Any] | None:
      """Parse --integration-options string into a dict matching the integration's declared options.
  
      Returns ``None`` when no options are provided.
      """
      import shlex
      parsed: dict[str, Any] = {}
      tokens = shlex.split(raw_options)
      declared_options = list(integration.options())
      declared = {opt.name.lstrip("-"): opt for opt in declared_options}
      allowed = ", ".join(sorted(opt.name for opt in declared_options))
      i = 0
      while i < len(tokens):
          token = tokens[i]
          if not token.startswith("-"):
              console.print(f"[red]Error:[/red] Unexpected integration option value '{token}'.")
              if allowed:
                  console.print(f"Allowed options: {allowed}")
              raise typer.Exit(1)
          name = token.lstrip("-")
          value: str | None = None
          # Handle --name=value syntax
          if "=" in name:
              name, value = name.split("=", 1)
          opt = declared.get(name)
          if not opt:
              console.print(f"[red]Error:[/red] Unknown integration option '{token}'.")
              if allowed:
                  console.print(f"Allowed options: {allowed}")
              raise typer.Exit(1)
          key = name.replace("-", "_")
          if opt.is_flag:
              if value is not None:
                  console.print(f"[red]Error:[/red] Option '{opt.name}' is a flag and does not accept a value.")
                  raise typer.Exit(1)
              parsed[key] = True
              i += 1
          elif value is not None:
              parsed[key] = value
              i += 1
          elif i + 1 < len(tokens) and not tokens[i + 1].startswith("-"):
              parsed[key] = tokens[i + 1]
              i += 2
          else:
              console.print(f"[red]Error:[/red] Option '{opt.name}' requires a value.")
              raise typer.Exit(1)
      return parsed or None
  
  
  def _update_init_options_for_integration(
      project_root: Path,
      integration: Any,
      script_type: str | None = None,
  ) -> None:
      """Update ``init-options.json`` to reflect *integration* as the active one."""
      from .integrations.base import SkillsIntegration
      opts = load_init_options(project_root)
      opts["integration"] = integration.key
      opts["ai"] = integration.key
      opts["context_file"] = integration.context_file
      if script_type:
          opts["script"] = script_type
      if isinstance(integration, SkillsIntegration):
          opts["ai_skills"] = True
      else:
          opts.pop("ai_skills", None)
      save_init_options(project_root, opts)
  
  
  @integration_app.command("uninstall")
  def integration_uninstall(
      key: str = typer.Argument(None, help="Integration key to uninstall (default: current integration)"),
      force: bool = typer.Option(False, "--force", help="Remove files even if modified"),
  ):
      """Uninstall an integration, safely preserving modified files."""
      from .integrations import get_integration
      from .integrations.manifest import IntegrationManifest
  
      project_root = Path.cwd()
  
      specify_dir = project_root / ".specify"
      if not specify_dir.exists():
          console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
          console.print("Run this command from a spec-kit project root")
          raise typer.Exit(1)
  
      current = _read_integration_json(project_root)
      installed_key = current.get("integration")
  
      if key is None:
          if not installed_key:
              console.print("[yellow]No integration is currently installed.[/yellow]")
              raise typer.Exit(0)
          key = installed_key
  
      if installed_key and installed_key != key:
          console.print(f"[red]Error:[/red] Integration '{key}' is not the currently installed integration ('{installed_key}').")
          raise typer.Exit(1)
  
      integration = get_integration(key)
  
      manifest_path = project_root / ".specify" / "integrations" / f"{key}.manifest.json"
      if not manifest_path.exists():
          console.print(f"[yellow]No manifest found for integration '{key}'. Nothing to uninstall.[/yellow]")
          _remove_integration_json(project_root)
          # Clear integration-related keys from init-options.json
          opts = load_init_options(project_root)
          if opts.get("integration") == key or opts.get("ai") == key:
              opts.pop("integration", None)
              opts.pop("ai", None)
              opts.pop("ai_skills", None)
              opts.pop("context_file", None)
              save_init_options(project_root, opts)
          raise typer.Exit(0)
  
      try:
          manifest = IntegrationManifest.load(key, project_root)
      except (ValueError, FileNotFoundError) as exc:
          console.print(f"[red]Error:[/red] Integration manifest for '{key}' is unreadable.")
          console.print(f"Manifest: {manifest_path}")
          console.print(
              f"To recover, delete the unreadable manifest, run "
              f"[cyan]specify integration uninstall {key}[/cyan] to clear stale metadata, "
              f"then run [cyan]specify integration install {key}[/cyan] to regenerate."
          )
          console.print(f"[dim]Details:[/dim] {exc}")
          raise typer.Exit(1)
  
      removed, skipped = manifest.uninstall(project_root, force=force)
  
      # Remove managed context section from the agent context file
      if integration:
          integration.remove_context_section(project_root)
  
      _remove_integration_json(project_root)
  
      # Update init-options.json to clear the integration
      opts = load_init_options(project_root)
      if opts.get("integration") == key or opts.get("ai") == key:
          opts.pop("integration", None)
          opts.pop("ai", None)
          opts.pop("ai_skills", None)
          opts.pop("context_file", None)
          save_init_options(project_root, opts)
  
      name = (integration.config or {}).get("name", key) if integration else key
      console.print(f"\n[green]✓[/green] Integration '{name}' uninstalled")
      if removed:
          console.print(f"  Removed {len(removed)} file(s)")
      if skipped:
          console.print(f"\n[yellow]⚠[/yellow]  {len(skipped)} modified file(s) were preserved:")
          for path in skipped:
              rel = path.relative_to(project_root) if path.is_absolute() else path
              console.print(f"    {rel}")
  
  
  @integration_app.command("switch")
  def integration_switch(
      target: str = typer.Argument(help="Integration key to switch to"),
      script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
      force: bool = typer.Option(False, "--force", help="Force removal of modified files during uninstall"),
      integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the target integration'),
  ):
      """Switch from the current integration to a different one."""
      from .integrations import INTEGRATION_REGISTRY, get_integration
      from .integrations.manifest import IntegrationManifest
  
      project_root = Path.cwd()
  
      specify_dir = project_root / ".specify"
      if not specify_dir.exists():
          console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
          console.print("Run this command from a spec-kit project root")
          raise typer.Exit(1)
  
      target_integration = get_integration(target)
      if target_integration is None:
          console.print(f"[red]Error:[/red] Unknown integration '{target}'")
          available = ", ".join(sorted(INTEGRATION_REGISTRY.keys()))
          console.print(f"Available integrations: {available}")
          raise typer.Exit(1)
  
      current = _read_integration_json(project_root)
      installed_key = current.get("integration")
  
      if installed_key == target:
          console.print(f"[yellow]Integration '{target}' is already installed. Nothing to switch.[/yellow]")
          raise typer.Exit(0)
  
      selected_script = _resolve_script_type(project_root, script)
  
      # Phase 1: Uninstall current integration (if any)
      if installed_key:
          current_integration = get_integration(installed_key)
          manifest_path = project_root / ".specify" / "integrations" / f"{installed_key}.manifest.json"
  
          if current_integration and manifest_path.exists():
              console.print(f"Uninstalling current integration: [cyan]{installed_key}[/cyan]")
              try:
                  old_manifest = IntegrationManifest.load(installed_key, project_root)
              except (ValueError, FileNotFoundError) as exc:
                  console.print(f"[red]Error:[/red] Could not read integration manifest for '{installed_key}': {manifest_path}")
                  console.print(f"[dim]{exc}[/dim]")
                  console.print(
                      f"To recover, delete the unreadable manifest at {manifest_path}, "
                      f"run [cyan]specify integration uninstall {installed_key}[/cyan], then retry."
                  )
                  raise typer.Exit(1)
              removed, skipped = old_manifest.uninstall(project_root, force=force)
              current_integration.remove_context_section(project_root)
              if removed:
                  console.print(f"  Removed {len(removed)} file(s)")
              if skipped:
                  console.print(f"  [yellow]⚠[/yellow]  {len(skipped)} modified file(s) preserved")
          elif not current_integration and manifest_path.exists():
              # Integration removed from registry but manifest exists — use manifest-only uninstall
              console.print(f"Uninstalling unknown integration '{installed_key}' via manifest")
              try:
                  old_manifest = IntegrationManifest.load(installed_key, project_root)
                  removed, skipped = old_manifest.uninstall(project_root, force=force)
                  if removed:
                      console.print(f"  Removed {len(removed)} file(s)")
                  if skipped:
                      console.print(f"  [yellow]⚠[/yellow]  {len(skipped)} modified file(s) preserved")
              except (ValueError, FileNotFoundError) as exc:
                  console.print(f"[yellow]Warning:[/yellow] Could not read manifest for '{installed_key}': {exc}")
          else:
              console.print(f"[red]Error:[/red] Integration '{installed_key}' is installed but has no manifest.")
              console.print(
                  f"Run [cyan]specify integration uninstall {installed_key}[/cyan] to clear metadata, "
                  f"then retry [cyan]specify integration switch {target}[/cyan]."
              )
              raise typer.Exit(1)
  
          # Clear metadata so a failed Phase 2 doesn't leave stale references
          _remove_integration_json(project_root)
          opts = load_init_options(project_root)
          opts.pop("integration", None)
          opts.pop("ai", None)
          opts.pop("ai_skills", None)
          opts.pop("context_file", None)
          save_init_options(project_root, opts)
  
      # Ensure shared infrastructure is present (safe to run unconditionally;
      # _install_shared_infra merges missing files without overwriting).
      _install_shared_infra(project_root, selected_script)
      if os.name != "nt":
          ensure_executable_scripts(project_root)
  
      # Phase 2: Install target integration
      console.print(f"Installing integration: [cyan]{target}[/cyan]")
      manifest = IntegrationManifest(
          target_integration.key, project_root, version=get_speckit_version()
      )
  
      parsed_options: dict[str, Any] | None = None
      if integration_options:
          parsed_options = _parse_integration_options(target_integration, integration_options)
  
      try:
          target_integration.setup(
              project_root, manifest,
              parsed_options=parsed_options,
              script_type=selected_script,
              raw_options=integration_options,
          )
          manifest.save()
          _write_integration_json(project_root, target_integration.key)
          _update_init_options_for_integration(project_root, target_integration, script_type=selected_script)
  
      except Exception as e:
          # Attempt rollback of any files written by setup
          try:
              target_integration.teardown(project_root, manifest, force=True)
          except Exception as rollback_err:
              # Suppress so the original setup error remains the primary failure
              console.print(f"[yellow]Warning:[/yellow] Failed to roll back integration '{target}': {rollback_err}")
          _remove_integration_json(project_root)
          console.print(f"[red]Error:[/red] Failed to install integration '{target}': {e}")
          raise typer.Exit(1)
  
      name = (target_integration.config or {}).get("name", target)
      console.print(f"\n[green]✓[/green] Switched to integration '{name}'")
  
  
  @integration_app.command("upgrade")
  def integration_upgrade(
      key: str | None = typer.Argument(None, help="Integration key to upgrade (default: current integration)"),
      force: bool = typer.Option(False, "--force", help="Force upgrade even if files are modified"),
      script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
      integration_options: str | None = typer.Option(None, "--integration-options", help="Options for the integration"),
  ):
      """Upgrade an integration by reinstalling with diff-aware file handling.
  
      Compares manifest hashes to detect locally modified files and
      blocks the upgrade unless --force is used.
      """
      from .integrations import get_integration
      from .integrations.manifest import IntegrationManifest
  
      project_root = Path.cwd()
  
      specify_dir = project_root / ".specify"
      if not specify_dir.exists():
          console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
          console.print("Run this command from a spec-kit project root")
          raise typer.Exit(1)
  
      current = _read_integration_json(project_root)
      installed_key = current.get("integration")
  
      if key is None:
          if not installed_key:
              console.print("[yellow]No integration is currently installed.[/yellow]")
              raise typer.Exit(0)
          key = installed_key
  
      if installed_key and installed_key != key:
          console.print(
              f"[red]Error:[/red] Integration '{key}' is not the currently installed integration ('{installed_key}')."
          )
          console.print(f"Use [cyan]specify integration switch {key}[/cyan] instead.")
          raise typer.Exit(1)
  
      integration = get_integration(key)
      if integration is None:
          console.print(f"[red]Error:[/red] Unknown integration '{key}'")
          raise typer.Exit(1)
  
      manifest_path = project_root / ".specify" / "integrations" / f"{key}.manifest.json"
      if not manifest_path.exists():
          console.print(f"[yellow]No manifest found for integration '{key}'. Nothing to upgrade.[/yellow]")
          console.print(f"Run [cyan]specify integration install {key}[/cyan] to perform a fresh install.")
          raise typer.Exit(0)
  
      try:
          old_manifest = IntegrationManifest.load(key, project_root)
      except (ValueError, FileNotFoundError) as exc:
          console.print(f"[red]Error:[/red] Integration manifest for '{key}' is unreadable: {exc}")
          raise typer.Exit(1)
  
      # Detect modified files via manifest hashes
      modified = old_manifest.check_modified()
      if modified and not force:
          console.print(f"[yellow]⚠[/yellow]  {len(modified)} file(s) have been modified since installation:")
          for rel in modified:
              console.print(f"    {rel}")
          console.print("\nUse [cyan]--force[/cyan] to overwrite modified files, or resolve manually.")
          raise typer.Exit(1)
  
      selected_script = _resolve_script_type(project_root, script)
  
      # Ensure shared infrastructure is present (safe to run unconditionally;
      # _install_shared_infra merges missing files without overwriting).
      _install_shared_infra(project_root, selected_script)
      if os.name != "nt":
          ensure_executable_scripts(project_root)
  
      # Phase 1: Install new files (overwrites existing; old-only files remain)
      console.print(f"Upgrading integration: [cyan]{key}[/cyan]")
      new_manifest = IntegrationManifest(key, project_root, version=get_speckit_version())
  
      parsed_options: dict[str, Any] | None = None
      if integration_options:
          parsed_options = _parse_integration_options(integration, integration_options)
  
      try:
          integration.setup(
              project_root,
              new_manifest,
              parsed_options=parsed_options,
              script_type=selected_script,
              raw_options=integration_options,
          )
          new_manifest.save()
          _write_integration_json(project_root, key)
          _update_init_options_for_integration(project_root, integration, script_type=selected_script)
      except Exception as exc:
          # Don't teardown — setup overwrites in-place, so teardown would
          # delete files that were working before the upgrade.  Just report.
          console.print(f"[red]Error:[/red] Failed to upgrade integration: {exc}")
          console.print("[yellow]The previous integration files may still be in place.[/yellow]")
          raise typer.Exit(1)
  
      # Phase 2: Remove stale files from old manifest that are not in the new one
      old_files = old_manifest.files
      new_files = new_manifest.files
      stale_keys = set(old_files) - set(new_files)
      if stale_keys:
          stale_manifest = IntegrationManifest(key, project_root, version="stale-cleanup")
          stale_manifest._files = {k: old_files[k] for k in stale_keys}
          stale_removed, _ = stale_manifest.uninstall(project_root, force=True)
          if stale_removed:
              console.print(f"  Removed {len(stale_removed)} stale file(s) from previous install")
  
      name = (integration.config or {}).get("name", key)
      console.print(f"\n[green]✓[/green] Integration '{name}' upgraded successfully")
  
  
  # ===== Preset Commands =====
  
  
  @preset_app.command("list")
  def preset_list():
      """List installed presets."""
      from .presets import PresetManager
  
      project_root = Path.cwd()
  
      specify_dir = project_root / ".specify"
      if not specify_dir.exists():
          console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
          console.print("Run this command from a spec-kit project root")
          raise typer.Exit(1)
  
      manager = PresetManager(project_root)
      installed = manager.list_installed()
  
      if not installed:
          console.print("[yellow]No presets installed.[/yellow]")
          console.print("\nInstall a preset with:")
          console.print("  [cyan]specify preset add <pack-name>[/cyan]")
          return
  
      console.print("\n[bold cyan]Installed Presets:[/bold cyan]\n")
      for pack in installed:
          status = "[green]enabled[/green]" if pack.get("enabled", True) else "[red]disabled[/red]"
          pri = pack.get('priority', 10)
          console.print(f"  [bold]{pack['name']}[/bold] ({pack['id']}) v{pack['version']} — {status} — priority {pri}")
          console.print(f"    {pack['description']}")
          if pack.get("tags"):
              tags_str = ", ".join(pack["tags"])
              console.print(f"    [dim]Tags: {tags_str}[/dim]")
          console.print(f"    [dim]Templates: {pack['template_count']}[/dim]")
          console.print()
  
  
  @preset_app.command("add")
  def preset_add(
      preset_id: str = typer.Argument(None, help="Preset ID to install from catalog"),
      from_url: str = typer.Option(None, "--from", help="Install from a URL (ZIP file)"),
      dev: str = typer.Option(None, "--dev", help="Install from local directory (development mode)"),
      priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"),
  ):
      """Install a preset."""
      from .presets import (
          PresetManager,
          PresetCatalog,
          PresetError,
          PresetValidationError,
          PresetCompatibilityError,
      )
  
      project_root = Path.cwd()
  
      specify_dir = project_root / ".specify"
      if not specify_dir.exists():
          console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
          console.print("Run this command from a spec-kit project root")
          raise typer.Exit(1)
  
      # Validate priority
      if priority < 1:
          console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
          raise typer.Exit(1)
  
      manager = PresetManager(project_root)
      speckit_version = get_speckit_version()
  
      try:
          if dev:
              dev_path = Path(dev).resolve()
              if not dev_path.exists():
                  console.print(f"[red]Error:[/red] Directory not found: {dev}")
                  raise typer.Exit(1)
  
              console.print(f"Installing preset from [cyan]{dev_path}[/cyan]...")
              manifest = manager.install_from_directory(dev_path, speckit_version, priority)
              console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
  
          elif from_url:
              # Validate URL scheme before downloading
              from urllib.parse import urlparse as _urlparse
              _parsed = _urlparse(from_url)
              _is_localhost = _parsed.hostname in ("localhost", "127.0.0.1", "::1")
              if _parsed.scheme != "https" and not (_parsed.scheme == "http" and _is_localhost):
                  console.print(f"[red]Error:[/red] URL must use HTTPS (got {_parsed.scheme}://). HTTP is only allowed for localhost.")
                  raise typer.Exit(1)
  
              console.print(f"Installing preset from [cyan]{from_url}[/cyan]...")
              import urllib.request
              import urllib.error
              import tempfile
  
              with tempfile.TemporaryDirectory() as tmpdir:
                  zip_path = Path(tmpdir) / "preset.zip"
                  try:
                      with urllib.request.urlopen(from_url, timeout=60) as response:
                          zip_path.write_bytes(response.read())
                  except urllib.error.URLError as e:
                      console.print(f"[red]Error:[/red] Failed to download: {e}")
                      raise typer.Exit(1)
  
                  manifest = manager.install_from_zip(zip_path, speckit_version, priority)
  
              console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
  
          elif preset_id:
              # Try bundled preset first, then catalog
              bundled_path = _locate_bundled_preset(preset_id)
              if bundled_path:
                  console.print(f"Installing bundled preset [cyan]{preset_id}[/cyan]...")
                  manifest = manager.install_from_directory(bundled_path, speckit_version, priority)
                  console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
              else:
                  catalog = PresetCatalog(project_root)
                  pack_info = catalog.get_pack_info(preset_id)
  
                  if not pack_info:
                      console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in catalog")
                      raise typer.Exit(1)
  
                  # Bundled presets should have been caught above; if we reach
                  # here the bundled files are missing from the installation.
                  if pack_info.get("bundled") and not pack_info.get("download_url"):
                      from .extensions import REINSTALL_COMMAND
                      console.print(
                          f"[red]Error:[/red] Preset '{preset_id}' is bundled with spec-kit "
                          f"but could not be found in the installed package."
                      )
                      console.print(
                          "\nThis usually means the spec-kit installation is incomplete or corrupted."
                      )
                      console.print("Try reinstalling spec-kit:")
                      console.print(f"  {REINSTALL_COMMAND}")
                      raise typer.Exit(1)
  
                  if not pack_info.get("_install_allowed", True):
                      catalog_name = pack_info.get("_catalog_name", "unknown")
                      console.print(f"[red]Error:[/red] Preset '{preset_id}' is from the '{catalog_name}' catalog which is discovery-only (install not allowed).")
                      console.print("Add the catalog with --install-allowed or install from the preset's repository directly with --from.")
                      raise typer.Exit(1)
  
                  console.print(f"Installing preset [cyan]{pack_info.get('name', preset_id)}[/cyan]...")
  
                  try:
                      zip_path = catalog.download_pack(preset_id)
                      manifest = manager.install_from_zip(zip_path, speckit_version, priority)
                      console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
                  finally:
                      if 'zip_path' in locals() and zip_path.exists():
                          zip_path.unlink(missing_ok=True)
          else:
              console.print("[red]Error:[/red] Specify a preset ID, --from URL, or --dev path")
              raise typer.Exit(1)
  
      except PresetCompatibilityError as e:
          console.print(f"[red]Compatibility Error:[/red] {e}")
          raise typer.Exit(1)
      except PresetValidationError as e:
          console.print(f"[red]Validation Error:[/red] {e}")
          raise typer.Exit(1)
      except PresetError as e:
          console.print(f"[red]Error:[/red] {e}")
          raise typer.Exit(1)
  
  
  @preset_app.command("remove")
  def preset_remove(
      preset_id: str = typer.Argument(..., help="Preset ID to remove"),
  ):
      """Remove an installed preset."""
      from .presets import PresetManager
  
      project_root = Path.cwd()
  
      specify_dir = project_root / ".specify"
      if not specify_dir.exists():
          console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
          console.print("Run this command from a spec-kit project root")
          raise typer.Exit(1)
  
      manager = PresetManager(project_root)
  
      if not manager.registry.is_installed(preset_id):
          console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed")
          raise typer.Exit(1)
  
      if manager.remove(preset_id):
          console.print(f"[green]✓[/green] Preset '{preset_id}' removed successfully")
      else:
          console.print(f"[red]Error:[/red] Failed to remove preset '{preset_id}'")
          raise typer.Exit(1)
  
  
  @preset_app.command("search")
  def preset_search(
      query: str = typer.Argument(None, help="Search query"),
      tag: str = typer.Option(None, "--tag", help="Filter by tag"),
      author: str = typer.Option(None, "--author", help="Filter by author"),
  ):
      """Search for presets in the catalog."""
      from .presets import PresetCatalog, PresetError
  
      project_root = Path.cwd()
  
      specify_dir = project_root / ".specify"
      if not specify_dir.exists():
          console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
          console.print("Run this command from a spec-kit project root")
          raise typer.Exit(1)
  
      catalog = PresetCatalog(project_root)
  
      try:
          results = catalog.search(query=query, tag=tag, author=author)
      except PresetError as e:
          console.print(f"[red]Error:[/red] {e}")
          raise typer.Exit(1)
  
      if not results:
          console.print("[yellow]No presets found matching your criteria.[/yellow]")
          return
  
      console.print(f"\n[bold cyan]Presets ({len(results)} found):[/bold cyan]\n")
      for pack in results:
          console.print(f"  [bold]{pack.get('name', pack['id'])}[/bold] ({pack['id']}) v{pack.get('version', '?')}")
          console.print(f"    {pack.get('description', '')}")
          if pack.get("tags"):
              tags_str = ", ".join(pack["tags"])
              console.print(f"    [dim]Tags: {tags_str}[/dim]")
          console.print()
  
  
  @preset_app.command("resolve")
  def preset_resolve(
      template_name: str = typer.Argument(..., help="Template name to resolve (e.g., spec-template)"),
  ):
      """Show which template will be resolved for a given name."""
      from .presets import PresetResolver
  
      project_root = Path.cwd()
  
      specify_dir = project_root / ".specify"
      if not specify_dir.exists():
          console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
          console.print("Run this command from a spec-kit project root")
          raise typer.Exit(1)
  
      resolver = PresetResolver(project_root)
      result = resolver.resolve_with_source(template_name)
  
      if result:
          console.print(f"  [bold]{template_name}[/bold]: {result['path']}")
          console.print(f"    [dim](from: {result['source']})[/dim]")
      else:
          console.print(f"  [yellow]{template_name}[/yellow]: not found")
          console.print("    [dim]No template with this name exists in the resolution stack[/dim]")
  
  
  @preset_app.command("info")
  def preset_info(
      preset_id: str = typer.Argument(..., help="Preset ID to get info about"),
  ):
      """Show detailed information about a preset."""
      from .extensions import normalize_priority
      from .presets import PresetCatalog, PresetManager, PresetError
  
      project_root = Path.cwd()
  
      specify_dir = project_root / ".specify"
      if not specify_dir.exists():
          console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
          console.print("Run this command from a spec-kit project root")
          raise typer.Exit(1)
  
      # Check if installed locally first
      manager = PresetManager(project_root)
      local_pack = manager.get_pack(preset_id)
  
      if local_pack:
          console.print(f"\n[bold cyan]Preset: {local_pack.name}[/bold cyan]\n")
          console.print(f"  ID:          {local_pack.id}")
          console.print(f"  Version:     {local_pack.version}")
          console.print(f"  Description: {local_pack.description}")
          if local_pack.author:
              console.print(f"  Author:      {local_pack.author}")
          if local_pack.tags:
              console.print(f"  Tags:        {', '.join(local_pack.tags)}")
          console.print(f"  Templates:   {len(local_pack.templates)}")
          for tmpl in local_pack.templates:
              console.print(f"    - {tmpl['name']} ({tmpl['type']}): {tmpl.get('description', '')}")
          repo = local_pack.data.get("preset", {}).get("repository")
          if repo:
              console.print(f"  Repository:  {repo}")
          license_val = local_pack.data.get("preset", {}).get("license")
          if license_val:
              console.print(f"  License:     {license_val}")
          console.print("\n  [green]Status: installed[/green]")
          # Get priority from registry
          pack_metadata = manager.registry.get(preset_id)
          priority = normalize_priority(pack_metadata.get("priority") if isinstance(pack_metadata, dict) else None)
          console.print(f"  [dim]Priority:[/dim] {priority}")
          console.print()
          return
  
      # Fall back to catalog
      catalog = PresetCatalog(project_root)
      try:
          pack_info = catalog.get_pack_info(preset_id)
      except PresetError:
          pack_info = None
  
      if not pack_info:
          console.print(f"[red]Error:[/red] Preset '{preset_id}' not found (not installed and not in catalog)")
          raise typer.Exit(1)
  
      console.print(f"\n[bold cyan]Preset: {pack_info.get('name', preset_id)}[/bold cyan]\n")
      console.print(f"  ID:          {pack_info['id']}")
      console.print(f"  Version:     {pack_info.get('version', '?')}")
      console.print(f"  Description: {pack_info.get('description', '')}")
      if pack_info.get("author"):
          console.print(f"  Author:      {pack_info['author']}")
      if pack_info.get("tags"):
          console.print(f"  Tags:        {', '.join(pack_info['tags'])}")
      if pack_info.get("repository"):
          console.print(f"  Repository:  {pack_info['repository']}")
      if pack_info.get("license"):
          console.print(f"  License:     {pack_info['license']}")
      console.print("\n  [yellow]Status: not installed[/yellow]")
      console.print(f"  Install with: [cyan]specify preset add {preset_id}[/cyan]")
      console.print()
  
  
  @preset_app.command("set-priority")
  def preset_set_priority(
      preset_id: str = typer.Argument(help="Preset ID"),
      priority: int = typer.Argument(help="New priority (lower = higher precedence)"),
  ):
      """Set the resolution priority of an installed preset."""
      from .presets import PresetManager
  
      project_root = Path.cwd()
  
      # Check if we're in a spec-kit project
      specify_dir = project_root / ".specify"
      if not specify_dir.exists():
          console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
          console.print("Run this command from a spec-kit project root")
          raise typer.Exit(1)
  
      # Validate priority
      if priority < 1:
          console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
          raise typer.Exit(1)
  
      manager = PresetManager(project_root)
  
      # Check if preset is installed
      if not manager.registry.is_installed(preset_id):
          console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed")
          raise typer.Exit(1)
  
      # Get current metadata
      metadata = manager.registry.get(preset_id)
      if metadata is None or not isinstance(metadata, dict):
          console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)")
          raise typer.Exit(1)
  
      from .extensions import normalize_priority
      raw_priority = metadata.get("priority")
      # Only skip if the stored value is already a valid int equal to requested priority
      # This ensures corrupted values (e.g., "high") get repaired even when setting to default (10)
      if isinstance(raw_priority, int) and raw_priority == priority:
          console.print(f"[yellow]Preset '{preset_id}' already has priority {priority}[/yellow]")
          raise typer.Exit(0)
  
      old_priority = normalize_priority(raw_priority)
  
      # Update priority
      manager.registry.update(preset_id, {"priority": priority})
  
      console.print(f"[green]✓[/green] Preset '{preset_id}' priority changed: {old_priority} → {priority}")
      console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]")
  
  
  @preset_app.command("enable")
  def preset_enable(
      preset_id: str = typer.Argument(help="Preset ID to enable"),
  ):
      """Enable a disabled preset."""
      from .presets import PresetManager
  
      project_root = Path.cwd()
  
      # Check if we're in a spec-kit project
      specify_dir = project_root / ".specify"
      if not specify_dir.exists():
          console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
          console.print("Run this command from a spec-kit project root")
          raise typer.Exit(1)
  
      manager = PresetManager(project_root)
  
      # Check if preset is installed
      if not manager.registry.is_installed(preset_id):
          console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed")
          raise typer.Exit(1)
  
      # Get current metadata
      metadata = manager.registry.get(preset_id)
      if metadata is None or not isinstance(metadata, dict):
          console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)")
          raise typer.Exit(1)
  
      if metadata.get("enabled", True):
          console.print(f"[yellow]Preset '{preset_id}' is already enabled[/yellow]")
          raise typer.Exit(0)
  
      # Enable the preset
      manager.registry.update(preset_id, {"enabled": True})
  
      console.print(f"[green]✓[/green] Preset '{preset_id}' enabled")
      console.print("\nTemplates from this preset will now be included in resolution.")
      console.print("[dim]Note: Previously registered commands/skills remain active.[/dim]")
  
  
  @preset_app.command("disable")
  def preset_disable(
      preset_id: str = typer.Argument(help="Preset ID to disable"),
  ):
      """Disable a preset without removing it."""
      from .presets import PresetManager
  
      project_root = Path.cwd()
  
      # Check if we're in a spec-kit project
      specify_dir = project_root / ".specify"
      if not specify_dir.exists():
          console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
          console.print("Run this command from a spec-kit project root")
          raise typer.Exit(1)
  
      manager = PresetManager(project_root)
  
      # Check if preset is installed
      if not manager.registry.is_installed(preset_id):
          console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed")
          raise typer.Exit(1)
  
      # Get current metadata
      metadata = manager.registry.get(preset_id)
      if metadata is None or not isinstance(metadata, dict):
          console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)")
          raise typer.Exit(1)
  
      if not metadata.get("enabled", True):
          console.print(f"[yellow]Preset '{preset_id}' is already disabled[/yellow]")
          raise typer.Exit(0)
  
      # Disable the preset
      manager.registry.update(preset_id, {"enabled": False})
  
      console.print(f"[green]✓[/green] Preset '{preset_id}' disabled")
      console.print("\nTemplates from this preset will be skipped during resolution.")
      console.print("[dim]Note: Previously registered commands/skills remain active until preset removal.[/dim]")
      console.print(f"To re-enable: specify preset enable {preset_id}")
  
  
  # ===== Preset Catalog Commands =====
  
  
  @preset_catalog_app.command("list")
  def preset_catalog_list():
      """List all active preset catalogs."""
      from .presets import PresetCatalog, PresetValidationError
  
      project_root = Path.cwd()
  
      specify_dir = project_root / ".specify"
      if not specify_dir.exists():
          console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
          console.print("Run this command from a spec-kit project root")
          raise typer.Exit(1)
  
      catalog = PresetCatalog(project_root)
  
      try:
          active_catalogs = catalog.get_active_catalogs()
      except PresetValidationError as e:
          console.print(f"[red]Error:[/red] {e}")
          raise typer.Exit(1)
  
      console.print("\n[bold cyan]Active Preset Catalogs:[/bold cyan]\n")
      for entry in active_catalogs:
          install_str = (
              "[green]install allowed[/green]"
              if entry.install_allowed
              else "[yellow]discovery only[/yellow]"
          )
          console.print(f"  [bold]{entry.name}[/bold] (priority {entry.priority})")
          if entry.description:
              console.print(f"     {entry.description}")
          console.print(f"     URL: {entry.url}")
          console.print(f"     Install: {install_str}")
          console.print()
  
      config_path = project_root / ".specify" / "preset-catalogs.yml"
      user_config_path = Path.home() / ".specify" / "preset-catalogs.yml"
      if os.environ.get("SPECKIT_PRESET_CATALOG_URL"):
          console.print("[dim]Catalog configured via SPECKIT_PRESET_CATALOG_URL environment variable.[/dim]")
      else:
          try:
              proj_loaded = config_path.exists() and catalog._load_catalog_config(config_path) is not None
          except PresetValidationError:
              proj_loaded = False
          if proj_loaded:
              console.print(f"[dim]Config: {config_path.relative_to(project_root)}[/dim]")
          else:
              try:
                  user_loaded = user_config_path.exists() and catalog._load_catalog_config(user_config_path) is not None
              except PresetValidationError:
                  user_loaded = False
              if user_loaded:
                  console.print("[dim]Config: ~/.specify/preset-catalogs.yml[/dim]")
              else:
                  console.print("[dim]Using built-in default catalog stack.[/dim]")
                  console.print(
                      "[dim]Add .specify/preset-catalogs.yml to customize.[/dim]"
                  )
  
  
  @preset_catalog_app.command("add")
  def preset_catalog_add(
      url: str = typer.Argument(help="Catalog URL (must use HTTPS)"),
      name: str = typer.Option(..., "--name", help="Catalog name"),
      priority: int = typer.Option(10, "--priority", help="Priority (lower = higher priority)"),
      install_allowed: bool = typer.Option(
          False, "--install-allowed/--no-install-allowed",
          help="Allow presets from this catalog to be installed",
      ),
      description: str = typer.Option("", "--description", help="Description of the catalog"),
  ):
      """Add a catalog to .specify/preset-catalogs.yml."""
      from .presets import PresetCatalog, PresetValidationError
  
      project_root = Path.cwd()
  
      specify_dir = project_root / ".specify"
      if not specify_dir.exists():
          console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
          console.print("Run this command from a spec-kit project root")
          raise typer.Exit(1)
  
      # Validate URL
      tmp_catalog = PresetCatalog(project_root)
      try:
          tmp_catalog._validate_catalog_url(url)
      except PresetValidationError as e:
          console.print(f"[red]Error:[/red] {e}")
          raise typer.Exit(1)
  
      config_path = specify_dir / "preset-catalogs.yml"
  
      # Load existing config
      if config_path.exists():
          try:
              config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
          except Exception as e:
              console.print(f"[red]Error:[/red] Failed to read {config_path}: {e}")
              raise typer.Exit(1)
      else:
          config = {}
  
      catalogs = config.get("catalogs", [])
      if not isinstance(catalogs, list):
          console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.")
          raise typer.Exit(1)
  
      # Check for duplicate name
      for existing in catalogs:
          if isinstance(existing, dict) and existing.get("name") == name:
              console.print(f"[yellow]Warning:[/yellow] A catalog named '{name}' already exists.")
              console.print("Use 'specify preset catalog remove' first, or choose a different name.")
              raise typer.Exit(1)
  
      catalogs.append({
          "name": name,
          "url": url,
          "priority": priority,
          "install_allowed": install_allowed,
          "description": description,
      })
  
      config["catalogs"] = catalogs
      config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
  
      install_label = "install allowed" if install_allowed else "discovery only"
      console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})")
      console.print(f"  URL: {url}")
      console.print(f"  Priority: {priority}")
      console.print(f"\nConfig saved to {config_path.relative_to(project_root)}")
  
  
  @preset_catalog_app.command("remove")
  def preset_catalog_remove(
      name: str = typer.Argument(help="Catalog name to remove"),
  ):
      """Remove a catalog from .specify/preset-catalogs.yml."""
      project_root = Path.cwd()
  
      specify_dir = project_root / ".specify"
      if not specify_dir.exists():
          console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
          console.print("Run this command from a spec-kit project root")
          raise typer.Exit(1)
  
      config_path = specify_dir / "preset-catalogs.yml"
      if not config_path.exists():
          console.print("[red]Error:[/red] No preset catalog config found. Nothing to remove.")
          raise typer.Exit(1)
  
      try:
          config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
      except Exception:
          console.print("[red]Error:[/red] Failed to read preset catalog config.")
          raise typer.Exit(1)
  
      catalogs = config.get("catalogs", [])
      if not isinstance(catalogs, list):
          console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.")
          raise typer.Exit(1)
      original_count = len(catalogs)
      catalogs = [c for c in catalogs if isinstance(c, dict) and c.get("name") != name]
  
      if len(catalogs) == original_count:
          console.print(f"[red]Error:[/red] Catalog '{name}' not found.")
          raise typer.Exit(1)
  
      config["catalogs"] = catalogs
      config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
  
      console.print(f"[green]✓[/green] Removed catalog '{name}'")
      if not catalogs:
          console.print("\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]")
  
  
  # ===== Extension Commands =====
  
  
  def _resolve_installed_extension(
      argument: str,
      installed_extensions: list,
      command_name: str = "command",
      allow_not_found: bool = False,
  ) -> tuple[Optional[str], Optional[str]]:
      """Resolve an extension argument (ID or display name) to an installed extension.
  
      Args:
          argument: Extension ID or display name provided by user
          installed_extensions: List of installed extension dicts from manager.list_installed()
          command_name: Name of the command for error messages (e.g., "enable", "disable")
          allow_not_found: If True, return (None, None) when not found instead of raising
  
      Returns:
          Tuple of (extension_id, display_name), or (None, None) if allow_not_found=True and not found
  
      Raises:
          typer.Exit: If extension not found (and allow_not_found=False) or name is ambiguous
      """
      from rich.table import Table
  
      # First, try exact ID match
      for ext in installed_extensions:
          if ext["id"] == argument:
              return (ext["id"], ext["name"])
  
      # If not found by ID, try display name match
      name_matches = [ext for ext in installed_extensions if ext["name"].lower() == argument.lower()]
  
      if len(name_matches) == 1:
          # Unique display-name match
          return (name_matches[0]["id"], name_matches[0]["name"])
      elif len(name_matches) > 1:
          # Ambiguous display-name match
          console.print(
              f"[red]Error:[/red] Extension name '{argument}' is ambiguous. "
              "Multiple installed extensions share this name:"
          )
          table = Table(title="Matching extensions")
          table.add_column("ID", style="cyan", no_wrap=True)
          table.add_column("Name", style="white")
          table.add_column("Version", style="green")
          for ext in name_matches:
              table.add_row(ext.get("id", ""), ext.get("name", ""), str(ext.get("version", "")))
          console.print(table)
          console.print("\nPlease rerun using the extension ID:")
          console.print(f"  [bold]specify extension {command_name} <extension-id>[/bold]")
          raise typer.Exit(1)
      else:
          # No match by ID or display name
          if allow_not_found:
              return (None, None)
          console.print(f"[red]Error:[/red] Extension '{argument}' is not installed")
          raise typer.Exit(1)
  
  
  def _resolve_catalog_extension(
      argument: str,
      catalog,
      command_name: str = "info",
  ) -> tuple[Optional[dict], Optional[Exception]]:
      """Resolve an extension argument (ID or display name) from the catalog.
  
      Args:
          argument: Extension ID or display name provided by user
          catalog: ExtensionCatalog instance
          command_name: Name of the command for error messages
  
      Returns:
          Tuple of (extension_info, catalog_error)
          - If found: (ext_info_dict, None)
          - If catalog error: (None, error)
          - If not found: (None, None)
      """
      from rich.table import Table
      from .extensions import ExtensionError
  
      try:
          # First try by ID
          ext_info = catalog.get_extension_info(argument)
          if ext_info:
              return (ext_info, None)
  
          # Try by display name - search using argument as query, then filter for exact match
          search_results = catalog.search(query=argument)
          name_matches = [ext for ext in search_results if ext["name"].lower() == argument.lower()]
  
          if len(name_matches) == 1:
              return (name_matches[0], None)
          elif len(name_matches) > 1:
              # Ambiguous display-name match in catalog
              console.print(
                  f"[red]Error:[/red] Extension name '{argument}' is ambiguous. "
                  "Multiple catalog extensions share this name:"
              )
              table = Table(title="Matching extensions")
              table.add_column("ID", style="cyan", no_wrap=True)
              table.add_column("Name", style="white")
              table.add_column("Version", style="green")
              table.add_column("Catalog", style="dim")
              for ext in name_matches:
                  table.add_row(
                      ext.get("id", ""),
                      ext.get("name", ""),
                      str(ext.get("version", "")),
                      ext.get("_catalog_name", ""),
                  )
              console.print(table)
              console.print("\nPlease rerun using the extension ID:")
              console.print(f"  [bold]specify extension {command_name} <extension-id>[/bold]")
              raise typer.Exit(1)
  
          # Not found
          return (None, None)
  
      except ExtensionError as e:
          return (None, e)
  
  
  @extension_app.command("list")
  def extension_list(
      available: bool = typer.Option(False, "--available", help="Show available extensions from catalog"),
      all_extensions: bool = typer.Option(False, "--all", help="Show both installed and available"),
  ):
      """List installed extensions."""
      from .extensions import ExtensionManager
  
      project_root = Path.cwd()
  
      # Check if we're in a spec-kit project
      specify_dir = project_root / ".specify"
      if not specify_dir.exists():
          console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
          console.print("Run this command from a spec-kit project root")
          raise typer.Exit(1)
  
      manager = ExtensionManager(project_root)
      installed = manager.list_installed()
  
      if not installed and not (available or all_extensions):
          console.print("[yellow]No extensions installed.[/yellow]")
          console.print("\nInstall an extension with:")
          console.print("  specify extension add <extension-name>")
          return
  
      if installed:
          console.print("\n[bold cyan]Installed Extensions:[/bold cyan]\n")
  
          for ext in installed:
              status_icon = "✓" if ext["enabled"] else "✗"
              status_color = "green" if ext["enabled"] else "red"
  
              console.print(f"  [{status_color}]{status_icon}[/{status_color}] [bold]{ext['name']}[/bold] (v{ext['version']})")
              console.print(f"     [dim]{ext['id']}[/dim]")
              console.print(f"     {ext['description']}")
              console.print(f"     Commands: {ext['command_count']} | Hooks: {ext['hook_count']} | Priority: {ext['priority']} | Status: {'Enabled' if ext['enabled'] else 'Disabled'}")
              console.print()
  
      if available or all_extensions:
          console.print("\nInstall an extension:")
          console.print("  [cyan]specify extension add <name>[/cyan]")
  
  
  @catalog_app.command("list")
  def catalog_list():
      """List all active extension catalogs."""
      from .extensions import ExtensionCatalog, ValidationError
  
      project_root = Path.cwd()
  
      specify_dir = project_root / ".specify"
      if not specify_dir.exists():
          console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
          console.print("Run this command from a spec-kit project root")
          raise typer.Exit(1)
  
      catalog = ExtensionCatalog(project_root)
  
      try:
          active_catalogs = catalog.get_active_catalogs()
      except ValidationError as e:
          console.print(f"[red]Error:[/red] {e}")
          raise typer.Exit(1)
  
      console.print("\n[bold cyan]Active Extension Catalogs:[/bold cyan]\n")
      for entry in active_catalogs:
          install_str = (
              "[green]install allowed[/green]"
              if entry.install_allowed
              else "[yellow]discovery only[/yellow]"
          )
          console.print(f"  [bold]{entry.name}[/bold] (priority {entry.priority})")
          if entry.description:
              console.print(f"     {entry.description}")
          console.print(f"     URL: {entry.url}")
          console.print(f"     Install: {install_str}")
          console.print()
  
      config_path = project_root / ".specify" / "extension-catalogs.yml"
      user_config_path = Path.home() / ".specify" / "extension-catalogs.yml"
      if os.environ.get("SPECKIT_CATALOG_URL"):
          console.print("[dim]Catalog configured via SPECKIT_CATALOG_URL environment variable.[/dim]")
      else:
          try:
              proj_loaded = config_path.exists() and catalog._load_catalog_config(config_path) is not None
          except ValidationError:
              proj_loaded = False
          if proj_loaded:
              console.print(f"[dim]Config: {config_path.relative_to(project_root)}[/dim]")
          else:
              try:
                  user_loaded = user_config_path.exists() and catalog._load_catalog_config(user_config_path) is not None
              except ValidationError:
                  user_loaded = False
              if user_loaded:
                  console.print("[dim]Config: ~/.specify/extension-catalogs.yml[/dim]")
              else:
                  console.print("[dim]Using built-in default catalog stack.[/dim]")
                  console.print(
                      "[dim]Add .specify/extension-catalogs.yml to customize.[/dim]"
                  )
  
  
  @catalog_app.command("add")
  def catalog_add(
      url: str = typer.Argument(help="Catalog URL (must use HTTPS)"),
      name: str = typer.Option(..., "--name", help="Catalog name"),
      priority: int = typer.Option(10, "--priority", help="Priority (lower = higher priority)"),
      install_allowed: bool = typer.Option(
          False, "--install-allowed/--no-install-allowed",
          help="Allow extensions from this catalog to be installed",
      ),
      description: str = typer.Option("", "--description", help="Description of the catalog"),
  ):
      """Add a catalog to .specify/extension-catalogs.yml."""
      from .extensions import ExtensionCatalog, ValidationError
  
      project_root = Path.cwd()
  
      specify_dir = project_root / ".specify"
      if not specify_dir.exists():
          console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
          console.print("Run this command from a spec-kit project root")
          raise typer.Exit(1)
  
      # Validate URL
      tmp_catalog = ExtensionCatalog(project_root)
      try:
          tmp_catalog._validate_catalog_url(url)
      except ValidationError as e:
          console.print(f"[red]Error:[/red] {e}")
          raise typer.Exit(1)
  
      config_path = specify_dir / "extension-catalogs.yml"
  
      # Load existing config
      if config_path.exists():
          try:
              config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
          except Exception as e:
              console.print(f"[red]Error:[/red] Failed to read {config_path}: {e}")
              raise typer.Exit(1)
      else:
          config = {}
  
      catalogs = config.get("catalogs", [])
      if not isinstance(catalogs, list):
          console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.")
          raise typer.Exit(1)
  
      # Check for duplicate name
      for existing in catalogs:
          if isinstance(existing, dict) and existing.get("name") == name:
              console.print(f"[yellow]Warning:[/yellow] A catalog named '{name}' already exists.")
              console.print("Use 'specify extension catalog remove' first, or choose a different name.")
              raise typer.Exit(1)
  
      catalogs.append({
          "name": name,
          "url": url,
          "priority": priority,
          "install_allowed": install_allowed,
          "description": description,
      })
  
      config["catalogs"] = catalogs
      config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
  
      install_label = "install allowed" if install_allowed else "discovery only"
      console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})")
      console.print(f"  URL: {url}")
      console.print(f"  Priority: {priority}")
      console.print(f"\nConfig saved to {config_path.relative_to(project_root)}")
  
  
  @catalog_app.command("remove")
  def catalog_remove(
      name: str = typer.Argument(help="Catalog name to remove"),
  ):
      """Remove a catalog from .specify/extension-catalogs.yml."""
      project_root = Path.cwd()
  
      specify_dir = project_root / ".specify"
      if not specify_dir.exists():
          console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
          console.print("Run this command from a spec-kit project root")
          raise typer.Exit(1)
  
      config_path = specify_dir / "extension-catalogs.yml"
      if not config_path.exists():
          console.print("[red]Error:[/red] No catalog config found. Nothing to remove.")
          raise typer.Exit(1)
  
      try:
          config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
      except Exception:
          console.print("[red]Error:[/red] Failed to read catalog config.")
          raise typer.Exit(1)
  
      catalogs = config.get("catalogs", [])
      if not isinstance(catalogs, list):
          console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.")
          raise typer.Exit(1)
      original_count = len(catalogs)
      catalogs = [c for c in catalogs if isinstance(c, dict) and c.get("name") != name]
  
      if len(catalogs) == original_count:
          console.print(f"[red]Error:[/red] Catalog '{name}' not found.")
          raise typer.Exit(1)
  
      config["catalogs"] = catalogs
      config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
  
      console.print(f"[green]✓[/green] Removed catalog '{name}'")
      if not catalogs:
          console.print("\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]")
  
  
  @extension_app.command("add")
  def extension_add(
      extension: str = typer.Argument(help="Extension name or path"),
      dev: bool = typer.Option(False, "--dev", help="Install from local directory"),
      from_url: Optional[str] = typer.Option(None, "--from", help="Install from custom URL"),
      priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"),
  ):
      """Install an extension."""
      from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError, REINSTALL_COMMAND
  
      project_root = Path.cwd()
  
      # Check if we're in a spec-kit project
      specify_dir = project_root / ".specify"
      if not specify_dir.exists():
          console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
          console.print("Run this command from a spec-kit project root")
          raise typer.Exit(1)
  
      # Validate priority
      if priority < 1:
          console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
          raise typer.Exit(1)
  
      manager = ExtensionManager(project_root)
      speckit_version = get_speckit_version()
  
      try:
          with console.status(f"[cyan]Installing extension: {extension}[/cyan]"):
              if dev:
                  # Install from local directory
                  source_path = Path(extension).expanduser().resolve()
                  if not source_path.exists():
                      console.print(f"[red]Error:[/red] Directory not found: {source_path}")
                      raise typer.Exit(1)
  
                  if not (source_path / "extension.yml").exists():
                      console.print(f"[red]Error:[/red] No extension.yml found in {source_path}")
                      raise typer.Exit(1)
  
                  manifest = manager.install_from_directory(source_path, speckit_version, priority=priority)
  
              elif from_url:
                  # Install from URL (ZIP file)
                  import urllib.request
                  import urllib.error
                  from urllib.parse import urlparse
  
                  # Validate URL
                  parsed = urlparse(from_url)
                  is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
  
                  if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
                      console.print("[red]Error:[/red] URL must use HTTPS for security.")
                      console.print("HTTP is only allowed for localhost URLs.")
                      raise typer.Exit(1)
  
                  # Warn about untrusted sources
                  console.print("[yellow]Warning:[/yellow] Installing from external URL.")
                  console.print("Only install extensions from sources you trust.\n")
                  console.print(f"Downloading from {from_url}...")
  
                  # Download ZIP to temp location
                  download_dir = project_root / ".specify" / "extensions" / ".cache" / "downloads"
                  download_dir.mkdir(parents=True, exist_ok=True)
                  zip_path = download_dir / f"{extension}-url-download.zip"
  
                  try:
                      with urllib.request.urlopen(from_url, timeout=60) as response:
                          zip_data = response.read()
                      zip_path.write_bytes(zip_data)
  
                      # Install from downloaded ZIP
                      manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority)
                  except urllib.error.URLError as e:
                      console.print(f"[red]Error:[/red] Failed to download from {from_url}: {e}")
                      raise typer.Exit(1)
                  finally:
                      # Clean up downloaded ZIP
                      if zip_path.exists():
                          zip_path.unlink()
  
              else:
                  # Try bundled extensions first (shipped with spec-kit)
                  bundled_path = _locate_bundled_extension(extension)
                  if bundled_path is not None:
                      manifest = manager.install_from_directory(bundled_path, speckit_version, priority=priority)
                  else:
                      # Install from catalog (also resolves display names to IDs)
                      catalog = ExtensionCatalog(project_root)
  
                      # Check if extension exists in catalog (supports both ID and display name)
                      ext_info, catalog_error = _resolve_catalog_extension(extension, catalog, "add")
                      if catalog_error:
                          console.print(f"[red]Error:[/red] Could not query extension catalog: {catalog_error}")
                          raise typer.Exit(1)
                      if not ext_info:
                          console.print(f"[red]Error:[/red] Extension '{extension}' not found in catalog")
                          console.print("\nSearch available extensions:")
                          console.print("  specify extension search")
                          raise typer.Exit(1)
  
                      # If catalog resolved a display name to an ID, check bundled again
                      resolved_id = ext_info['id']
                      if resolved_id != extension:
                          bundled_path = _locate_bundled_extension(resolved_id)
                          if bundled_path is not None:
                              manifest = manager.install_from_directory(bundled_path, speckit_version, priority=priority)
  
                      if bundled_path is None:
                          # Bundled extensions without a download URL must come from the local package
                          if ext_info.get("bundled") and not ext_info.get("download_url"):
                              console.print(
                                  f"[red]Error:[/red] Extension '{ext_info['id']}' is bundled with spec-kit "
                                  f"but could not be found in the installed package."
                              )
                              console.print(
                                  "\nThis usually means the spec-kit installation is incomplete or corrupted."
                              )
                              console.print("Try reinstalling spec-kit:")
                              console.print(f"  {REINSTALL_COMMAND}")
                              raise typer.Exit(1)
  
                          # Enforce install_allowed policy
                          if not ext_info.get("_install_allowed", True):
                              catalog_name = ext_info.get("_catalog_name", "community")
                              console.print(
                                  f"[red]Error:[/red] '{extension}' is available in the "
                                  f"'{catalog_name}' catalog but installation is not allowed from that catalog."
                              )
                              console.print(
                                  f"\nTo enable installation, add '{extension}' to an approved catalog "
                                  f"(install_allowed: true) in .specify/extension-catalogs.yml."
                              )
                              raise typer.Exit(1)
  
                          # Download extension ZIP (use resolved ID, not original argument which may be display name)
                          extension_id = ext_info['id']
                          console.print(f"Downloading {ext_info['name']} v{ext_info.get('version', 'unknown')}...")
                          zip_path = catalog.download_extension(extension_id)
  
                          try:
                              # Install from downloaded ZIP
                              manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority)
                          finally:
                              # Clean up downloaded ZIP
                              if zip_path.exists():
                                  zip_path.unlink()
  
          console.print("\n[green]✓[/green] Extension installed successfully!")
          console.print(f"\n[bold]{manifest.name}[/bold] (v{manifest.version})")
          console.print(f"  {manifest.description}")
  
          for warning in manifest.warnings:
              console.print(f"\n[yellow]⚠  Compatibility warning:[/yellow] {warning}")
  
          console.print("\n[bold cyan]Provided commands:[/bold cyan]")
          for cmd in manifest.commands:
              console.print(f"  • {cmd['name']} - {cmd.get('description', '')}")
  
          # Report agent skills registration
          reg_meta = manager.registry.get(manifest.id)
          reg_skills = reg_meta.get("registered_skills", []) if reg_meta else []
          # Normalize to guard against corrupted registry entries
          if not isinstance(reg_skills, list):
              reg_skills = []
          if reg_skills:
              console.print(f"\n[green]✓[/green] {len(reg_skills)} agent skill(s) auto-registered")
  
          console.print("\n[yellow]⚠[/yellow]  Configuration may be required")
          console.print(f"   Check: .specify/extensions/{manifest.id}/")
  
      except ValidationError as e:
          console.print(f"\n[red]Validation Error:[/red] {e}")
          raise typer.Exit(1)
      except CompatibilityError as e:
          console.print(f"\n[red]Compatibility Error:[/red] {e}")
          raise typer.Exit(1)
      except ExtensionError as e:
          console.print(f"\n[red]Error:[/red] {e}")
          raise typer.Exit(1)
  
  
  @extension_app.command("remove")
  def extension_remove(
      extension: str = typer.Argument(help="Extension ID or name to remove"),
      keep_config: bool = typer.Option(False, "--keep-config", help="Don't remove config files"),
      force: bool = typer.Option(False, "--force", help="Skip confirmation"),
  ):
      """Uninstall an extension."""
      from .extensions import ExtensionManager
  
      project_root = Path.cwd()
  
      # Check if we're in a spec-kit project
      specify_dir = project_root / ".specify"
      if not specify_dir.exists():
          console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
          console.print("Run this command from a spec-kit project root")
          raise typer.Exit(1)
  
      manager = ExtensionManager(project_root)
  
      # Resolve extension ID from argument (handles ambiguous names)
      installed = manager.list_installed()
      extension_id, display_name = _resolve_installed_extension(extension, installed, "remove")
  
      # Get extension info for command and skill counts
      ext_manifest = manager.get_extension(extension_id)
      reg_meta = manager.registry.get(extension_id)
      # Derive cmd_count from the registry's registered_commands (includes aliases)
      # rather than from the manifest (primary commands only). Use max() across
      # agents to get the per-agent count; sum() would double-count since users
      # think in logical commands, not per-agent file counts.
      # Use get() without a default so we can distinguish "key missing" (fall back
      # to manifest) from "key present but empty dict" (zero commands registered).
      registered_commands = reg_meta.get("registered_commands") if isinstance(reg_meta, dict) else None
      if isinstance(registered_commands, dict):
          cmd_count = max(
              (len(v) for v in registered_commands.values() if isinstance(v, list)),
              default=0,
          )
      else:
          cmd_count = len(ext_manifest.commands) if ext_manifest else 0
      raw_skills = reg_meta.get("registered_skills") if reg_meta else None
      skill_count = len(raw_skills) if isinstance(raw_skills, list) else 0
  
      # Confirm removal
      if not force:
          console.print("\n[yellow]⚠  This will remove:[/yellow]")
          console.print(f"   • {cmd_count} command{'s' if cmd_count != 1 else ''} per agent")
          if skill_count:
              console.print(f"   • {skill_count} agent skill(s)")
          console.print(f"   • Extension directory: .specify/extensions/{extension_id}/")
          if not keep_config:
              console.print("   • Config files (will be backed up)")
          console.print()
  
          confirm = typer.confirm("Continue?")
          if not confirm:
              console.print("Cancelled")
              raise typer.Exit(0)
  
      # Remove extension
      success = manager.remove(extension_id, keep_config=keep_config)
  
      if success:
          console.print(f"\n[green]✓[/green] Extension '{display_name}' removed successfully")
          if keep_config:
              console.print(f"\nConfig files preserved in .specify/extensions/{extension_id}/")
          else:
              console.print(f"\nConfig files backed up to .specify/extensions/.backup/{extension_id}/")
          console.print(f"\nTo reinstall: specify extension add {extension_id}")
      else:
          console.print("[red]Error:[/red] Failed to remove extension")
          raise typer.Exit(1)
  
  
  @extension_app.command("search")
  def extension_search(
      query: str = typer.Argument(None, help="Search query (optional)"),
      tag: Optional[str] = typer.Option(None, "--tag", help="Filter by tag"),
      author: Optional[str] = typer.Option(None, "--author", help="Filter by author"),
      verified: bool = typer.Option(False, "--verified", help="Show only verified extensions"),
  ):
      """Search for available extensions in catalog."""
      from .extensions import ExtensionCatalog, ExtensionError
  
      project_root = Path.cwd()
  
      # Check if we're in a spec-kit project
      specify_dir = project_root / ".specify"
      if not specify_dir.exists():
          console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
          console.print("Run this command from a spec-kit project root")
          raise typer.Exit(1)
  
      catalog = ExtensionCatalog(project_root)
  
      try:
          console.print("🔍 Searching extension catalog...")
          results = catalog.search(query=query, tag=tag, author=author, verified_only=verified)
  
          if not results:
              console.print("\n[yellow]No extensions found matching criteria[/yellow]")
              if query or tag or author or verified:
                  console.print("\nTry:")
                  console.print("  • Broader search terms")
                  console.print("  • Remove filters")
                  console.print("  • specify extension search (show all)")
              raise typer.Exit(0)
  
          console.print(f"\n[green]Found {len(results)} extension(s):[/green]\n")
  
          for ext in results:
              # Extension header
              verified_badge = " [green]✓ Verified[/green]" if ext.get("verified") else ""
              console.print(f"[bold]{ext['name']}[/bold] (v{ext['version']}){verified_badge}")
              console.print(f"  {ext['description']}")
  
              # Metadata
              console.print(f"\n  [dim]Author:[/dim] {ext.get('author', 'Unknown')}")
              if ext.get('tags'):
                  tags_str = ", ".join(ext['tags'])
                  console.print(f"  [dim]Tags:[/dim] {tags_str}")
  
              # Source catalog
              catalog_name = ext.get("_catalog_name", "")
              install_allowed = ext.get("_install_allowed", True)
              if catalog_name:
                  if install_allowed:
                      console.print(f"  [dim]Catalog:[/dim] {catalog_name}")
                  else:
                      console.print(f"  [dim]Catalog:[/dim] {catalog_name} [yellow](discovery only — not installable)[/yellow]")
  
              # Stats
              stats = []
              if ext.get('downloads') is not None:
                  stats.append(f"Downloads: {ext['downloads']:,}")
              if ext.get('stars') is not None:
                  stats.append(f"Stars: {ext['stars']}")
              if stats:
                  console.print(f"  [dim]{' | '.join(stats)}[/dim]")
  
              # Links
              if ext.get('repository'):
                  console.print(f"  [dim]Repository:[/dim] {ext['repository']}")
  
              # Install command (show warning if not installable)
              if install_allowed:
                  console.print(f"\n  [cyan]Install:[/cyan] specify extension add {ext['id']}")
              else:
                  console.print(f"\n  [yellow]⚠[/yellow]  Not directly installable from '{catalog_name}'.")
                  console.print(
                      f"  Add to an approved catalog with install_allowed: true, "
                      f"or install from a ZIP URL: specify extension add {ext['id']} --from <zip-url>"
                  )
              console.print()
  
      except ExtensionError as e:
          console.print(f"\n[red]Error:[/red] {e}")
          console.print("\nTip: The catalog may be temporarily unavailable. Try again later.")
          raise typer.Exit(1)
  
  
  @extension_app.command("info")
  def extension_info(
      extension: str = typer.Argument(help="Extension ID or name"),
  ):
      """Show detailed information about an extension."""
      from .extensions import ExtensionCatalog, ExtensionManager, normalize_priority
  
      project_root = Path.cwd()
  
      # Check if we're in a spec-kit project
      specify_dir = project_root / ".specify"
      if not specify_dir.exists():
          console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
          console.print("Run this command from a spec-kit project root")
          raise typer.Exit(1)
  
      catalog = ExtensionCatalog(project_root)
      manager = ExtensionManager(project_root)
      installed = manager.list_installed()
  
      # Try to resolve from installed extensions first (by ID or name)
      # Use allow_not_found=True since the extension may be catalog-only
      resolved_installed_id, resolved_installed_name = _resolve_installed_extension(
          extension, installed, "info", allow_not_found=True
      )
  
      # Try catalog lookup (with error handling)
      # If we resolved an installed extension by display name, use its ID for catalog lookup
      # to ensure we get the correct catalog entry (not a different extension with same name)
      lookup_key = resolved_installed_id if resolved_installed_id else extension
      ext_info, catalog_error = _resolve_catalog_extension(lookup_key, catalog, "info")
  
      # Case 1: Found in catalog - show full catalog info
      if ext_info:
          _print_extension_info(ext_info, manager)
          return
  
      # Case 2: Installed locally but catalog lookup failed or not in catalog
      if resolved_installed_id:
          # Get local manifest info
          ext_manifest = manager.get_extension(resolved_installed_id)
          metadata = manager.registry.get(resolved_installed_id)
          metadata_is_dict = isinstance(metadata, dict)
          if not metadata_is_dict:
              console.print(
                  "[yellow]Warning:[/yellow] Extension metadata appears to be corrupted; "
                  "some information may be unavailable."
              )
          version = metadata.get("version", "unknown") if metadata_is_dict else "unknown"
  
          console.print(f"\n[bold]{resolved_installed_name}[/bold] (v{version})")
          console.print(f"ID: {resolved_installed_id}")
          console.print()
  
          if ext_manifest:
              console.print(f"{ext_manifest.description}")
              console.print()
              # Author is optional in extension.yml, safely retrieve it
              author = ext_manifest.data.get("extension", {}).get("author")
              if author:
                  console.print(f"[dim]Author:[/dim] {author}")
                  console.print()
  
              if ext_manifest.commands:
                  console.print("[bold]Commands:[/bold]")
                  for cmd in ext_manifest.commands:
                      console.print(f"  • {cmd['name']}: {cmd.get('description', '')}")
                  console.print()
  
          # Show catalog status
          if catalog_error:
              console.print(f"[yellow]Catalog unavailable:[/yellow] {catalog_error}")
              console.print("[dim]Note: Using locally installed extension; catalog info could not be verified.[/dim]")
          else:
              console.print("[yellow]Note:[/yellow] Not found in catalog (custom/local extension)")
  
          console.print()
          console.print("[green]✓ Installed[/green]")
          priority = normalize_priority(metadata.get("priority") if metadata_is_dict else None)
          console.print(f"[dim]Priority:[/dim] {priority}")
          console.print(f"\nTo remove: specify extension remove {resolved_installed_id}")
          return
  
      # Case 3: Not found anywhere
      if catalog_error:
          console.print(f"[red]Error:[/red] Could not query extension catalog: {catalog_error}")
          console.print("\nTry again when online, or use the extension ID directly.")
      else:
          console.print(f"[red]Error:[/red] Extension '{extension}' not found")
          console.print("\nTry: specify extension search")
      raise typer.Exit(1)
  
  
  def _print_extension_info(ext_info: dict, manager):
      """Print formatted extension info from catalog data."""
      from .extensions import normalize_priority
  
      # Header
      verified_badge = " [green]✓ Verified[/green]" if ext_info.get("verified") else ""
      console.print(f"\n[bold]{ext_info['name']}[/bold] (v{ext_info['version']}){verified_badge}")
      console.print(f"ID: {ext_info['id']}")
      console.print()
  
      # Description
      console.print(f"{ext_info['description']}")
      console.print()
  
      # Author and License
      console.print(f"[dim]Author:[/dim] {ext_info.get('author', 'Unknown')}")
      console.print(f"[dim]License:[/dim] {ext_info.get('license', 'Unknown')}")
  
      # Source catalog
      if ext_info.get("_catalog_name"):
          install_allowed = ext_info.get("_install_allowed", True)
          install_note = "" if install_allowed else " [yellow](discovery only)[/yellow]"
          console.print(f"[dim]Source catalog:[/dim] {ext_info['_catalog_name']}{install_note}")
      console.print()
  
      # Requirements
      if ext_info.get('requires'):
          console.print("[bold]Requirements:[/bold]")
          reqs = ext_info['requires']
          if reqs.get('speckit_version'):
              console.print(f"  • Spec Kit: {reqs['speckit_version']}")
          if reqs.get('tools'):
              for tool in reqs['tools']:
                  tool_name = tool['name']
                  tool_version = tool.get('version', 'any')
                  required = " (required)" if tool.get('required') else " (optional)"
                  console.print(f"  • {tool_name}: {tool_version}{required}")
          console.print()
  
      # Provides
      if ext_info.get('provides'):
          console.print("[bold]Provides:[/bold]")
          provides = ext_info['provides']
          if provides.get('commands'):
              console.print(f"  • Commands: {provides['commands']}")
          if provides.get('hooks'):
              console.print(f"  • Hooks: {provides['hooks']}")
          console.print()
  
      # Tags
      if ext_info.get('tags'):
          tags_str = ", ".join(ext_info['tags'])
          console.print(f"[bold]Tags:[/bold] {tags_str}")
          console.print()
  
      # Statistics
      stats = []
      if ext_info.get('downloads') is not None:
          stats.append(f"Downloads: {ext_info['downloads']:,}")
      if ext_info.get('stars') is not None:
          stats.append(f"Stars: {ext_info['stars']}")
      if stats:
          console.print(f"[bold]Statistics:[/bold] {' | '.join(stats)}")
          console.print()
  
      # Links
      console.print("[bold]Links:[/bold]")
      if ext_info.get('repository'):
          console.print(f"  • Repository: {ext_info['repository']}")
      if ext_info.get('homepage'):
          console.print(f"  • Homepage: {ext_info['homepage']}")
      if ext_info.get('documentation'):
          console.print(f"  • Documentation: {ext_info['documentation']}")
      if ext_info.get('changelog'):
          console.print(f"  • Changelog: {ext_info['changelog']}")
      console.print()
  
      # Installation status and command
      is_installed = manager.registry.is_installed(ext_info['id'])
      install_allowed = ext_info.get("_install_allowed", True)
      if is_installed:
          console.print("[green]✓ Installed[/green]")
          metadata = manager.registry.get(ext_info['id'])
          priority = normalize_priority(metadata.get("priority") if isinstance(metadata, dict) else None)
          console.print(f"[dim]Priority:[/dim] {priority}")
          console.print(f"\nTo remove: specify extension remove {ext_info['id']}")
      elif install_allowed:
          console.print("[yellow]Not installed[/yellow]")
          console.print(f"\n[cyan]Install:[/cyan] specify extension add {ext_info['id']}")
      else:
          catalog_name = ext_info.get("_catalog_name", "community")
          console.print("[yellow]Not installed[/yellow]")
          console.print(
              f"\n[yellow]⚠[/yellow]  '{ext_info['id']}' is available in the '{catalog_name}' catalog "
              f"but not in your approved catalog. Add it to .specify/extension-catalogs.yml "
              f"with install_allowed: true to enable installation."
          )
  
  
  @extension_app.command("update")
  def extension_update(
      extension: str = typer.Argument(None, help="Extension ID or name to update (or all)"),
  ):
      """Update extension(s) to latest version."""
      from .extensions import (
          ExtensionManager,
          ExtensionCatalog,
          ExtensionError,
          ValidationError,
          CommandRegistrar,
          HookExecutor,
          normalize_priority,
      )
      from packaging import version as pkg_version
      import shutil
  
      project_root = Path.cwd()
  
      # Check if we're in a spec-kit project
      specify_dir = project_root / ".specify"
      if not specify_dir.exists():
          console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
          console.print("Run this command from a spec-kit project root")
          raise typer.Exit(1)
  
      manager = ExtensionManager(project_root)
      catalog = ExtensionCatalog(project_root)
      speckit_version = get_speckit_version()
  
      try:
          # Get list of extensions to update
          installed = manager.list_installed()
          if extension:
              # Update specific extension - resolve ID from argument (handles ambiguous names)
              extension_id, _ = _resolve_installed_extension(extension, installed, "update")
              extensions_to_update = [extension_id]
          else:
              # Update all extensions
              extensions_to_update = [ext["id"] for ext in installed]
  
          if not extensions_to_update:
              console.print("[yellow]No extensions installed[/yellow]")
              raise typer.Exit(0)
  
          console.print("🔄 Checking for updates...\n")
  
          updates_available = []
  
          for ext_id in extensions_to_update:
              # Get installed version
              metadata = manager.registry.get(ext_id)
              if metadata is None or not isinstance(metadata, dict) or "version" not in metadata:
                  console.print(f"⚠  {ext_id}: Registry entry corrupted or missing (skipping)")
                  continue
              try:
                  installed_version = pkg_version.Version(metadata["version"])
              except pkg_version.InvalidVersion:
                  console.print(
                      f"⚠  {ext_id}: Invalid installed version '{metadata.get('version')}' in registry (skipping)"
                  )
                  continue
  
              # Get catalog info
              ext_info = catalog.get_extension_info(ext_id)
              if not ext_info:
                  console.print(f"⚠  {ext_id}: Not found in catalog (skipping)")
                  continue
  
              # Check if installation is allowed from this catalog
              if not ext_info.get("_install_allowed", True):
                  console.print(f"⚠  {ext_id}: Updates not allowed from '{ext_info.get('_catalog_name', 'catalog')}' (skipping)")
                  continue
  
              try:
                  catalog_version = pkg_version.Version(ext_info["version"])
              except pkg_version.InvalidVersion:
                  console.print(
                      f"⚠  {ext_id}: Invalid catalog version '{ext_info.get('version')}' (skipping)"
                  )
                  continue
  
              if catalog_version > installed_version:
                  updates_available.append(
                      {
                          "id": ext_id,
                          "name": ext_info.get("name", ext_id),  # Display name for status messages
                          "installed": str(installed_version),
                          "available": str(catalog_version),
                          "download_url": ext_info.get("download_url"),
                      }
                  )
              else:
                  console.print(f"✓ {ext_id}: Up to date (v{installed_version})")
  
          if not updates_available:
              console.print("\n[green]All extensions are up to date![/green]")
              raise typer.Exit(0)
  
          # Show available updates
          console.print("\n[bold]Updates available:[/bold]\n")
          for update in updates_available:
              console.print(
                  f"  • {update['id']}: {update['installed']} → {update['available']}"
              )
  
          console.print()
          confirm = typer.confirm("Update these extensions?")
          if not confirm:
              console.print("Cancelled")
              raise typer.Exit(0)
  
          # Perform updates with atomic backup/restore
          console.print()
          updated_extensions = []
          failed_updates = []
          registrar = CommandRegistrar()
          hook_executor = HookExecutor(project_root)
  
          for update in updates_available:
              extension_id = update["id"]
              ext_name = update["name"]  # Use display name for user-facing messages
              console.print(f"📦 Updating {ext_name}...")
  
              # Backup paths
              backup_base = manager.extensions_dir / ".backup" / f"{extension_id}-update"
              backup_ext_dir = backup_base / "extension"
              backup_commands_dir = backup_base / "commands"
              backup_config_dir = backup_base / "config"
  
              # Store backup state
              backup_registry_entry = None
              backup_hooks = None  # None means no hooks key in config; {} means hooks key existed
              backed_up_command_files = {}
  
              try:
                  # 1. Backup registry entry (always, even if extension dir doesn't exist)
                  backup_registry_entry = manager.registry.get(extension_id)
  
                  # 2. Backup extension directory
                  extension_dir = manager.extensions_dir / extension_id
                  if extension_dir.exists():
                      backup_base.mkdir(parents=True, exist_ok=True)
                      if backup_ext_dir.exists():
                          shutil.rmtree(backup_ext_dir)
                      shutil.copytree(extension_dir, backup_ext_dir)
  
                      # Backup config files separately so they can be restored
                      # after a successful install (install_from_directory clears dest dir).
                      config_files = list(extension_dir.glob("*-config.yml")) + list(
                          extension_dir.glob("*-config.local.yml")
                      )
                      for cfg_file in config_files:
                          backup_config_dir.mkdir(parents=True, exist_ok=True)
                          shutil.copy2(cfg_file, backup_config_dir / cfg_file.name)
  
                  # 3. Backup command files for all agents
                  from .agents import CommandRegistrar as _AgentReg
                  registered_commands = backup_registry_entry.get("registered_commands", {})
                  for agent_name, cmd_names in registered_commands.items():
                      if agent_name not in registrar.AGENT_CONFIGS:
                          continue
                      agent_config = registrar.AGENT_CONFIGS[agent_name]
                      commands_dir = project_root / agent_config["dir"]
  
                      for cmd_name in cmd_names:
                          output_name = _AgentReg._compute_output_name(agent_name, cmd_name, agent_config)
                          cmd_file = commands_dir / f"{output_name}{agent_config['extension']}"
                          if cmd_file.exists():
                              backup_cmd_path = backup_commands_dir / agent_name / cmd_file.name
                              backup_cmd_path.parent.mkdir(parents=True, exist_ok=True)
                              shutil.copy2(cmd_file, backup_cmd_path)
                              backed_up_command_files[str(cmd_file)] = str(backup_cmd_path)
  
                          # Also backup copilot prompt files
                          if agent_name == "copilot":
                              prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md"
                              if prompt_file.exists():
                                  backup_prompt_path = backup_commands_dir / "copilot-prompts" / prompt_file.name
                                  backup_prompt_path.parent.mkdir(parents=True, exist_ok=True)
                                  shutil.copy2(prompt_file, backup_prompt_path)
                                  backed_up_command_files[str(prompt_file)] = str(backup_prompt_path)
  
                  # 4. Backup hooks from extensions.yml
                  # Use backup_hooks=None to indicate config had no "hooks" key (don't create on restore)
                  # Use backup_hooks={} to indicate config had "hooks" key with no hooks for this extension
                  config = hook_executor.get_project_config()
                  if "hooks" in config:
                      backup_hooks = {}  # Config has hooks key - preserve this fact
                      for hook_name, hook_list in config["hooks"].items():
                          ext_hooks = [h for h in hook_list if h.get("extension") == extension_id]
                          if ext_hooks:
                              backup_hooks[hook_name] = ext_hooks
  
                  # 5. Download new version
                  zip_path = catalog.download_extension(extension_id)
                  try:
                      # 6. Validate extension ID from ZIP BEFORE modifying installation
                      # Handle both root-level and nested extension.yml (GitHub auto-generated ZIPs)
                      with zipfile.ZipFile(zip_path, "r") as zf:
                          import yaml
                          manifest_data = None
                          namelist = zf.namelist()
  
                          # First try root-level extension.yml
                          if "extension.yml" in namelist:
                              with zf.open("extension.yml") as f:
                                  manifest_data = yaml.safe_load(f) or {}
                          else:
                              # Look for extension.yml in a single top-level subdirectory
                              # (e.g., "repo-name-branch/extension.yml")
                              manifest_paths = [n for n in namelist if n.endswith("/extension.yml") and n.count("/") == 1]
                              if len(manifest_paths) == 1:
                                  with zf.open(manifest_paths[0]) as f:
                                      manifest_data = yaml.safe_load(f) or {}
  
                          if manifest_data is None:
                              raise ValueError("Downloaded extension archive is missing 'extension.yml'")
  
                      zip_extension_id = manifest_data.get("extension", {}).get("id")
                      if zip_extension_id != extension_id:
                          raise ValueError(
                              f"Extension ID mismatch: expected '{extension_id}', got '{zip_extension_id}'"
                          )
  
                      # 7. Remove old extension (handles command file cleanup and registry removal)
                      manager.remove(extension_id, keep_config=True)
  
                      # 8. Install new version
                      _ = manager.install_from_zip(zip_path, speckit_version)
  
                      # Restore user config files from backup after successful install.
                      new_extension_dir = manager.extensions_dir / extension_id
                      if backup_config_dir.exists() and new_extension_dir.exists():
                          for cfg_file in backup_config_dir.iterdir():
                              if cfg_file.is_file():
                                  shutil.copy2(cfg_file, new_extension_dir / cfg_file.name)
  
                      # 9. Restore metadata from backup (installed_at, enabled state)
                      if backup_registry_entry and isinstance(backup_registry_entry, dict):
                          # Copy current registry entry to avoid mutating internal
                          # registry state before explicit restore().
                          current_metadata = manager.registry.get(extension_id)
                          if current_metadata is None or not isinstance(current_metadata, dict):
                              raise RuntimeError(
                                  f"Registry entry for '{extension_id}' missing or corrupted after install — update incomplete"
                              )
                          new_metadata = dict(current_metadata)
  
                          # Preserve the original installation timestamp
                          if "installed_at" in backup_registry_entry:
                              new_metadata["installed_at"] = backup_registry_entry["installed_at"]
  
                          # Preserve the original priority (normalized to handle corruption)
                          if "priority" in backup_registry_entry:
                              new_metadata["priority"] = normalize_priority(backup_registry_entry["priority"])
  
                          # If extension was disabled before update, disable it again
                          if not backup_registry_entry.get("enabled", True):
                              new_metadata["enabled"] = False
  
                          # Use restore() instead of update() because update() always
                          # preserves the existing installed_at, ignoring our override
                          manager.registry.restore(extension_id, new_metadata)
  
                          # Also disable hooks in extensions.yml if extension was disabled
                          if not backup_registry_entry.get("enabled", True):
                              config = hook_executor.get_project_config()
                              if "hooks" in config:
                                  for hook_name in config["hooks"]:
                                      for hook in config["hooks"][hook_name]:
                                          if hook.get("extension") == extension_id:
                                              hook["enabled"] = False
                                  hook_executor.save_project_config(config)
                  finally:
                      # Clean up downloaded ZIP
                      if zip_path.exists():
                          zip_path.unlink()
  
                  # 10. Clean up backup on success
                  if backup_base.exists():
                      shutil.rmtree(backup_base)
  
                  console.print(f"   [green]✓[/green] Updated to v{update['available']}")
                  updated_extensions.append(ext_name)
  
              except KeyboardInterrupt:
                  raise
              except Exception as e:
                  console.print(f"   [red]✗[/red] Failed: {e}")
                  failed_updates.append((ext_name, str(e)))
  
                  # Rollback on failure
                  console.print(f"   [yellow]↩[/yellow] Rolling back {ext_name}...")
  
                  try:
                      # Restore extension directory
                      # Only perform destructive rollback if backup exists (meaning we
                      # actually modified the extension). This avoids deleting a valid
                      # installation when failure happened before changes were made.
                      extension_dir = manager.extensions_dir / extension_id
                      if backup_ext_dir.exists():
                          if extension_dir.exists():
                              shutil.rmtree(extension_dir)
                          shutil.copytree(backup_ext_dir, extension_dir)
  
                      # Remove any NEW command files created by failed install
                      # (files that weren't in the original backup)
                      try:
                          new_registry_entry = manager.registry.get(extension_id)
                          if new_registry_entry is None or not isinstance(new_registry_entry, dict):
                              new_registered_commands = {}
                          else:
                              new_registered_commands = new_registry_entry.get("registered_commands", {})
                          for agent_name, cmd_names in new_registered_commands.items():
                              if agent_name not in registrar.AGENT_CONFIGS:
                                  continue
                              agent_config = registrar.AGENT_CONFIGS[agent_name]
                              commands_dir = project_root / agent_config["dir"]
  
                              for cmd_name in cmd_names:
                                  output_name = _AgentReg._compute_output_name(agent_name, cmd_name, agent_config)
                                  cmd_file = commands_dir / f"{output_name}{agent_config['extension']}"
                                  # Delete if it exists and wasn't in our backup
                                  if cmd_file.exists() and str(cmd_file) not in backed_up_command_files:
                                      cmd_file.unlink()
  
                                  # Also handle copilot prompt files
                                  if agent_name == "copilot":
                                      prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md"
                                      if prompt_file.exists() and str(prompt_file) not in backed_up_command_files:
                                          prompt_file.unlink()
                      except KeyError:
                          pass  # No new registry entry exists, nothing to clean up
  
                      # Restore backed up command files
                      for original_path, backup_path in backed_up_command_files.items():
                          backup_file = Path(backup_path)
                          if backup_file.exists():
                              original_file = Path(original_path)
                              original_file.parent.mkdir(parents=True, exist_ok=True)
                              shutil.copy2(backup_file, original_file)
  
                      # Restore hooks in extensions.yml
                      # - backup_hooks=None means original config had no "hooks" key
                      # - backup_hooks={} or {...} means config had hooks key
                      config = hook_executor.get_project_config()
                      if "hooks" in config:
                          modified = False
  
                          if backup_hooks is None:
                              # Original config had no "hooks" key; remove it entirely
                              del config["hooks"]
                              modified = True
                          else:
                              # Remove any hooks for this extension added by failed install
                              for hook_name, hooks_list in config["hooks"].items():
                                  original_len = len(hooks_list)
                                  config["hooks"][hook_name] = [
                                      h for h in hooks_list
                                      if h.get("extension") != extension_id
                                  ]
                                  if len(config["hooks"][hook_name]) != original_len:
                                      modified = True
  
                              # Add back the backed up hooks if any
                              if backup_hooks:
                                  for hook_name, hooks in backup_hooks.items():
                                      if hook_name not in config["hooks"]:
                                          config["hooks"][hook_name] = []
                                      config["hooks"][hook_name].extend(hooks)
                                      modified = True
  
                          if modified:
                              hook_executor.save_project_config(config)
  
                      # Restore registry entry (use restore() since entry was removed)
                      if backup_registry_entry:
                          manager.registry.restore(extension_id, backup_registry_entry)
  
                      console.print("   [green]✓[/green] Rollback successful")
                      # Clean up backup directory only on successful rollback
                      if backup_base.exists():
                          shutil.rmtree(backup_base)
                  except Exception as rollback_error:
                      console.print(f"   [red]✗[/red] Rollback failed: {rollback_error}")
                      console.print(f"   [dim]Backup preserved at: {backup_base}[/dim]")
  
          # Summary
          console.print()
          if updated_extensions:
              console.print(f"[green]✓[/green] Successfully updated {len(updated_extensions)} extension(s)")
          if failed_updates:
              console.print(f"[red]✗[/red] Failed to update {len(failed_updates)} extension(s):")
              for ext_name, error in failed_updates:
                  console.print(f"   • {ext_name}: {error}")
              raise typer.Exit(1)
  
      except ValidationError as e:
          console.print(f"\n[red]Validation Error:[/red] {e}")
          raise typer.Exit(1)
      except ExtensionError as e:
          console.print(f"\n[red]Error:[/red] {e}")
          raise typer.Exit(1)
  
  
  @extension_app.command("enable")
  def extension_enable(
      extension: str = typer.Argument(help="Extension ID or name to enable"),
  ):
      """Enable a disabled extension."""
      from .extensions import ExtensionManager, HookExecutor
  
      project_root = Path.cwd()
  
      # Check if we're in a spec-kit project
      specify_dir = project_root / ".specify"
      if not specify_dir.exists():
          console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
          console.print("Run this command from a spec-kit project root")
          raise typer.Exit(1)
  
      manager = ExtensionManager(project_root)
      hook_executor = HookExecutor(project_root)
  
      # Resolve extension ID from argument (handles ambiguous names)
      installed = manager.list_installed()
      extension_id, display_name = _resolve_installed_extension(extension, installed, "enable")
  
      # Update registry
      metadata = manager.registry.get(extension_id)
      if metadata is None or not isinstance(metadata, dict):
          console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)")
          raise typer.Exit(1)
  
      if metadata.get("enabled", True):
          console.print(f"[yellow]Extension '{display_name}' is already enabled[/yellow]")
          raise typer.Exit(0)
  
      manager.registry.update(extension_id, {"enabled": True})
  
      # Enable hooks in extensions.yml
      config = hook_executor.get_project_config()
      if "hooks" in config:
          for hook_name in config["hooks"]:
              for hook in config["hooks"][hook_name]:
                  if hook.get("extension") == extension_id:
                      hook["enabled"] = True
          hook_executor.save_project_config(config)
  
      console.print(f"[green]✓[/green] Extension '{display_name}' enabled")
  
  
  @extension_app.command("disable")
  def extension_disable(
      extension: str = typer.Argument(help="Extension ID or name to disable"),
  ):
      """Disable an extension without removing it."""
      from .extensions import ExtensionManager, HookExecutor
  
      project_root = Path.cwd()
  
      # Check if we're in a spec-kit project
      specify_dir = project_root / ".specify"
      if not specify_dir.exists():
          console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
          console.print("Run this command from a spec-kit project root")
          raise typer.Exit(1)
  
      manager = ExtensionManager(project_root)
      hook_executor = HookExecutor(project_root)
  
      # Resolve extension ID from argument (handles ambiguous names)
      installed = manager.list_installed()
      extension_id, display_name = _resolve_installed_extension(extension, installed, "disable")
  
      # Update registry
      metadata = manager.registry.get(extension_id)
      if metadata is None or not isinstance(metadata, dict):
          console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)")
          raise typer.Exit(1)
  
      if not metadata.get("enabled", True):
          console.print(f"[yellow]Extension '{display_name}' is already disabled[/yellow]")
          raise typer.Exit(0)
  
      manager.registry.update(extension_id, {"enabled": False})
  
      # Disable hooks in extensions.yml
      config = hook_executor.get_project_config()
      if "hooks" in config:
          for hook_name in config["hooks"]:
              for hook in config["hooks"][hook_name]:
                  if hook.get("extension") == extension_id:
                      hook["enabled"] = False
          hook_executor.save_project_config(config)
  
      console.print(f"[green]✓[/green] Extension '{display_name}' disabled")
      console.print("\nCommands will no longer be available. Hooks will not execute.")
      console.print(f"To re-enable: specify extension enable {extension_id}")
  
  
  @extension_app.command("set-priority")
  def extension_set_priority(
      extension: str = typer.Argument(help="Extension ID or name"),
      priority: int = typer.Argument(help="New priority (lower = higher precedence)"),
  ):
      """Set the resolution priority of an installed extension."""
      from .extensions import ExtensionManager
  
      project_root = Path.cwd()
  
      # Check if we're in a spec-kit project
      specify_dir = project_root / ".specify"
      if not specify_dir.exists():
          console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
          console.print("Run this command from a spec-kit project root")
          raise typer.Exit(1)
  
      # Validate priority
      if priority < 1:
          console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
          raise typer.Exit(1)
  
      manager = ExtensionManager(project_root)
  
      # Resolve extension ID from argument (handles ambiguous names)
      installed = manager.list_installed()
      extension_id, display_name = _resolve_installed_extension(extension, installed, "set-priority")
  
      # Get current metadata
      metadata = manager.registry.get(extension_id)
      if metadata is None or not isinstance(metadata, dict):
          console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)")
          raise typer.Exit(1)
  
      from .extensions import normalize_priority
      raw_priority = metadata.get("priority")
      # Only skip if the stored value is already a valid int equal to requested priority
      # This ensures corrupted values (e.g., "high") get repaired even when setting to default (10)
      if isinstance(raw_priority, int) and raw_priority == priority:
          console.print(f"[yellow]Extension '{display_name}' already has priority {priority}[/yellow]")
          raise typer.Exit(0)
  
      old_priority = normalize_priority(raw_priority)
  
      # Update priority
      manager.registry.update(extension_id, {"priority": priority})
  
      console.print(f"[green]✓[/green] Extension '{display_name}' priority changed: {old_priority} → {priority}")
      console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]")
  
  
  # ===== Workflow Commands =====
  
  workflow_app = typer.Typer(
      name="workflow",
      help="Manage and run automation workflows",
      add_completion=False,
  )
  app.add_typer(workflow_app, name="workflow")
  
  workflow_catalog_app = typer.Typer(
      name="catalog",
      help="Manage workflow catalogs",
      add_completion=False,
  )
  workflow_app.add_typer(workflow_catalog_app, name="catalog")
  
  
  @workflow_app.command("run")
  def workflow_run(
      source: str = typer.Argument(..., help="Workflow ID or YAML file path"),
      input_values: list[str] | None = typer.Option(
          None, "--input", "-i", help="Input values as key=value pairs"
      ),
  ):
      """Run a workflow from an installed ID or local YAML path."""
      from .workflows.engine import WorkflowEngine
  
      project_root = Path.cwd()
      if not (project_root / ".specify").exists():
          console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
          raise typer.Exit(1)
      engine = WorkflowEngine(project_root)
      engine.on_step_start = lambda sid, label: console.print(f"  \u25b8 [{sid}] {label} \u2026")
  
      try:
          definition = engine.load_workflow(source)
      except FileNotFoundError:
          console.print(f"[red]Error:[/red] Workflow not found: {source}")
          raise typer.Exit(1)
      except ValueError as exc:
          console.print(f"[red]Error:[/red] Invalid workflow: {exc}")
          raise typer.Exit(1)
  
      # Validate
      errors = engine.validate(definition)
      if errors:
          console.print("[red]Workflow validation failed:[/red]")
          for err in errors:
              console.print(f"  • {err}")
          raise typer.Exit(1)
  
      # Parse inputs
      inputs: dict[str, Any] = {}
      if input_values:
          for kv in input_values:
              if "=" not in kv:
                  console.print(f"[red]Error:[/red] Invalid input format: {kv!r} (expected key=value)")
                  raise typer.Exit(1)
              key, _, value = kv.partition("=")
              inputs[key.strip()] = value.strip()
  
      console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})")
      console.print(f"[dim]Version: {definition.version}[/dim]\n")
  
      try:
          state = engine.execute(definition, inputs)
      except ValueError as exc:
          console.print(f"[red]Error:[/red] {exc}")
          raise typer.Exit(1)
      except Exception as exc:
          console.print(f"[red]Workflow failed:[/red] {exc}")
          raise typer.Exit(1)
  
      status_colors = {
          "completed": "green",
          "paused": "yellow",
          "failed": "red",
          "aborted": "red",
      }
      color = status_colors.get(state.status.value, "white")
      console.print(f"\n[{color}]Status: {state.status.value}[/{color}]")
      console.print(f"[dim]Run ID: {state.run_id}[/dim]")
  
      if state.status.value == "paused":
          console.print(f"\nResume with: [cyan]specify workflow resume {state.run_id}[/cyan]")
  
  
  @workflow_app.command("resume")
  def workflow_resume(
      run_id: str = typer.Argument(..., help="Run ID to resume"),
  ):
      """Resume a paused or failed workflow run."""
      from .workflows.engine import WorkflowEngine
  
      project_root = Path.cwd()
      if not (project_root / ".specify").exists():
          console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
          raise typer.Exit(1)
      engine = WorkflowEngine(project_root)
      engine.on_step_start = lambda sid, label: console.print(f"  \u25b8 [{sid}] {label} \u2026")
  
      try:
          state = engine.resume(run_id)
      except FileNotFoundError:
          console.print(f"[red]Error:[/red] Run not found: {run_id}")
          raise typer.Exit(1)
      except ValueError as exc:
          console.print(f"[red]Error:[/red] {exc}")
          raise typer.Exit(1)
      except Exception as exc:
          console.print(f"[red]Resume failed:[/red] {exc}")
          raise typer.Exit(1)
  
      status_colors = {
          "completed": "green",
          "paused": "yellow",
          "failed": "red",
          "aborted": "red",
      }
      color = status_colors.get(state.status.value, "white")
      console.print(f"\n[{color}]Status: {state.status.value}[/{color}]")
  
  
  @workflow_app.command("status")
  def workflow_status(
      run_id: str | None = typer.Argument(None, help="Run ID to inspect (shows all if omitted)"),
  ):
      """Show workflow run status."""
      from .workflows.engine import WorkflowEngine
  
      project_root = Path.cwd()
      if not (project_root / ".specify").exists():
          console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
          raise typer.Exit(1)
      engine = WorkflowEngine(project_root)
  
      if run_id:
          try:
              from .workflows.engine import RunState
              state = RunState.load(run_id, project_root)
          except FileNotFoundError:
              console.print(f"[red]Error:[/red] Run not found: {run_id}")
              raise typer.Exit(1)
  
          status_colors = {
              "completed": "green",
              "paused": "yellow",
              "failed": "red",
              "aborted": "red",
              "running": "blue",
              "created": "dim",
          }
          color = status_colors.get(state.status.value, "white")
  
          console.print(f"\n[bold cyan]Workflow Run: {state.run_id}[/bold cyan]")
          console.print(f"  Workflow: {state.workflow_id}")
          console.print(f"  Status:   [{color}]{state.status.value}[/{color}]")
          console.print(f"  Created:  {state.created_at}")
          console.print(f"  Updated:  {state.updated_at}")
  
          if state.current_step_id:
              console.print(f"  Current:  {state.current_step_id}")
  
          if state.step_results:
              console.print(f"\n  [bold]Steps ({len(state.step_results)}):[/bold]")
              for step_id, step_data in state.step_results.items():
                  s = step_data.get("status", "unknown")
                  sc = {"completed": "green", "failed": "red", "paused": "yellow"}.get(s, "white")
                  console.print(f"    [{sc}]●[/{sc}] {step_id}: {s}")
      else:
          runs = engine.list_runs()
          if not runs:
              console.print("[yellow]No workflow runs found.[/yellow]")
              return
  
          console.print("\n[bold cyan]Workflow Runs:[/bold cyan]\n")
          for run_data in runs:
              s = run_data.get("status", "unknown")
              sc = {"completed": "green", "failed": "red", "paused": "yellow", "running": "blue"}.get(s, "white")
              console.print(
                  f"  [{sc}]●[/{sc}] {run_data['run_id']}  "
                  f"{run_data.get('workflow_id', '?')}  "
                  f"[{sc}]{s}[/{sc}]  "
                  f"[dim]{run_data.get('updated_at', '?')}[/dim]"
              )
  
  
  @workflow_app.command("list")
  def workflow_list():
      """List installed workflows."""
      from .workflows.catalog import WorkflowRegistry
  
      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)")
          raise typer.Exit(1)
  
      registry = WorkflowRegistry(project_root)
      installed = registry.list()
  
      if not installed:
          console.print("[yellow]No workflows installed.[/yellow]")
          console.print("\nInstall a workflow with:")
          console.print("  [cyan]specify workflow add <workflow-id>[/cyan]")
          return
  
      console.print("\n[bold cyan]Installed Workflows:[/bold cyan]\n")
      for wf_id, wf_data in installed.items():
          console.print(f"  [bold]{wf_data.get('name', wf_id)}[/bold] ({wf_id}) v{wf_data.get('version', '?')}")
          desc = wf_data.get("description", "")
          if desc:
              console.print(f"    {desc}")
          console.print()
  
  
  @workflow_app.command("add")
  def workflow_add(
      source: str = typer.Argument(..., help="Workflow ID, URL, or local path"),
  ):
      """Install a workflow from catalog, URL, or local path."""
      from .workflows.catalog import WorkflowCatalog, WorkflowRegistry, WorkflowCatalogError
      from .workflows.engine import WorkflowDefinition
  
      project_root = 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)")
          raise typer.Exit(1)
  
      registry = WorkflowRegistry(project_root)
      workflows_dir = project_root / ".specify" / "workflows"
  
      def _validate_and_install_local(yaml_path: Path, source_label: str) -> None:
          """Validate and install a workflow from a local YAML file."""
          try:
              definition = WorkflowDefinition.from_yaml(yaml_path)
          except (ValueError, yaml.YAMLError) as exc:
              console.print(f"[red]Error:[/red] Invalid workflow YAML: {exc}")
              raise typer.Exit(1)
          if not definition.id or not definition.id.strip():
              console.print("[red]Error:[/red] Workflow definition has an empty or missing 'id'")
              raise typer.Exit(1)
  
          from .workflows.engine import validate_workflow
          errors = validate_workflow(definition)
          if errors:
              console.print("[red]Error:[/red] Workflow validation failed:")
              for err in errors:
                  console.print(f"  \u2022 {err}")
              raise typer.Exit(1)
  
          dest_dir = workflows_dir / definition.id
          dest_dir.mkdir(parents=True, exist_ok=True)
          import shutil
          shutil.copy2(yaml_path, dest_dir / "workflow.yml")
          registry.add(definition.id, {
              "name": definition.name,
              "version": definition.version,
              "description": definition.description,
              "source": source_label,
          })
          console.print(f"[green]✓[/green] Workflow '{definition.name}' ({definition.id}) installed")
  
      # Try as URL (http/https)
      if source.startswith("http://") or source.startswith("https://"):
          from ipaddress import ip_address
          from urllib.parse import urlparse
          from urllib.request import urlopen  # noqa: S310
  
          parsed_src = urlparse(source)
          src_host = parsed_src.hostname or ""
          src_loopback = src_host == "localhost"
          if not src_loopback:
              try:
                  src_loopback = ip_address(src_host).is_loopback
              except ValueError:
                  # Host is not an IP literal (e.g., a DNS name); keep default non-loopback.
                  pass
          if parsed_src.scheme != "https" and not (parsed_src.scheme == "http" and src_loopback):
              console.print("[red]Error:[/red] Only HTTPS URLs are allowed, except HTTP for localhost.")
              raise typer.Exit(1)
  
          import tempfile
          try:
              with urlopen(source, timeout=30) as resp:  # noqa: S310
                  final_url = resp.geturl()
                  final_parsed = urlparse(final_url)
                  final_host = final_parsed.hostname or ""
                  final_lb = final_host == "localhost"
                  if not final_lb:
                      try:
                          final_lb = ip_address(final_host).is_loopback
                      except ValueError:
                          # Redirect host is not an IP literal; keep loopback as determined above.
                          pass
                  if final_parsed.scheme != "https" and not (final_parsed.scheme == "http" and final_lb):
                      console.print(f"[red]Error:[/red] URL redirected to non-HTTPS: {final_url}")
                      raise typer.Exit(1)
                  with tempfile.NamedTemporaryFile(suffix=".yml", delete=False) as tmp:
                      tmp.write(resp.read())
                      tmp_path = Path(tmp.name)
          except typer.Exit:
              raise
          except Exception as exc:
              console.print(f"[red]Error:[/red] Failed to download workflow: {exc}")
              raise typer.Exit(1)
          try:
              _validate_and_install_local(tmp_path, source)
          finally:
              tmp_path.unlink(missing_ok=True)
          return
  
      # Try as a local file/directory
      source_path = Path(source)
      if source_path.exists():
          if source_path.is_file() and source_path.suffix in (".yml", ".yaml"):
              _validate_and_install_local(source_path, str(source_path))
              return
          elif source_path.is_dir():
              wf_file = source_path / "workflow.yml"
              if not wf_file.exists():
                  console.print(f"[red]Error:[/red] No workflow.yml found in {source}")
                  raise typer.Exit(1)
              _validate_and_install_local(wf_file, str(source_path))
              return
  
      # Try from catalog
      catalog = WorkflowCatalog(project_root)
      try:
          info = catalog.get_workflow_info(source)
      except WorkflowCatalogError as exc:
          console.print(f"[red]Error:[/red] {exc}")
          raise typer.Exit(1)
  
      if not info:
          console.print(f"[red]Error:[/red] Workflow '{source}' not found in catalog")
          raise typer.Exit(1)
  
      if not info.get("_install_allowed", True):
          console.print(f"[yellow]Warning:[/yellow] Workflow '{source}' is from a discovery-only catalog")
          console.print("Direct installation is not enabled for this catalog source.")
          raise typer.Exit(1)
  
      workflow_url = info.get("url")
      if not workflow_url:
          console.print(f"[red]Error:[/red] Workflow '{source}' does not have an install URL in the catalog")
          raise typer.Exit(1)
  
      # Validate URL scheme (HTTPS required, HTTP allowed for localhost only)
      from ipaddress import ip_address
      from urllib.parse import urlparse
  
      parsed_url = urlparse(workflow_url)
      url_host = parsed_url.hostname or ""
      is_loopback = False
      if url_host == "localhost":
          is_loopback = True
      else:
          try:
              is_loopback = ip_address(url_host).is_loopback
          except ValueError:
              # Host is not an IP literal (e.g., a regular hostname); treat as non-loopback.
              pass
      if parsed_url.scheme != "https" and not (parsed_url.scheme == "http" and is_loopback):
          console.print(
              f"[red]Error:[/red] Workflow '{source}' has an invalid install URL. "
              "Only HTTPS URLs are allowed, except HTTP for localhost/loopback."
          )
          raise typer.Exit(1)
  
      workflow_dir = workflows_dir / source
      # Validate that source is a safe directory name (no path traversal)
      try:
          workflow_dir.resolve().relative_to(workflows_dir.resolve())
      except ValueError:
          console.print(f"[red]Error:[/red] Invalid workflow ID: {source!r}")
          raise typer.Exit(1)
      workflow_file = workflow_dir / "workflow.yml"
  
      try:
          from urllib.request import urlopen  # noqa: S310 — URL comes from catalog
  
          workflow_dir.mkdir(parents=True, exist_ok=True)
          with urlopen(workflow_url, timeout=30) as response:  # noqa: S310
              # Validate final URL after redirects
              final_url = response.geturl()
              final_parsed = urlparse(final_url)
              final_host = final_parsed.hostname or ""
              final_loopback = final_host == "localhost"
              if not final_loopback:
                  try:
                      final_loopback = ip_address(final_host).is_loopback
                  except ValueError:
                      # Host is not an IP literal (e.g., a regular hostname); treat as non-loopback.
                      pass
              if final_parsed.scheme != "https" and not (final_parsed.scheme == "http" and final_loopback):
                  if workflow_dir.exists():
                      import shutil
                      shutil.rmtree(workflow_dir, ignore_errors=True)
                  console.print(
                      f"[red]Error:[/red] Workflow '{source}' redirected to non-HTTPS URL: {final_url}"
                  )
                  raise typer.Exit(1)
              workflow_file.write_bytes(response.read())
      except Exception as exc:
          if workflow_dir.exists():
              import shutil
              shutil.rmtree(workflow_dir, ignore_errors=True)
          console.print(f"[red]Error:[/red] Failed to install workflow '{source}' from catalog: {exc}")
          raise typer.Exit(1)
  
      # Validate the downloaded workflow before registering
      try:
          definition = WorkflowDefinition.from_yaml(workflow_file)
      except (ValueError, yaml.YAMLError) as exc:
          import shutil
          shutil.rmtree(workflow_dir, ignore_errors=True)
          console.print(f"[red]Error:[/red] Downloaded workflow is invalid: {exc}")
          raise typer.Exit(1)
  
      from .workflows.engine import validate_workflow
      errors = validate_workflow(definition)
      if errors:
          import shutil
          shutil.rmtree(workflow_dir, ignore_errors=True)
          console.print("[red]Error:[/red] Downloaded workflow validation failed:")
          for err in errors:
              console.print(f"  \u2022 {err}")
          raise typer.Exit(1)
  
      # Enforce that the workflow's internal ID matches the catalog key
      if definition.id and definition.id != source:
          import shutil
          shutil.rmtree(workflow_dir, ignore_errors=True)
          console.print(
              f"[red]Error:[/red] Workflow ID in YAML ({definition.id!r}) "
              f"does not match catalog key ({source!r}). "
              f"The catalog entry may be misconfigured."
          )
          raise typer.Exit(1)
  
      registry.add(source, {
          "name": definition.name or info.get("name", source),
          "version": definition.version or info.get("version", "0.0.0"),
          "description": definition.description or info.get("description", ""),
          "source": "catalog",
          "catalog_name": info.get("_catalog_name", ""),
          "url": workflow_url,
      })
      console.print(f"[green]✓[/green] Workflow '{info.get('name', source)}' installed from catalog")
  
  
  @workflow_app.command("remove")
  def workflow_remove(
      workflow_id: str = typer.Argument(..., help="Workflow ID to uninstall"),
  ):
      """Uninstall a workflow."""
      from .workflows.catalog import WorkflowRegistry
  
      project_root = 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)")
          raise typer.Exit(1)
  
      registry = WorkflowRegistry(project_root)
  
      if not registry.is_installed(workflow_id):
          console.print(f"[red]Error:[/red] Workflow '{workflow_id}' is not installed")
          raise typer.Exit(1)
  
      # Remove workflow files
      workflow_dir = project_root / ".specify" / "workflows" / workflow_id
      if workflow_dir.exists():
          import shutil
          shutil.rmtree(workflow_dir)
  
      registry.remove(workflow_id)
      console.print(f"[green]✓[/green] Workflow '{workflow_id}' removed")
  
  
  @workflow_app.command("search")
  def workflow_search(
      query: str | None = typer.Argument(None, help="Search query"),
      tag: str | None = typer.Option(None, "--tag", help="Filter by tag"),
  ):
      """Search workflow catalogs."""
      from .workflows.catalog import WorkflowCatalog, WorkflowCatalogError
  
      project_root = Path.cwd()
      if not (project_root / ".specify").exists():
          console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
          raise typer.Exit(1)
      catalog = WorkflowCatalog(project_root)
  
      try:
          results = catalog.search(query=query, tag=tag)
      except WorkflowCatalogError as exc:
          console.print(f"[red]Error:[/red] {exc}")
          raise typer.Exit(1)
  
      if not results:
          console.print("[yellow]No workflows found.[/yellow]")
          return
  
      console.print(f"\n[bold cyan]Workflows ({len(results)}):[/bold cyan]\n")
      for wf in results:
          console.print(f"  [bold]{wf.get('name', wf.get('id', '?'))}[/bold] ({wf.get('id', '?')}) v{wf.get('version', '?')}")
          desc = wf.get("description", "")
          if desc:
              console.print(f"    {desc}")
          tags = wf.get("tags", [])
          if tags:
              console.print(f"    [dim]Tags: {', '.join(tags)}[/dim]")
          console.print()
  
  
  @workflow_app.command("info")
  def workflow_info(
      workflow_id: str = typer.Argument(..., help="Workflow ID"),
  ):
      """Show workflow details and step graph."""
      from .workflows.catalog import WorkflowCatalog, WorkflowRegistry, WorkflowCatalogError
      from .workflows.engine import WorkflowEngine
  
      project_root = Path.cwd()
      if not (project_root / ".specify").exists():
          console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
          raise typer.Exit(1)
  
      # Check installed first
      registry = WorkflowRegistry(project_root)
      installed = registry.get(workflow_id)
  
      engine = WorkflowEngine(project_root)
  
      definition = None
      try:
          definition = engine.load_workflow(workflow_id)
      except FileNotFoundError:
          # Local workflow definition not found on disk; fall back to
          # catalog/registry lookup below.
          pass
  
      if definition:
          console.print(f"\n[bold cyan]{definition.name}[/bold cyan] ({definition.id})")
          console.print(f"  Version:     {definition.version}")
          if definition.author:
              console.print(f"  Author:      {definition.author}")
          if definition.description:
              console.print(f"  Description: {definition.description}")
          if definition.default_integration:
              console.print(f"  Integration: {definition.default_integration}")
          if installed:
              console.print("  [green]Installed[/green]")
  
          if definition.inputs:
              console.print("\n  [bold]Inputs:[/bold]")
              for name, inp in definition.inputs.items():
                  if isinstance(inp, dict):
                      req = "required" if inp.get("required") else "optional"
                      console.print(f"    {name} ({inp.get('type', 'string')}) — {req}")
  
          if definition.steps:
              console.print(f"\n  [bold]Steps ({len(definition.steps)}):[/bold]")
              for step in definition.steps:
                  stype = step.get("type", "command")
                  console.print(f"    → {step.get('id', '?')} [{stype}]")
          return
  
      # Try catalog
      catalog = WorkflowCatalog(project_root)
      try:
          info = catalog.get_workflow_info(workflow_id)
      except WorkflowCatalogError:
          info = None
  
      if info:
          console.print(f"\n[bold cyan]{info.get('name', workflow_id)}[/bold cyan] ({workflow_id})")
          console.print(f"  Version:     {info.get('version', '?')}")
          if info.get("description"):
              console.print(f"  Description: {info['description']}")
          if info.get("tags"):
              console.print(f"  Tags:        {', '.join(info['tags'])}")
          console.print("  [yellow]Not installed[/yellow]")
      else:
          console.print(f"[red]Error:[/red] Workflow '{workflow_id}' not found")
          raise typer.Exit(1)
  
  
  @workflow_catalog_app.command("list")
  def workflow_catalog_list():
      """List configured workflow catalog sources."""
      from .workflows.catalog import WorkflowCatalog, WorkflowCatalogError
  
      project_root = Path.cwd()
      catalog = WorkflowCatalog(project_root)
  
      try:
          configs = catalog.get_catalog_configs()
      except WorkflowCatalogError as exc:
          console.print(f"[red]Error:[/red] {exc}")
          raise typer.Exit(1)
  
      console.print("\n[bold cyan]Workflow Catalog Sources:[/bold cyan]\n")
      for i, cfg in enumerate(configs):
          install_status = "[green]install allowed[/green]" if cfg["install_allowed"] else "[yellow]discovery only[/yellow]"
          console.print(f"  [{i}] [bold]{cfg['name']}[/bold] — {install_status}")
          console.print(f"      {cfg['url']}")
          if cfg.get("description"):
              console.print(f"      [dim]{cfg['description']}[/dim]")
          console.print()
  
  
  @workflow_catalog_app.command("add")
  def workflow_catalog_add(
      url: str = typer.Argument(..., help="Catalog URL to add"),
      name: str = typer.Option(None, "--name", help="Catalog name"),
  ):
      """Add a workflow catalog source."""
      from .workflows.catalog import WorkflowCatalog, WorkflowValidationError
  
      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)")
          raise typer.Exit(1)
  
      catalog = WorkflowCatalog(project_root)
      try:
          catalog.add_catalog(url, name)
      except WorkflowValidationError as exc:
          console.print(f"[red]Error:[/red] {exc}")
          raise typer.Exit(1)
  
      console.print(f"[green]✓[/green] Catalog source added: {url}")
  
  
  @workflow_catalog_app.command("remove")
  def workflow_catalog_remove(
      index: int = typer.Argument(..., help="Catalog index to remove (from 'catalog list')"),
  ):
      """Remove a workflow catalog source by index."""
      from .workflows.catalog import WorkflowCatalog, WorkflowValidationError
  
      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)")
          raise typer.Exit(1)
  
      catalog = WorkflowCatalog(project_root)
      try:
          removed_name = catalog.remove_catalog(index)
      except WorkflowValidationError as exc:
          console.print(f"[red]Error:[/red] {exc}")
          raise typer.Exit(1)
  
      console.print(f"[green]✓[/green] Catalog source '{removed_name}' removed")
  
  
  def main():
      app()
  
  if __name__ == "__main__":
      main()
  