[
  {
    "path": ".devcontainer/devcontainer.json",
    "content": "// For format details, see https://aka.ms/devcontainer.json. For config options, see the\n// README at: https://github.com/devcontainers/templates/tree/main/src/python\n{\n\t\"name\": \"SpecKitDevContainer\",\n\t// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile\n\t\"image\": \"mcr.microsoft.com/devcontainers/python:3.13-trixie\", // based on Debian \"Trixie\" (13)\n\t\"features\": {\n\t\t\"ghcr.io/devcontainers/features/common-utils:2\": {\n\t\t\t\"installZsh\": true,\n\t\t\t\"installOhMyZsh\": true,\n\t\t\t\"installOhMyZshConfig\": true,\n\t\t\t\"upgradePackages\": true,\n\t\t\t\"username\": \"devcontainer\",\n\t\t\t\"userUid\": \"automatic\",\n\t\t\t\"userGid\": \"automatic\"\n\t\t},\n\t\t\"ghcr.io/devcontainers/features/dotnet:2\": {\n\t\t\t\"version\": \"lts\"\n\t\t},\n\t\t\"ghcr.io/devcontainers/features/git:1\": {\n\t\t\t\"ppa\": true,\n\t\t\t\"version\": \"latest\"\n\t\t},\n\t\t\"ghcr.io/devcontainers/features/node\": {\n\t\t\t\"version\": \"lts\"\n\t\t}\n\t},\n\n\t// Use 'forwardPorts' to make a list of ports inside the container available locally.\n  \"forwardPorts\": [\n\t8080 // for Spec-Kit documentation site\n  ],\n  \"containerUser\": \"devcontainer\",\n  \"updateRemoteUserUID\": true,\n  \"postCreateCommand\": \"chmod +x ./.devcontainer/post-create.sh && ./.devcontainer/post-create.sh\",\n  \"postStartCommand\": \"git config --global --add safe.directory ${containerWorkspaceFolder}\",\n  \"customizations\": {\n    \"vscode\": {\n      \"extensions\": [\n\t\t\"mhutchie.git-graph\",\n\t\t\"eamodio.gitlens\",\n\t\t\"anweber.reveal-button\",\n\t\t\"chrisdias.promptboost\",\n\t\t// Github Copilot\n\t\t\"GitHub.copilot\",\n\t\t\"GitHub.copilot-chat\",\n\t\t// Codex\n\t\t\"openai.chatgpt\",\n\t\t// Kilo Code\n\t\t\"kilocode.Kilo-Code\",\n\t\t// Roo Code\n\t\t\"RooVeterinaryInc.roo-cline\",\n\t\t// Claude Code\n\t\t\"anthropic.claude-code\"\n\t],\n      \"settings\": {\n\t\t\"debug.javascript.autoAttachFilter\": \"disabled\", // fix running commands in integrated terminal\n\n\t\t// Specify settings for Github Copilot\n\t\t\"git.autofetch\": true,\n\t\t\"chat.promptFilesRecommendations\": {\n\t\t\t\"speckit.constitution\": true,\n\t\t\t\"speckit.specify\": true,\n\t\t\t\"speckit.plan\": true,\n\t\t\t\"speckit.tasks\": true,\n\t\t\t\"speckit.implement\": true\n\t\t},\n\t\t\"chat.tools.terminal.autoApprove\": {\n\t\t\t\".specify/scripts/bash/\": true,\n\t\t\t\".specify/scripts/powershell/\": true\n\t\t}\n      }\n    }\n  }\n}\n"
  },
  {
    "path": ".devcontainer/post-create.sh",
    "content": "#!/bin/bash\n\n# Exit immediately on error, treat unset variables as an error, and fail if any command in a pipeline fails.\nset -euo pipefail\n\n# Function to run a command and show logs only on error\nrun_command() {\n    local command_to_run=\"$*\"\n    local output\n    local exit_code\n\n    # Capture all output (stdout and stderr)\n    output=$(eval \"$command_to_run\" 2>&1) || exit_code=$?\n    exit_code=${exit_code:-0}\n\n    if [ $exit_code -ne 0 ]; then\n        echo -e \"\\033[0;31m[ERROR] Command failed (Exit Code $exit_code): $command_to_run\\033[0m\" >&2\n        echo -e \"\\033[0;31m$output\\033[0m\" >&2\n\n        exit $exit_code\n    fi\n}\n\n# Installing CLI-based AI Agents\n\necho -e \"\\n🤖 Installing Copilot CLI...\"\nrun_command \"npm install -g @github/copilot@latest\"\necho \"✅ Done\"\n\necho -e \"\\n🤖 Installing Claude CLI...\"\nrun_command \"npm install -g @anthropic-ai/claude-code@latest\"\necho \"✅ Done\"\n\necho -e \"\\n🤖 Installing Codex CLI...\"\nrun_command \"npm install -g @openai/codex@latest\"\necho \"✅ Done\"\n\necho -e \"\\n🤖 Installing Gemini CLI...\"\nrun_command \"npm install -g @google/gemini-cli@latest\"\necho \"✅ Done\"\n\necho -e \"\\n🤖 Installing Augie CLI...\"\nrun_command \"npm install -g @augmentcode/auggie@latest\"\necho \"✅ Done\"\n\necho -e \"\\n🤖 Installing Qwen Code CLI...\"\nrun_command \"npm install -g @qwen-code/qwen-code@latest\"\necho \"✅ Done\"\n\necho -e \"\\n🤖 Installing OpenCode CLI...\"\nrun_command \"npm install -g opencode-ai@latest\"\necho \"✅ Done\"\n\necho -e \"\\n🤖 Installing Junie CLI...\"\nrun_command \"npm install -g @jetbrains/junie-cli@latest\"\necho \"✅ Done\"\n\necho -e \"\\n🤖 Installing Pi Coding Agent...\"\nrun_command \"npm install -g @mariozechner/pi-coding-agent@latest\"\necho \"✅ Done\"\n\necho -e \"\\n🤖 Installing Kiro CLI...\"\n# https://kiro.dev/docs/cli/\nKIRO_INSTALLER_URL=\"https://kiro.dev/install.sh\"\nKIRO_INSTALLER_SHA256=\"7487a65cf310b7fb59b357c4b5e6e3f3259d383f4394ecedb39acf70f307cffb\"\nKIRO_INSTALLER_PATH=\"$(mktemp)\"\n\ncleanup_kiro_installer() {\n  rm -f \"$KIRO_INSTALLER_PATH\"\n}\ntrap cleanup_kiro_installer EXIT\n\nrun_command \"curl -fsSL \\\"$KIRO_INSTALLER_URL\\\" -o \\\"$KIRO_INSTALLER_PATH\\\"\"\nrun_command \"echo \\\"$KIRO_INSTALLER_SHA256  $KIRO_INSTALLER_PATH\\\" | sha256sum -c -\"\n\nrun_command \"bash \\\"$KIRO_INSTALLER_PATH\\\"\"\n\nkiro_binary=\"\"\nif command -v kiro-cli >/dev/null 2>&1; then\n  kiro_binary=\"kiro-cli\"\nelif command -v kiro >/dev/null 2>&1; then\n  kiro_binary=\"kiro\"\nelse\n  echo -e \"\\033[0;31m[ERROR] Kiro CLI installation did not create 'kiro-cli' or 'kiro' in PATH.\\033[0m\" >&2\n  exit 1\nfi\n\nrun_command \"$kiro_binary --help > /dev/null\"\necho \"✅ Done\"\n\necho -e \"\\n🤖 Installing Kimi CLI...\"\n# https://code.kimi.com\nrun_command \"pipx install kimi-cli\"\necho \"✅ Done\"\n\necho -e \"\\n🤖 Installing CodeBuddy CLI...\"\nrun_command \"npm install -g @tencent-ai/codebuddy-code@latest\"\necho \"✅ Done\"\n\n# Installing UV (Python package manager)\necho -e \"\\n🐍 Installing UV - Python Package Manager...\"\nrun_command \"pipx install uv\"\necho \"✅ Done\"\n\n# Installing DocFx (for documentation site)\necho -e \"\\n📚 Installing DocFx...\"\nrun_command \"dotnet tool update -g docfx\"\necho \"✅ Done\"\n\necho -e \"\\n🧹 Cleaning cache...\"\nrun_command \"sudo apt-get autoclean\"\nrun_command \"sudo apt-get clean\"\n\necho \"✅ Setup completed. Happy coding! 🚀\"\n"
  },
  {
    "path": ".gitattributes",
    "content": "* text=auto eol=lf\n"
  },
  {
    "path": ".github/CODEOWNERS",
    "content": "# Global code owner\n* @mnriem\n\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/agent_request.yml",
    "content": "name: Agent Request\ndescription: Request support for a new AI agent/assistant in Spec Kit\ntitle: \"[Agent]: Add support for \"\nlabels: [\"agent-request\", \"enhancement\", \"needs-triage\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for requesting a new agent! Before submitting, please check if the agent is already supported.\n        \n        **Currently supported agents**: Claude Code, Gemini CLI, GitHub Copilot, Cursor, Qwen Code, opencode, Codex CLI, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy, Qoder CLI, Kiro CLI, Amp, SHAI, Tabnine CLI, Antigravity, IBM Bob, Mistral Vibe, Kimi Code, Trae, Pi Coding Agent, iFlow CLI\n\n  - type: input\n    id: agent-name\n    attributes:\n      label: Agent Name\n      description: What is the name of the AI agent/assistant?\n      placeholder: \"e.g., SuperCoder AI\"\n    validations:\n      required: true\n\n  - type: input\n    id: website\n    attributes:\n      label: Official Website\n      description: Link to the agent's official website or documentation\n      placeholder: \"https://...\"\n    validations:\n      required: true\n\n  - type: dropdown\n    id: agent-type\n    attributes:\n      label: Agent Type\n      description: How is the agent accessed?\n      options:\n        - CLI tool (command-line interface)\n        - IDE extension/plugin\n        - Both CLI and IDE\n        - Other\n    validations:\n      required: true\n\n  - type: input\n    id: cli-command\n    attributes:\n      label: CLI Command (if applicable)\n      description: What command is used to invoke the agent from terminal?\n      placeholder: \"e.g., supercode, ai-assistant\"\n\n  - type: input\n    id: install-method\n    attributes:\n      label: Installation Method\n      description: How is the agent installed?\n      placeholder: \"e.g., npm install -g supercode, pip install supercode, IDE marketplace\"\n    validations:\n      required: true\n\n  - type: textarea\n    id: command-structure\n    attributes:\n      label: Command/Workflow Structure\n      description: How does the agent define custom commands or workflows?\n      placeholder: |\n        - Command file format (Markdown, YAML, TOML, etc.)\n        - Directory location (e.g., .supercode/commands/)\n        - Example command file structure\n    validations:\n      required: true\n\n  - type: textarea\n    id: argument-pattern\n    attributes:\n      label: Argument Passing Pattern\n      description: How does the agent handle arguments in commands?\n      placeholder: |\n        e.g., Uses {{args}}, $ARGUMENTS, %ARGS%, or other placeholder format\n        Example: \"Run test suite with {{args}}\"\n\n  - type: dropdown\n    id: popularity\n    attributes:\n      label: Popularity/Usage\n      description: How widely is this agent used?\n      options:\n        - Widely used (thousands+ of users)\n        - Growing adoption (hundreds of users)\n        - New/emerging (less than 100 users)\n        - Unknown\n    validations:\n      required: true\n\n  - type: textarea\n    id: documentation\n    attributes:\n      label: Documentation Links\n      description: Links to relevant documentation for custom commands/workflows\n      placeholder: |\n        - Command documentation: https://...\n        - API/CLI reference: https://...\n        - Examples: https://...\n\n  - type: textarea\n    id: use-case\n    attributes:\n      label: Use Case\n      description: Why do you want this agent supported in Spec Kit?\n      placeholder: Explain your workflow and how this agent fits into your development process\n    validations:\n      required: true\n\n  - type: textarea\n    id: example-command\n    attributes:\n      label: Example Command File\n      description: If possible, provide an example of a command file for this agent\n      render: markdown\n      placeholder: |\n        ```toml\n        description = \"Example command\"\n        prompt = \"Do something with {{args}}\"\n        ```\n\n  - type: checkboxes\n    id: contribution\n    attributes:\n      label: Contribution\n      description: Are you willing to help implement support for this agent?\n      options:\n        - label: I can help test the integration\n        - label: I can provide example command files\n        - label: I can help with documentation\n        - label: I can submit a pull request for the integration\n\n  - type: textarea\n    id: context\n    attributes:\n      label: Additional Context\n      description: Any other relevant information about this agent\n      placeholder: Screenshots, community links, comparison to existing agents, etc.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yml",
    "content": "name: Bug Report\ndescription: Report a bug or unexpected behavior in Specify CLI or Spec Kit\ntitle: \"[Bug]: \"\nlabels: [\"bug\", \"needs-triage\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for taking the time to report a bug! Please fill out the sections below to help us diagnose and fix the issue.\n\n  - type: textarea\n    id: description\n    attributes:\n      label: Bug Description\n      description: A clear and concise description of what the bug is.\n      placeholder: What went wrong?\n    validations:\n      required: true\n\n  - type: textarea\n    id: reproduce\n    attributes:\n      label: Steps to Reproduce\n      description: Steps to reproduce the behavior\n      placeholder: |\n        1. Run command '...'\n        2. Execute script '...'\n        3. See error\n    validations:\n      required: true\n\n  - type: textarea\n    id: expected\n    attributes:\n      label: Expected Behavior\n      description: What did you expect to happen?\n      placeholder: Describe the expected outcome\n    validations:\n      required: true\n\n  - type: textarea\n    id: actual\n    attributes:\n      label: Actual Behavior\n      description: What actually happened?\n      placeholder: Describe what happened instead\n    validations:\n      required: true\n\n  - type: input\n    id: version\n    attributes:\n      label: Specify CLI Version\n      description: \"Run `specify version` or `pip show spec-kit`\"\n      placeholder: \"e.g., 1.3.0\"\n    validations:\n      required: true\n\n  - type: dropdown\n    id: ai-agent\n    attributes:\n      label: AI Agent\n      description: Which AI agent are you using?\n      options:\n        - Claude Code\n        - Gemini CLI\n        - GitHub Copilot\n        - Cursor\n        - Qwen Code\n        - opencode\n        - Codex CLI\n        - Windsurf\n        - Kilo Code\n        - Auggie CLI\n        - Roo Code\n        - CodeBuddy\n        - Qoder CLI\n        - Kiro CLI\n        - Amp\n        - SHAI\n        - IBM Bob\n        - Antigravity\n        - Not applicable\n    validations:\n      required: true\n\n  - type: input\n    id: os\n    attributes:\n      label: Operating System\n      description: Your operating system and version\n      placeholder: \"e.g., macOS 14.2, Ubuntu 22.04, Windows 11\"\n    validations:\n      required: true\n\n  - type: input\n    id: python\n    attributes:\n      label: Python Version\n      description: \"Run `python --version` or `python3 --version`\"\n      placeholder: \"e.g., Python 3.11.5\"\n    validations:\n      required: true\n\n  - type: textarea\n    id: logs\n    attributes:\n      label: Error Logs\n      description: Please paste any relevant error messages or logs\n      render: shell\n      placeholder: Paste error output here\n\n  - type: textarea\n    id: context\n    attributes:\n      label: Additional Context\n      description: Add any other context about the problem\n      placeholder: Screenshots, related issues, workarounds attempted, etc.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\ncontact_links:\n  - name: 💬 General Discussion\n    url: https://github.com/github/spec-kit/discussions\n    about: Ask questions, share ideas, or discuss Spec-Driven Development\n  - name: 📖 Documentation\n    url: https://github.com/github/spec-kit/blob/main/README.md\n    about: Read the Spec Kit documentation and guides\n  - name: 🛠️ Extension Development Guide\n    url: https://github.com/github/spec-kit/blob/main/extensions/EXTENSION-DEVELOPMENT-GUIDE.md\n    about: Learn how to develop and publish Spec Kit extensions\n  - name: 🤝 Contributing Guide\n    url: https://github.com/github/spec-kit/blob/main/CONTRIBUTING.md\n    about: Learn how to contribute to Spec Kit\n  - name: 🔒 Security Issues\n    url: https://github.com/github/spec-kit/blob/main/SECURITY.md\n    about: Report security vulnerabilities privately\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/extension_submission.yml",
    "content": "name: Extension Submission\ndescription: Submit your extension to the Spec Kit catalog\ntitle: \"[Extension]: Add \"\nlabels: [\"extension-submission\", \"enhancement\", \"needs-triage\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for contributing an extension! This template helps you submit your extension to the community catalog.\n        \n        **Before submitting:**\n        - Review the [Extension Publishing Guide](https://github.com/github/spec-kit/blob/main/extensions/EXTENSION-PUBLISHING-GUIDE.md)\n        - Ensure your extension has a valid `extension.yml` manifest\n        - Create a GitHub release with a version tag (e.g., v1.0.0)\n        - Test installation: `specify extension add --from <your-release-url>`\n\n  - type: input\n    id: extension-id\n    attributes:\n      label: Extension ID\n      description: Unique extension identifier (lowercase with hyphens only)\n      placeholder: \"e.g., jira-integration\"\n    validations:\n      required: true\n\n  - type: input\n    id: extension-name\n    attributes:\n      label: Extension Name\n      description: Human-readable extension name\n      placeholder: \"e.g., Jira Integration\"\n    validations:\n      required: true\n\n  - type: input\n    id: version\n    attributes:\n      label: Version\n      description: Semantic version number\n      placeholder: \"e.g., 1.0.0\"\n    validations:\n      required: true\n\n  - type: textarea\n    id: description\n    attributes:\n      label: Description\n      description: Brief description of what your extension does (under 200 characters)\n      placeholder: Integrates Jira issue tracking with Spec Kit workflows for seamless task management\n    validations:\n      required: true\n\n  - type: input\n    id: author\n    attributes:\n      label: Author\n      description: Your name or organization\n      placeholder: \"e.g., John Doe or Acme Corp\"\n    validations:\n      required: true\n\n  - type: input\n    id: repository\n    attributes:\n      label: Repository URL\n      description: GitHub repository URL for your extension\n      placeholder: \"https://github.com/your-org/spec-kit-your-extension\"\n    validations:\n      required: true\n\n  - type: input\n    id: download-url\n    attributes:\n      label: Download URL\n      description: URL to the GitHub release archive (e.g., v1.0.0.zip)\n      placeholder: \"https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip\"\n    validations:\n      required: true\n\n  - type: input\n    id: license\n    attributes:\n      label: License\n      description: Open source license type\n      placeholder: \"e.g., MIT, Apache-2.0\"\n    validations:\n      required: true\n\n  - type: input\n    id: homepage\n    attributes:\n      label: Homepage (optional)\n      description: Link to extension homepage or documentation site\n      placeholder: \"https://...\"\n\n  - type: input\n    id: documentation\n    attributes:\n      label: Documentation URL (optional)\n      description: Link to detailed documentation\n      placeholder: \"https://github.com/your-org/spec-kit-your-extension/blob/main/docs/\"\n\n  - type: input\n    id: changelog\n    attributes:\n      label: Changelog URL (optional)\n      description: Link to changelog file\n      placeholder: \"https://github.com/your-org/spec-kit-your-extension/blob/main/CHANGELOG.md\"\n\n  - type: input\n    id: speckit-version\n    attributes:\n      label: Required Spec Kit Version\n      description: Minimum Spec Kit version required\n      placeholder: \"e.g., >=0.1.0\"\n    validations:\n      required: true\n\n  - type: textarea\n    id: required-tools\n    attributes:\n      label: Required Tools (optional)\n      description: List any external tools or dependencies required\n      placeholder: |\n        - jira-cli (>=1.0.0) - required\n        - python (>=3.8) - optional\n      render: markdown\n\n  - type: input\n    id: commands-count\n    attributes:\n      label: Number of Commands\n      description: How many commands does your extension provide?\n      placeholder: \"e.g., 3\"\n    validations:\n      required: true\n\n  - type: input\n    id: hooks-count\n    attributes:\n      label: Number of Hooks (optional)\n      description: How many hooks does your extension provide?\n      placeholder: \"e.g., 0\"\n\n  - type: textarea\n    id: tags\n    attributes:\n      label: Tags\n      description: 2-5 relevant tags (lowercase, separated by commas)\n      placeholder: \"issue-tracking, jira, atlassian, automation\"\n    validations:\n      required: true\n\n  - type: textarea\n    id: features\n    attributes:\n      label: Key Features\n      description: List the main features and capabilities of your extension\n      placeholder: |\n        - Create Jira issues from specs\n        - Sync task status with Jira\n        - Link specs to existing issues\n        - Generate Jira reports\n    validations:\n      required: true\n\n  - type: checkboxes\n    id: testing\n    attributes:\n      label: Testing Checklist\n      description: Confirm that your extension has been tested\n      options:\n        - label: Extension installs successfully via download URL\n          required: true\n        - label: All commands execute without errors\n          required: true\n        - label: Documentation is complete and accurate\n          required: true\n        - label: No security vulnerabilities identified\n          required: true\n        - label: Tested on at least one real project\n          required: true\n\n  - type: checkboxes\n    id: requirements\n    attributes:\n      label: Submission Requirements\n      description: Verify your extension meets all requirements\n      options:\n        - label: Valid `extension.yml` manifest included\n          required: true\n        - label: README.md with installation and usage instructions\n          required: true\n        - label: LICENSE file included\n          required: true\n        - label: GitHub release created with version tag\n          required: true\n        - label: All command files exist and are properly formatted\n          required: true\n        - label: Extension ID follows naming conventions (lowercase-with-hyphens)\n          required: true\n\n  - type: textarea\n    id: testing-details\n    attributes:\n      label: Testing Details\n      description: Describe how you tested your extension\n      placeholder: |\n        **Tested on:**\n        - macOS 14.0 with Spec Kit v0.1.0\n        - Linux Ubuntu 22.04 with Spec Kit v0.1.0\n        \n        **Test project:** [Link or description]\n        \n        **Test scenarios:**\n        1. Installed extension\n        2. Configured settings\n        3. Ran all commands\n        4. Verified outputs\n    validations:\n      required: true\n\n  - type: textarea\n    id: example-usage\n    attributes:\n      label: Example Usage\n      description: Provide a simple example of using your extension\n      render: markdown\n      placeholder: |\n        ```bash\n        # Install extension\n        specify extension add --from https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip\n        \n        # Use a command\n        /speckit.your-extension.command-name arg1 arg2\n        ```\n    validations:\n      required: true\n\n  - type: textarea\n    id: catalog-entry\n    attributes:\n      label: Proposed Catalog Entry\n      description: Provide the JSON entry for catalog.json (helps reviewers)\n      render: json\n      placeholder: |\n        {\n          \"your-extension\": {\n            \"name\": \"Your Extension\",\n            \"id\": \"your-extension\",\n            \"description\": \"Brief description\",\n            \"author\": \"Your Name\",\n            \"version\": \"1.0.0\",\n            \"download_url\": \"https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip\",\n            \"repository\": \"https://github.com/your-org/spec-kit-your-extension\",\n            \"homepage\": \"https://github.com/your-org/spec-kit-your-extension\",\n            \"license\": \"MIT\",\n            \"requires\": {\n              \"speckit_version\": \">=0.1.0\"\n            },\n            \"provides\": {\n              \"commands\": 3\n            },\n            \"tags\": [\"category\", \"tool\"],\n            \"verified\": false,\n            \"downloads\": 0,\n            \"stars\": 0,\n            \"created_at\": \"2026-02-20T00:00:00Z\",\n            \"updated_at\": \"2026-02-20T00:00:00Z\"\n          }\n        }\n    validations:\n      required: true\n\n  - type: textarea\n    id: additional-context\n    attributes:\n      label: Additional Context\n      description: Any other information that would help reviewers\n      placeholder: Screenshots, demo videos, links to related projects, etc.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.yml",
    "content": "name: Feature Request\ndescription: Suggest a new feature or enhancement for Specify CLI or Spec Kit\ntitle: \"[Feature]: \"\nlabels: [\"enhancement\", \"needs-triage\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for suggesting a feature! Please provide details below to help us understand and evaluate your request.\n\n  - type: textarea\n    id: problem\n    attributes:\n      label: Problem Statement\n      description: Is your feature request related to a problem? Please describe.\n      placeholder: \"I'm frustrated when...\"\n    validations:\n      required: true\n\n  - type: textarea\n    id: solution\n    attributes:\n      label: Proposed Solution\n      description: Describe the solution you'd like\n      placeholder: What would you like to happen?\n    validations:\n      required: true\n\n  - type: textarea\n    id: alternatives\n    attributes:\n      label: Alternatives Considered\n      description: Have you considered any alternative solutions or workarounds?\n      placeholder: What other approaches might work?\n\n  - type: dropdown\n    id: component\n    attributes:\n      label: Component\n      description: Which component does this feature relate to?\n      options:\n        - Specify CLI (initialization, commands)\n        - Spec templates (BDD, Testing Strategy, etc.)\n        - Agent integrations (command files, workflows)\n        - Scripts (Bash/PowerShell utilities)\n        - Documentation\n        - CI/CD workflows\n        - Other\n    validations:\n      required: true\n\n  - type: dropdown\n    id: ai-agent\n    attributes:\n      label: AI Agent (if applicable)\n      description: Does this feature relate to a specific AI agent?\n      options:\n        - All agents\n        - Claude Code\n        - Gemini CLI\n        - GitHub Copilot\n        - Cursor\n        - Qwen Code\n        - opencode\n        - Codex CLI\n        - Windsurf\n        - Kilo Code\n        - Auggie CLI\n        - Roo Code\n        - CodeBuddy\n        - Qoder CLI\n        - Kiro CLI\n        - Amp\n        - SHAI\n        - IBM Bob\n        - Antigravity\n        - Not applicable\n\n  - type: textarea\n    id: use-cases\n    attributes:\n      label: Use Cases\n      description: Describe specific use cases where this feature would be valuable\n      placeholder: |\n        1. When working on large projects...\n        2. During spec review...\n        3. When integrating with CI/CD...\n\n  - type: textarea\n    id: acceptance\n    attributes:\n      label: Acceptance Criteria\n      description: How would you know this feature is complete and working?\n      placeholder: |\n        - [ ] Feature does X\n        - [ ] Documentation is updated\n        - [ ] Works with all supported agents\n\n  - type: textarea\n    id: context\n    attributes:\n      label: Additional Context\n      description: Add any other context, screenshots, or examples\n      placeholder: Links to similar features, mockups, related discussions, etc.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/preset_submission.yml",
    "content": "name: Preset Submission\ndescription: Submit your preset to the Spec Kit preset catalog\ntitle: \"[Preset]: Add \"\nlabels: [\"preset-submission\", \"enhancement\", \"needs-triage\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for contributing a preset! This template helps you submit your preset to the community catalog.\n        \n        **Before submitting:**\n        - Review the [Preset Publishing Guide](https://github.com/github/spec-kit/blob/main/presets/PUBLISHING.md)\n        - Ensure your preset has a valid `preset.yml` manifest\n        - Create a GitHub release with a version tag (e.g., v1.0.0)\n        - Test installation from the release archive: `specify preset add --from <download-url>`\n\n  - type: input\n    id: preset-id\n    attributes:\n      label: Preset ID\n      description: Unique preset identifier (lowercase with hyphens only)\n      placeholder: \"e.g., healthcare-compliance\"\n    validations:\n      required: true\n\n  - type: input\n    id: preset-name\n    attributes:\n      label: Preset Name\n      description: Human-readable preset name\n      placeholder: \"e.g., Healthcare Compliance\"\n    validations:\n      required: true\n\n  - type: input\n    id: version\n    attributes:\n      label: Version\n      description: Semantic version number\n      placeholder: \"e.g., 1.0.0\"\n    validations:\n      required: true\n\n  - type: textarea\n    id: description\n    attributes:\n      label: Description\n      description: Brief description of what your preset does (under 200 characters)\n      placeholder: Enforces HIPAA-compliant spec workflows with audit templates and compliance checklists\n    validations:\n      required: true\n\n  - type: input\n    id: author\n    attributes:\n      label: Author\n      description: Your name or organization\n      placeholder: \"e.g., John Doe or Acme Corp\"\n    validations:\n      required: true\n\n  - type: input\n    id: repository\n    attributes:\n      label: Repository URL\n      description: GitHub repository URL for your preset\n      placeholder: \"https://github.com/your-org/spec-kit-your-preset\"\n    validations:\n      required: true\n\n  - type: input\n    id: download-url\n    attributes:\n      label: Download URL\n      description: URL to the GitHub release archive for your preset (e.g., https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip)\n      placeholder: \"https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip\"\n    validations:\n      required: true\n\n  - type: input\n    id: license\n    attributes:\n      label: License\n      description: Open source license type\n      placeholder: \"e.g., MIT, Apache-2.0\"\n    validations:\n      required: true\n\n  - type: input\n    id: speckit-version\n    attributes:\n      label: Required Spec Kit Version\n      description: Minimum Spec Kit version required\n      placeholder: \"e.g., >=0.3.0\"\n    validations:\n      required: true\n\n  - type: textarea\n    id: templates-provided\n    attributes:\n      label: Templates Provided\n      description: List the template overrides your preset provides\n      placeholder: |\n        - spec-template.md — adds compliance section\n        - plan-template.md — includes audit checkpoints\n        - checklist-template.md — HIPAA compliance checklist\n    validations:\n      required: true\n\n  - type: textarea\n    id: commands-provided\n    attributes:\n      label: Commands Provided (optional)\n      description: List any command overrides your preset provides\n      placeholder: |\n        - speckit.specify.md — customized for compliance workflows\n\n  - type: textarea\n    id: tags\n    attributes:\n      label: Tags\n      description: 2-5 relevant tags (lowercase, separated by commas)\n      placeholder: \"compliance, healthcare, hipaa, audit\"\n    validations:\n      required: true\n\n  - type: textarea\n    id: features\n    attributes:\n      label: Key Features\n      description: List the main features and capabilities of your preset\n      placeholder: |\n        - HIPAA-compliant spec templates\n        - Audit trail checklists\n        - Compliance review workflow\n    validations:\n      required: true\n\n  - type: checkboxes\n    id: testing\n    attributes:\n      label: Testing Checklist\n      description: Confirm that your preset has been tested\n      options:\n        - label: Preset installs successfully via `specify preset add`\n          required: true\n        - label: Template resolution works correctly after installation\n          required: true\n        - label: Documentation is complete and accurate\n          required: true\n        - label: Tested on at least one real project\n          required: true\n\n  - type: checkboxes\n    id: requirements\n    attributes:\n      label: Submission Requirements\n      description: Verify your preset meets all requirements\n      options:\n        - label: Valid `preset.yml` manifest included\n          required: true\n        - label: README.md with description and usage instructions\n          required: true\n        - label: LICENSE file included\n          required: true\n        - label: GitHub release created with version tag\n          required: true\n        - label: Preset ID follows naming conventions (lowercase-with-hyphens)\n          required: true\n"
  },
  {
    "path": ".github/PULL_REQUEST_TEMPLATE.md",
    "content": "## Description\n\n<!-- What does this PR do? Why is it needed? -->\n\n## Testing\n\n<!-- How did you test your changes? -->\n\n- [ ] Tested locally with `uv run specify --help`\n- [ ] Ran existing tests with `uv sync && uv run pytest`\n- [ ] Tested with a sample project (if applicable)\n\n## AI Disclosure\n\n<!-- Per our Contributing guidelines, AI assistance must be disclosed. -->\n<!-- See: https://github.com/github/spec-kit/blob/main/CONTRIBUTING.md#ai-contributions-in-spec-kit -->\n\n- [ ] I **did not** use AI assistance for this contribution\n- [ ] I **did** use AI assistance (describe below)\n\n<!-- If you used AI, briefly describe how (e.g., \"Code generated by Copilot\", \"Consulted ChatGPT for approach\"): -->\n\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\nupdates:\n  - package-ecosystem: \"pip\"\n    directory: \"/\"\n    schedule:\n      interval: \"weekly\"\n\n  - package-ecosystem: \"github-actions\"\n    directory: \"/\"\n    schedule:\n      interval: \"weekly\"\n"
  },
  {
    "path": ".github/workflows/RELEASE-PROCESS.md",
    "content": "# Release Process\n\nThis document describes the automated release process for Spec Kit.\n\n## Overview\n\nThe release process is split into two workflows to ensure version consistency:\n\n1. **Release Trigger Workflow** (`release-trigger.yml`) - Manages versioning and triggers release\n2. **Release Workflow** (`release.yml`) - Builds and publishes artifacts\n\nThis separation ensures that git tags always point to commits with the correct version in `pyproject.toml`.\n\n## Before Creating a Release\n\n**Important**: Write clear, descriptive commit messages!\n\n### How CHANGELOG.md Works\n\nThe CHANGELOG is **automatically generated** from your git commit messages:\n\n1. **During Development**: Write clear, descriptive commit messages:\n   ```bash\n   git commit -m \"feat: Add new authentication feature\"\n   git commit -m \"fix: Resolve timeout issue in API client (#123)\"\n   git commit -m \"docs: Update installation instructions\"\n   ```\n\n2. **When Releasing**: The release trigger workflow automatically:\n   - Finds all commits since the last release tag\n   - Formats them as changelog entries\n   - Inserts them into CHANGELOG.md\n   - Commits the updated changelog before creating the new tag\n\n### Commit Message Best Practices\n\nGood commit messages make good changelogs:\n- **Be descriptive**: \"Add user authentication\" not \"Update files\"\n- **Reference issues/PRs**: Include `(#123)` for automated linking\n- **Use conventional commits** (optional): `feat:`, `fix:`, `docs:`, `chore:`\n- **Keep it concise**: One line is ideal, details go in commit body\n\n**Example commits that become good changelog entries:**\n```\nfix: prepend YAML frontmatter to Cursor .mdc files (#1699)\nfeat: add generic agent support with customizable command directories (#1639)\ndocs: document dual-catalog system for extensions (#1689)\n```\n\n## Creating a Release\n\n### Option 1: Auto-Increment (Recommended for patches)\n\n1. Go to **Actions** → **Release Trigger**\n2. Click **Run workflow**\n3. Leave the version field **empty**\n4. Click **Run workflow**\n\nThe workflow will:\n- Auto-increment the patch version (e.g., `0.1.10` → `0.1.11`)\n- Update `pyproject.toml`\n- Update `CHANGELOG.md` by adding a new section for the release based on commits since the last tag\n- Commit changes to a `chore/release-vX.Y.Z` branch\n- Create and push the git tag from that branch\n- Open a PR to merge the version bump into `main`\n- Trigger the release workflow automatically via the tag push\n\n### Option 2: Manual Version (For major/minor bumps)\n\n1. Go to **Actions** → **Release Trigger**\n2. Click **Run workflow**\n3. Enter the desired version (e.g., `0.2.0` or `v0.2.0`)\n4. Click **Run workflow**\n\nThe workflow will:\n- Use your specified version\n- Update `pyproject.toml`\n- Update `CHANGELOG.md` by adding a new section for the release based on commits since the last tag\n- Commit changes to a `chore/release-vX.Y.Z` branch\n- Create and push the git tag from that branch\n- Open a PR to merge the version bump into `main`\n- Trigger the release workflow automatically via the tag push\n\n## What Happens Next\n\nOnce the release trigger workflow completes:\n\n1. A `chore/release-vX.Y.Z` branch is pushed with the version bump commit\n2. The git tag is pushed, pointing to that commit\n3. The **Release Workflow** is automatically triggered by the tag push\n4. Release artifacts are built for all supported agents\n5. A GitHub Release is created with all assets\n6. A PR is opened to merge the version bump branch into `main`\n\n> **Note**: Merge the auto-opened PR after the release is published to keep `main` in sync.\n\n## Workflow Details\n\n### Release Trigger Workflow\n\n**File**: `.github/workflows/release-trigger.yml`\n\n**Trigger**: Manual (`workflow_dispatch`)\n\n**Permissions Required**: `contents: write`\n\n**Steps**:\n1. Checkout repository\n2. Determine version (manual or auto-increment)\n3. Check if tag already exists (prevents duplicates)\n4. Create `chore/release-vX.Y.Z` branch\n5. Update `pyproject.toml`\n6. Update `CHANGELOG.md` from git commits\n7. Commit changes\n8. Push branch and tag\n9. Open PR to merge version bump into `main`\n\n### Release Workflow\n\n**File**: `.github/workflows/release.yml`\n\n**Trigger**: Tag push (`v*`)\n\n**Permissions Required**: `contents: write`\n\n**Steps**:\n1. Checkout repository at tag\n2. Extract version from tag name\n3. Check if release already exists\n4. Build release package variants (all agents × shell/powershell)\n5. Generate release notes from commits\n6. Create GitHub Release with all assets\n\n## Version Constraints\n\n- Tags must follow format: `v{MAJOR}.{MINOR}.{PATCH}`\n- Example valid versions: `v0.1.11`, `v0.2.0`, `v1.0.0`\n- Auto-increment only bumps patch version\n- Cannot create duplicate tags (workflow will fail)\n\n## Benefits of This Approach\n\n✅ **Version Consistency**: Git tags point to commits with matching `pyproject.toml` version\n\n✅ **Single Source of Truth**: Version set once, used everywhere\n\n✅ **Prevents Drift**: No more manual version synchronization needed\n\n✅ **Clean Separation**: Versioning logic separate from artifact building\n\n✅ **Flexibility**: Supports both auto-increment and manual versioning\n\n## Troubleshooting\n\n### No Commits Since Last Release\n\nIf you run the release trigger workflow when there are no new commits since the last tag:\n- The workflow will still succeed\n- The CHANGELOG will show \"- Initial release\" if it's the first release\n- Or it will be empty if there are no commits\n- Consider adding meaningful commits before releasing\n\n**Best Practice**: Use descriptive commit messages - they become your changelog!\n\n### Tag Already Exists\n\nIf you see \"Error: Tag vX.Y.Z already exists!\", you need to:\n- Choose a different version number, or\n- Delete the existing tag if it was created in error\n\n### Release Workflow Didn't Trigger\n\nCheck that:\n- The release trigger workflow completed successfully\n- The tag was pushed (check repository tags)\n- The release workflow is enabled in Actions settings\n\n### Version Mismatch\n\nIf `pyproject.toml` doesn't match the latest tag:\n- Run the release trigger workflow to sync versions\n- Or manually update `pyproject.toml` and push changes before running the release trigger\n\n## Legacy Behavior (Pre-v0.1.10)\n\nBefore this change, the release workflow:\n- Created tags automatically on main branch pushes\n- Updated `pyproject.toml` AFTER creating the tag\n- Resulted in tags pointing to commits with outdated versions\n\nThis has been fixed in v0.1.10+.\n"
  },
  {
    "path": ".github/workflows/codeql.yml",
    "content": "name: \"CodeQL\"\n\non:\n  push:\n    branches: [ main ]\n  pull_request:\n    branches: [ main ]\n\njobs:\n  analyze:\n    name: Analyze\n    runs-on: ubuntu-latest\n    permissions:\n      security-events: write\n      contents: read\n    strategy:\n      fail-fast: false\n      matrix:\n        language: [ 'actions', 'python' ]\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Initialize CodeQL\n        uses: github/codeql-action/init@v4\n        with:\n          languages: ${{ matrix.language }}\n\n      - name: Perform CodeQL Analysis\n        uses: github/codeql-action/analyze@v4\n        with:\n          category: \"/language:${{ matrix.language }}\"\n"
  },
  {
    "path": ".github/workflows/docs.yml",
    "content": "# Build and deploy DocFX documentation to GitHub Pages\nname: Deploy Documentation to Pages\n\non:\n  # Runs on pushes targeting the default branch\n  push:\n    branches: [\"main\"]\n    paths:\n      - 'docs/**'\n\n  # Allows you to run this workflow manually from the Actions tab\n  workflow_dispatch:\n\n# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages\npermissions:\n  contents: read\n  pages: write\n  id-token: write\n\n# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.\n# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.\nconcurrency:\n  group: \"pages\"\n  cancel-in-progress: false\n\njobs:\n  # Build job\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0 # Fetch all history for git info\n\n      - name: Setup .NET\n        uses: actions/setup-dotnet@v4\n        with:\n          dotnet-version: '8.x'\n\n      - name: Setup DocFX\n        run: dotnet tool install -g docfx\n\n      - name: Build with DocFX\n        run: |\n          cd docs\n          docfx docfx.json\n\n      - name: Setup Pages\n        uses: actions/configure-pages@v5\n\n      - name: Upload artifact\n        uses: actions/upload-pages-artifact@v3\n        with:\n          path: 'docs/_site'\n\n  # Deploy job\n  deploy:\n    environment:\n      name: github-pages\n      url: ${{ steps.deployment.outputs.page_url }}\n    runs-on: ubuntu-latest\n    needs: build\n    steps:\n      - name: Deploy to GitHub Pages\n        id: deployment\n        uses: actions/deploy-pages@v4\n\n"
  },
  {
    "path": ".github/workflows/lint.yml",
    "content": "name: Lint\npermissions:\n  contents: read\n\non:\n  push:\n    branches: [\"main\"]\n  pull_request:\n\njobs:\n  markdownlint:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n\n      - name: Run markdownlint-cli2\n        uses: DavidAnson/markdownlint-cli2-action@v19\n        with:\n          globs: |\n            '**/*.md'\n            !extensions/**/*.md\n"
  },
  {
    "path": ".github/workflows/release-trigger.yml",
    "content": "name: Release Trigger\n\non:\n  workflow_dispatch:\n    inputs:\n      version:\n        description: 'Version to release (e.g., 0.1.11). Leave empty to auto-increment patch version.'\n        required: false\n        type: string\n\njobs:\n  bump-version:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: write\n      pull-requests: write\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0\n          token: ${{ secrets.RELEASE_PAT }}\n\n      - name: Configure Git\n        run: |\n          git config user.name \"github-actions[bot]\"\n          git config user.email \"41898282+github-actions[bot]@users.noreply.github.com\"\n\n      - name: Determine version\n        id: version\n        env:\n          INPUT_VERSION: ${{ github.event.inputs.version }}\n        run: |\n          if [[ -n \"$INPUT_VERSION\" ]]; then\n            # Manual version specified - strip optional v prefix\n            VERSION=\"${INPUT_VERSION#v}\"\n            # Validate strict semver format to prevent injection\n            if [[ ! \"$VERSION\" =~ ^[0-9]+\\.[0-9]+\\.[0-9]+$ ]]; then\n              echo \"Error: Invalid version format '$VERSION'. Must be X.Y.Z (e.g. 1.2.3 or v1.2.3)\"\n              exit 1\n            fi\n            echo \"version=$VERSION\" >> $GITHUB_OUTPUT\n            echo \"tag=v$VERSION\" >> $GITHUB_OUTPUT\n            echo \"Using manual version: $VERSION\"\n          else\n            # Auto-increment patch version\n            LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo \"v0.0.0\")\n            echo \"Latest tag: $LATEST_TAG\"\n\n            # Extract version number and increment\n            VERSION=$(echo $LATEST_TAG | sed 's/v//')\n            IFS='.' read -ra VERSION_PARTS <<< \"$VERSION\"\n            MAJOR=${VERSION_PARTS[0]:-0}\n            MINOR=${VERSION_PARTS[1]:-0}\n            PATCH=${VERSION_PARTS[2]:-0}\n\n            # Increment patch version\n            PATCH=$((PATCH + 1))\n            NEW_VERSION=\"$MAJOR.$MINOR.$PATCH\"\n\n            echo \"version=$NEW_VERSION\" >> $GITHUB_OUTPUT\n            echo \"tag=v$NEW_VERSION\" >> $GITHUB_OUTPUT\n            echo \"Auto-incremented version: $NEW_VERSION\"\n          fi\n\n      - name: Check if tag already exists\n        run: |\n          if git rev-parse \"${{ steps.version.outputs.tag }}\" >/dev/null 2>&1; then\n            echo \"Error: Tag ${{ steps.version.outputs.tag }} already exists!\"\n            exit 1\n          fi\n\n      - name: Create release branch\n        run: |\n          BRANCH=\"chore/release-${{ steps.version.outputs.tag }}\"\n          git checkout -b \"$BRANCH\"\n          echo \"branch=$BRANCH\" >> $GITHUB_ENV\n\n      - name: Update pyproject.toml\n        run: |\n          sed -i \"s/version = \\\".*\\\"/version = \\\"${{ steps.version.outputs.version }}\\\"/\" pyproject.toml\n          echo \"Updated pyproject.toml to version ${{ steps.version.outputs.version }}\"\n\n      - name: Update CHANGELOG.md\n        run: |\n          if [ -f \"CHANGELOG.md\" ]; then\n            DATE=$(date +%Y-%m-%d)\n\n            # Get the previous tag by sorting all version tags numerically\n            # (git describe --tags only finds tags reachable from HEAD,\n            #  which misses tags on unmerged release branches)\n            PREVIOUS_TAG=$(git tag -l 'v*' --sort=-version:refname | head -n 1)\n\n            echo \"Generating changelog from commits...\"\n            if [[ -n \"$PREVIOUS_TAG\" ]]; then\n              echo \"Changes since $PREVIOUS_TAG\"\n              COMMITS=$(git log --oneline \"$PREVIOUS_TAG\"..HEAD --no-merges --pretty=format:\"- %s\" 2>/dev/null || echo \"- Initial release\")\n            else\n              echo \"No previous tag found - this is the first release\"\n              COMMITS=\"- Initial release\"\n            fi\n\n            # Create new changelog entry\n            {\n              head -n 8 CHANGELOG.md\n              echo \"\"\n              echo \"## [${{ steps.version.outputs.version }}] - $DATE\"\n              echo \"\"\n              echo \"### Changes\"\n              echo \"\"\n              echo \"$COMMITS\"\n              echo \"\"\n              tail -n +9 CHANGELOG.md\n            } > CHANGELOG.md.tmp\n            mv CHANGELOG.md.tmp CHANGELOG.md\n\n            echo \"✅ Updated CHANGELOG.md with commits since $PREVIOUS_TAG\"\n          else\n            echo \"No CHANGELOG.md found\"\n          fi\n\n      - name: Commit version bump\n        run: |\n          if [ -f \"CHANGELOG.md\" ]; then\n            git add pyproject.toml CHANGELOG.md\n          else\n            git add pyproject.toml\n          fi\n\n          if git diff --cached --quiet; then\n            echo \"No changes to commit\"\n          else\n            git commit -m \"chore: bump version to ${{ steps.version.outputs.version }}\"\n            echo \"Changes committed\"\n          fi\n\n      - name: Create and push tag\n        run: |\n          git tag -a \"${{ steps.version.outputs.tag }}\" -m \"Release ${{ steps.version.outputs.tag }}\"\n          git push origin \"${{ env.branch }}\"\n          git push origin \"${{ steps.version.outputs.tag }}\"\n          echo \"Branch ${{ env.branch }} and tag ${{ steps.version.outputs.tag }} pushed\"\n\n      - name: Open pull request\n        env:\n          GITHUB_TOKEN: ${{ secrets.RELEASE_PAT }}\n        run: |\n          gh pr create \\\n            --base main \\\n            --head \"${{ env.branch }}\" \\\n            --title \"chore: bump version to ${{ steps.version.outputs.version }}\" \\\n            --body \"Automated version bump to ${{ steps.version.outputs.version }}.\n\n          This PR was created by the Release Trigger workflow. The git tag \\`${{ steps.version.outputs.tag }}\\` has already been pushed and the release artifacts are being built.\n\n          Merge this PR to record the version bump and changelog update on \\`main\\`.\"\n\n      - name: Summary\n        run: |\n          echo \"✅ Version bumped to ${{ steps.version.outputs.version }}\"\n          echo \"✅ Tag ${{ steps.version.outputs.tag }} created and pushed\"\n          echo \"✅ PR opened to merge version bump into main\"\n          echo \"🚀 Release workflow is building artifacts from the tag\"\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Create Release\n\non:\n  push:\n    tags:\n      - 'v*'\n\njobs:\n  release:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: write\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0\n          token: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Extract version from tag\n        id: version\n        run: |\n          VERSION=${GITHUB_REF#refs/tags/}\n          echo \"tag=$VERSION\" >> $GITHUB_OUTPUT\n          echo \"Building release for $VERSION\"\n\n      - name: Check if release already exists\n        id: check_release\n        run: |\n          chmod +x .github/workflows/scripts/check-release-exists.sh\n          .github/workflows/scripts/check-release-exists.sh ${{ steps.version.outputs.tag }}\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Create release package variants\n        if: steps.check_release.outputs.exists == 'false'\n        run: |\n          chmod +x .github/workflows/scripts/create-release-packages.sh\n          .github/workflows/scripts/create-release-packages.sh ${{ steps.version.outputs.tag }}\n\n      - name: Generate release notes\n        if: steps.check_release.outputs.exists == 'false'\n        id: release_notes\n        run: |\n          chmod +x .github/workflows/scripts/generate-release-notes.sh\n          # Get the previous tag for changelog generation\n          PREVIOUS_TAG=$(git describe --tags --abbrev=0 ${{ steps.version.outputs.tag }}^ 2>/dev/null || echo \"\")\n          # Default to v0.0.0 if no previous tag is found (e.g., first release)\n          if [ -z \"$PREVIOUS_TAG\" ]; then\n            PREVIOUS_TAG=\"v0.0.0\"\n          fi\n          .github/workflows/scripts/generate-release-notes.sh ${{ steps.version.outputs.tag }} \"$PREVIOUS_TAG\"\n\n      - name: Create GitHub Release\n        if: steps.check_release.outputs.exists == 'false'\n        run: |\n          chmod +x .github/workflows/scripts/create-github-release.sh\n          .github/workflows/scripts/create-github-release.sh ${{ steps.version.outputs.tag }}\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n\n"
  },
  {
    "path": ".github/workflows/scripts/check-release-exists.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\n# check-release-exists.sh\n# Check if a GitHub release already exists for the given version\n# Usage: check-release-exists.sh <version>\n\nif [[ $# -ne 1 ]]; then\n  echo \"Usage: $0 <version>\" >&2\n  exit 1\nfi\n\nVERSION=\"$1\"\n\nif gh release view \"$VERSION\" >/dev/null 2>&1; then\n  echo \"exists=true\" >> $GITHUB_OUTPUT\n  echo \"Release $VERSION already exists, skipping...\"\nelse\n  echo \"exists=false\" >> $GITHUB_OUTPUT\n  echo \"Release $VERSION does not exist, proceeding...\"\nfi\n"
  },
  {
    "path": ".github/workflows/scripts/create-github-release.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\n# create-github-release.sh\n# Create a GitHub release with all template zip files\n# Usage: create-github-release.sh <version>\n\nif [[ $# -ne 1 ]]; then\n  echo \"Usage: $0 <version>\" >&2\n  exit 1\nfi\n\nVERSION=\"$1\"\n\n# Remove 'v' prefix from version for release title\nVERSION_NO_V=${VERSION#v}\n\ngh release create \"$VERSION\" \\\n  .genreleases/spec-kit-template-copilot-sh-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-copilot-ps-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-claude-sh-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-claude-ps-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-gemini-sh-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-gemini-ps-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-cursor-agent-sh-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-cursor-agent-ps-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-opencode-sh-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-opencode-ps-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-qwen-sh-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-qwen-ps-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-windsurf-sh-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-windsurf-ps-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-junie-sh-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-junie-ps-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-codex-sh-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-codex-ps-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-kilocode-sh-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-kilocode-ps-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-auggie-sh-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-auggie-ps-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-roo-sh-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-roo-ps-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-codebuddy-sh-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-codebuddy-ps-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-qodercli-sh-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-qodercli-ps-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-amp-sh-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-amp-ps-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-shai-sh-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-shai-ps-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-tabnine-sh-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-tabnine-ps-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-kiro-cli-sh-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-kiro-cli-ps-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-agy-sh-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-agy-ps-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-bob-sh-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-bob-ps-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-vibe-sh-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-vibe-ps-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-kimi-sh-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-kimi-ps-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-trae-sh-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-trae-ps-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-pi-sh-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-pi-ps-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-iflow-sh-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-iflow-ps-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-generic-sh-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-generic-ps-\"$VERSION\".zip \\\n  --title \"Spec Kit Templates - $VERSION_NO_V\" \\\n  --notes-file release_notes.md\n"
  },
  {
    "path": ".github/workflows/scripts/create-release-packages.ps1",
    "content": "#!/usr/bin/env pwsh\n#requires -Version 7.0\n\n<#\n.SYNOPSIS\n    Build Spec Kit template release archives for each supported AI assistant and script type.\n\n.DESCRIPTION\n    create-release-packages.ps1 (workflow-local)\n    Build Spec Kit template release archives for each supported AI assistant and script type.\n\n.PARAMETER Version\n    Version string with leading 'v' (e.g., v0.2.0)\n\n.PARAMETER Agents\n    Comma or space separated subset of agents to build (default: all)\n    Valid agents: claude, gemini, copilot, cursor-agent, qwen, opencode, windsurf, junie, codex, kilocode, auggie, roo, codebuddy, amp, kiro-cli, bob, qodercli, shai, tabnine, agy, vibe, kimi, trae, pi, iflow, generic\n\n.PARAMETER Scripts\n    Comma or space separated subset of script types to build (default: both)\n    Valid scripts: sh, ps\n\n.EXAMPLE\n    .\\create-release-packages.ps1 -Version v0.2.0\n\n.EXAMPLE\n    .\\create-release-packages.ps1 -Version v0.2.0 -Agents claude,copilot -Scripts sh\n\n.EXAMPLE\n    .\\create-release-packages.ps1 -Version v0.2.0 -Agents claude -Scripts ps\n#>\n\nparam(\n    [Parameter(Mandatory=$true, Position=0)]\n    [string]$Version,\n\n    [Parameter(Mandatory=$false)]\n    [string]$Agents = \"\",\n\n    [Parameter(Mandatory=$false)]\n    [string]$Scripts = \"\"\n)\n\n$ErrorActionPreference = \"Stop\"\n\n# Validate version format\nif ($Version -notmatch '^v\\d+\\.\\d+\\.\\d+$') {\n    Write-Error \"Version must look like v0.0.0\"\n    exit 1\n}\n\nWrite-Host \"Building release packages for $Version\"\n\n# Create and use .genreleases directory for all build artifacts\n$GenReleasesDir = \".genreleases\"\nif (Test-Path $GenReleasesDir) {\n    Remove-Item -Path $GenReleasesDir -Recurse -Force -ErrorAction SilentlyContinue\n}\nNew-Item -ItemType Directory -Path $GenReleasesDir -Force | Out-Null\n\nfunction Rewrite-Paths {\n    param([string]$Content)\n\n    $Content = $Content -replace '(/?)\\bmemory/', '.specify/memory/'\n    $Content = $Content -replace '(/?)\\bscripts/', '.specify/scripts/'\n    $Content = $Content -replace '(/?)\\btemplates/', '.specify/templates/'\n    return $Content\n}\n\nfunction Generate-Commands {\n    param(\n        [string]$Agent,\n        [string]$Extension,\n        [string]$ArgFormat,\n        [string]$OutputDir,\n        [string]$ScriptVariant\n    )\n\n    New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null\n\n    $templates = Get-ChildItem -Path \"templates/commands/*.md\" -File -ErrorAction SilentlyContinue\n\n    foreach ($template in $templates) {\n        $name = [System.IO.Path]::GetFileNameWithoutExtension($template.Name)\n\n        # Read file content and normalize line endings\n        $fileContent = (Get-Content -Path $template.FullName -Raw) -replace \"`r`n\", \"`n\"\n\n        # Extract description from YAML frontmatter\n        $description = \"\"\n        if ($fileContent -match '(?m)^description:\\s*(.+)$') {\n            $description = $matches[1]\n        }\n\n        # Extract script command from YAML frontmatter\n        $scriptCommand = \"\"\n        if ($fileContent -match \"(?m)^\\s*${ScriptVariant}:\\s*(.+)$\") {\n            $scriptCommand = $matches[1]\n        }\n\n        if ([string]::IsNullOrEmpty($scriptCommand)) {\n            Write-Warning \"No script command found for $ScriptVariant in $($template.Name)\"\n            $scriptCommand = \"(Missing script command for $ScriptVariant)\"\n        }\n\n        # Extract agent_script command from YAML frontmatter if present\n        $agentScriptCommand = \"\"\n        if ($fileContent -match \"(?ms)agent_scripts:.*?^\\s*${ScriptVariant}:\\s*(.+?)$\") {\n            $agentScriptCommand = $matches[1].Trim()\n        }\n\n        # Replace {SCRIPT} placeholder with the script command\n        $body = $fileContent -replace '\\{SCRIPT\\}', $scriptCommand\n\n        # Replace {AGENT_SCRIPT} placeholder with the agent script command if found\n        if (-not [string]::IsNullOrEmpty($agentScriptCommand)) {\n            $body = $body -replace '\\{AGENT_SCRIPT\\}', $agentScriptCommand\n        }\n\n        # Remove the scripts: and agent_scripts: sections from frontmatter\n        $lines = $body -split \"`n\"\n        $outputLines = @()\n        $inFrontmatter = $false\n        $skipScripts = $false\n        $dashCount = 0\n\n        foreach ($line in $lines) {\n            if ($line -match '^---$') {\n                $outputLines += $line\n                $dashCount++\n                if ($dashCount -eq 1) {\n                    $inFrontmatter = $true\n                } else {\n                    $inFrontmatter = $false\n                }\n                continue\n            }\n\n            if ($inFrontmatter) {\n                if ($line -match '^(scripts|agent_scripts):$') {\n                    $skipScripts = $true\n                    continue\n                }\n                if ($line -match '^[a-zA-Z].*:' -and $skipScripts) {\n                    $skipScripts = $false\n                }\n                if ($skipScripts -and $line -match '^\\s+') {\n                    continue\n                }\n            }\n\n            $outputLines += $line\n        }\n\n        $body = $outputLines -join \"`n\"\n\n        # Apply other substitutions\n        $body = $body -replace '\\{ARGS\\}', $ArgFormat\n        $body = $body -replace '__AGENT__', $Agent\n        $body = Rewrite-Paths -Content $body\n\n        # Generate output file based on extension\n        $outputFile = Join-Path $OutputDir \"speckit.$name.$Extension\"\n\n        switch ($Extension) {\n            'toml' {\n                $body = $body -replace '\\\\', '\\\\'\n                $output = \"description = `\"$description`\"`n`nprompt = `\"`\"`\"`n$body`n`\"`\"`\"\"\n                Set-Content -Path $outputFile -Value $output -NoNewline\n            }\n            'md' {\n                Set-Content -Path $outputFile -Value $body -NoNewline\n            }\n            'agent.md' {\n                Set-Content -Path $outputFile -Value $body -NoNewline\n            }\n        }\n    }\n}\n\nfunction Generate-CopilotPrompts {\n    param(\n        [string]$AgentsDir,\n        [string]$PromptsDir\n    )\n\n    New-Item -ItemType Directory -Path $PromptsDir -Force | Out-Null\n\n    $agentFiles = Get-ChildItem -Path \"$AgentsDir/speckit.*.agent.md\" -File -ErrorAction SilentlyContinue\n\n    foreach ($agentFile in $agentFiles) {\n        $basename = $agentFile.Name -replace '\\.agent\\.md$', ''\n        $promptFile = Join-Path $PromptsDir \"$basename.prompt.md\"\n\n        $content = @\"\n---\nagent: $basename\n---\n\"@\n        Set-Content -Path $promptFile -Value $content\n    }\n}\n\n# Create skills in <skills_dir>\\<name>\\SKILL.md format.\n# Most agents use hyphenated names (e.g. speckit-plan); Kimi is the\n# current dotted-name exception (e.g. speckit.plan).\nfunction New-Skills {\n    param(\n        [string]$SkillsDir,\n        [string]$ScriptVariant,\n        [string]$AgentName,\n        [string]$Separator = '-'\n    )\n\n    $templates = Get-ChildItem -Path \"templates/commands/*.md\" -File -ErrorAction SilentlyContinue\n\n    foreach ($template in $templates) {\n        $name = [System.IO.Path]::GetFileNameWithoutExtension($template.Name)\n        $skillName = \"speckit${Separator}$name\"\n        $skillDir = Join-Path $SkillsDir $skillName\n        New-Item -ItemType Directory -Force -Path $skillDir | Out-Null\n\n        $fileContent = (Get-Content -Path $template.FullName -Raw) -replace \"`r`n\", \"`n\"\n\n        # Extract description\n        $description = \"Spec Kit: $name workflow\"\n        if ($fileContent -match '(?m)^description:\\s*(.+)$') {\n            $description = $matches[1]\n        }\n\n        # Extract script command\n        $scriptCommand = \"(Missing script command for $ScriptVariant)\"\n        if ($fileContent -match \"(?m)^\\s*${ScriptVariant}:\\s*(.+)$\") {\n            $scriptCommand = $matches[1]\n        }\n\n        # Extract agent_script command from frontmatter if present\n        $agentScriptCommand = \"\"\n        if ($fileContent -match \"(?ms)agent_scripts:.*?^\\s*${ScriptVariant}:\\s*(.+?)$\") {\n            $agentScriptCommand = $matches[1].Trim()\n        }\n\n        # Replace {SCRIPT}, strip scripts sections, rewrite paths\n        $body = $fileContent -replace '\\{SCRIPT\\}', $scriptCommand\n        if (-not [string]::IsNullOrEmpty($agentScriptCommand)) {\n            $body = $body -replace '\\{AGENT_SCRIPT\\}', $agentScriptCommand\n        }\n\n        $lines = $body -split \"`n\"\n        $outputLines = @()\n        $inFrontmatter = $false\n        $skipScripts = $false\n        $dashCount = 0\n\n        foreach ($line in $lines) {\n            if ($line -match '^---$') {\n                $outputLines += $line\n                $dashCount++\n                $inFrontmatter = ($dashCount -eq 1)\n                continue\n            }\n            if ($inFrontmatter) {\n                if ($line -match '^(scripts|agent_scripts):$') { $skipScripts = $true; continue }\n                if ($line -match '^[a-zA-Z].*:' -and $skipScripts) { $skipScripts = $false }\n                if ($skipScripts -and $line -match '^\\s+') { continue }\n            }\n            $outputLines += $line\n        }\n\n        $body = $outputLines -join \"`n\"\n        $body = $body -replace '\\{ARGS\\}', '$ARGUMENTS'\n        $body = $body -replace '__AGENT__', $AgentName\n        $body = Rewrite-Paths -Content $body\n\n        # Strip existing frontmatter, keep only body\n        $templateBody = \"\"\n        $fmCount = 0\n        $inBody = $false\n        foreach ($line in ($body -split \"`n\")) {\n            if ($line -match '^---$') {\n                $fmCount++\n                if ($fmCount -eq 2) { $inBody = $true }\n                continue\n            }\n            if ($inBody) { $templateBody += \"$line`n\" }\n        }\n\n        $skillContent = \"---`nname: `\"$skillName`\"`ndescription: `\"$description`\"`n---`n`n$templateBody\"\n        Set-Content -Path (Join-Path $skillDir \"SKILL.md\") -Value $skillContent -NoNewline\n    }\n}\n\nfunction Build-Variant {\n    param(\n        [string]$Agent,\n        [string]$Script\n    )\n\n    $baseDir = Join-Path $GenReleasesDir \"sdd-${Agent}-package-${Script}\"\n    Write-Host \"Building $Agent ($Script) package...\"\n    New-Item -ItemType Directory -Path $baseDir -Force | Out-Null\n\n    # Copy base structure but filter scripts by variant\n    $specDir = Join-Path $baseDir \".specify\"\n    New-Item -ItemType Directory -Path $specDir -Force | Out-Null\n\n    # Copy memory directory\n    if (Test-Path \"memory\") {\n        Copy-Item -Path \"memory\" -Destination $specDir -Recurse -Force\n        Write-Host \"Copied memory -> .specify\"\n    }\n\n    # Only copy the relevant script variant directory\n    if (Test-Path \"scripts\") {\n        $scriptsDestDir = Join-Path $specDir \"scripts\"\n        New-Item -ItemType Directory -Path $scriptsDestDir -Force | Out-Null\n\n        switch ($Script) {\n            'sh' {\n                if (Test-Path \"scripts/bash\") {\n                    Copy-Item -Path \"scripts/bash\" -Destination $scriptsDestDir -Recurse -Force\n                    Write-Host \"Copied scripts/bash -> .specify/scripts\"\n                }\n            }\n            'ps' {\n                if (Test-Path \"scripts/powershell\") {\n                    Copy-Item -Path \"scripts/powershell\" -Destination $scriptsDestDir -Recurse -Force\n                    Write-Host \"Copied scripts/powershell -> .specify/scripts\"\n                }\n            }\n        }\n\n        Get-ChildItem -Path \"scripts\" -File -ErrorAction SilentlyContinue | ForEach-Object {\n            Copy-Item -Path $_.FullName -Destination $scriptsDestDir -Force\n        }\n    }\n\n    # Copy templates (excluding commands directory and vscode-settings.json)\n    if (Test-Path \"templates\") {\n        $templatesDestDir = Join-Path $specDir \"templates\"\n        New-Item -ItemType Directory -Path $templatesDestDir -Force | Out-Null\n\n        Get-ChildItem -Path \"templates\" -Recurse -File | Where-Object {\n            $_.FullName -notmatch 'templates[/\\\\]commands[/\\\\]' -and $_.Name -ne 'vscode-settings.json'\n        } | ForEach-Object {\n            $relativePath = $_.FullName.Substring((Resolve-Path \"templates\").Path.Length + 1)\n            $destFile = Join-Path $templatesDestDir $relativePath\n            $destFileDir = Split-Path $destFile -Parent\n            New-Item -ItemType Directory -Path $destFileDir -Force | Out-Null\n            Copy-Item -Path $_.FullName -Destination $destFile -Force\n        }\n        Write-Host \"Copied templates -> .specify/templates\"\n    }\n\n    # Generate agent-specific command files\n    switch ($Agent) {\n        'claude' {\n            $cmdDir = Join-Path $baseDir \".claude/commands\"\n            Generate-Commands -Agent 'claude' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script\n        }\n        'gemini' {\n            $cmdDir = Join-Path $baseDir \".gemini/commands\"\n            Generate-Commands -Agent 'gemini' -Extension 'toml' -ArgFormat '{{args}}' -OutputDir $cmdDir -ScriptVariant $Script\n            if (Test-Path \"agent_templates/gemini/GEMINI.md\") {\n                Copy-Item -Path \"agent_templates/gemini/GEMINI.md\" -Destination (Join-Path $baseDir \"GEMINI.md\")\n            }\n        }\n        'copilot' {\n            $agentsDir = Join-Path $baseDir \".github/agents\"\n            Generate-Commands -Agent 'copilot' -Extension 'agent.md' -ArgFormat '$ARGUMENTS' -OutputDir $agentsDir -ScriptVariant $Script\n\n            $promptsDir = Join-Path $baseDir \".github/prompts\"\n            Generate-CopilotPrompts -AgentsDir $agentsDir -PromptsDir $promptsDir\n\n            $vscodeDir = Join-Path $baseDir \".vscode\"\n            New-Item -ItemType Directory -Path $vscodeDir -Force | Out-Null\n            if (Test-Path \"templates/vscode-settings.json\") {\n                Copy-Item -Path \"templates/vscode-settings.json\" -Destination (Join-Path $vscodeDir \"settings.json\")\n            }\n        }\n        'cursor-agent' {\n            $cmdDir = Join-Path $baseDir \".cursor/commands\"\n            Generate-Commands -Agent 'cursor-agent' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script\n        }\n        'qwen' {\n            $cmdDir = Join-Path $baseDir \".qwen/commands\"\n            Generate-Commands -Agent 'qwen' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script\n            if (Test-Path \"agent_templates/qwen/QWEN.md\") {\n                Copy-Item -Path \"agent_templates/qwen/QWEN.md\" -Destination (Join-Path $baseDir \"QWEN.md\")\n            }\n        }\n        'opencode' {\n            $cmdDir = Join-Path $baseDir \".opencode/command\"\n            Generate-Commands -Agent 'opencode' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script\n        }\n        'windsurf' {\n            $cmdDir = Join-Path $baseDir \".windsurf/workflows\"\n            Generate-Commands -Agent 'windsurf' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script\n        }\n        'junie' {\n            $cmdDir = Join-Path $baseDir \".junie/commands\"\n            Generate-Commands -Agent 'junie' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script\n        }\n        'codex' {\n            $skillsDir = Join-Path $baseDir \".agents/skills\"\n            New-Item -ItemType Directory -Force -Path $skillsDir | Out-Null\n            New-Skills -SkillsDir $skillsDir -ScriptVariant $Script -AgentName 'codex' -Separator '-'\n        }\n        'kilocode' {\n            $cmdDir = Join-Path $baseDir \".kilocode/workflows\"\n            Generate-Commands -Agent 'kilocode' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script\n        }\n        'auggie' {\n            $cmdDir = Join-Path $baseDir \".augment/commands\"\n            Generate-Commands -Agent 'auggie' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script\n        }\n        'roo' {\n            $cmdDir = Join-Path $baseDir \".roo/commands\"\n            Generate-Commands -Agent 'roo' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script\n        }\n        'codebuddy' {\n            $cmdDir = Join-Path $baseDir \".codebuddy/commands\"\n            Generate-Commands -Agent 'codebuddy' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script\n        }\n        'amp' {\n            $cmdDir = Join-Path $baseDir \".agents/commands\"\n            Generate-Commands -Agent 'amp' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script\n        }\n        'kiro-cli' {\n            $cmdDir = Join-Path $baseDir \".kiro/prompts\"\n            Generate-Commands -Agent 'kiro-cli' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script\n        }\n        'bob' {\n            $cmdDir = Join-Path $baseDir \".bob/commands\"\n            Generate-Commands -Agent 'bob' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script\n        }\n        'qodercli' {\n            $cmdDir = Join-Path $baseDir \".qoder/commands\"\n            Generate-Commands -Agent 'qodercli' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script\n        }\n        'shai' {\n            $cmdDir = Join-Path $baseDir \".shai/commands\"\n            Generate-Commands -Agent 'shai' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script\n        }\n        'tabnine' {\n            $cmdDir = Join-Path $baseDir \".tabnine/agent/commands\"\n            Generate-Commands -Agent 'tabnine' -Extension 'toml' -ArgFormat '{{args}}' -OutputDir $cmdDir -ScriptVariant $Script\n            $tabnineTemplate = Join-Path 'agent_templates' 'tabnine/TABNINE.md'\n            if (Test-Path $tabnineTemplate) { Copy-Item $tabnineTemplate (Join-Path $baseDir 'TABNINE.md') }\n        }\n        'agy' {\n            $cmdDir = Join-Path $baseDir \".agent/commands\"\n            Generate-Commands -Agent 'agy' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script\n        }\n        'vibe' {\n            $cmdDir = Join-Path $baseDir \".vibe/prompts\"\n            Generate-Commands -Agent 'vibe' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script\n        }\n        'kimi' {\n            $skillsDir = Join-Path $baseDir \".kimi/skills\"\n            New-Item -ItemType Directory -Force -Path $skillsDir | Out-Null\n            New-Skills -SkillsDir $skillsDir -ScriptVariant $Script -AgentName 'kimi' -Separator '.'\n        }\n        'trae' {\n            $rulesDir = Join-Path $baseDir \".trae/rules\"\n            New-Item -ItemType Directory -Force -Path $rulesDir | Out-Null\n            Generate-Commands -Agent 'trae' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $rulesDir -ScriptVariant $Script\n        }\n        'pi' {\n            $cmdDir = Join-Path $baseDir \".pi/prompts\"\n            Generate-Commands -Agent 'pi' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script\n        }\n        'iflow' {\n            $cmdDir = Join-Path $baseDir \".iflow/commands\"\n            Generate-Commands -Agent 'iflow' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script\n        }\n        'generic' {\n            $cmdDir = Join-Path $baseDir \".speckit/commands\"\n            Generate-Commands -Agent 'generic' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script\n        }\n        default {\n            throw \"Unsupported agent '$Agent'.\"\n        }\n    }\n\n    # Create zip archive\n    $zipFile = Join-Path $GenReleasesDir \"spec-kit-template-${Agent}-${Script}-${Version}.zip\"\n    Compress-Archive -Path \"$baseDir/*\" -DestinationPath $zipFile -Force\n    Write-Host \"Created $zipFile\"\n}\n\n# Define all agents and scripts\n$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'junie', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'kiro-cli', 'bob', 'qodercli', 'shai', 'tabnine', 'agy', 'vibe', 'kimi', 'trae', 'pi', 'iflow', 'generic')\n$AllScripts = @('sh', 'ps')\n\nfunction Normalize-List {\n    param([string]$Input)\n\n    if ([string]::IsNullOrEmpty($Input)) {\n        return @()\n    }\n\n    $items = $Input -split '[,\\s]+' | Where-Object { $_ } | Select-Object -Unique\n    return $items\n}\n\nfunction Validate-Subset {\n    param(\n        [string]$Type,\n        [string[]]$Allowed,\n        [string[]]$Items\n    )\n\n    $ok = $true\n    foreach ($item in $Items) {\n        if ($item -notin $Allowed) {\n            Write-Error \"Unknown $Type '$item' (allowed: $($Allowed -join ', '))\"\n            $ok = $false\n        }\n    }\n    return $ok\n}\n\n# Determine agent list\nif (-not [string]::IsNullOrEmpty($Agents)) {\n    $AgentList = Normalize-List -Input $Agents\n    if (-not (Validate-Subset -Type 'agent' -Allowed $AllAgents -Items $AgentList)) {\n        exit 1\n    }\n} else {\n    $AgentList = $AllAgents\n}\n\n# Determine script list\nif (-not [string]::IsNullOrEmpty($Scripts)) {\n    $ScriptList = Normalize-List -Input $Scripts\n    if (-not (Validate-Subset -Type 'script' -Allowed $AllScripts -Items $ScriptList)) {\n        exit 1\n    }\n} else {\n    $ScriptList = $AllScripts\n}\n\nWrite-Host \"Agents: $($AgentList -join ', ')\"\nWrite-Host \"Scripts: $($ScriptList -join ', ')\"\n\n# Build all variants\nforeach ($agent in $AgentList) {\n    foreach ($script in $ScriptList) {\n        Build-Variant -Agent $agent -Script $script\n    }\n}\n\nWrite-Host \"`nArchives in ${GenReleasesDir}:\"\nGet-ChildItem -Path $GenReleasesDir -Filter \"spec-kit-template-*-${Version}.zip\" | ForEach-Object {\n    Write-Host \"  $($_.Name)\"\n}\n"
  },
  {
    "path": ".github/workflows/scripts/create-release-packages.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\n# create-release-packages.sh (workflow-local)\n# Build Spec Kit template release archives for each supported AI assistant and script type.\n# Usage: .github/workflows/scripts/create-release-packages.sh <version>\n#   Version argument should include leading 'v'.\n#   Optionally set AGENTS and/or SCRIPTS env vars to limit what gets built.\n#     AGENTS  : space or comma separated subset of: claude gemini copilot cursor-agent qwen opencode windsurf junie codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi trae pi iflow generic (default: all)\n#     SCRIPTS : space or comma separated subset of: sh ps (default: both)\n#   Examples:\n#     AGENTS=claude SCRIPTS=sh $0 v0.2.0\n#     AGENTS=\"copilot,gemini\" $0 v0.2.0\n#     SCRIPTS=ps $0 v0.2.0\n\nif [[ $# -ne 1 ]]; then\n  echo \"Usage: $0 <version-with-v-prefix>\" >&2\n  exit 1\nfi\nNEW_VERSION=\"$1\"\nif [[ ! $NEW_VERSION =~ ^v[0-9]+\\.[0-9]+\\.[0-9]+$ ]]; then\n  echo \"Version must look like v0.0.0\" >&2\n  exit 1\nfi\n\necho \"Building release packages for $NEW_VERSION\"\n\n# Create and use .genreleases directory for all build artifacts\nGENRELEASES_DIR=\".genreleases\"\nmkdir -p \"$GENRELEASES_DIR\"\nrm -rf \"$GENRELEASES_DIR\"/* || true\n\nrewrite_paths() {\n  sed -E \\\n    -e 's@(/?)memory/@.specify/memory/@g' \\\n    -e 's@(/?)scripts/@.specify/scripts/@g' \\\n    -e 's@(/?)templates/@.specify/templates/@g' \\\n    -e 's@\\.specify\\.specify/@.specify/@g'\n}\n\ngenerate_commands() {\n  local agent=$1 ext=$2 arg_format=$3 output_dir=$4 script_variant=$5\n  mkdir -p \"$output_dir\"\n  for template in templates/commands/*.md; do\n    [[ -f \"$template\" ]] || continue\n    local name description script_command agent_script_command body\n    name=$(basename \"$template\" .md)\n\n    # Normalize line endings\n    file_content=$(tr -d '\\r' < \"$template\")\n\n    # Extract description and script command from YAML frontmatter\n    description=$(printf '%s\\n' \"$file_content\" | awk '/^description:/ {sub(/^description:[[:space:]]*/, \"\"); print; exit}')\n    script_command=$(printf '%s\\n' \"$file_content\" | awk -v sv=\"$script_variant\" '/^[[:space:]]*'\"$script_variant\"':[[:space:]]*/ {sub(/^[[:space:]]*'\"$script_variant\"':[[:space:]]*/, \"\"); print; exit}')\n\n    if [[ -z $script_command ]]; then\n      echo \"Warning: no script command found for $script_variant in $template\" >&2\n      script_command=\"(Missing script command for $script_variant)\"\n    fi\n\n    # Extract agent_script command from YAML frontmatter if present\n    agent_script_command=$(printf '%s\\n' \"$file_content\" | awk '\n      /^agent_scripts:$/ { in_agent_scripts=1; next }\n      in_agent_scripts && /^[[:space:]]*'\"$script_variant\"':[[:space:]]*/ {\n        sub(/^[[:space:]]*'\"$script_variant\"':[[:space:]]*/, \"\")\n        print\n        exit\n      }\n      in_agent_scripts && /^[a-zA-Z]/ { in_agent_scripts=0 }\n    ')\n\n    # Replace {SCRIPT} placeholder with the script command\n    body=$(printf '%s\\n' \"$file_content\" | sed \"s|{SCRIPT}|${script_command}|g\")\n\n    # Replace {AGENT_SCRIPT} placeholder with the agent script command if found\n    if [[ -n $agent_script_command ]]; then\n      body=$(printf '%s\\n' \"$body\" | sed \"s|{AGENT_SCRIPT}|${agent_script_command}|g\")\n    fi\n\n    # Remove the scripts: and agent_scripts: sections from frontmatter while preserving YAML structure\n    body=$(printf '%s\\n' \"$body\" | awk '\n      /^---$/ { print; if (++dash_count == 1) in_frontmatter=1; else in_frontmatter=0; next }\n      in_frontmatter && /^scripts:$/ { skip_scripts=1; next }\n      in_frontmatter && /^agent_scripts:$/ { skip_scripts=1; next }\n      in_frontmatter && /^[a-zA-Z].*:/ && skip_scripts { skip_scripts=0 }\n      in_frontmatter && skip_scripts && /^[[:space:]]/ { next }\n      { print }\n    ')\n\n    # Apply other substitutions\n    body=$(printf '%s\\n' \"$body\" | sed \"s/{ARGS}/$arg_format/g\" | sed \"s/__AGENT__/$agent/g\" | rewrite_paths)\n\n    case $ext in\n      toml)\n        body=$(printf '%s\\n' \"$body\" | sed 's/\\\\/\\\\\\\\/g')\n        { echo \"description = \\\"$description\\\"\"; echo; echo \"prompt = \\\"\\\"\\\"\"; echo \"$body\"; echo \"\\\"\\\"\\\"\"; } > \"$output_dir/speckit.$name.$ext\" ;;\n      md)\n        echo \"$body\" > \"$output_dir/speckit.$name.$ext\" ;;\n      agent.md)\n        echo \"$body\" > \"$output_dir/speckit.$name.$ext\" ;;\n    esac\n  done\n}\n\ngenerate_copilot_prompts() {\n  local agents_dir=$1 prompts_dir=$2\n  mkdir -p \"$prompts_dir\"\n\n  # Generate a .prompt.md file for each .agent.md file\n  for agent_file in \"$agents_dir\"/speckit.*.agent.md; do\n    [[ -f \"$agent_file\" ]] || continue\n\n    local basename=$(basename \"$agent_file\" .agent.md)\n    local prompt_file=\"$prompts_dir/${basename}.prompt.md\"\n\n    cat > \"$prompt_file\" <<EOF\n---\nagent: ${basename}\n---\nEOF\n  done\n}\n\n# Create skills in <skills_dir>/<name>/SKILL.md format.\n# Most agents use hyphenated names (e.g. speckit-plan); Kimi is the\n# current dotted-name exception (e.g. speckit.plan).\ncreate_skills() {\n  local skills_dir=\"$1\"\n  local script_variant=\"$2\"\n  local agent_name=\"$3\"\n  local separator=\"${4:-\"-\"}\"\n\n  for template in templates/commands/*.md; do\n    [[ -f \"$template\" ]] || continue\n    local name\n    name=$(basename \"$template\" .md)\n    local skill_name=\"speckit${separator}${name}\"\n    local skill_dir=\"${skills_dir}/${skill_name}\"\n    mkdir -p \"$skill_dir\"\n\n    local file_content\n    file_content=$(tr -d '\\r' < \"$template\")\n\n    # Extract description from frontmatter\n    local description\n    description=$(printf '%s\\n' \"$file_content\" | awk '/^description:/ {sub(/^description:[[:space:]]*/, \"\"); print; exit}')\n    [[ -z \"$description\" ]] && description=\"Spec Kit: ${name} workflow\"\n\n    # Extract script command\n    local script_command\n    script_command=$(printf '%s\\n' \"$file_content\" | awk -v sv=\"$script_variant\" '/^[[:space:]]*'\"$script_variant\"':[[:space:]]*/ {sub(/^[[:space:]]*'\"$script_variant\"':[[:space:]]*/, \"\"); print; exit}')\n    [[ -z \"$script_command\" ]] && script_command=\"(Missing script command for $script_variant)\"\n\n    # Extract agent_script command from frontmatter if present\n    local agent_script_command\n    agent_script_command=$(printf '%s\\n' \"$file_content\" | awk '\n      /^agent_scripts:$/ { in_agent_scripts=1; next }\n      in_agent_scripts && /^[[:space:]]*'\"$script_variant\"':[[:space:]]*/ {\n        sub(/^[[:space:]]*'\"$script_variant\"':[[:space:]]*/, \"\")\n        print\n        exit\n      }\n      in_agent_scripts && /^[a-zA-Z]/ { in_agent_scripts=0 }\n    ')\n\n    # Build body: replace placeholders, strip scripts sections, rewrite paths\n    local body\n    body=$(printf '%s\\n' \"$file_content\" | sed \"s|{SCRIPT}|${script_command}|g\")\n    if [[ -n $agent_script_command ]]; then\n      body=$(printf '%s\\n' \"$body\" | sed \"s|{AGENT_SCRIPT}|${agent_script_command}|g\")\n    fi\n    body=$(printf '%s\\n' \"$body\" | awk '\n      /^---$/ { print; if (++dash_count == 1) in_frontmatter=1; else in_frontmatter=0; next }\n      in_frontmatter && /^scripts:$/ { skip_scripts=1; next }\n      in_frontmatter && /^agent_scripts:$/ { skip_scripts=1; next }\n      in_frontmatter && /^[a-zA-Z].*:/ && skip_scripts { skip_scripts=0 }\n      in_frontmatter && skip_scripts && /^[[:space:]]/ { next }\n      { print }\n    ')\n    body=$(printf '%s\\n' \"$body\" | sed 's/{ARGS}/\\$ARGUMENTS/g' | sed \"s/__AGENT__/$agent_name/g\" | rewrite_paths)\n\n    # Strip existing frontmatter and prepend skills frontmatter.\n    local template_body\n    template_body=$(printf '%s\\n' \"$body\" | awk '/^---/{p++; if(p==2){found=1; next}} found')\n\n    {\n      printf -- '---\\n'\n      printf 'name: \"%s\"\\n' \"$skill_name\"\n      printf 'description: \"%s\"\\n' \"$description\"\n      printf -- '---\\n\\n'\n      printf '%s\\n' \"$template_body\"\n    } > \"$skill_dir/SKILL.md\"\n  done\n}\n\nbuild_variant() {\n  local agent=$1 script=$2\n  local base_dir=\"$GENRELEASES_DIR/sdd-${agent}-package-${script}\"\n  echo \"Building $agent ($script) package...\"\n  mkdir -p \"$base_dir\"\n\n  # Copy base structure but filter scripts by variant\n  SPEC_DIR=\"$base_dir/.specify\"\n  mkdir -p \"$SPEC_DIR\"\n\n  [[ -d memory ]] && { cp -r memory \"$SPEC_DIR/\"; echo \"Copied memory -> .specify\"; }\n\n  # Only copy the relevant script variant directory\n  if [[ -d scripts ]]; then\n    mkdir -p \"$SPEC_DIR/scripts\"\n    case $script in\n      sh)\n        [[ -d scripts/bash ]] && { cp -r scripts/bash \"$SPEC_DIR/scripts/\"; echo \"Copied scripts/bash -> .specify/scripts\"; }\n        find scripts -maxdepth 1 -type f -exec cp {} \"$SPEC_DIR/scripts/\" \\; 2>/dev/null || true\n        ;;\n      ps)\n        [[ -d scripts/powershell ]] && { cp -r scripts/powershell \"$SPEC_DIR/scripts/\"; echo \"Copied scripts/powershell -> .specify/scripts\"; }\n        find scripts -maxdepth 1 -type f -exec cp {} \"$SPEC_DIR/scripts/\" \\; 2>/dev/null || true\n        ;;\n    esac\n  fi\n\n  [[ -d templates ]] && { mkdir -p \"$SPEC_DIR/templates\"; find templates -type f -not -path \"templates/commands/*\" -not -name \"vscode-settings.json\" -exec cp --parents {} \"$SPEC_DIR\"/ \\; ; echo \"Copied templates -> .specify/templates\"; }\n\n  case $agent in\n    claude)\n      mkdir -p \"$base_dir/.claude/commands\"\n      generate_commands claude md \"\\$ARGUMENTS\" \"$base_dir/.claude/commands\" \"$script\" ;;\n    gemini)\n      mkdir -p \"$base_dir/.gemini/commands\"\n      generate_commands gemini toml \"{{args}}\" \"$base_dir/.gemini/commands\" \"$script\"\n      [[ -f agent_templates/gemini/GEMINI.md ]] && cp agent_templates/gemini/GEMINI.md \"$base_dir/GEMINI.md\" ;;\n    copilot)\n      mkdir -p \"$base_dir/.github/agents\"\n      generate_commands copilot agent.md \"\\$ARGUMENTS\" \"$base_dir/.github/agents\" \"$script\"\n      generate_copilot_prompts \"$base_dir/.github/agents\" \"$base_dir/.github/prompts\"\n      mkdir -p \"$base_dir/.vscode\"\n      [[ -f templates/vscode-settings.json ]] && cp templates/vscode-settings.json \"$base_dir/.vscode/settings.json\"\n      ;;\n    cursor-agent)\n      mkdir -p \"$base_dir/.cursor/commands\"\n      generate_commands cursor-agent md \"\\$ARGUMENTS\" \"$base_dir/.cursor/commands\" \"$script\" ;;\n    qwen)\n      mkdir -p \"$base_dir/.qwen/commands\"\n      generate_commands qwen md \"\\$ARGUMENTS\" \"$base_dir/.qwen/commands\" \"$script\"\n      [[ -f agent_templates/qwen/QWEN.md ]] && cp agent_templates/qwen/QWEN.md \"$base_dir/QWEN.md\" ;;\n    opencode)\n      mkdir -p \"$base_dir/.opencode/command\"\n      generate_commands opencode md \"\\$ARGUMENTS\" \"$base_dir/.opencode/command\" \"$script\" ;;\n    windsurf)\n      mkdir -p \"$base_dir/.windsurf/workflows\"\n      generate_commands windsurf md \"\\$ARGUMENTS\" \"$base_dir/.windsurf/workflows\" \"$script\" ;;\n    junie)\n      mkdir -p \"$base_dir/.junie/commands\"\n      generate_commands junie md \"\\$ARGUMENTS\" \"$base_dir/.junie/commands\" \"$script\" ;;\n    codex)\n      mkdir -p \"$base_dir/.agents/skills\"\n      create_skills \"$base_dir/.agents/skills\" \"$script\" \"codex\" \"-\" ;;\n    kilocode)\n      mkdir -p \"$base_dir/.kilocode/workflows\"\n      generate_commands kilocode md \"\\$ARGUMENTS\" \"$base_dir/.kilocode/workflows\" \"$script\" ;;\n    auggie)\n      mkdir -p \"$base_dir/.augment/commands\"\n      generate_commands auggie md \"\\$ARGUMENTS\" \"$base_dir/.augment/commands\" \"$script\" ;;\n    roo)\n      mkdir -p \"$base_dir/.roo/commands\"\n      generate_commands roo md \"\\$ARGUMENTS\" \"$base_dir/.roo/commands\" \"$script\" ;;\n    codebuddy)\n      mkdir -p \"$base_dir/.codebuddy/commands\"\n      generate_commands codebuddy md \"\\$ARGUMENTS\" \"$base_dir/.codebuddy/commands\" \"$script\" ;;\n    qodercli)\n      mkdir -p \"$base_dir/.qoder/commands\"\n      generate_commands qodercli md \"\\$ARGUMENTS\" \"$base_dir/.qoder/commands\" \"$script\" ;;\n    amp)\n      mkdir -p \"$base_dir/.agents/commands\"\n      generate_commands amp md \"\\$ARGUMENTS\" \"$base_dir/.agents/commands\" \"$script\" ;;\n    shai)\n      mkdir -p \"$base_dir/.shai/commands\"\n      generate_commands shai md \"\\$ARGUMENTS\" \"$base_dir/.shai/commands\" \"$script\" ;;\n    tabnine)\n      mkdir -p \"$base_dir/.tabnine/agent/commands\"\n      generate_commands tabnine toml \"{{args}}\" \"$base_dir/.tabnine/agent/commands\" \"$script\"\n      [[ -f agent_templates/tabnine/TABNINE.md ]] && cp agent_templates/tabnine/TABNINE.md \"$base_dir/TABNINE.md\" ;;\n    kiro-cli)\n      mkdir -p \"$base_dir/.kiro/prompts\"\n      generate_commands kiro-cli md \"\\$ARGUMENTS\" \"$base_dir/.kiro/prompts\" \"$script\" ;;\n    agy)\n      mkdir -p \"$base_dir/.agent/commands\"\n      generate_commands agy md \"\\$ARGUMENTS\" \"$base_dir/.agent/commands\" \"$script\" ;;\n    bob)\n      mkdir -p \"$base_dir/.bob/commands\"\n      generate_commands bob md \"\\$ARGUMENTS\" \"$base_dir/.bob/commands\" \"$script\" ;;\n    vibe)\n      mkdir -p \"$base_dir/.vibe/prompts\"\n      generate_commands vibe md \"\\$ARGUMENTS\" \"$base_dir/.vibe/prompts\" \"$script\" ;;\n    kimi)\n      mkdir -p \"$base_dir/.kimi/skills\"\n      create_skills \"$base_dir/.kimi/skills\" \"$script\" \"kimi\" \".\" ;;\n    trae)\n      mkdir -p \"$base_dir/.trae/rules\"\n      generate_commands trae md \"\\$ARGUMENTS\" \"$base_dir/.trae/rules\" \"$script\" ;;\n    pi)\n      mkdir -p \"$base_dir/.pi/prompts\"\n      generate_commands pi md \"\\$ARGUMENTS\" \"$base_dir/.pi/prompts\" \"$script\" ;;\n    iflow)\n      mkdir -p \"$base_dir/.iflow/commands\"\n      generate_commands iflow md \"\\$ARGUMENTS\" \"$base_dir/.iflow/commands\" \"$script\" ;;\n    generic)\n      mkdir -p \"$base_dir/.speckit/commands\"\n      generate_commands generic md \"\\$ARGUMENTS\" \"$base_dir/.speckit/commands\" \"$script\" ;;\n  esac\n  ( cd \"$base_dir\" && zip -r \"../spec-kit-template-${agent}-${script}-${NEW_VERSION}.zip\" . )\n  echo \"Created $GENRELEASES_DIR/spec-kit-template-${agent}-${script}-${NEW_VERSION}.zip\"\n}\n\n# Determine agent list\nALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf junie codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi trae pi iflow generic)\nALL_SCRIPTS=(sh ps)\n\nnorm_list() {\n  tr ',\\n' '  ' | awk '{for(i=1;i<=NF;i++){if(!seen[$i]++){printf((out?\"\\n\":\"\") $i);out=1}}}END{printf(\"\\n\")}'\n}\n\nvalidate_subset() {\n  local type=$1; shift; local -n allowed=$1; shift; local items=(\"$@\")\n  local invalid=0\n  for it in \"${items[@]}\"; do\n    local found=0\n    for a in \"${allowed[@]}\"; do [[ $it == \"$a\" ]] && { found=1; break; }; done\n    if [[ $found -eq 0 ]]; then\n      echo \"Error: unknown $type '$it' (allowed: ${allowed[*]})\" >&2\n      invalid=1\n    fi\n  done\n  return $invalid\n}\n\nif [[ -n ${AGENTS:-} ]]; then\n  mapfile -t AGENT_LIST < <(printf '%s' \"$AGENTS\" | norm_list)\n  validate_subset agent ALL_AGENTS \"${AGENT_LIST[@]}\" || exit 1\nelse\n  AGENT_LIST=(\"${ALL_AGENTS[@]}\")\nfi\n\nif [[ -n ${SCRIPTS:-} ]]; then\n  mapfile -t SCRIPT_LIST < <(printf '%s' \"$SCRIPTS\" | norm_list)\n  validate_subset script ALL_SCRIPTS \"${SCRIPT_LIST[@]}\" || exit 1\nelse\n  SCRIPT_LIST=(\"${ALL_SCRIPTS[@]}\")\nfi\n\necho \"Agents: ${AGENT_LIST[*]}\"\necho \"Scripts: ${SCRIPT_LIST[*]}\"\n\nfor agent in \"${AGENT_LIST[@]}\"; do\n  for script in \"${SCRIPT_LIST[@]}\"; do\n    build_variant \"$agent\" \"$script\"\n  done\ndone\n\necho \"Archives in $GENRELEASES_DIR:\"\nls -1 \"$GENRELEASES_DIR\"/spec-kit-template-*-\"${NEW_VERSION}\".zip\n"
  },
  {
    "path": ".github/workflows/scripts/generate-release-notes.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\n# generate-release-notes.sh\n# Generate release notes from git history\n# Usage: generate-release-notes.sh <new_version> <last_tag>\n\nif [[ $# -ne 2 ]]; then\n  echo \"Usage: $0 <new_version> <last_tag>\" >&2\n  exit 1\nfi\n\nNEW_VERSION=\"$1\"\nLAST_TAG=\"$2\"\n\n# Get commits since last tag\nif [ \"$LAST_TAG\" = \"v0.0.0\" ]; then\n  # Check how many commits we have and use that as the limit\n  COMMIT_COUNT=$(git rev-list --count HEAD)\n  if [ \"$COMMIT_COUNT\" -gt 10 ]; then\n    COMMITS=$(git log --oneline --pretty=format:\"- %s\" HEAD~10..HEAD)\n  else\n    COMMITS=$(git log --oneline --pretty=format:\"- %s\" HEAD~$COMMIT_COUNT..HEAD 2>/dev/null || git log --oneline --pretty=format:\"- %s\")\n  fi\nelse\n  COMMITS=$(git log --oneline --pretty=format:\"- %s\" $LAST_TAG..HEAD)\nfi\n\n# Create release notes\ncat > release_notes.md << EOF\nThis is the latest set of releases that you can use with your agent of choice. We recommend using the Specify CLI to scaffold your projects, however you can download these independently and manage them yourself.\n\n## Changelog\n\n$COMMITS\n\nEOF\n\necho \"Generated release notes:\"\ncat release_notes.md\n"
  },
  {
    "path": ".github/workflows/scripts/get-next-version.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\n# get-next-version.sh\n# Calculate the next version based on the latest git tag and output GitHub Actions variables\n# Usage: get-next-version.sh\n\n# Get the latest tag, or use v0.0.0 if no tags exist\nLATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo \"v0.0.0\")\necho \"latest_tag=$LATEST_TAG\" >> $GITHUB_OUTPUT\n\n# Extract version number and increment\nVERSION=$(echo $LATEST_TAG | sed 's/v//')\nIFS='.' read -ra VERSION_PARTS <<< \"$VERSION\"\nMAJOR=${VERSION_PARTS[0]:-0}\nMINOR=${VERSION_PARTS[1]:-0}\nPATCH=${VERSION_PARTS[2]:-0}\n\n# Increment patch version\nPATCH=$((PATCH + 1))\nNEW_VERSION=\"v$MAJOR.$MINOR.$PATCH\"\n\necho \"new_version=$NEW_VERSION\" >> $GITHUB_OUTPUT\necho \"New version will be: $NEW_VERSION\"\n"
  },
  {
    "path": ".github/workflows/scripts/simulate-release.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\n# simulate-release.sh\n# Simulate the release process locally without pushing to GitHub\n# Usage: simulate-release.sh [version]\n#   If version is omitted, auto-increments patch version\n\n# Colors for output\nGREEN='\\033[0;32m'\nBLUE='\\033[0;34m'\nYELLOW='\\033[1;33m'\nRED='\\033[0;31m'\nNC='\\033[0m' # No Color\n\necho -e \"${BLUE}🧪 Simulating Release Process Locally${NC}\"\necho \"======================================\"\necho \"\"\n\n# Step 1: Determine version\nif [[ -n \"${1:-}\" ]]; then\n  VERSION=\"${1#v}\"\n  TAG=\"v$VERSION\"\n  echo -e \"${GREEN}📝 Using manual version: $VERSION${NC}\"\nelse\n  LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo \"v0.0.0\")\n  echo -e \"${BLUE}Latest tag: $LATEST_TAG${NC}\"\n  \n  VERSION=$(echo $LATEST_TAG | sed 's/v//')\n  IFS='.' read -ra VERSION_PARTS <<< \"$VERSION\"\n  MAJOR=${VERSION_PARTS[0]:-0}\n  MINOR=${VERSION_PARTS[1]:-0}\n  PATCH=${VERSION_PARTS[2]:-0}\n  \n  PATCH=$((PATCH + 1))\n  VERSION=\"$MAJOR.$MINOR.$PATCH\"\n  TAG=\"v$VERSION\"\n  echo -e \"${GREEN}📝 Auto-incremented to: $VERSION${NC}\"\nfi\n\necho \"\"\n\n# Step 2: Check if tag exists\nif git rev-parse \"$TAG\" >/dev/null 2>&1; then\n  echo -e \"${RED}❌ Error: Tag $TAG already exists!${NC}\"\n  echo \"   Please use a different version or delete the tag first.\"\n  exit 1\nfi\necho -e \"${GREEN}✓ Tag $TAG is available${NC}\"\n\n# Step 3: Backup current state\necho \"\"\necho -e \"${YELLOW}💾 Creating backup of current state...${NC}\"\nBACKUP_DIR=$(mktemp -d)\ncp pyproject.toml \"$BACKUP_DIR/pyproject.toml.bak\"\ncp CHANGELOG.md \"$BACKUP_DIR/CHANGELOG.md.bak\"\necho -e \"${GREEN}✓ Backup created at: $BACKUP_DIR${NC}\"\n\n# Step 4: Update pyproject.toml\necho \"\"\necho -e \"${YELLOW}📝 Updating pyproject.toml...${NC}\"\nsed -i.tmp \"s/version = \\\".*\\\"/version = \\\"$VERSION\\\"/\" pyproject.toml\nrm -f pyproject.toml.tmp\necho -e \"${GREEN}✓ Updated pyproject.toml to version $VERSION${NC}\"\n\n# Step 5: Update CHANGELOG.md\necho \"\"\necho -e \"${YELLOW}📝 Updating CHANGELOG.md...${NC}\"\nDATE=$(date +%Y-%m-%d)\n\n# Get the previous tag to compare commits\nPREVIOUS_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo \"\")\n\nif [[ -n \"$PREVIOUS_TAG\" ]]; then\n  echo \"   Generating changelog from commits since $PREVIOUS_TAG\"\n  # Get commits since last tag, format as bullet points\n  COMMITS=$(git log --oneline \"$PREVIOUS_TAG\"..HEAD --no-merges --pretty=format:\"- %s\" 2>/dev/null || echo \"- Initial release\")\nelse\n  echo \"   No previous tag found - this is the first release\"\n  COMMITS=\"- Initial release\"\nfi\n\n# Create temp file with new entry\n{\n  head -n 8 CHANGELOG.md\n  echo \"\"\n  echo \"## [$VERSION] - $DATE\"\n  echo \"\"\n  echo \"### Changed\"\n  echo \"\"\n  echo \"$COMMITS\"\n  echo \"\"\n  tail -n +9 CHANGELOG.md\n} > CHANGELOG.md.tmp\nmv CHANGELOG.md.tmp CHANGELOG.md\necho -e \"${GREEN}✓ Updated CHANGELOG.md with commits since $PREVIOUS_TAG${NC}\"\n\n# Step 6: Show what would be committed\necho \"\"\necho -e \"${YELLOW}📋 Changes that would be committed:${NC}\"\ngit diff pyproject.toml CHANGELOG.md\n\n# Step 7: Create temporary tag (no push)\necho \"\"\necho -e \"${YELLOW}🏷️  Creating temporary local tag...${NC}\"\ngit tag -a \"$TAG\" -m \"Simulated release $TAG\" 2>/dev/null || true\necho -e \"${GREEN}✓ Tag $TAG created locally${NC}\"\n\n# Step 8: Simulate release artifact creation\necho \"\"\necho -e \"${YELLOW}📦 Simulating release package creation...${NC}\"\necho \"   (High-level simulation only; packaging script is not executed)\"\necho \"\"\n\n# Check if script exists and is executable\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nif [[ -x \"$SCRIPT_DIR/create-release-packages.sh\" ]]; then\n  echo -e \"${BLUE}In a real release, the following command would be run to create packages:${NC}\"\n  echo \"  $SCRIPT_DIR/create-release-packages.sh \\\"$TAG\\\"\"\n  echo \"\"\n  echo \"This simulation does not enumerate individual package files to avoid\"\n  echo \"drifting from the actual behavior of create-release-packages.sh.\"\nelse\n  echo -e \"${RED}⚠️  create-release-packages.sh not found or not executable${NC}\"\nfi\n\n# Step 9: Simulate release notes generation\necho \"\"\necho -e \"${YELLOW}📄 Simulating release notes generation...${NC}\"\necho \"\"\nPREVIOUS_TAG=$(git describe --tags --abbrev=0 $TAG^ 2>/dev/null || echo \"\")\nif [[ -n \"$PREVIOUS_TAG\" ]]; then\n  echo -e \"${BLUE}Changes since $PREVIOUS_TAG:${NC}\"\n  git log --oneline \"$PREVIOUS_TAG\"..\"$TAG\" | head -n 10\n  echo \"\"\nelse\n  echo -e \"${BLUE}No previous tag found - this would be the first release${NC}\"\nfi\n\n# Step 10: Summary\necho \"\"\necho -e \"${GREEN}🎉 Simulation Complete!${NC}\"\necho \"======================================\"\necho \"\"\necho -e \"${BLUE}Summary:${NC}\"\necho \"  Version: $VERSION\"\necho \"  Tag: $TAG\"\necho \"  Backup: $BACKUP_DIR\"\necho \"\"\necho -e \"${YELLOW}⚠️  SIMULATION ONLY - NO CHANGES PUSHED${NC}\"\necho \"\"\necho -e \"${BLUE}Next steps:${NC}\"\necho \"  1. Review the changes above\"\necho \"  2. To keep changes: git add pyproject.toml CHANGELOG.md && git commit\"\necho \"  3. To discard changes: git checkout pyproject.toml CHANGELOG.md && git tag -d $TAG\"\necho \"  4. To restore from backup: cp $BACKUP_DIR/* .\"\necho \"\"\necho -e \"${BLUE}To run the actual release:${NC}\"\necho \"  Go to: https://github.com/github/spec-kit/actions/workflows/release-trigger.yml\"\necho \"  Click 'Run workflow' and enter version: $VERSION\"\necho \"\"\n"
  },
  {
    "path": ".github/workflows/scripts/update-version.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\n# update-version.sh\n# Update version in pyproject.toml (for release artifacts only)\n# Usage: update-version.sh <version>\n\nif [[ $# -ne 1 ]]; then\n  echo \"Usage: $0 <version>\" >&2\n  exit 1\nfi\n\nVERSION=\"$1\"\n\n# Remove 'v' prefix for Python versioning\nPYTHON_VERSION=${VERSION#v}\n\nif [ -f \"pyproject.toml\" ]; then\n  sed -i \"s/version = \\\".*\\\"/version = \\\"$PYTHON_VERSION\\\"/\" pyproject.toml\n  echo \"Updated pyproject.toml version to $PYTHON_VERSION (for release artifacts only)\"\nelse\n  echo \"Warning: pyproject.toml not found, skipping version update\"\nfi\n"
  },
  {
    "path": ".github/workflows/stale.yml",
    "content": "name: 'Close stale issues and PRs'\n\non:\n  schedule:\n    - cron: '0 0 * * *' # Run daily at midnight UTC\n  workflow_dispatch: # Allow manual triggering\n\npermissions:\n  issues: write\n  pull-requests: write\n\njobs:\n  stale:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/stale@v10\n        with:\n          # Days of inactivity before an issue or PR becomes stale\n          days-before-stale: 150\n          # Days of inactivity before a stale issue or PR is closed (after being marked stale)\n          days-before-close: 30\n          \n          # Stale issue settings\n          stale-issue-message: 'This issue has been automatically marked as stale because it has not had any activity for 150 days. It will be closed in 30 days if no further activity occurs.'\n          close-issue-message: 'This issue has been automatically closed due to inactivity (180 days total). If you believe this issue is still relevant, please reopen it or create a new issue.'\n          stale-issue-label: 'stale'\n          \n          # Stale PR settings\n          stale-pr-message: 'This pull request has been automatically marked as stale because it has not had any activity for 150 days. It will be closed in 30 days if no further activity occurs.'\n          close-pr-message: 'This pull request has been automatically closed due to inactivity (180 days total). If you believe this PR is still relevant, please reopen it or create a new PR.'\n          stale-pr-label: 'stale'\n          \n          # Exempt issues and PRs with these labels from being marked as stale\n          exempt-issue-labels: 'pinned,security'\n          exempt-pr-labels: 'pinned,security'\n          \n          # Only issues or PRs with all of these labels are checked\n          # Leave empty to check all issues and PRs\n          any-of-labels: ''\n          \n          # Operations per run (helps avoid rate limits)\n          operations-per-run: 100\n"
  },
  {
    "path": ".github/workflows/test.yml",
    "content": "name: Test & Lint Python\n\npermissions:\n  contents: read\n\non:\n  push:\n    branches: [\"main\"]\n  pull_request:\n\njobs:\n  ruff:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Install uv\n        uses: astral-sh/setup-uv@v7\n\n      - name: Set up Python\n        uses: actions/setup-python@v6\n        with:\n          python-version: \"3.13\"\n\n      - name: Run ruff check\n        run: uvx ruff check src/\n\n  pytest:\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        python-version: [\"3.11\", \"3.12\", \"3.13\"]\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Install uv\n        uses: astral-sh/setup-uv@v7\n\n      - name: Set up Python ${{ matrix.python-version }}\n        uses: actions/setup-python@v6\n        with:\n          python-version: ${{ matrix.python-version }}\n\n      - name: Install dependencies\n        run: uv sync --extra test\n\n      - name: Run tests\n        run: uv run pytest\n"
  },
  {
    "path": ".gitignore",
    "content": "# Python\n__pycache__/\n*.py[cod]\n*$py.class\n*.so\n.Python\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib/\nlib64/\nparts/\nsdist/\nvar/\nwheels/\n*.egg-info/\n.installed.cfg\n*.egg\n\n# Virtual environments\nvenv/\nENV/\nenv/\n.venv\n\n# IDE\n.vscode/\n.idea/\n*.swp\n*.swo\n.DS_Store\n*.tmp\n\n# Project specific\n*.log\n.env\n.env.local\n*.lock\n\n# Spec Kit-specific files\n.genreleases/\n*.zip\nsdd-*/\ndocs/dev\n\n# Extension system\n.specify/extensions/.cache/\n.specify/extensions/.backup/\n.specify/extensions/*/local-config.yml\n"
  },
  {
    "path": ".markdownlint-cli2.jsonc",
    "content": "{\n  // https://github.com/DavidAnson/markdownlint/blob/main/doc/Rules.md\n  \"config\": {\n    \"default\": true,\n    \"MD003\": {\n      \"style\": \"atx\"\n    },\n    \"MD007\": {\n      \"indent\": 2\n    },\n    \"MD013\": false,\n    \"MD024\": {\n      \"siblings_only\": true\n    },\n    \"MD033\": false,\n    \"MD041\": false,\n    \"MD049\": {\n      \"style\": \"asterisk\"\n    },\n    \"MD050\": {\n      \"style\": \"asterisk\"\n    },\n    \"MD036\": false,\n    \"MD060\": false\n  },\n  \"ignores\": [\n    \".genreleases/\"\n  ]\n}"
  },
  {
    "path": "AGENTS.md",
    "content": "# AGENTS.md\n\n## About Spec Kit and Specify\n\n**GitHub Spec Kit** is a comprehensive toolkit for implementing Spec-Driven Development (SDD) - a methodology that emphasizes creating clear specifications before implementation. The toolkit includes templates, scripts, and workflows that guide development teams through a structured approach to building software.\n\n**Specify CLI** is the command-line interface that bootstraps projects with the Spec Kit framework. It sets up the necessary directory structures, templates, and AI agent integrations to support the Spec-Driven Development workflow.\n\nThe toolkit supports multiple AI coding assistants, allowing teams to use their preferred tools while maintaining consistent project structure and development practices.\n\n---\n\n## Adding New Agent Support\n\nThis section explains how to add support for new AI agents/assistants to the Specify CLI. Use this guide as a reference when integrating new AI tools into the Spec-Driven Development workflow.\n\n### Overview\n\nSpecify supports multiple AI agents by generating agent-specific command files and directory structures when initializing projects. Each agent has its own conventions for:\n\n- **Command file formats** (Markdown, TOML, etc.)\n- **Directory structures** (`.claude/commands/`, `.windsurf/workflows/`, etc.)\n- **Command invocation patterns** (slash commands, CLI tools, etc.)\n- **Argument passing conventions** (`$ARGUMENTS`, `{{args}}`, etc.)\n\n### Current Supported Agents\n\n| Agent                      | Directory              | Format   | CLI Tool        | Description                 |\n| -------------------------- | ---------------------- | -------- | --------------- | --------------------------- |\n| **Claude Code**            | `.claude/commands/`    | Markdown | `claude`        | Anthropic's Claude Code CLI |\n| **Gemini CLI**             | `.gemini/commands/`    | TOML     | `gemini`        | Google's Gemini CLI         |\n| **GitHub Copilot**         | `.github/agents/`      | Markdown | N/A (IDE-based) | GitHub Copilot in VS Code   |\n| **Cursor**                 | `.cursor/commands/`    | Markdown | `cursor-agent`  | Cursor CLI                  |\n| **Qwen Code**              | `.qwen/commands/`      | Markdown | `qwen`          | Alibaba's Qwen Code CLI     |\n| **opencode**               | `.opencode/command/`   | Markdown | `opencode`      | opencode CLI                |\n| **Codex CLI**              | `.codex/prompts/`      | Markdown | `codex`         | Codex CLI                   |\n| **Windsurf**               | `.windsurf/workflows/` | Markdown | N/A (IDE-based) | Windsurf IDE workflows      |\n| **Junie**                  | `.junie/commands/`     | Markdown | `junie`         | Junie by JetBrains          |\n| **Kilo Code**              | `.kilocode/workflows/` | Markdown | N/A (IDE-based) | Kilo Code IDE               |\n| **Auggie CLI**             | `.augment/commands/`   | Markdown | `auggie`        | Auggie CLI                  |\n| **Roo Code**               | `.roo/commands/`       | Markdown | N/A (IDE-based) | Roo Code IDE                |\n| **CodeBuddy CLI**          | `.codebuddy/commands/` | Markdown | `codebuddy`     | CodeBuddy CLI               |\n| **Qoder CLI**              | `.qoder/commands/`     | Markdown | `qodercli`      | Qoder CLI                   |\n| **Kiro CLI**               | `.kiro/prompts/`       | Markdown | `kiro-cli`      | Kiro CLI                    |\n| **Amp**                    | `.agents/commands/`    | Markdown | `amp`           | Amp CLI                     |\n| **SHAI**                   | `.shai/commands/`      | Markdown | `shai`          | SHAI CLI                    |\n| **Tabnine CLI**            | `.tabnine/agent/commands/` | TOML | `tabnine`       | Tabnine CLI                 |\n| **Kimi Code**              | `.kimi/skills/`        | Markdown | `kimi`          | Kimi Code CLI (Moonshot AI) |\n| **Pi Coding Agent**        | `.pi/prompts/`         | Markdown | `pi`            | Pi terminal coding agent    |\n| **iFlow CLI**              | `.iflow/commands/`     | Markdown | `iflow`         | iFlow CLI (iflow-ai)        |\n| **IBM Bob**                | `.bob/commands/`       | Markdown | N/A (IDE-based) | IBM Bob IDE                 |\n| **Trae**                   | `.trae/rules/`         | Markdown | N/A (IDE-based) | Trae IDE                    |\n| **Generic**                | User-specified via `--ai-commands-dir` | Markdown | N/A | Bring your own agent        |\n\n### Step-by-Step Integration Guide\n\nFollow these steps to add a new agent (using a hypothetical new agent as an example):\n\n#### 1. Add to AGENT_CONFIG\n\n**IMPORTANT**: Use the actual CLI tool name as the key, not a shortened version.\n\nAdd the new agent to the `AGENT_CONFIG` dictionary in `src/specify_cli/__init__.py`. This is the **single source of truth** for all agent metadata:\n\n```python\nAGENT_CONFIG = {\n    # ... existing agents ...\n    \"new-agent-cli\": {  # Use the ACTUAL CLI tool name (what users type in terminal)\n        \"name\": \"New Agent Display Name\",\n        \"folder\": \".newagent/\",  # Directory for agent files\n        \"commands_subdir\": \"commands\",  # Subdirectory name for command files (default: \"commands\")\n        \"install_url\": \"https://example.com/install\",  # URL for installation docs (or None if IDE-based)\n        \"requires_cli\": True,  # True if CLI tool required, False for IDE-based agents\n    },\n}\n```\n\n**Key Design Principle**: The dictionary key should match the actual executable name that users install. For example:\n\n- ✅ Use `\"cursor-agent\"` because the CLI tool is literally called `cursor-agent`\n- ❌ Don't use `\"cursor\"` as a shortcut if the tool is `cursor-agent`\n\nThis eliminates the need for special-case mappings throughout the codebase.\n\n**Field Explanations**:\n\n- `name`: Human-readable display name shown to users\n- `folder`: Directory where agent-specific files are stored (relative to project root)\n- `commands_subdir`: Subdirectory name within the agent folder where command/prompt files are stored (default: `\"commands\"`)\n  - Most agents use `\"commands\"` (e.g., `.claude/commands/`)\n  - Some agents use alternative names: `\"agents\"` (copilot), `\"workflows\"` (windsurf, kilocode), `\"prompts\"` (codex, kiro-cli, pi), `\"command\"` (opencode - singular)\n  - This field enables `--ai-skills` to locate command templates correctly for skill generation\n- `install_url`: Installation documentation URL (set to `None` for IDE-based agents)\n- `requires_cli`: Whether the agent requires a CLI tool check during initialization\n\n#### 2. Update CLI Help Text\n\nUpdate the `--ai` parameter help text in the `init()` command to include the new agent:\n\n```python\nai_assistant: str = typer.Option(None, \"--ai\", help=\"AI assistant to use: claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, codebuddy, new-agent-cli, or kiro-cli\"),\n```\n\nAlso update any function docstrings, examples, and error messages that list available agents.\n\n#### 3. Update README Documentation\n\nUpdate the **Supported AI Agents** section in `README.md` to include the new agent:\n\n- Add the new agent to the table with appropriate support level (Full/Partial)\n- Include the agent's official website link\n- Add any relevant notes about the agent's implementation\n- Ensure the table formatting remains aligned and consistent\n\n#### 4. Update Release Package Script\n\nModify `.github/workflows/scripts/create-release-packages.sh`:\n\n##### Add to ALL_AGENTS array\n\n```bash\nALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf kiro-cli)\n```\n\n##### Add case statement for directory structure\n\n```bash\ncase $agent in\n  # ... existing cases ...\n  windsurf)\n    mkdir -p \"$base_dir/.windsurf/workflows\"\n    generate_commands windsurf md \"\\$ARGUMENTS\" \"$base_dir/.windsurf/workflows\" \"$script\" ;;\nesac\n```\n\n#### 4. Update GitHub Release Script\n\nModify `.github/workflows/scripts/create-github-release.sh` to include the new agent's packages:\n\n```bash\ngh release create \"$VERSION\" \\\n  # ... existing packages ...\n  .genreleases/spec-kit-template-windsurf-sh-\"$VERSION\".zip \\\n  .genreleases/spec-kit-template-windsurf-ps-\"$VERSION\".zip \\\n  # Add new agent packages here\n```\n\n#### 5. Update Agent Context Scripts\n\n##### Bash script (`scripts/bash/update-agent-context.sh`)\n\nAdd file variable:\n\n```bash\nWINDSURF_FILE=\"$REPO_ROOT/.windsurf/rules/specify-rules.md\"\n```\n\nAdd to case statement:\n\n```bash\ncase \"$AGENT_TYPE\" in\n  # ... existing cases ...\n  windsurf) update_agent_file \"$WINDSURF_FILE\" \"Windsurf\" ;;\n  \"\")\n    # ... existing checks ...\n    [ -f \"$WINDSURF_FILE\" ] && update_agent_file \"$WINDSURF_FILE\" \"Windsurf\";\n    # Update default creation condition\n    ;;\nesac\n```\n\n##### PowerShell script (`scripts/powershell/update-agent-context.ps1`)\n\nAdd file variable:\n\n```powershell\n$windsurfFile = Join-Path $repoRoot '.windsurf/rules/specify-rules.md'\n```\n\nAdd to switch statement:\n\n```powershell\nswitch ($AgentType) {\n    # ... existing cases ...\n    'windsurf' { Update-AgentFile $windsurfFile 'Windsurf' }\n    '' {\n        foreach ($pair in @(\n            # ... existing pairs ...\n            @{file=$windsurfFile; name='Windsurf'}\n        )) {\n            if (Test-Path $pair.file) { Update-AgentFile $pair.file $pair.name }\n        }\n        # Update default creation condition\n    }\n}\n```\n\n#### 6. Update CLI Tool Checks (Optional)\n\nFor agents that require CLI tools, add checks in the `check()` command and agent validation:\n\n```python\n# In check() command\ntracker.add(\"windsurf\", \"Windsurf IDE (optional)\")\nwindsurf_ok = check_tool_for_tracker(\"windsurf\", \"https://windsurf.com/\", tracker)\n\n# In init validation (only if CLI tool required)\nelif selected_ai == \"windsurf\":\n    if not check_tool(\"windsurf\", \"Install from: https://windsurf.com/\"):\n        console.print(\"[red]Error:[/red] Windsurf CLI is required for Windsurf projects\")\n        agent_tool_missing = True\n```\n\n**Note**: CLI tool checks are now handled automatically based on the `requires_cli` field in AGENT_CONFIG. No additional code changes needed in the `check()` or `init()` commands - they automatically loop through AGENT_CONFIG and check tools as needed.\n\n## Important Design Decisions\n\n### Using Actual CLI Tool Names as Keys\n\n**CRITICAL**: When adding a new agent to AGENT_CONFIG, always use the **actual executable name** as the dictionary key, not a shortened or convenient version.\n\n**Why this matters:**\n\n- The `check_tool()` function uses `shutil.which(tool)` to find executables in the system PATH\n- If the key doesn't match the actual CLI tool name, you'll need special-case mappings throughout the codebase\n- This creates unnecessary complexity and maintenance burden\n\n**Example - The Cursor Lesson:**\n\n❌ **Wrong approach** (requires special-case mapping):\n\n```python\nAGENT_CONFIG = {\n    \"cursor\": {  # Shorthand that doesn't match the actual tool\n        \"name\": \"Cursor\",\n        # ...\n    }\n}\n\n# Then you need special cases everywhere:\ncli_tool = agent_key\nif agent_key == \"cursor\":\n    cli_tool = \"cursor-agent\"  # Map to the real tool name\n```\n\n✅ **Correct approach** (no mapping needed):\n\n```python\nAGENT_CONFIG = {\n    \"cursor-agent\": {  # Matches the actual executable name\n        \"name\": \"Cursor\",\n        # ...\n    }\n}\n\n# No special cases needed - just use agent_key directly!\n```\n\n**Benefits of this approach:**\n\n- Eliminates special-case logic scattered throughout the codebase\n- Makes the code more maintainable and easier to understand\n- Reduces the chance of bugs when adding new agents\n- Tool checking \"just works\" without additional mappings\n\n#### 7. Update Devcontainer files (Optional)\n\nFor agents that have VS Code extensions or require CLI installation, update the devcontainer configuration files:\n\n##### VS Code Extension-based Agents\n\nFor agents available as VS Code extensions, add them to `.devcontainer/devcontainer.json`:\n\n```json\n{\n  \"customizations\": {\n    \"vscode\": {\n      \"extensions\": [\n        // ... existing extensions ...\n        // [New Agent Name]\n        \"[New Agent Extension ID]\"\n      ]\n    }\n  }\n}\n```\n\n##### CLI-based Agents\n\nFor agents that require CLI tools, add installation commands to `.devcontainer/post-create.sh`:\n\n```bash\n#!/bin/bash\n\n# Existing installations...\n\necho -e \"\\n🤖 Installing [New Agent Name] CLI...\"\n# run_command \"npm install -g [agent-cli-package]@latest\" # Example for node-based CLI\n# or other installation instructions (must be non-interactive and compatible with Linux Debian \"Trixie\" or later)...\necho \"✅ Done\"\n\n```\n\n**Quick Tips:**\n\n- **Extension-based agents**: Add to the `extensions` array in `devcontainer.json`\n- **CLI-based agents**: Add installation scripts to `post-create.sh`\n- **Hybrid agents**: May require both extension and CLI installation\n- **Test thoroughly**: Ensure installations work in the devcontainer environment\n\n## Agent Categories\n\n### CLI-Based Agents\n\nRequire a command-line tool to be installed:\n\n- **Claude Code**: `claude` CLI\n- **Gemini CLI**: `gemini` CLI\n- **Cursor**: `cursor-agent` CLI\n- **Qwen Code**: `qwen` CLI\n- **opencode**: `opencode` CLI\n- **Junie**: `junie` CLI\n- **Kiro CLI**: `kiro-cli` CLI\n- **CodeBuddy CLI**: `codebuddy` CLI\n- **Qoder CLI**: `qodercli` CLI\n- **Amp**: `amp` CLI\n- **SHAI**: `shai` CLI\n- **Tabnine CLI**: `tabnine` CLI\n- **Kimi Code**: `kimi` CLI\n- **Pi Coding Agent**: `pi` CLI\n\n### IDE-Based Agents\n\nWork within integrated development environments:\n\n- **GitHub Copilot**: Built into VS Code/compatible editors\n- **Windsurf**: Built into Windsurf IDE\n- **IBM Bob**: Built into IBM Bob IDE\n\n## Command File Formats\n\n### Markdown Format\n\nUsed by: Claude, Cursor, opencode, Windsurf, Junie, Kiro CLI, Amp, SHAI, IBM Bob, Kimi Code, Qwen, Pi\n\n**Standard format:**\n\n```markdown\n---\ndescription: \"Command description\"\n---\n\nCommand content with {SCRIPT} and $ARGUMENTS placeholders.\n```\n\n**GitHub Copilot Chat Mode format:**\n\n```markdown\n---\ndescription: \"Command description\"\nmode: speckit.command-name\n---\n\nCommand content with {SCRIPT} and $ARGUMENTS placeholders.\n```\n\n### TOML Format\n\nUsed by: Gemini, Tabnine\n\n```toml\ndescription = \"Command description\"\n\nprompt = \"\"\"\nCommand content with {SCRIPT} and {{args}} placeholders.\n\"\"\"\n```\n\n## Directory Conventions\n\n- **CLI agents**: Usually `.<agent-name>/commands/`\n- **Common prompt-based exceptions**:\n  - Codex: `.codex/prompts/`\n  - Kiro CLI: `.kiro/prompts/`\n  - Pi: `.pi/prompts/`\n- **IDE agents**: Follow IDE-specific patterns:\n  - Copilot: `.github/agents/`\n  - Cursor: `.cursor/commands/`\n  - Windsurf: `.windsurf/workflows/`\n\n## Argument Patterns\n\nDifferent agents use different argument placeholders:\n\n- **Markdown/prompt-based**: `$ARGUMENTS`\n- **TOML-based**: `{{args}}`\n- **Script placeholders**: `{SCRIPT}` (replaced with actual script path)\n- **Agent placeholders**: `__AGENT__` (replaced with agent name)\n\n## Testing New Agent Integration\n\n1. **Build test**: Run package creation script locally\n2. **CLI test**: Test `specify init --ai <agent>` command\n3. **File generation**: Verify correct directory structure and files\n4. **Command validation**: Ensure generated commands work with the agent\n5. **Context update**: Test agent context update scripts\n\n## Common Pitfalls\n\n1. **Using shorthand keys instead of actual CLI tool names**: Always use the actual executable name as the AGENT_CONFIG key (e.g., `\"cursor-agent\"` not `\"cursor\"`). This prevents the need for special-case mappings throughout the codebase.\n2. **Forgetting update scripts**: Both bash and PowerShell scripts must be updated when adding new agents.\n3. **Incorrect `requires_cli` value**: Set to `True` only for agents that actually have CLI tools to check; set to `False` for IDE-based agents.\n4. **Wrong argument format**: Use correct placeholder format for each agent type (`$ARGUMENTS` for Markdown, `{{args}}` for TOML).\n5. **Directory naming**: Follow agent-specific conventions exactly (check existing agents for patterns).\n6. **Help text inconsistency**: Update all user-facing text consistently (help strings, docstrings, README, error messages).\n\n## Future Considerations\n\nWhen adding new agents:\n\n- Consider the agent's native command/workflow patterns\n- Ensure compatibility with the Spec-Driven Development process\n- Document any special requirements or limitations\n- Update this guide with lessons learned\n- Verify the actual CLI tool name before adding to AGENT_CONFIG\n\n---\n\n*This documentation should be updated whenever new agents are added to maintain accuracy and completeness.*\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\n\n## [0.3.2] - 2026-03-19\n\n### Changes\n\n- Add conduct extension to community catalog (#1908)\n- feat(extensions): add verify-tasks extension to community catalog (#1871)\n- feat(presets): add enable/disable toggle and update semantics (#1891)\n- feat: add iFlow CLI support (#1875)\n- feat(commands): wire before/after hook events into specify and plan templates (#1886)\n- docs(catalog): add speckit-utils to community catalog (#1896)\n- docs: Add Extensions & Presets section to README (#1898)\n- chore: update DocGuard extension to v0.9.11 (#1899)\n- Update cognitive-squad catalog entry — Triadic Model, full lifecycle (#1884)\n- feat: register spec-kit-iterate extension (#1887)\n- fix(scripts): add explicit positional binding to PowerShell create-new-feature params (#1885)\n- fix(scripts): encode residual JSON control chars as \\uXXXX instead of stripping (#1872)\n- chore: update DocGuard extension to v0.9.10 (#1890)\n- Feature/spec kit add pi coding agent pullrequest (#1853)\n- feat: register spec-kit-learn extension (#1883)\n\n\n## [0.3.1] - 2026-03-17\n\n### Changed\n\n- docs: add greenfield Spring Boot pirate-speak preset demo to README (#1878)\n- fix(ai-skills): exclude non-speckit copilot agent markdown from skills (#1867)\n- feat: add Trae IDE support as a new agent (#1817)\n- feat(cli): polite deep merge for settings.json and support JSONC (#1874)\n- feat(extensions,presets): add priority-based resolution ordering (#1855)\n- fix(scripts): suppress stdout from git fetch in create-new-feature.sh (#1876)\n- fix(scripts): harden bash scripts — escape, compat, and error handling (#1869)\n- Add cognitive-squad to community extension catalog (#1870)\n- docs: add Go / React brownfield walkthrough to community walkthroughs (#1868)\n- chore: update DocGuard extension to v0.9.8 (#1859)\n- Feature: add specify status command (#1837)\n- fix(extensions): show extension ID in list output (#1843)\n- feat(extensions): add Archive and Reconcile extensions to community catalog (#1844)\n- feat: Add DocGuard CDD enforcement extension to community catalog (#1838)\n\n\n## [0.3.0] - 2026-03-13\n\n### Changed\n\n- No changes have been documented for this release yet.\n\n<!-- Entries for 0.2.x and earlier releases are documented in their respective sections below. -->\n- make c ignores consistent with c++ (#1747)\n- chore: bump version to 0.1.13 (#1746)\n- feat: add kiro-cli and AGENT_CONFIG consistency coverage (#1690)\n- feat: add verify extension to community catalog (#1726)\n- Add Retrospective Extension to community catalog README table (#1741)\n- fix(scripts): add empty description validation and branch checkout error handling (#1559)\n- fix: correct Copilot extension command registration (#1724)\n- fix(implement): remove Makefile from C ignore patterns (#1558)\n- Add sync extension to community catalog (#1728)\n- fix(checklist): clarify file handling behavior for append vs create (#1556)\n- fix(clarify): correct conflicting question limit from 10 to 5 (#1557)\n- chore: bump version to 0.1.12 (#1737)\n- fix: use RELEASE_PAT so tag push triggers release workflow (#1736)\n- fix: release-trigger uses release branch + PR instead of direct push to main (#1733)\n- fix: Split release process to sync pyproject.toml version with git tags (#1732)\n\n\n## [Unreleased]\n\n### Added\n\n- feat(cli): polite deep merge for VSCode settings.json with JSONC support via `json5` and zero-data-loss fallbacks\n- feat(presets): Pluggable preset system with preset catalog and template resolver\n- Preset manifest (`preset.yml`) with validation for artifact, command, and script types\n- `PresetManifest`, `PresetRegistry`, `PresetManager`, `PresetCatalog`, `PresetResolver` classes in `src/specify_cli/presets.py`\n- CLI commands: `specify preset search`, `specify preset add`, `specify preset list`, `specify preset remove`, `specify preset resolve`, `specify preset info`\n- CLI commands: `specify preset catalog list`, `specify preset catalog add`, `specify preset catalog remove` for multi-catalog management\n- `PresetCatalogEntry` dataclass and multi-catalog support mirroring the extension catalog system\n- `--preset` option for `specify init` to install presets during initialization\n- Priority-based preset resolution: presets with lower priority number win (`--priority` flag)\n- `resolve_template()` / `Resolve-Template` helpers in bash and PowerShell common scripts\n- Template resolution priority stack: overrides → presets → extensions → core\n- Preset catalog files (`presets/catalog.json`, `presets/catalog.community.json`)\n- Preset scaffold directory (`presets/scaffold/`)\n- Scripts updated to use template resolution instead of hardcoded paths\n- feat(presets): Preset command overrides now propagate to agent skills when `--ai-skills` was used during init\n- feat: `specify init` persists CLI options to `.specify/init-options.json` for downstream operations\n- feat(extensions): support `.extensionignore` to exclude files/folders during `specify extension add` (#1781)\n\n## [0.2.1] - 2026-03-11\n\n### Changed\n\n- Added February 2026 newsletter (#1812)\n- feat: add Kimi Code CLI agent support (#1790)\n- docs: fix broken links in quickstart guide (#1759) (#1797)\n- docs: add catalog cli help documentation (#1793) (#1794)\n- fix: use quiet checkout to avoid exception on git checkout (#1792)\n- feat(extensions): support .extensionignore to exclude files during install (#1781)\n- feat: add Codex support for extension command registration (#1767)\n- chore: bump version to 0.2.0 (#1786)\n- fix: sync agent list comments with actual supported agents (#1785)\n- feat(extensions): support multiple active catalogs simultaneously (#1720)\n- Pavel/add tabnine cli support (#1503)\n- Add Understanding extension to community catalog (#1778)\n- Add ralph extension to community catalog (#1780)\n- Update README with project initialization instructions (#1772)\n- feat: add review extension to community catalog (#1775)\n- Add fleet extension to community catalog (#1771)\n- Integration of Mistral vibe support into speckit (#1725)\n- fix: Remove duplicate options in specify.md (#1765)\n- fix: use global branch numbering instead of per-short-name detection (#1757)\n- Add Community Walkthroughs section to README (#1766)\n- feat(extensions): add Jira Integration to community catalog (#1764)\n- Add Azure DevOps Integration extension to community catalog (#1734)\n- Fix docs: update Antigravity link and add initialization example (#1748)\n- fix: wire after_tasks and after_implement hook events into command templates (#1702)\n- make c ignores consistent with c++ (#1747)\n- chore: bump version to 0.1.13 (#1746)\n- feat: add kiro-cli and AGENT_CONFIG consistency coverage (#1690)\n- feat: add verify extension to community catalog (#1726)\n- Add Retrospective Extension to community catalog README table (#1741)\n- fix(scripts): add empty description validation and branch checkout error handling (#1559)\n- fix: correct Copilot extension command registration (#1724)\n- fix(implement): remove Makefile from C ignore patterns (#1558)\n- Add sync extension to community catalog (#1728)\n- fix(checklist): clarify file handling behavior for append vs create (#1556)\n- fix(clarify): correct conflicting question limit from 10 to 5 (#1557)\n- chore: bump version to 0.1.12 (#1737)\n- fix: use RELEASE_PAT so tag push triggers release workflow (#1736)\n- fix: release-trigger uses release branch + PR instead of direct push to main (#1733)\n- fix: Split release process to sync pyproject.toml version with git tags (#1732)\n\n## [0.2.0] - 2026-03-09\n\n### Changed\n\n- feat: add Kimi Code CLI agent support\n- fix: sync agent list comments with actual supported agents (#1785)\n- feat(extensions): support multiple active catalogs simultaneously (#1720)\n- Pavel/add tabnine cli support (#1503)\n- Add Understanding extension to community catalog (#1778)\n- Add ralph extension to community catalog (#1780)\n- Update README with project initialization instructions (#1772)\n- feat: add review extension to community catalog (#1775)\n- Add fleet extension to community catalog (#1771)\n- Integration of Mistral vibe support into speckit (#1725)\n- fix: Remove duplicate options in specify.md (#1765)\n- fix: use global branch numbering instead of per-short-name detection (#1757)\n- Add Community Walkthroughs section to README (#1766)\n- feat(extensions): add Jira Integration to community catalog (#1764)\n- Add Azure DevOps Integration extension to community catalog (#1734)\n- Fix docs: update Antigravity link and add initialization example (#1748)\n- fix: wire after_tasks and after_implement hook events into command templates (#1702)\n- make c ignores consistent with c++ (#1747)\n- chore: bump version to 0.1.13 (#1746)\n- feat: add kiro-cli and AGENT_CONFIG consistency coverage (#1690)\n- feat: add verify extension to community catalog (#1726)\n- Add Retrospective Extension to community catalog README table (#1741)\n- fix(scripts): add empty description validation and branch checkout error handling (#1559)\n- fix: correct Copilot extension command registration (#1724)\n- fix(implement): remove Makefile from C ignore patterns (#1558)\n- Add sync extension to community catalog (#1728)\n- fix(checklist): clarify file handling behavior for append vs create (#1556)\n- fix(clarify): correct conflicting question limit from 10 to 5 (#1557)\n- chore: bump version to 0.1.12 (#1737)\n- fix: use RELEASE_PAT so tag push triggers release workflow (#1736)\n- fix: release-trigger uses release branch + PR instead of direct push to main (#1733)\n- fix: Split release process to sync pyproject.toml version with git tags (#1732)\n\n## [0.1.14] - 2026-03-09\n\n### Added\n\n- feat: add Tabnine CLI agent support\n- **Multi-Catalog Support (#1707)**: Extension catalog system now supports multiple active catalogs simultaneously via a catalog stack\n  - New `specify extension catalog list` command lists all active catalogs with name, URL, priority, and `install_allowed` status\n  - New `specify extension catalog add` and `specify extension catalog remove` commands for project-scoped catalog management\n  - Default built-in stack includes `catalog.json` (default, installable) and `catalog.community.json` (community, discovery only) — community extensions are now surfaced in search results out of the box\n  - `specify extension search` aggregates results across all active catalogs, annotating each result with source catalog\n  - `specify extension add` enforces `install_allowed` policy — extensions from discovery-only catalogs cannot be installed directly\n  - Project-level `.specify/extension-catalogs.yml` and user-level `~/.specify/extension-catalogs.yml` config files supported, with project-level taking precedence\n  - `SPECKIT_CATALOG_URL` environment variable still works for backward compatibility (replaces full stack with single catalog)\n  - All catalog URLs require HTTPS (HTTP allowed for localhost development)\n  - New `CatalogEntry` dataclass in `extensions.py` for catalog stack representation\n  - Per-URL hash-based caching for non-default catalogs; legacy cache preserved for default catalog\n  - Higher-priority catalogs win on merge conflicts (same extension id in multiple catalogs)\n  - 13 new tests covering catalog stack resolution, merge conflicts, URL validation, and `install_allowed` enforcement\n  - Updated RFC, Extension User Guide, and Extension API Reference documentation\n\n## [0.1.13] - 2026-03-03\n\n### Changed\n\n- feat: add kiro-cli and AGENT_CONFIG consistency coverage (#1690)\n- feat: add verify extension to community catalog (#1726)\n- Add Retrospective Extension to community catalog README table (#1741)\n- fix(scripts): add empty description validation and branch checkout error handling (#1559)\n- fix: correct Copilot extension command registration (#1724)\n- fix(implement): remove Makefile from C ignore patterns (#1558)\n- Add sync extension to community catalog (#1728)\n- fix(checklist): clarify file handling behavior for append vs create (#1556)\n- fix(clarify): correct conflicting question limit from 10 to 5 (#1557)\n- chore: bump version to 0.1.12 (#1737)\n- fix: use RELEASE_PAT so tag push triggers release workflow (#1736)\n- fix: release-trigger uses release branch + PR instead of direct push to main (#1733)\n- fix: Split release process to sync pyproject.toml version with git tags (#1732)\n\n\n## [0.1.13] - 2026-03-03\n\n### Fixed\n\n- **Copilot Extension Commands Not Visible**: Fixed extension commands not appearing in GitHub Copilot when installed via `specify extension add --dev`\n  - Changed Copilot file extension from `.md` to `.agent.md` in `CommandRegistrar.AGENT_CONFIGS` so Copilot recognizes agent files\n  - Added generation of companion `.prompt.md` files in `.github/prompts/` during extension command registration, matching the release packaging behavior\n  - Added cleanup of `.prompt.md` companion files when removing extensions via `specify extension remove`\n- Fixed a syntax regression in `src/specify_cli/__init__.py` in `_build_ai_assistant_help()` that broke `ruff` and `pytest` collection in CI.\n## [0.1.12] - 2026-03-02\n\n### Changed\n\n- fix: use RELEASE_PAT so tag push triggers release workflow (#1736)\n- fix: release-trigger uses release branch + PR instead of direct push to main (#1733)\n- fix: Split release process to sync pyproject.toml version with git tags (#1732)\n\n\n## [0.1.10] - 2026-03-02\n\n### Fixed\n\n- **Version Sync Issue (#1721)**: Fixed version mismatch between `pyproject.toml` and git release tags\n  - Split release process into two workflows: `release-trigger.yml` for version management and `release.yml` for artifact building\n  - Version bump now happens BEFORE tag creation, ensuring tags point to commits with correct version\n  - Supports both manual version specification and auto-increment (patch version)\n  - Git tags now accurately reflect the version in `pyproject.toml` at that commit\n  - Prevents confusion when installing from source\n\n## [0.1.9] - 2026-02-28\n\n### Changed\n\n- Updated dependency: bumped astral-sh/setup-uv from 6 to 7\n\n## [0.1.8] - 2026-02-28\n\n### Changed\n\n- Updated dependency: bumped actions/setup-python from 5 to 6\n\n## [0.1.7] - 2026-02-27\n\n### Changed\n\n- Updated outdated GitHub Actions versions\n- Documented dual-catalog system for extensions\n\n### Fixed\n\n- Fixed version command in documentation\n\n### Added\n\n- Added Cleanup Extension to README\n- Added retrospective extension to community catalog\n\n## [0.1.6] - 2026-02-23\n\n### Fixed\n\n- **Parameter Ordering Issues (#1641)**: Fixed CLI parameter parsing issue where option flags were incorrectly consumed as values for preceding options\n  - Added validation to detect when `--ai` or `--ai-commands-dir` incorrectly consume following flags like `--here` or `--ai-skills`\n  - Now provides clear error messages: \"Invalid value for --ai: '--here'\"\n  - Includes helpful hints suggesting proper usage and listing available agents\n  - Commands like `specify init --ai-skills --ai --here` now fail with actionable feedback instead of confusing \"Must specify project name\" errors\n  - Added comprehensive test suite (5 new tests) to prevent regressions\n\n## [0.1.5] - 2026-02-21\n\n### Fixed\n\n- **AI Skills Installation Bug (#1658)**: Fixed `--ai-skills` flag not generating skill files for GitHub Copilot and other agents with non-standard command directory structures\n  - Added `commands_subdir` field to `AGENT_CONFIG` to explicitly specify the subdirectory name for each agent\n  - Affected agents now work correctly: copilot (`.github/agents/`), opencode (`.opencode/command/`), windsurf (`.windsurf/workflows/`), codex (`.codex/prompts/`), kilocode (`.kilocode/workflows/`), q (`.amazonq/prompts/`), and agy (`.agent/workflows/`)\n  - The `install_ai_skills()` function now uses the correct path for all agents instead of assuming `commands/` for everyone\n\n## [0.1.4] - 2026-02-20\n\n### Fixed\n\n- **Qoder CLI detection**: Renamed `AGENT_CONFIG` key from `\"qoder\"` to `\"qodercli\"` to match the actual executable name, fixing `specify check` and `specify init --ai` detection failures\n\n## [0.1.3] - 2026-02-20\n\n### Added\n\n- **Generic Agent Support**: Added `--ai generic` option for unsupported AI agents (\"bring your own agent\")\n  - Requires `--ai-commands-dir <path>` to specify where the agent reads commands from\n  - Generates Markdown commands with `$ARGUMENTS` format (compatible with most agents)\n  - Example: `specify init my-project --ai generic --ai-commands-dir .myagent/commands/`\n  - Enables users to start with Spec Kit immediately while their agent awaits formal support\n\n## [0.0.102] - 2026-02-20\n\n- fix: include 'src/**' path in release workflow triggers (#1646)\n\n## [0.0.101] - 2026-02-19\n\n- chore(deps): bump github/codeql-action from 3 to 4 (#1635)\n\n## [0.0.100] - 2026-02-19\n\n- Add pytest and Python linting (ruff) to CI (#1637)\n- feat: add pull request template for better contribution guidelines (#1634)\n\n## [0.0.99] - 2026-02-19\n\n- Feat/ai skills (#1632)\n\n## [0.0.98] - 2026-02-19\n\n- chore(deps): bump actions/stale from 9 to 10 (#1623)\n- feat: add dependabot configuration for pip and GitHub Actions updates (#1622)\n"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nIn the interest of fostering an open and welcoming environment, we as\ncontributors and maintainers pledge to making participation in our project and\nour community a harassment-free experience for everyone, regardless of age, body\nsize, disability, ethnicity, gender identity and expression, level of experience,\nnationality, personal appearance, race, religion, or sexual identity and\norientation.\n\n## Our Standards\n\nExamples of behavior that contributes to creating a positive environment\ninclude:\n\n- Using welcoming and inclusive language\n- Being respectful of differing viewpoints and experiences\n- Gracefully accepting constructive criticism\n- Focusing on what is best for the community\n- Showing empathy towards other community members\n\nExamples of unacceptable behavior by participants include:\n\n- The use of sexualized language or imagery and unwelcome sexual attention or\n  advances\n- Trolling, insulting/derogatory comments, and personal or political attacks\n- Public or private harassment\n- Publishing others' private information, such as a physical or electronic\n  address, without explicit permission\n- Other conduct which could reasonably be considered inappropriate in a\n  professional setting\n\n## Our Responsibilities\n\nProject maintainers are responsible for clarifying the standards of acceptable\nbehavior and are expected to take appropriate and fair corrective action in\nresponse to any instances of unacceptable behavior.\n\nProject maintainers have the right and responsibility to remove, edit, or\nreject comments, commits, code, wiki edits, issues, and other contributions\nthat are not aligned to this Code of Conduct, or to ban temporarily or\npermanently any contributor for other behaviors that they deem inappropriate,\nthreatening, offensive, or harmful.\n\n## Scope\n\nThis Code of Conduct applies both within project spaces and in public spaces\nwhen an individual is representing the project or its community. Examples of\nrepresenting a project or community include using an official project e-mail\naddress, posting via an official social media account, or acting as an appointed\nrepresentative at an online or offline event. Representation of a project may be\nfurther defined and clarified by project maintainers.\n\n## Enforcement\n\nInstances of abusive, harassing, or otherwise unacceptable behavior may be\nreported by contacting the project team at <opensource@github.com>. All\ncomplaints will be reviewed and investigated and will result in a response that\nis deemed necessary and appropriate to the circumstances. The project team is\nobligated to maintain confidentiality with regard to the reporter of an incident.\nFurther details of specific enforcement policies may be posted separately.\n\nProject maintainers who do not follow or enforce the Code of Conduct in good\nfaith may face temporary or permanent repercussions as determined by other\nmembers of the project's leadership.\n\n## Attribution\n\nThis Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,\navailable at [http://contributor-covenant.org/version/1/4][version]\n\n[homepage]: http://contributor-covenant.org\n[version]: http://contributor-covenant.org/version/1/4/\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to Spec Kit\n\nHi there! We're thrilled that you'd like to contribute to Spec Kit. Contributions to this project are [released](https://help.github.com/articles/github-terms-of-service/#6-contributions-under-repository-license) to the public under the [project's open source license](LICENSE).\n\nPlease note that this project is released with a [Contributor Code of Conduct](CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms.\n\n## Prerequisites for running and testing code\n\nThese are one time installations required to be able to test your changes locally as part of the pull request (PR) submission process.\n\n1. Install [Python 3.11+](https://www.python.org/downloads/)\n1. Install [uv](https://docs.astral.sh/uv/) for package management\n1. Install [Git](https://git-scm.com/downloads)\n1. Have an [AI coding agent available](README.md#-supported-ai-agents)\n\n<details>\n<summary><b>💡 Hint if you are using <code>VSCode</code> or <code>GitHub Codespaces</code> as your IDE</b></summary>\n\n<br>\n\nProvided you have [Docker](https://docker.com) installed on your machine, you can leverage [Dev Containers](https://containers.dev) through this [VSCode extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers), to easily set up your development environment, with aforementioned tools already installed and configured, thanks to the `.devcontainer/devcontainer.json` file (located at the root of the project).\n\nTo do so, simply:\n\n- Checkout the repo\n- Open it with VSCode\n- Open the [Command Palette](https://code.visualstudio.com/docs/getstarted/userinterface#_command-palette) and select \"Dev Containers: Open Folder in Container...\"\n\nOn [GitHub Codespaces](https://github.com/features/codespaces) it's even simpler, as it leverages the `.devcontainer/devcontainer.json` automatically upon opening the codespace.\n\n</details>\n\n## Submitting a pull request\n\n> [!NOTE]\n> If your pull request introduces a large change that materially impacts the work of the CLI or the rest of the repository (e.g., you're introducing new templates, arguments, or otherwise major changes), make sure that it was **discussed and agreed upon** by the project maintainers. Pull requests with large changes that did not have a prior conversation and agreement will be closed.\n\n1. Fork and clone the repository\n1. Configure and install the dependencies: `uv sync`\n1. Make sure the CLI works on your machine: `uv run specify --help`\n1. Create a new branch: `git checkout -b my-branch-name`\n1. Make your change, add tests, and make sure everything still works\n1. Test the CLI functionality with a sample project if relevant\n1. Push to your fork and submit a pull request\n1. Wait for your pull request to be reviewed and merged.\n\nHere are a few things you can do that will increase the likelihood of your pull request being accepted:\n\n- Follow the project's coding conventions.\n- Write tests for new functionality.\n- Update documentation (`README.md`, `spec-driven.md`) if your changes affect user-facing features.\n- Keep your change as focused as possible. If there are multiple changes you would like to make that are not dependent upon each other, consider submitting them as separate pull requests.\n- Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html).\n- Test your changes with the Spec-Driven Development workflow to ensure compatibility.\n\n## Development workflow\n\nWhen working on spec-kit:\n\n1. Test changes with the `specify` CLI commands (`/speckit.specify`, `/speckit.plan`, `/speckit.tasks`) in your coding agent of choice\n2. Verify templates are working correctly in `templates/` directory\n3. Test script functionality in the `scripts/` directory\n4. Ensure memory files (`memory/constitution.md`) are updated if major process changes are made\n\n### Testing template and command changes locally\n\nRunning `uv run specify init` pulls released packages, which won’t include your local changes.  \nTo test your templates, commands, and other changes locally, follow these steps:\n\n1. **Create release packages**\n\n   Run the following command to generate the local packages:\n\n   ```bash\n   ./.github/workflows/scripts/create-release-packages.sh v1.0.0\n   ```\n\n2. **Copy the relevant package to your test project**\n\n   ```bash\n   cp -r .genreleases/sdd-copilot-package-sh/. <path-to-test-project>/\n   ```\n\n3. **Open and test the agent**\n\n   Navigate to your test project folder and open the agent to verify your implementation.\n\n## AI contributions in Spec Kit\n\n> [!IMPORTANT]\n>\n> If you are using **any kind of AI assistance** to contribute to Spec Kit,\n> it must be disclosed in the pull request or issue.\n\nWe welcome and encourage the use of AI tools to help improve Spec Kit! Many valuable contributions have been enhanced with AI assistance for code generation, issue detection, and feature definition.\n\nThat being said, if you are using any kind of AI assistance (e.g., agents, ChatGPT) while contributing to Spec Kit,\n**this must be disclosed in the pull request or issue**, along with the extent to which AI assistance was used (e.g., documentation comments vs. code generation).\n\nIf your PR responses or comments are being generated by an AI, disclose that as well.\n\nAs an exception, trivial spacing or typo fixes don't need to be disclosed, so long as the changes are limited to small parts of the code or short phrases.\n\nAn example disclosure:\n\n> This PR was written primarily by GitHub Copilot.\n\nOr a more detailed disclosure:\n\n> I consulted ChatGPT to understand the codebase but the solution\n> was fully authored manually by myself.\n\nFailure to disclose this is first and foremost rude to the human operators on the other end of the pull request, but it also makes it difficult to\ndetermine how much scrutiny to apply to the contribution.\n\nIn a perfect world, AI assistance would produce equal or higher quality work than any human. That isn't the world we live in today, and in most cases\nwhere human supervision or expertise is not in the loop, it's generating code that cannot be reasonably maintained or evolved.\n\n### What we're looking for\n\nWhen submitting AI-assisted contributions, please ensure they include:\n\n- **Clear disclosure of AI use** - You are transparent about AI use and degree to which you're using it for the contribution\n- **Human understanding and testing** - You've personally tested the changes and understand what they do\n- **Clear rationale** - You can explain why the change is needed and how it fits within Spec Kit's goals\n- **Concrete evidence** - Include test cases, scenarios, or examples that demonstrate the improvement\n- **Your own analysis** - Share your thoughts on the end-to-end developer experience\n\n### What we'll close\n\nWe reserve the right to close contributions that appear to be:\n\n- Untested changes submitted without verification\n- Generic suggestions that don't address specific Spec Kit needs\n- Bulk submissions that show no human review or understanding\n\n### Guidelines for success\n\nThe key is demonstrating that you understand and have validated your proposed changes. If a maintainer can easily tell that a contribution was generated entirely by AI without human input or testing, it likely needs more work before submission.\n\nContributors who consistently submit low-effort AI-generated changes may be restricted from further contributions at the maintainers' discretion.\n\nPlease be respectful to maintainers and disclose AI assistance.\n\n## Resources\n\n- [Spec-Driven Development Methodology](./spec-driven.md)\n- [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/)\n- [Using Pull Requests](https://help.github.com/articles/about-pull-requests/)\n- [GitHub Help](https://help.github.com)\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright GitHub, Inc.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\">\n    <img src=\"./media/logo_large.webp\" alt=\"Spec Kit Logo\" width=\"200\" height=\"200\"/>\n    <h1>🌱 Spec Kit</h1>\n    <h3><em>Build high-quality software faster.</em></h3>\n</div>\n\n<p align=\"center\">\n    <strong>An open source toolkit that allows you to focus on product scenarios and predictable outcomes instead of vibe coding every piece from scratch.</strong>\n</p>\n\n<p align=\"center\">\n    <a href=\"https://github.com/github/spec-kit/actions/workflows/release.yml\"><img src=\"https://github.com/github/spec-kit/actions/workflows/release.yml/badge.svg\" alt=\"Release\"/></a>\n    <a href=\"https://github.com/github/spec-kit/stargazers\"><img src=\"https://img.shields.io/github/stars/github/spec-kit?style=social\" alt=\"GitHub stars\"/></a>\n    <a href=\"https://github.com/github/spec-kit/blob/main/LICENSE\"><img src=\"https://img.shields.io/github/license/github/spec-kit\" alt=\"License\"/></a>\n    <a href=\"https://github.github.io/spec-kit/\"><img src=\"https://img.shields.io/badge/docs-GitHub_Pages-blue\" alt=\"Documentation\"/></a>\n</p>\n\n---\n\n## Table of Contents\n\n- [🤔 What is Spec-Driven Development?](#-what-is-spec-driven-development)\n- [⚡ Get Started](#-get-started)\n- [📽️ Video Overview](#️-video-overview)\n- [🚶 Community Walkthroughs](#-community-walkthroughs)\n- [🤖 Supported AI Agents](#-supported-ai-agents)\n- [🔧 Specify CLI Reference](#-specify-cli-reference)\n- [🧩 Making Spec Kit Your Own: Extensions & Presets](#-making-spec-kit-your-own-extensions--presets)\n- [📚 Core Philosophy](#-core-philosophy)\n- [🌟 Development Phases](#-development-phases)\n- [🎯 Experimental Goals](#-experimental-goals)\n- [🔧 Prerequisites](#-prerequisites)\n- [📖 Learn More](#-learn-more)\n- [📋 Detailed Process](#-detailed-process)\n- [🔍 Troubleshooting](#-troubleshooting)\n- [💬 Support](#-support)\n- [🙏 Acknowledgements](#-acknowledgements)\n- [📄 License](#-license)\n\n## 🤔 What is Spec-Driven Development?\n\nSpec-Driven Development **flips the script** on traditional software development. For decades, code has been king — specifications were just scaffolding we built and discarded once the \"real work\" of coding began. Spec-Driven Development changes this: **specifications become executable**, directly generating working implementations rather than just guiding them.\n\n## ⚡ Get Started\n\n### 1. Install Specify CLI\n\nChoose your preferred installation method:\n\n#### Option 1: Persistent Installation (Recommended)\n\nInstall once and use everywhere:\n\n```bash\nuv tool install specify-cli --from git+https://github.com/github/spec-kit.git\n```\n\nThen use the tool directly:\n\n```bash\n# Create new project\nspecify init <PROJECT_NAME>\n\n# Or initialize in existing project\nspecify init . --ai claude\n# or\nspecify init --here --ai claude\n\n# Check installed tools\nspecify check\n```\n\nTo upgrade Specify, see the [Upgrade Guide](./docs/upgrade.md) for detailed instructions. Quick upgrade:\n\n```bash\nuv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git\n```\n\n#### Option 2: One-time Usage\n\nRun directly without installing:\n\n```bash\n# Create new project\nuvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME>\n\n# Or initialize in existing project\nuvx --from git+https://github.com/github/spec-kit.git specify init . --ai claude\n# or\nuvx --from git+https://github.com/github/spec-kit.git specify init --here --ai claude\n```\n\n**Benefits of persistent installation:**\n\n- Tool stays installed and available in PATH\n- No need to create shell aliases\n- Better tool management with `uv tool list`, `uv tool upgrade`, `uv tool uninstall`\n- Cleaner shell configuration\n\n### 2. Establish project principles\n\nLaunch your AI assistant in the project directory. Most agents expose spec-kit as `/speckit.*` slash commands; Codex CLI in skills mode uses `$speckit-*` instead.\n\nUse the **`/speckit.constitution`** command to create your project's governing principles and development guidelines that will guide all subsequent development.\n\n```bash\n/speckit.constitution Create principles focused on code quality, testing standards, user experience consistency, and performance requirements\n```\n\n### 3. Create the spec\n\nUse the **`/speckit.specify`** command to describe what you want to build. Focus on the **what** and **why**, not the tech stack.\n\n```bash\n/speckit.specify Build an application that can help me organize my photos in separate photo albums. Albums are grouped by date and can be re-organized by dragging and dropping on the main page. Albums are never in other nested albums. Within each album, photos are previewed in a tile-like interface.\n```\n\n### 4. Create a technical implementation plan\n\nUse the **`/speckit.plan`** command to provide your tech stack and architecture choices.\n\n```bash\n/speckit.plan The application uses Vite with minimal number of libraries. Use vanilla HTML, CSS, and JavaScript as much as possible. Images are not uploaded anywhere and metadata is stored in a local SQLite database.\n```\n\n### 5. Break down into tasks\n\nUse **`/speckit.tasks`** to create an actionable task list from your implementation plan.\n\n```bash\n/speckit.tasks\n```\n\n### 6. Execute implementation\n\nUse **`/speckit.implement`** to execute all tasks and build your feature according to the plan.\n\n```bash\n/speckit.implement\n```\n\nFor detailed step-by-step instructions, see our [comprehensive guide](./spec-driven.md).\n\n## 📽️ Video Overview\n\nWant to see Spec Kit in action? Watch our [video overview](https://www.youtube.com/watch?v=a9eR1xsfvHg&pp=0gcJCckJAYcqIYzv)!\n\n[![Spec Kit video header](/media/spec-kit-video-header.jpg)](https://www.youtube.com/watch?v=a9eR1xsfvHg&pp=0gcJCckJAYcqIYzv)\n\n## 🚶 Community Walkthroughs\n\nSee Spec-Driven Development in action across different scenarios with these community-contributed walkthroughs:\n\n- **[Greenfield .NET CLI tool](https://github.com/mnriem/spec-kit-dotnet-cli-demo)** — Builds a Timezone Utility as a .NET single-binary CLI tool from a blank directory, covering the full spec-kit workflow: constitution, specify, plan, tasks, and multi-pass implement using GitHub Copilot agents.\n\n- **[Greenfield Spring Boot + React platform](https://github.com/mnriem/spec-kit-spring-react-demo)** — Builds an LLM performance analytics platform (REST API, graphs, iteration tracking) from scratch using Spring Boot, embedded React, PostgreSQL, and Docker Compose, with a clarify step and a cross-artifact consistency analysis pass included.\n\n- **[Brownfield ASP.NET CMS extension](https://github.com/mnriem/spec-kit-aspnet-brownfield-demo)** — Extends an existing open-source .NET CMS (CarrotCakeCMS-Core, ~307,000 lines of C#, Razor, SQL, JavaScript, and config files) with two new features — cross-platform Docker Compose infrastructure and a token-authenticated headless REST API — demonstrating how spec-kit fits into existing codebases without prior specs or a constitution.\n\n- **[Brownfield Java runtime extension](https://github.com/mnriem/spec-kit-java-brownfield-demo)** — Extends an existing open-source Jakarta EE runtime (Piranha, ~420,000 lines of Java, XML, JSP, HTML, and config files across 180 Maven modules) with a password-protected Server Admin Console, demonstrating spec-kit on a large multi-module Java project with no prior specs or constitution.\n\n- **[Brownfield Go / React dashboard demo](https://github.com/mnriem/spec-kit-go-brownfield-demo)** — Demonstrates spec-kit driven entirely from the **terminal using GitHub Copilot CLI**. Extends NASA's open-source Hermes ground support system (Go) with a lightweight React-based web telemetry dashboard, showing that the full constitution → specify → plan → tasks → implement workflow works from the terminal.\n\n- **[Greenfield Spring Boot MVC with a custom preset](https://github.com/mnriem/spec-kit-pirate-speak-preset-demo)** — Builds a Spring Boot MVC application from scratch using a custom pirate-speak preset, demonstrating how presets can reshape the entire spec-kit experience: specifications become \"Voyage Manifests,\" plans become \"Battle Plans,\" and tasks become \"Crew Assignments\" — all generated in full pirate vernacular without changing any tooling.\n\n## 🤖 Supported AI Agents\n\n| Agent                                                                                | Support | Notes                                                                                                                                     |\n| ------------------------------------------------------------------------------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------- |\n| [Qoder CLI](https://qoder.com/cli)                                                   | ✅      |                                                                                                                                           |\n| [Kiro CLI](https://kiro.dev/docs/cli/)                                               | ✅      | Use `--ai kiro-cli` (alias: `--ai kiro`)                                                                                                 |\n| [Amp](https://ampcode.com/)                                                          | ✅      |                                                                                                                                           |\n| [Auggie CLI](https://docs.augmentcode.com/cli/overview)                              | ✅      |                                                                                                                                           |\n| [Claude Code](https://www.anthropic.com/claude-code)                                 | ✅      |                                                                                                                                           |\n| [CodeBuddy CLI](https://www.codebuddy.ai/cli)                                        | ✅      |                                                                                                                                           |\n| [Codex CLI](https://github.com/openai/codex)                                         | ✅      | Requires `--ai-skills`. Codex recommends [skills](https://developers.openai.com/codex/skills) and treats [custom prompts](https://developers.openai.com/codex/custom-prompts) as deprecated. Spec-kit installs Codex skills into `.agents/skills` and invokes them as `$speckit-<command>`. |\n| [Cursor](https://cursor.sh/)                                                         | ✅      |                                                                                                                                           |\n| [Gemini CLI](https://github.com/google-gemini/gemini-cli)                            | ✅      |                                                                                                                                           |\n| [GitHub Copilot](https://code.visualstudio.com/)                                     | ✅      |                                                                                                                                           |\n| [IBM Bob](https://www.ibm.com/products/bob)                                          | ✅      | IDE-based agent with slash command support                                                                                                |\n| [Jules](https://jules.google.com/)                                                   | ✅      |                                                                                                                                           |\n| [Kilo Code](https://github.com/Kilo-Org/kilocode)                                    | ✅      |                                                                                                                                           |\n| [opencode](https://opencode.ai/)                                                     | ✅      |                                                                                                                                           |\n| [Pi Coding Agent](https://pi.dev)                                                    | ✅      | Pi doesn't have MCP support out of the box, so `taskstoissues` won't work as intended. MCP support can be added via [extensions](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent#extensions) |\n| [Qwen Code](https://github.com/QwenLM/qwen-code)                                     | ✅      |                                                                                                                                           |\n| [Roo Code](https://roocode.com/)                                                     | ✅      |                                                                                                                                           |\n| [SHAI (OVHcloud)](https://github.com/ovh/shai)                                       | ✅      |                                                                                                                                           |\n| [Tabnine CLI](https://docs.tabnine.com/main/getting-started/tabnine-cli)             | ✅      |                                                                                                                                           |\n| [Mistral Vibe](https://github.com/mistralai/mistral-vibe)                            | ✅      |                                                                                                                                           |\n| [Kimi Code](https://code.kimi.com/)                                                  | ✅      |                                                                                                                                           |\n| [iFlow CLI](https://docs.iflow.cn/en/cli/quickstart)                                 | ✅      |                                                                                                                                           |\n| [Windsurf](https://windsurf.com/)                                                    | ✅      |                                                                                                                                           |\n| [Junie](https://junie.jetbrains.com/)                                                | ✅      |                                                                                                                                           |\n| [Antigravity (agy)](https://antigravity.google/)                                     | ✅      | Requires `--ai-skills` |\n| [Trae](https://www.trae.ai/)                                                         | ✅      |                                                                                                                                           |\n| Generic                                                                              | ✅      | Bring your own agent — use `--ai generic --ai-commands-dir <path>` for unsupported agents                                                 |\n\n## 🔧 Specify CLI Reference\n\nThe `specify` command supports the following options:\n\n### Commands\n\n| Command | Description                                                                                                                                                                                                                                                                              |\n| ------- |------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|\n| `init`  | Initialize a new Specify project from the latest template                                                                                                                                                                                                                                |\n| `check` | Check for installed tools: `git` plus all CLI-based agents configured in `AGENT_CONFIG` (for example: `claude`, `gemini`, `code`/`code-insiders`, `cursor-agent`, `windsurf`, `junie`, `qwen`, `opencode`, `codex`, `kiro-cli`, `shai`, `qodercli`, `vibe`, `kimi`, `iflow`, `pi`, etc.) |\n\n### `specify init` Arguments & Options\n\n| Argument/Option        | Type     | Description                                                                                                                                                                                                                                                                                                                                                                               |\n| ---------------------- | -------- |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|\n| `<project-name>`       | Argument | Name for your new project directory (optional if using `--here`, or use `.` for current directory)                                                                                                                                                                                                                                                                                        |\n| `--ai`                 | Option   | AI assistant to use (see `AGENT_CONFIG` for the full, up-to-date list). Common options include: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `junie`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `kiro-cli` (`kiro` alias), `agy`, `bob`, `qodercli`, `vibe`, `kimi`, `iflow`, `pi`, or `generic` (requires `--ai-commands-dir`) |\n| `--ai-commands-dir`    | Option   | Directory for agent command files (required with `--ai generic`, e.g. `.myagent/commands/`)                                                                                                                                                                                                                                                                                               |\n| `--script`             | Option   | Script variant to use: `sh` (bash/zsh) or `ps` (PowerShell)                                                                                                                                                                                                                                                                                                                               |\n| `--ignore-agent-tools` | Flag     | Skip checks for AI agent tools like Claude Code                                                                                                                                                                                                                                                                                                                                           |\n| `--no-git`             | Flag     | Skip git repository initialization                                                                                                                                                                                                                                                                                                                                                        |\n| `--here`               | Flag     | Initialize project in the current directory instead of creating a new one                                                                                                                                                                                                                                                                                                                 |\n| `--force`              | Flag     | Force merge/overwrite when initializing in current directory (skip confirmation)                                                                                                                                                                                                                                                                                                          |\n| `--skip-tls`           | Flag     | Skip SSL/TLS verification (not recommended)                                                                                                                                                                                                                                                                                                                                               |\n| `--debug`              | Flag     | Enable detailed debug output for troubleshooting                                                                                                                                                                                                                                                                                                                                          |\n| `--github-token`       | Option   | GitHub token for API requests (or set GH_TOKEN/GITHUB_TOKEN env variable)                                                                                                                                                                                                                                                                                                                 |\n| `--ai-skills`          | Flag     | Install Prompt.MD templates as agent skills in agent-specific `skills/` directory (requires `--ai`)                                                                                                                                                                                                                                                                                       |\n\n### Examples\n\n```bash\n# Basic project initialization\nspecify init my-project\n\n# Initialize with specific AI assistant\nspecify init my-project --ai claude\n\n# Initialize with Cursor support\nspecify init my-project --ai cursor-agent\n\n# Initialize with Qoder support\nspecify init my-project --ai qodercli\n\n# Initialize with Windsurf support\nspecify init my-project --ai windsurf\n\n# Initialize with Kiro CLI support\nspecify init my-project --ai kiro-cli\n\n# Initialize with Amp support\nspecify init my-project --ai amp\n\n# Initialize with SHAI support\nspecify init my-project --ai shai\n\n# Initialize with Mistral Vibe support\nspecify init my-project --ai vibe\n\n# Initialize with IBM Bob support\nspecify init my-project --ai bob\n\n# Initialize with Pi Coding Agent support\nspecify init my-project --ai pi\n\n# Initialize with Codex CLI support\nspecify init my-project --ai codex --ai-skills\n\n# Initialize with Antigravity support\nspecify init my-project --ai agy --ai-skills\n\n# Initialize with an unsupported agent (generic / bring your own agent)\nspecify init my-project --ai generic --ai-commands-dir .myagent/commands/\n\n# Initialize with PowerShell scripts (Windows/cross-platform)\nspecify init my-project --ai copilot --script ps\n\n# Initialize in current directory\nspecify init . --ai copilot\n# or use the --here flag\nspecify init --here --ai copilot\n\n# Force merge into current (non-empty) directory without confirmation\nspecify init . --force --ai copilot\n# or\nspecify init --here --force --ai copilot\n\n# Skip git initialization\nspecify init my-project --ai gemini --no-git\n\n# Enable debug output for troubleshooting\nspecify init my-project --ai claude --debug\n\n# Use GitHub token for API requests (helpful for corporate environments)\nspecify init my-project --ai claude --github-token ghp_your_token_here\n\n# Install agent skills with the project\nspecify init my-project --ai claude --ai-skills\n\n# Initialize in current directory with agent skills\nspecify init --here --ai gemini --ai-skills\n\n# Check system requirements\nspecify check\n```\n\n### Available Slash Commands\n\nAfter running `specify init`, your AI coding agent will have access to these slash commands for structured development.\n\nFor Codex CLI, `--ai-skills` installs spec-kit as agent skills instead of slash-command prompt files. In Codex skills mode, invoke spec-kit as `$speckit-constitution`, `$speckit-specify`, `$speckit-plan`, `$speckit-tasks`, and `$speckit-implement`.\n\n#### Core Commands\n\nEssential commands for the Spec-Driven Development workflow:\n\n| Command                 | Description                                                              |\n| ----------------------- | ------------------------------------------------------------------------ |\n| `/speckit.constitution` | Create or update project governing principles and development guidelines |\n| `/speckit.specify`      | Define what you want to build (requirements and user stories)            |\n| `/speckit.plan`         | Create technical implementation plans with your chosen tech stack        |\n| `/speckit.tasks`        | Generate actionable task lists for implementation                        |\n| `/speckit.implement`    | Execute all tasks to build the feature according to the plan             |\n\n#### Optional Commands\n\nAdditional commands for enhanced quality and validation:\n\n| Command              | Description                                                                                                                          |\n| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |\n| `/speckit.clarify`   | Clarify underspecified areas (recommended before `/speckit.plan`; formerly `/quizme`)                                                |\n| `/speckit.analyze`   | Cross-artifact consistency & coverage analysis (run after `/speckit.tasks`, before `/speckit.implement`)                             |\n| `/speckit.checklist` | Generate custom quality checklists that validate requirements completeness, clarity, and consistency (like \"unit tests for English\") |\n\n### Environment Variables\n\n| Variable          | Description                                                                                                                                                                                                                                                                                            |\n| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| `SPECIFY_FEATURE` | Override feature detection for non-Git repositories. Set to the feature directory name (e.g., `001-photo-albums`) to work on a specific feature when not using Git branches.<br/>\\*\\*Must be set in the context of the agent you're working with prior to using `/speckit.plan` or follow-up commands. |\n\n## 🧩 Making Spec Kit Your Own: Extensions & Presets\n\nSpec Kit can be tailored to your needs through two complementary systems — **extensions** and **presets** — plus project-local overrides for one-off adjustments:\n\n```mermaid\nblock-beta\n    columns 1\n    overrides[\"⬆ Highest priority\\nProject-Local Overrides\\n.specify/templates/overrides/\"]\n    presets[\"Presets — Customize core & extensions\\n.specify/presets/<preset-id>/templates/\"]\n    extensions[\"Extensions — Add new capabilities\\n.specify/extensions/<ext-id>/templates/\"]\n    core[\"Spec Kit Core — Built-in SDD commands & templates\\n.specify/templates/\\n⬇ Lowest priority\"]\n\n    style overrides fill:transparent,stroke:#999\n    style presets fill:transparent,stroke:#4a9eda\n    style extensions fill:transparent,stroke:#4a9e4a\n    style core fill:transparent,stroke:#e6a817\n```\n\n**Templates** are resolved at **runtime** — Spec Kit walks the stack top-down and uses the first match. Project-local overrides (`.specify/templates/overrides/`) let you make one-off adjustments for a single project without creating a full preset. **Commands** are applied at **install time** — when you run `specify extension add` or `specify preset add`, command files are written into agent directories (e.g., `.claude/commands/`). If multiple presets or extensions provide the same command, the highest-priority version wins. On removal, the next-highest-priority version is restored automatically. If no overrides or customizations exist, Spec Kit uses its core defaults.\n\n### Extensions — Add New Capabilities\n\nUse **extensions** when you need functionality that goes beyond Spec Kit's core. Extensions introduce new commands and templates — for example, adding domain-specific workflows that are not covered by the built-in SDD commands, integrating with external tools, or adding entirely new development phases. They expand *what Spec Kit can do*.\n\n```bash\n# Search available extensions\nspecify extension search\n\n# Install an extension\nspecify extension add <extension-name>\n```\n\nFor example, extensions could add Jira integration, post-implementation code review, V-Model test traceability, or project health diagnostics.\n\nSee the [Extensions README](./extensions/README.md) for the full guide, the complete community catalog, and how to build and publish your own.\n\n### Presets — Customize Existing Workflows\n\nUse **presets** when you want to change *how* Spec Kit works without adding new capabilities. Presets override the templates and commands that ship with the core *and* with installed extensions — for example, enforcing a compliance-oriented spec format, using domain-specific terminology, or applying organizational standards to plans and tasks. They customize the artifacts and instructions that Spec Kit and its extensions produce.\n\n```bash\n# Search available presets\nspecify preset search\n\n# Install a preset\nspecify preset add <preset-name>\n```\n\nFor example, presets could restructure spec templates to require regulatory traceability, adapt the workflow to fit the methodology you use (e.g., Agile, Kanban, Waterfall, jobs-to-be-done, or domain-driven design), add mandatory security review gates to plans, enforce test-first task ordering, or localize the entire workflow to a different language. The [pirate-speak demo](https://github.com/mnriem/spec-kit-pirate-speak-preset-demo) shows just how deep the customization can go. Multiple presets can be stacked with priority ordering.\n\nSee the [Presets README](./presets/README.md) for the full guide, including resolution order, priority, and how to create your own.\n\n### When to Use Which\n\n| Goal | Use |\n| --- | --- |\n| Add a brand-new command or workflow | Extension |\n| Customize the format of specs, plans, or tasks | Preset |\n| Integrate an external tool or service | Extension |\n| Enforce organizational or regulatory standards | Preset |\n| Ship reusable domain-specific templates | Either — presets for template overrides, extensions for templates bundled with new commands |\n\n## 📚 Core Philosophy\n\nSpec-Driven Development is a structured process that emphasizes:\n\n- **Intent-driven development** where specifications define the \"*what*\" before the \"*how*\"\n- **Rich specification creation** using guardrails and organizational principles\n- **Multi-step refinement** rather than one-shot code generation from prompts\n- **Heavy reliance** on advanced AI model capabilities for specification interpretation\n\n## 🌟 Development Phases\n\n| Phase                                    | Focus                    | Key Activities                                                                                                                                                     |\n| ---------------------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| **0-to-1 Development** (\"Greenfield\")    | Generate from scratch    | <ul><li>Start with high-level requirements</li><li>Generate specifications</li><li>Plan implementation steps</li><li>Build production-ready applications</li></ul> |\n| **Creative Exploration**                 | Parallel implementations | <ul><li>Explore diverse solutions</li><li>Support multiple technology stacks & architectures</li><li>Experiment with UX patterns</li></ul>                         |\n| **Iterative Enhancement** (\"Brownfield\") | Brownfield modernization | <ul><li>Add features iteratively</li><li>Modernize legacy systems</li><li>Adapt processes</li></ul>                                                                |\n\n## 🎯 Experimental Goals\n\nOur research and experimentation focus on:\n\n### Technology independence\n\n- Create applications using diverse technology stacks\n- Validate the hypothesis that Spec-Driven Development is a process not tied to specific technologies, programming languages, or frameworks\n\n### Enterprise constraints\n\n- Demonstrate mission-critical application development\n- Incorporate organizational constraints (cloud providers, tech stacks, engineering practices)\n- Support enterprise design systems and compliance requirements\n\n### User-centric development\n\n- Build applications for different user cohorts and preferences\n- Support various development approaches (from vibe-coding to AI-native development)\n\n### Creative & iterative processes\n\n- Validate the concept of parallel implementation exploration\n- Provide robust iterative feature development workflows\n- Extend processes to handle upgrades and modernization tasks\n\n## 🔧 Prerequisites\n\n- **Linux/macOS/Windows**\n- [Supported](#-supported-ai-agents) AI coding agent.\n- [uv](https://docs.astral.sh/uv/) for package management\n- [Python 3.11+](https://www.python.org/downloads/)\n- [Git](https://git-scm.com/downloads)\n\nIf you encounter issues with an agent, please open an issue so we can refine the integration.\n\n## 📖 Learn More\n\n- **[Complete Spec-Driven Development Methodology](./spec-driven.md)** - Deep dive into the full process\n- **[Detailed Walkthrough](#-detailed-process)** - Step-by-step implementation guide\n\n---\n\n## 📋 Detailed Process\n\n<details>\n<summary>Click to expand the detailed step-by-step walkthrough</summary>\n\nYou can use the Specify CLI to bootstrap your project, which will bring in the required artifacts in your environment. Run:\n\n```bash\nspecify init <project_name>\n```\n\nOr initialize in the current directory:\n\n```bash\nspecify init .\n# or use the --here flag\nspecify init --here\n# Skip confirmation when the directory already has files\nspecify init . --force\n# or\nspecify init --here --force\n```\n\n![Specify CLI bootstrapping a new project in the terminal](./media/specify_cli.gif)\n\nYou will be prompted to select the AI agent you are using. You can also proactively specify it directly in the terminal:\n\n```bash\nspecify init <project_name> --ai claude\nspecify init <project_name> --ai gemini\nspecify init <project_name> --ai copilot\n\n# Or in current directory:\nspecify init . --ai claude\nspecify init . --ai codex --ai-skills\n\n# or use --here flag\nspecify init --here --ai claude\nspecify init --here --ai codex --ai-skills\n\n# Force merge into a non-empty current directory\nspecify init . --force --ai claude\n\n# or\nspecify init --here --force --ai claude\n```\n\nThe CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi, or Mistral Vibe installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command:\n\n```bash\nspecify init <project_name> --ai claude --ignore-agent-tools\n```\n\n### **STEP 1:** Establish project principles\n\nGo to the project folder and run your AI agent. In our example, we're using `claude`.\n\n![Bootstrapping Claude Code environment](./media/bootstrap-claude-code.gif)\n\nYou will know that things are configured correctly if you see the `/speckit.constitution`, `/speckit.specify`, `/speckit.plan`, `/speckit.tasks`, and `/speckit.implement` commands available.\n\nThe first step should be establishing your project's governing principles using the `/speckit.constitution` command. This helps ensure consistent decision-making throughout all subsequent development phases:\n\n```text\n/speckit.constitution Create principles focused on code quality, testing standards, user experience consistency, and performance requirements. Include governance for how these principles should guide technical decisions and implementation choices.\n```\n\nThis step creates or updates the `.specify/memory/constitution.md` file with your project's foundational guidelines that the AI agent will reference during specification, planning, and implementation phases.\n\n### **STEP 2:** Create project specifications\n\nWith your project principles established, you can now create the functional specifications. Use the `/speckit.specify` command and then provide the concrete requirements for the project you want to develop.\n\n> [!IMPORTANT]\n> Be as explicit as possible about *what* you are trying to build and *why*. **Do not focus on the tech stack at this point**.\n\nAn example prompt:\n\n```text\nDevelop Taskify, a team productivity platform. It should allow users to create projects, add team members,\nassign tasks, comment and move tasks between boards in Kanban style. In this initial phase for this feature,\nlet's call it \"Create Taskify,\" let's have multiple users but the users will be declared ahead of time, predefined.\nI want five users in two different categories, one product manager and four engineers. Let's create three\ndifferent sample projects. Let's have the standard Kanban columns for the status of each task, such as \"To Do,\"\n\"In Progress,\" \"In Review,\" and \"Done.\" There will be no login for this application as this is just the very\nfirst testing thing to ensure that our basic features are set up. For each task in the UI for a task card,\nyou should be able to change the current status of the task between the different columns in the Kanban work board.\nYou should be able to leave an unlimited number of comments for a particular card. You should be able to, from that task\ncard, assign one of the valid users. When you first launch Taskify, it's going to give you a list of the five users to pick\nfrom. There will be no password required. When you click on a user, you go into the main view, which displays the list of\nprojects. When you click on a project, you open the Kanban board for that project. You're going to see the columns.\nYou'll be able to drag and drop cards back and forth between different columns. You will see any cards that are\nassigned to you, the currently logged in user, in a different color from all the other ones, so you can quickly\nsee yours. You can edit any comments that you make, but you can't edit comments that other people made. You can\ndelete any comments that you made, but you can't delete comments anybody else made.\n```\n\nAfter this prompt is entered, you should see Claude Code kick off the planning and spec drafting process. Claude Code will also trigger some of the built-in scripts to set up the repository.\n\nOnce this step is completed, you should have a new branch created (e.g., `001-create-taskify`), as well as a new specification in the `specs/001-create-taskify` directory.\n\nThe produced specification should contain a set of user stories and functional requirements, as defined in the template.\n\nAt this stage, your project folder contents should resemble the following:\n\n```text\n└── .specify\n    ├── memory\n    │  └── constitution.md\n    ├── scripts\n    │  ├── check-prerequisites.sh\n    │  ├── common.sh\n    │  ├── create-new-feature.sh\n    │  ├── setup-plan.sh\n    │  └── update-claude-md.sh\n    ├── specs\n    │  └── 001-create-taskify\n    │      └── spec.md\n    └── templates\n        ├── plan-template.md\n        ├── spec-template.md\n        └── tasks-template.md\n```\n\n### **STEP 3:** Functional specification clarification (required before planning)\n\nWith the baseline specification created, you can go ahead and clarify any of the requirements that were not captured properly within the first shot attempt.\n\nYou should run the structured clarification workflow **before** creating a technical plan to reduce rework downstream.\n\nPreferred order:\n\n1. Use `/speckit.clarify` (structured) – sequential, coverage-based questioning that records answers in a Clarifications section.\n2. Optionally follow up with ad-hoc free-form refinement if something still feels vague.\n\nIf you intentionally want to skip clarification (e.g., spike or exploratory prototype), explicitly state that so the agent doesn't block on missing clarifications.\n\nExample free-form refinement prompt (after `/speckit.clarify` if still needed):\n\n```text\nFor each sample project or project that you create there should be a variable number of tasks between 5 and 15\ntasks for each one randomly distributed into different states of completion. Make sure that there's at least\none task in each stage of completion.\n```\n\nYou should also ask Claude Code to validate the **Review & Acceptance Checklist**, checking off the things that are validated/pass the requirements, and leave the ones that are not unchecked. The following prompt can be used:\n\n```text\nRead the review and acceptance checklist, and check off each item in the checklist if the feature spec meets the criteria. Leave it empty if it does not.\n```\n\nIt's important to use the interaction with Claude Code as an opportunity to clarify and ask questions around the specification - **do not treat its first attempt as final**.\n\n### **STEP 4:** Generate a plan\n\nYou can now be specific about the tech stack and other technical requirements. You can use the `/speckit.plan` command that is built into the project template with a prompt like this:\n\n```text\nWe are going to generate this using .NET Aspire, using Postgres as the database. The frontend should use\nBlazor server with drag-and-drop task boards, real-time updates. There should be a REST API created with a projects API,\ntasks API, and a notifications API.\n```\n\nThe output of this step will include a number of implementation detail documents, with your directory tree resembling this:\n\n```text\n.\n├── CLAUDE.md\n├── memory\n│  └── constitution.md\n├── scripts\n│  ├── check-prerequisites.sh\n│  ├── common.sh\n│  ├── create-new-feature.sh\n│  ├── setup-plan.sh\n│  └── update-claude-md.sh\n├── specs\n│  └── 001-create-taskify\n│      ├── contracts\n│      │  ├── api-spec.json\n│      │  └── signalr-spec.md\n│      ├── data-model.md\n│      ├── plan.md\n│      ├── quickstart.md\n│      ├── research.md\n│      └── spec.md\n└── templates\n    ├── CLAUDE-template.md\n    ├── plan-template.md\n    ├── spec-template.md\n    └── tasks-template.md\n```\n\nCheck the `research.md` document to ensure that the right tech stack is used, based on your instructions. You can ask Claude Code to refine it if any of the components stand out, or even have it check the locally-installed version of the platform/framework you want to use (e.g., .NET).\n\nAdditionally, you might want to ask Claude Code to research details about the chosen tech stack if it's something that is rapidly changing (e.g., .NET Aspire, JS frameworks), with a prompt like this:\n\n```text\nI want you to go through the implementation plan and implementation details, looking for areas that could\nbenefit from additional research as .NET Aspire is a rapidly changing library. For those areas that you identify that\nrequire further research, I want you to update the research document with additional details about the specific\nversions that we are going to be using in this Taskify application and spawn parallel research tasks to clarify\nany details using research from the web.\n```\n\nDuring this process, you might find that Claude Code gets stuck researching the wrong thing - you can help nudge it in the right direction with a prompt like this:\n\n```text\nI think we need to break this down into a series of steps. First, identify a list of tasks\nthat you would need to do during implementation that you're not sure of or would benefit\nfrom further research. Write down a list of those tasks. And then for each one of these tasks,\nI want you to spin up a separate research task so that the net results is we are researching\nall of those very specific tasks in parallel. What I saw you doing was it looks like you were\nresearching .NET Aspire in general and I don't think that's gonna do much for us in this case.\nThat's way too untargeted research. The research needs to help you solve a specific targeted question.\n```\n\n> [!NOTE]\n> Claude Code might be over-eager and add components that you did not ask for. Ask it to clarify the rationale and the source of the change.\n\n### **STEP 5:** Have Claude Code validate the plan\n\nWith the plan in place, you should have Claude Code run through it to make sure that there are no missing pieces. You can use a prompt like this:\n\n```text\nNow I want you to go and audit the implementation plan and the implementation detail files.\nRead through it with an eye on determining whether or not there is a sequence of tasks that you need\nto be doing that are obvious from reading this. Because I don't know if there's enough here. For example,\nwhen I look at the core implementation, it would be useful to reference the appropriate places in the implementation\ndetails where it can find the information as it walks through each step in the core implementation or in the refinement.\n```\n\nThis helps refine the implementation plan and helps you avoid potential blind spots that Claude Code missed in its planning cycle. Once the initial refinement pass is complete, ask Claude Code to go through the checklist once more before you can get to the implementation.\n\nYou can also ask Claude Code (if you have the [GitHub CLI](https://docs.github.com/en/github-cli/github-cli) installed) to go ahead and create a pull request from your current branch to `main` with a detailed description, to make sure that the effort is properly tracked.\n\n> [!NOTE]\n> Before you have the agent implement it, it's also worth prompting Claude Code to cross-check the details to see if there are any over-engineered pieces (remember - it can be over-eager). If over-engineered components or decisions exist, you can ask Claude Code to resolve them. Ensure that Claude Code follows the [constitution](base/memory/constitution.md) as the foundational piece that it must adhere to when establishing the plan.\n\n### **STEP 6:** Generate task breakdown with /speckit.tasks\n\nWith the implementation plan validated, you can now break down the plan into specific, actionable tasks that can be executed in the correct order. Use the `/speckit.tasks` command to automatically generate a detailed task breakdown from your implementation plan:\n\n```text\n/speckit.tasks\n```\n\nThis step creates a `tasks.md` file in your feature specification directory that contains:\n\n- **Task breakdown organized by user story** - Each user story becomes a separate implementation phase with its own set of tasks\n- **Dependency management** - Tasks are ordered to respect dependencies between components (e.g., models before services, services before endpoints)\n- **Parallel execution markers** - Tasks that can run in parallel are marked with `[P]` to optimize development workflow\n- **File path specifications** - Each task includes the exact file paths where implementation should occur\n- **Test-driven development structure** - If tests are requested, test tasks are included and ordered to be written before implementation\n- **Checkpoint validation** - Each user story phase includes checkpoints to validate independent functionality\n\nThe generated tasks.md provides a clear roadmap for the `/speckit.implement` command, ensuring systematic implementation that maintains code quality and allows for incremental delivery of user stories.\n\n### **STEP 7:** Implementation\n\nOnce ready, use the `/speckit.implement` command to execute your implementation plan:\n\n```text\n/speckit.implement\n```\n\nThe `/speckit.implement` command will:\n\n- Validate that all prerequisites are in place (constitution, spec, plan, and tasks)\n- Parse the task breakdown from `tasks.md`\n- Execute tasks in the correct order, respecting dependencies and parallel execution markers\n- Follow the TDD approach defined in your task plan\n- Provide progress updates and handle errors appropriately\n\n> [!IMPORTANT]\n> The AI agent will execute local CLI commands (such as `dotnet`, `npm`, etc.) - make sure you have the required tools installed on your machine.\n\nOnce the implementation is complete, test the application and resolve any runtime errors that may not be visible in CLI logs (e.g., browser console errors). You can copy and paste such errors back to your AI agent for resolution.\n\n</details>\n\n---\n\n## 🔍 Troubleshooting\n\n### Git Credential Manager on Linux\n\nIf you're having issues with Git authentication on Linux, you can install Git Credential Manager:\n\n```bash\n#!/usr/bin/env bash\nset -e\necho \"Downloading Git Credential Manager v2.6.1...\"\nwget https://github.com/git-ecosystem/git-credential-manager/releases/download/v2.6.1/gcm-linux_amd64.2.6.1.deb\necho \"Installing Git Credential Manager...\"\nsudo dpkg -i gcm-linux_amd64.2.6.1.deb\necho \"Configuring Git to use GCM...\"\ngit config --global credential.helper manager\necho \"Cleaning up...\"\nrm gcm-linux_amd64.2.6.1.deb\n```\n\n## 💬 Support\n\nFor support, please open a [GitHub issue](https://github.com/github/spec-kit/issues/new). We welcome bug reports, feature requests, and questions about using Spec-Driven Development.\n\n## 🙏 Acknowledgements\n\nThis project is heavily influenced by and based on the work and research of [John Lam](https://github.com/jflam).\n\n## 📄 License\n\nThis project is licensed under the terms of the MIT open source license. Please refer to the [LICENSE](./LICENSE) file for the full terms.\n"
  },
  {
    "path": "SECURITY.md",
    "content": "# Security Policy\n\nThanks for helping make GitHub safe for everyone.\n\nGitHub takes the security of our software products and services seriously, including all of the open source code repositories managed through our GitHub organizations, such as [GitHub](https://github.com/GitHub).\n\nEven though [open source repositories are outside of the scope of our bug bounty program](https://bounty.github.com/index.html#scope) and therefore not eligible for bounty rewards, we will ensure that your finding gets passed along to the appropriate maintainers for remediation.\n\n## Reporting Security Issues\n\nIf you believe you have found a security vulnerability in any GitHub-owned repository, please report it to us through coordinated disclosure.\n\n**Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.**\n\nInstead, please send an email to opensource-security[@]github.com.\n\nPlease include as much of the information listed below as you can to help us better understand and resolve the issue:\n\n- The type of issue (e.g., buffer overflow, SQL injection, or cross-site scripting)\n- Full paths of source file(s) related to the manifestation of the issue\n- The location of the affected source code (tag/branch/commit or direct URL)\n- Any special configuration required to reproduce the issue\n- Step-by-step instructions to reproduce the issue\n- Proof-of-concept or exploit code (if possible)\n- Impact of the issue, including how an attacker might exploit the issue\n\nThis information will help us triage your report more quickly.\n\n## Policy\n\nSee [GitHub's Safe Harbor Policy](https://docs.github.com/en/site-policy/security-policies/github-bug-bounty-program-legal-safe-harbor#1-safe-harbor-terms)\n"
  },
  {
    "path": "SUPPORT.md",
    "content": "# Support\n\n## How to get help\n\nPlease search existing [issues](https://github.com/github/spec-kit/issues) and [discussions](https://github.com/github/spec-kit/discussions) before creating new ones to avoid duplicates.\n\n- Review the [README](./README.md) for getting started instructions and troubleshooting tips\n- Check the [comprehensive guide](./spec-driven.md) for detailed documentation on the Spec-Driven Development process\n- Ask in [GitHub Discussions](https://github.com/github/spec-kit/discussions) for questions about using Spec Kit or the Spec-Driven Development methodology\n- Open a [GitHub issue](https://github.com/github/spec-kit/issues/new) for bug reports and feature requests\n\n## Project Status\n\n**Spec Kit** is under active development and maintained by GitHub staff and the community. We will do our best to respond to support, feature requests, and community questions as time permits.\n\n## GitHub Support Policy\n\nSupport for this project is limited to the resources listed above.\n"
  },
  {
    "path": "docs/.gitignore",
    "content": "# DocFX build output\n_site/\nobj/\n.docfx/\n\n# Temporary files\n*.tmp\n*.log\n\n"
  },
  {
    "path": "docs/README.md",
    "content": "# Documentation\n\nThis folder contains the documentation source files for Spec Kit, built using [DocFX](https://dotnet.github.io/docfx/).\n\n## Building Locally\n\nTo build the documentation locally:\n\n1. Install DocFX:\n\n   ```bash\n   dotnet tool install -g docfx\n   ```\n\n2. Build the documentation:\n\n   ```bash\n   cd docs\n   docfx docfx.json --serve\n   ```\n\n3. Open your browser to `http://localhost:8080` to view the documentation.\n\n## Structure\n\n- `docfx.json` - DocFX configuration file\n- `index.md` - Main documentation homepage\n- `toc.yml` - Table of contents configuration\n- `installation.md` - Installation guide\n- `quickstart.md` - Quick start guide\n- `_site/` - Generated documentation output (ignored by git)\n\n## Deployment\n\nDocumentation is automatically built and deployed to GitHub Pages when changes are pushed to the `main` branch. The workflow is defined in `.github/workflows/docs.yml`.\n"
  },
  {
    "path": "docs/docfx.json",
    "content": "{\n  \"build\": {\n    \"content\": [\n      {\n        \"files\": [\n          \"*.md\",\n          \"toc.yml\"\n        ]\n      },\n      {\n        \"files\": [\n          \"../README.md\",\n          \"../CONTRIBUTING.md\",\n          \"../CODE_OF_CONDUCT.md\",\n          \"../SECURITY.md\",\n          \"../SUPPORT.md\"\n        ],\n        \"dest\": \".\"\n      }\n    ],\n    \"resource\": [\n      {\n        \"files\": [\n          \"images/**\"\n        ]\n      },\n      {\n        \"files\": [\n          \"../media/**\"\n        ],\n        \"dest\": \"media\"\n      }\n    ],\n    \"overwrite\": [\n      {\n        \"files\": [\n          \"apidoc/**.md\"\n        ],\n        \"exclude\": [\n          \"obj/**\",\n          \"_site/**\"\n        ]\n      }\n    ],\n    \"dest\": \"_site\",\n    \"globalMetadataFiles\": [],\n    \"fileMetadataFiles\": [],\n    \"template\": [\n      \"default\",\n      \"modern\"\n    ],\n    \"postProcessors\": [],\n    \"markdownEngineName\": \"markdig\",\n    \"noLangKeyword\": false,\n    \"keepFileLink\": false,\n    \"cleanupCacheHistory\": false,\n    \"disableGitFeatures\": false,\n    \"globalMetadata\": {\n      \"_appTitle\": \"Spec Kit Documentation\",\n      \"_appName\": \"Spec Kit\",\n      \"_appFooter\": \"Spec Kit - A specification-driven development toolkit\",\n      \"_enableSearch\": true,\n      \"_disableContribution\": false,\n      \"_gitContribute\": {\n        \"repo\": \"https://github.com/github/spec-kit\",\n        \"branch\": \"main\"\n      }\n    }\n  }\n}\n\n"
  },
  {
    "path": "docs/index.md",
    "content": "# Spec Kit\n\n*Build high-quality software faster.*\n\n**An effort to allow organizations to focus on product scenarios rather than writing undifferentiated code with the help of Spec-Driven Development.**\n\n## What is Spec-Driven Development?\n\nSpec-Driven Development **flips the script** on traditional software development. For decades, code has been king — specifications were just scaffolding we built and discarded once the \"real work\" of coding began. Spec-Driven Development changes this: **specifications become executable**, directly generating working implementations rather than just guiding them.\n\n## Getting Started\n\n- [Installation Guide](installation.md)\n- [Quick Start Guide](quickstart.md)\n- [Upgrade Guide](upgrade.md)\n- [Local Development](local-development.md)\n\n## Core Philosophy\n\nSpec-Driven Development is a structured process that emphasizes:\n\n- **Intent-driven development** where specifications define the \"*what*\" before the \"*how*\"\n- **Rich specification creation** using guardrails and organizational principles\n- **Multi-step refinement** rather than one-shot code generation from prompts\n- **Heavy reliance** on advanced AI model capabilities for specification interpretation\n\n## Development Phases\n\n| Phase | Focus | Key Activities |\n|-------|-------|----------------|\n| **0-to-1 Development** (\"Greenfield\") | Generate from scratch | <ul><li>Start with high-level requirements</li><li>Generate specifications</li><li>Plan implementation steps</li><li>Build production-ready applications</li></ul> |\n| **Creative Exploration** | Parallel implementations | <ul><li>Explore diverse solutions</li><li>Support multiple technology stacks & architectures</li><li>Experiment with UX patterns</li></ul> |\n| **Iterative Enhancement** (\"Brownfield\") | Brownfield modernization | <ul><li>Add features iteratively</li><li>Modernize legacy systems</li><li>Adapt processes</li></ul> |\n\n## Experimental Goals\n\nOur research and experimentation focus on:\n\n### Technology Independence\n\n- Create applications using diverse technology stacks\n- Validate the hypothesis that Spec-Driven Development is a process not tied to specific technologies, programming languages, or frameworks\n\n### Enterprise Constraints\n\n- Demonstrate mission-critical application development\n- Incorporate organizational constraints (cloud providers, tech stacks, engineering practices)\n- Support enterprise design systems and compliance requirements\n\n### User-Centric Development\n\n- Build applications for different user cohorts and preferences\n- Support various development approaches (from vibe-coding to AI-native development)\n\n### Creative & Iterative Processes\n\n- Validate the concept of parallel implementation exploration\n- Provide robust iterative feature development workflows\n- Extend processes to handle upgrades and modernization tasks\n\n## Contributing\n\nPlease see our [Contributing Guide](https://github.com/github/spec-kit/blob/main/CONTRIBUTING.md) for information on how to contribute to this project.\n\n## Support\n\nFor support, please check our [Support Guide](https://github.com/github/spec-kit/blob/main/SUPPORT.md) or open an issue on GitHub.\n"
  },
  {
    "path": "docs/installation.md",
    "content": "# Installation Guide\n\n## Prerequisites\n\n- **Linux/macOS** (or Windows; PowerShell scripts now supported without WSL)\n- AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli), [Gemini CLI](https://github.com/google-gemini/gemini-cli), or [Pi Coding Agent](https://pi.dev)\n- [uv](https://docs.astral.sh/uv/) for package management\n- [Python 3.11+](https://www.python.org/downloads/)\n- [Git](https://git-scm.com/downloads)\n\n## Installation\n\n### Initialize a New Project\n\nThe easiest way to get started is to initialize a new project:\n\n```bash\nuvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME>\n```\n\nOr initialize in the current directory:\n\n```bash\nuvx --from git+https://github.com/github/spec-kit.git specify init .\n# or use the --here flag\nuvx --from git+https://github.com/github/spec-kit.git specify init --here\n```\n\n### Specify AI Agent\n\nYou can proactively specify your AI agent during initialization:\n\n```bash\nuvx --from git+https://github.com/github/spec-kit.git specify init <project_name> --ai claude\nuvx --from git+https://github.com/github/spec-kit.git specify init <project_name> --ai gemini\nuvx --from git+https://github.com/github/spec-kit.git specify init <project_name> --ai copilot\nuvx --from git+https://github.com/github/spec-kit.git specify init <project_name> --ai codebuddy\nuvx --from git+https://github.com/github/spec-kit.git specify init <project_name> --ai pi\n```\n\n### Specify Script Type (Shell vs PowerShell)\n\nAll automation scripts now have both Bash (`.sh`) and PowerShell (`.ps1`) variants.\n\nAuto behavior:\n\n- Windows default: `ps`\n- Other OS default: `sh`\n- Interactive mode: you'll be prompted unless you pass `--script`\n\nForce a specific script type:\n\n```bash\nuvx --from git+https://github.com/github/spec-kit.git specify init <project_name> --script sh\nuvx --from git+https://github.com/github/spec-kit.git specify init <project_name> --script ps\n```\n\n### Ignore Agent Tools Check\n\nIf you prefer to get the templates without checking for the right tools:\n\n```bash\nuvx --from git+https://github.com/github/spec-kit.git specify init <project_name> --ai claude --ignore-agent-tools\n```\n\n## Verification\n\nAfter initialization, you should see the following commands available in your AI agent:\n\n- `/speckit.specify` - Create specifications\n- `/speckit.plan` - Generate implementation plans  \n- `/speckit.tasks` - Break down into actionable tasks\n\nThe `.specify/scripts` directory will contain both `.sh` and `.ps1` scripts.\n\n## Troubleshooting\n\n### Git Credential Manager on Linux\n\nIf you're having issues with Git authentication on Linux, you can install Git Credential Manager:\n\n```bash\n#!/usr/bin/env bash\nset -e\necho \"Downloading Git Credential Manager v2.6.1...\"\nwget https://github.com/git-ecosystem/git-credential-manager/releases/download/v2.6.1/gcm-linux_amd64.2.6.1.deb\necho \"Installing Git Credential Manager...\"\nsudo dpkg -i gcm-linux_amd64.2.6.1.deb\necho \"Configuring Git to use GCM...\"\ngit config --global credential.helper manager\necho \"Cleaning up...\"\nrm gcm-linux_amd64.2.6.1.deb\n```\n"
  },
  {
    "path": "docs/local-development.md",
    "content": "# Local Development Guide\n\nThis guide shows how to iterate on the `specify` CLI locally without publishing a release or committing to `main` first.\n\n> Scripts now have both Bash (`.sh`) and PowerShell (`.ps1`) variants. The CLI auto-selects based on OS unless you pass `--script sh|ps`.\n\n## 1. Clone and Switch Branches\n\n```bash\ngit clone https://github.com/github/spec-kit.git\ncd spec-kit\n# Work on a feature branch\ngit checkout -b your-feature-branch\n```\n\n## 2. Run the CLI Directly (Fastest Feedback)\n\nYou can execute the CLI via the module entrypoint without installing anything:\n\n```bash\n# From repo root\npython -m src.specify_cli --help\npython -m src.specify_cli init demo-project --ai claude --ignore-agent-tools --script sh\n```\n\nIf you prefer invoking the script file style (uses shebang):\n\n```bash\npython src/specify_cli/__init__.py init demo-project --script ps\n```\n\n## 3. Use Editable Install (Isolated Environment)\n\nCreate an isolated environment using `uv` so dependencies resolve exactly like end users get them:\n\n```bash\n# Create & activate virtual env (uv auto-manages .venv)\nuv venv\nsource .venv/bin/activate  # or on Windows PowerShell: .venv\\Scripts\\Activate.ps1\n\n# Install project in editable mode\nuv pip install -e .\n\n# Now 'specify' entrypoint is available\nspecify --help\n```\n\nRe-running after code edits requires no reinstall because of editable mode.\n\n## 4. Invoke with uvx Directly From Git (Current Branch)\n\n`uvx` can run from a local path (or a Git ref) to simulate user flows:\n\n```bash\nuvx --from . specify init demo-uvx --ai copilot --ignore-agent-tools --script sh\n```\n\nYou can also point uvx at a specific branch without merging:\n\n```bash\n# Push your working branch first\ngit push origin your-feature-branch\nuvx --from git+https://github.com/github/spec-kit.git@your-feature-branch specify init demo-branch-test --script ps\n```\n\n### 4a. Absolute Path uvx (Run From Anywhere)\n\nIf you're in another directory, use an absolute path instead of `.`:\n\n```bash\nuvx --from /mnt/c/GitHub/spec-kit specify --help\nuvx --from /mnt/c/GitHub/spec-kit specify init demo-anywhere --ai copilot --ignore-agent-tools --script sh\n```\n\nSet an environment variable for convenience:\n\n```bash\nexport SPEC_KIT_SRC=/mnt/c/GitHub/spec-kit\nuvx --from \"$SPEC_KIT_SRC\" specify init demo-env --ai copilot --ignore-agent-tools --script ps\n```\n\n(Optional) Define a shell function:\n\n```bash\nspecify-dev() { uvx --from /mnt/c/GitHub/spec-kit specify \"$@\"; }\n# Then\nspecify-dev --help\n```\n\n## 5. Testing Script Permission Logic\n\nAfter running an `init`, check that shell scripts are executable on POSIX systems:\n\n```bash\nls -l scripts | grep .sh\n# Expect owner execute bit (e.g. -rwxr-xr-x)\n```\n\nOn Windows you will instead use the `.ps1` scripts (no chmod needed).\n\n## 6. Run Lint / Basic Checks (Add Your Own)\n\nCurrently no enforced lint config is bundled, but you can quickly sanity check importability:\n\n```bash\npython -c \"import specify_cli; print('Import OK')\"\n```\n\n## 7. Build a Wheel Locally (Optional)\n\nValidate packaging before publishing:\n\n```bash\nuv build\nls dist/\n```\n\nInstall the built artifact into a fresh throwaway environment if needed.\n\n## 8. Using a Temporary Workspace\n\nWhen testing `init --here` in a dirty directory, create a temp workspace:\n\n```bash\nmkdir /tmp/spec-test && cd /tmp/spec-test\npython -m src.specify_cli init --here --ai claude --ignore-agent-tools --script sh  # if repo copied here\n```\n\nOr copy only the modified CLI portion if you want a lighter sandbox.\n\n## 9. Debug Network / TLS Skips\n\nIf you need to bypass TLS validation while experimenting:\n\n```bash\nspecify check --skip-tls\nspecify init demo --skip-tls --ai gemini --ignore-agent-tools --script ps\n```\n\n(Use only for local experimentation.)\n\n## 10. Rapid Edit Loop Summary\n\n| Action | Command |\n|--------|---------|\n| Run CLI directly | `python -m src.specify_cli --help` |\n| Editable install | `uv pip install -e .` then `specify ...` |\n| Local uvx run (repo root) | `uvx --from . specify ...` |\n| Local uvx run (abs path) | `uvx --from /mnt/c/GitHub/spec-kit specify ...` |\n| Git branch uvx | `uvx --from git+URL@branch specify ...` |\n| Build wheel | `uv build` |\n\n## 11. Cleaning Up\n\nRemove build artifacts / virtual env quickly:\n\n```bash\nrm -rf .venv dist build *.egg-info\n```\n\n## 12. Common Issues\n\n| Symptom | Fix |\n|---------|-----|\n| `ModuleNotFoundError: typer` | Run `uv pip install -e .` |\n| Scripts not executable (Linux) | Re-run init or `chmod +x scripts/*.sh` |\n| Git step skipped | You passed `--no-git` or Git not installed |\n| Wrong script type downloaded | Pass `--script sh` or `--script ps` explicitly |\n| TLS errors on corporate network | Try `--skip-tls` (not for production) |\n\n## 13. Next Steps\n\n- Update docs and run through Quick Start using your modified CLI\n- Open a PR when satisfied\n- (Optional) Tag a release once changes land in `main`\n"
  },
  {
    "path": "docs/quickstart.md",
    "content": "# Quick Start Guide\n\nThis guide will help you get started with Spec-Driven Development using Spec Kit.\n\n> [!NOTE]\n> All automation scripts now provide both Bash (`.sh`) and PowerShell (`.ps1`) variants. The `specify` CLI auto-selects based on OS unless you pass `--script sh|ps`.\n\n## The 6-Step Process\n\n> [!TIP]\n> **Context Awareness**: Spec Kit commands automatically detect the active feature based on your current Git branch (e.g., `001-feature-name`). To switch between different specifications, simply switch Git branches.\n\n### Step 1: Install Specify\n\n**In your terminal**, run the `specify` CLI command to initialize your project:\n\n```bash\n# Create a new project directory\nuvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME>\n\n# OR initialize in the current directory\nuvx --from git+https://github.com/github/spec-kit.git specify init .\n```\n\nPick script type explicitly (optional):\n\n```bash\nuvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME> --script ps  # Force PowerShell\nuvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME> --script sh  # Force POSIX shell\n```\n\n### Step 2: Define Your Constitution\n\n**In your AI Agent's chat interface**, use the `/speckit.constitution` slash command to establish the core rules and principles for your project. You should provide your project's specific principles as arguments.\n\n```markdown\n/speckit.constitution This project follows a \"Library-First\" approach. All features must be implemented as standalone libraries first. We use TDD strictly. We prefer functional programming patterns.\n```\n\n### Step 3: Create the Spec\n\n**In the chat**, use the `/speckit.specify` slash command to describe what you want to build. Focus on the **what** and **why**, not the tech stack.\n\n```markdown\n/speckit.specify Build an application that can help me organize my photos in separate photo albums. Albums are grouped by date and can be re-organized by dragging and dropping on the main page. Albums are never in other nested albums. Within each album, photos are previewed in a tile-like interface.\n```\n\n### Step 4: Refine the Spec\n\n**In the chat**, use the `/speckit.clarify` slash command to identify and resolve ambiguities in your specification. You can provide specific focus areas as arguments.\n\n```bash\n/speckit.clarify Focus on security and performance requirements.\n```\n\n### Step 5: Create a Technical Implementation Plan\n\n**In the chat**, use the `/speckit.plan` slash command to provide your tech stack and architecture choices.\n\n```markdown\n/speckit.plan The application uses Vite with minimal number of libraries. Use vanilla HTML, CSS, and JavaScript as much as possible. Images are not uploaded anywhere and metadata is stored in a local SQLite database.\n```\n\n### Step 6: Break Down and Implement\n\n**In the chat**, use the `/speckit.tasks` slash command to create an actionable task list.\n\n```markdown\n/speckit.tasks\n```\n\nOptionally, validate the plan with `/speckit.analyze`:\n\n```markdown\n/speckit.analyze\n```\n\nThen, use the `/speckit.implement` slash command to execute the plan.\n\n```markdown\n/speckit.implement\n```\n\n> [!TIP]\n> **Phased Implementation**: For complex projects, implement in phases to avoid overwhelming the agent's context. Start with core functionality, validate it works, then add features incrementally.\n\n## Detailed Example: Building Taskify\n\nHere's a complete example of building a team productivity platform:\n\n### Step 1: Define Constitution\n\nInitialize the project's constitution to set ground rules:\n\n```markdown\n/speckit.constitution Taskify is a \"Security-First\" application. All user inputs must be validated. We use a microservices architecture. Code must be fully documented.\n```\n\n### Step 2: Define Requirements with `/speckit.specify`\n\n```text\nDevelop Taskify, a team productivity platform. It should allow users to create projects, add team members,\nassign tasks, comment and move tasks between boards in Kanban style. In this initial phase for this feature,\nlet's call it \"Create Taskify,\" let's have multiple users but the users will be declared ahead of time, predefined.\nI want five users in two different categories, one product manager and four engineers. Let's create three\ndifferent sample projects. Let's have the standard Kanban columns for the status of each task, such as \"To Do,\"\n\"In Progress,\" \"In Review,\" and \"Done.\" There will be no login for this application as this is just the very\nfirst testing thing to ensure that our basic features are set up.\n```\n\n### Step 3: Refine the Specification\n\nUse the `/speckit.clarify` command to interactively resolve any ambiguities in your specification. You can also provide specific details you want to ensure are included.\n\n```bash\n/speckit.clarify I want to clarify the task card details. For each task in the UI for a task card, you should be able to change the current status of the task between the different columns in the Kanban work board. You should be able to leave an unlimited number of comments for a particular card. You should be able to, from that task card, assign one of the valid users.\n```\n\nYou can continue to refine the spec with more details using `/speckit.clarify`:\n\n```bash\n/speckit.clarify When you first launch Taskify, it's going to give you a list of the five users to pick from. There will be no password required. When you click on a user, you go into the main view, which displays the list of projects. When you click on a project, you open the Kanban board for that project. You're going to see the columns. You'll be able to drag and drop cards back and forth between different columns. You will see any cards that are assigned to you, the currently logged in user, in a different color from all the other ones, so you can quickly see yours. You can edit any comments that you make, but you can't edit comments that other people made. You can delete any comments that you made, but you can't delete comments anybody else made.\n```\n\n### Step 4: Validate the Spec\n\nValidate the specification checklist using the `/speckit.checklist` command:\n\n```bash\n/speckit.checklist\n```\n\n### Step 5: Generate Technical Plan with `/speckit.plan`\n\nBe specific about your tech stack and technical requirements:\n\n```bash\n/speckit.plan We are going to generate this using .NET Aspire, using Postgres as the database. The frontend should use Blazor server with drag-and-drop task boards, real-time updates. There should be a REST API created with a projects API, tasks API, and a notifications API.\n```\n\n### Step 6: Define Tasks\n\nGenerate an actionable task list using the `/speckit.tasks` command:\n\n```bash\n/speckit.tasks\n```\n\n### Step 7: Validate and Implement\n\nHave your AI agent audit the implementation plan using `/speckit.analyze`:\n\n```bash\n/speckit.analyze\n```\n\nFinally, implement the solution:\n\n```bash\n/speckit.implement\n```\n\n> [!TIP]\n> **Phased Implementation**: For large projects like Taskify, consider implementing in phases (e.g., Phase 1: Basic project/task structure, Phase 2: Kanban functionality, Phase 3: Comments and assignments). This prevents context saturation and allows for validation at each stage.\n\n## Key Principles\n\n- **Be explicit** about what you're building and why\n- **Don't focus on tech stack** during specification phase\n- **Iterate and refine** your specifications before implementation\n- **Validate** the plan before coding begins\n- **Let the AI agent handle** the implementation details\n\n## Next Steps\n\n- Read the [complete methodology](https://github.com/github/spec-kit/blob/main/spec-driven.md) for in-depth guidance\n- Check out [more examples](https://github.com/github/spec-kit/tree/main/templates) in the repository\n- Explore the [source code on GitHub](https://github.com/github/spec-kit)\n"
  },
  {
    "path": "docs/toc.yml",
    "content": "# Home page\n- name: Home\n  href: index.md\n\n# Getting started section\n- name: Getting Started\n  items:\n    - name: Installation\n      href: installation.md\n    - name: Quick Start\n      href: quickstart.md\n    - name: Upgrade\n      href: upgrade.md\n\n# Development workflows\n- name: Development\n  items:\n    - name: Local Development\n      href: local-development.md\n"
  },
  {
    "path": "docs/upgrade.md",
    "content": "# Upgrade Guide\n\n> You have Spec Kit installed and want to upgrade to the latest version to get new features, bug fixes, or updated slash commands. This guide covers both upgrading the CLI tool and updating your project files.\n\n---\n\n## Quick Reference\n\n| What to Upgrade | Command | When to Use |\n|----------------|---------|-------------|\n| **CLI Tool Only** | `uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git` | Get latest CLI features without touching project files |\n| **Project Files** | `specify init --here --force --ai <your-agent>` | Update slash commands, templates, and scripts in your project |\n| **Both** | Run CLI upgrade, then project update | Recommended for major version updates |\n\n---\n\n## Part 1: Upgrade the CLI Tool\n\nThe CLI tool (`specify`) is separate from your project files. Upgrade it to get the latest features and bug fixes.\n\n### If you installed with `uv tool install`\n\n```bash\nuv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git\n```\n\n### If you use one-shot `uvx` commands\n\nNo upgrade needed—`uvx` always fetches the latest version. Just run your commands as normal:\n\n```bash\nuvx --from git+https://github.com/github/spec-kit.git specify init --here --ai copilot\n```\n\n### Verify the upgrade\n\n```bash\nspecify check\n```\n\nThis shows installed tools and confirms the CLI is working.\n\n---\n\n## Part 2: Updating Project Files\n\nWhen Spec Kit releases new features (like new slash commands or updated templates), you need to refresh your project's Spec Kit files.\n\n### What gets updated?\n\nRunning `specify init --here --force` will update:\n\n- ✅ **Slash command files** (`.claude/commands/`, `.github/prompts/`, etc.)\n- ✅ **Script files** (`.specify/scripts/`)\n- ✅ **Template files** (`.specify/templates/`)\n- ✅ **Shared memory files** (`.specify/memory/`) - **⚠️ See warnings below**\n\n### What stays safe?\n\nThese files are **never touched** by the upgrade—the template packages don't even contain them:\n\n- ✅ **Your specifications** (`specs/001-my-feature/spec.md`, etc.) - **CONFIRMED SAFE**\n- ✅ **Your implementation plans** (`specs/001-my-feature/plan.md`, `tasks.md`, etc.) - **CONFIRMED SAFE**\n- ✅ **Your source code** - **CONFIRMED SAFE**\n- ✅ **Your git history** - **CONFIRMED SAFE**\n\nThe `specs/` directory is completely excluded from template packages and will never be modified during upgrades.\n\n### Update command\n\nRun this inside your project directory:\n\n```bash\nspecify init --here --force --ai <your-agent>\n```\n\nReplace `<your-agent>` with your AI assistant. Refer to this list of [Supported AI Agents](../README.md#-supported-ai-agents)\n\n**Example:**\n\n```bash\nspecify init --here --force --ai copilot\n```\n\n### Understanding the `--force` flag\n\nWithout `--force`, the CLI warns you and asks for confirmation:\n\n```text\nWarning: Current directory is not empty (25 items)\nTemplate files will be merged with existing content and may overwrite existing files\nProceed? [y/N]\n```\n\nWith `--force`, it skips the confirmation and proceeds immediately.\n\n**Important: Your `specs/` directory is always safe.** The `--force` flag only affects template files (commands, scripts, templates, memory). Your feature specifications, plans, and tasks in `specs/` are never included in upgrade packages and cannot be overwritten.\n\n---\n\n## ⚠️ Important Warnings\n\n### 1. Constitution file will be overwritten\n\n**Known issue:** `specify init --here --force` currently overwrites `.specify/memory/constitution.md` with the default template, erasing any customizations you made.\n\n**Workaround:**\n\n```bash\n# 1. Back up your constitution before upgrading\ncp .specify/memory/constitution.md .specify/memory/constitution-backup.md\n\n# 2. Run the upgrade\nspecify init --here --force --ai copilot\n\n# 3. Restore your customized constitution\nmv .specify/memory/constitution-backup.md .specify/memory/constitution.md\n```\n\nOr use git to restore it:\n\n```bash\n# After upgrade, restore from git history\ngit restore .specify/memory/constitution.md\n```\n\n### 2. Custom template modifications\n\nIf you customized any templates in `.specify/templates/`, the upgrade will overwrite them. Back them up first:\n\n```bash\n# Back up custom templates\ncp -r .specify/templates .specify/templates-backup\n\n# After upgrade, merge your changes back manually\n```\n\n### 3. Duplicate slash commands (IDE-based agents)\n\nSome IDE-based agents (like Kilo Code, Windsurf) may show **duplicate slash commands** after upgrading—both old and new versions appear.\n\n**Solution:** Manually delete the old command files from your agent's folder.\n\n**Example for Kilo Code:**\n\n```bash\n# Navigate to the agent's commands folder\ncd .kilocode/rules/\n\n# List files and identify duplicates\nls -la\n\n# Delete old versions (example filenames - yours may differ)\nrm speckit.specify-old.md\nrm speckit.plan-v1.md\n```\n\nRestart your IDE to refresh the command list.\n\n---\n\n## Common Scenarios\n\n### Scenario 1: \"I just want new slash commands\"\n\n```bash\n# Upgrade CLI (if using persistent install)\nuv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git\n\n# Update project files to get new commands\nspecify init --here --force --ai copilot\n\n# Restore your constitution if customized\ngit restore .specify/memory/constitution.md\n```\n\n### Scenario 2: \"I customized templates and constitution\"\n\n```bash\n# 1. Back up customizations\ncp .specify/memory/constitution.md /tmp/constitution-backup.md\ncp -r .specify/templates /tmp/templates-backup\n\n# 2. Upgrade CLI\nuv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git\n\n# 3. Update project\nspecify init --here --force --ai copilot\n\n# 4. Restore customizations\nmv /tmp/constitution-backup.md .specify/memory/constitution.md\n# Manually merge template changes if needed\n```\n\n### Scenario 3: \"I see duplicate slash commands in my IDE\"\n\nThis happens with IDE-based agents (Kilo Code, Windsurf, Roo Code, etc.).\n\n```bash\n# Find the agent folder (example: .kilocode/rules/)\ncd .kilocode/rules/\n\n# List all files\nls -la\n\n# Delete old command files\nrm speckit.old-command-name.md\n\n# Restart your IDE\n```\n\n### Scenario 4: \"I'm working on a project without Git\"\n\nIf you initialized your project with `--no-git`, you can still upgrade:\n\n```bash\n# Manually back up files you customized\ncp .specify/memory/constitution.md /tmp/constitution-backup.md\n\n# Run upgrade\nspecify init --here --force --ai copilot --no-git\n\n# Restore customizations\nmv /tmp/constitution-backup.md .specify/memory/constitution.md\n```\n\nThe `--no-git` flag skips git initialization but doesn't affect file updates.\n\n---\n\n## Using `--no-git` Flag\n\nThe `--no-git` flag tells Spec Kit to **skip git repository initialization**. This is useful when:\n\n- You manage version control differently (Mercurial, SVN, etc.)\n- Your project is part of a larger monorepo with existing git setup\n- You're experimenting and don't want version control yet\n\n**During initial setup:**\n\n```bash\nspecify init my-project --ai copilot --no-git\n```\n\n**During upgrade:**\n\n```bash\nspecify init --here --force --ai copilot --no-git\n```\n\n### What `--no-git` does NOT do\n\n❌ Does NOT prevent file updates\n❌ Does NOT skip slash command installation\n❌ Does NOT affect template merging\n\nIt **only** skips running `git init` and creating the initial commit.\n\n### Working without Git\n\nIf you use `--no-git`, you'll need to manage feature directories manually:\n\n**Set the `SPECIFY_FEATURE` environment variable** before using planning commands:\n\n```bash\n# Bash/Zsh\nexport SPECIFY_FEATURE=\"001-my-feature\"\n\n# PowerShell\n$env:SPECIFY_FEATURE = \"001-my-feature\"\n```\n\nThis tells Spec Kit which feature directory to use when creating specs, plans, and tasks.\n\n**Why this matters:** Without git, Spec Kit can't detect your current branch name to determine the active feature. The environment variable provides that context manually.\n\n---\n\n## Troubleshooting\n\n### \"Slash commands not showing up after upgrade\"\n\n**Cause:** Agent didn't reload the command files.\n\n**Fix:**\n\n1. **Restart your IDE/editor** completely (not just reload window)\n2. **For CLI-based agents**, verify files exist:\n\n   ```bash\n   ls -la .claude/commands/      # Claude Code\n   ls -la .gemini/commands/      # Gemini\n   ls -la .cursor/commands/      # Cursor\n   ls -la .pi/prompts/           # Pi Coding Agent\n   ```\n\n3. **Check agent-specific setup:**\n   - Codex requires `CODEX_HOME` environment variable\n   - Some agents need workspace restart or cache clearing\n\n### \"I lost my constitution customizations\"\n\n**Fix:** Restore from git or backup:\n\n```bash\n# If you committed before upgrading\ngit restore .specify/memory/constitution.md\n\n# If you backed up manually\ncp /tmp/constitution-backup.md .specify/memory/constitution.md\n```\n\n**Prevention:** Always commit or back up `constitution.md` before upgrading.\n\n### \"Warning: Current directory is not empty\"\n\n**Full warning message:**\n\n```text\nWarning: Current directory is not empty (25 items)\nTemplate files will be merged with existing content and may overwrite existing files\nDo you want to continue? [y/N]\n```\n\n**What this means:**\n\nThis warning appears when you run `specify init --here` (or `specify init .`) in a directory that already has files. It's telling you:\n\n1. **The directory has existing content** - In the example, 25 files/folders\n2. **Files will be merged** - New template files will be added alongside your existing files\n3. **Some files may be overwritten** - If you already have Spec Kit files (`.claude/`, `.specify/`, etc.), they'll be replaced with the new versions\n\n**What gets overwritten:**\n\nOnly Spec Kit infrastructure files:\n\n- Agent command files (`.claude/commands/`, `.github/prompts/`, etc.)\n- Scripts in `.specify/scripts/`\n- Templates in `.specify/templates/`\n- Memory files in `.specify/memory/` (including constitution)\n\n**What stays untouched:**\n\n- Your `specs/` directory (specifications, plans, tasks)\n- Your source code files\n- Your `.git/` directory and git history\n- Any other files not part of Spec Kit templates\n\n**How to respond:**\n\n- **Type `y` and press Enter** - Proceed with the merge (recommended if upgrading)\n- **Type `n` and press Enter** - Cancel the operation\n- **Use `--force` flag** - Skip this confirmation entirely:\n\n  ```bash\n  specify init --here --force --ai copilot\n  ```\n\n**When you see this warning:**\n\n- ✅ **Expected** when upgrading an existing Spec Kit project\n- ✅ **Expected** when adding Spec Kit to an existing codebase\n- ⚠️ **Unexpected** if you thought you were creating a new project in an empty directory\n\n**Prevention tip:** Before upgrading, commit or back up your `.specify/memory/constitution.md` if you customized it.\n\n### \"CLI upgrade doesn't seem to work\"\n\nVerify the installation:\n\n```bash\n# Check installed tools\nuv tool list\n\n# Should show specify-cli\n\n# Verify path\nwhich specify\n\n# Should point to the uv tool installation directory\n```\n\nIf not found, reinstall:\n\n```bash\nuv tool uninstall specify-cli\nuv tool install specify-cli --from git+https://github.com/github/spec-kit.git\n```\n\n### \"Do I need to run specify every time I open my project?\"\n\n**Short answer:** No, you only run `specify init` once per project (or when upgrading).\n\n**Explanation:**\n\nThe `specify` CLI tool is used for:\n\n- **Initial setup:** `specify init` to bootstrap Spec Kit in your project\n- **Upgrades:** `specify init --here --force` to update templates and commands\n- **Diagnostics:** `specify check` to verify tool installation\n\nOnce you've run `specify init`, the slash commands (like `/speckit.specify`, `/speckit.plan`, etc.) are **permanently installed** in your project's agent folder (`.claude/`, `.github/prompts/`, `.pi/prompts/`, etc.). Your AI assistant reads these command files directly—no need to run `specify` again.\n\n**If your agent isn't recognizing slash commands:**\n\n1. **Verify command files exist:**\n\n   ```bash\n   # For GitHub Copilot\n   ls -la .github/prompts/\n\n   # For Claude\n   ls -la .claude/commands/\n\n   # For Pi\n   ls -la .pi/prompts/\n   ```\n\n2. **Restart your IDE/editor completely** (not just reload window)\n\n3. **Check you're in the correct directory** where you ran `specify init`\n\n4. **For some agents**, you may need to reload the workspace or clear cache\n\n**Related issue:** If Copilot can't open local files or uses PowerShell commands unexpectedly, this is typically an IDE context issue, not related to `specify`. Try:\n\n- Restarting VS Code\n- Checking file permissions\n- Ensuring the workspace folder is properly opened\n\n---\n\n## Version Compatibility\n\nSpec Kit follows semantic versioning for major releases. The CLI and project files are designed to be compatible within the same major version.\n\n**Best practice:** Keep both CLI and project files in sync by upgrading both together during major version changes.\n\n---\n\n## Next Steps\n\nAfter upgrading:\n\n- **Test new slash commands:** Run `/speckit.constitution` or another command to verify everything works\n- **Review release notes:** Check [GitHub Releases](https://github.com/github/spec-kit/releases) for new features and breaking changes\n- **Update workflows:** If new commands were added, update your team's development workflows\n- **Check documentation:** Visit [github.io/spec-kit](https://github.github.io/spec-kit/) for updated guides\n"
  },
  {
    "path": "extensions/EXTENSION-API-REFERENCE.md",
    "content": "# Extension API Reference\n\nTechnical reference for Spec Kit extension system APIs and manifest schema.\n\n## Table of Contents\n\n1. [Extension Manifest](#extension-manifest)\n2. [Python API](#python-api)\n3. [Command File Format](#command-file-format)\n4. [Configuration Schema](#configuration-schema)\n5. [Hook System](#hook-system)\n6. [CLI Commands](#cli-commands)\n\n---\n\n## Extension Manifest\n\n### Schema Version 1.0\n\nFile: `extension.yml`\n\n```yaml\nschema_version: \"1.0\"  # Required\n\nextension:\n  id: string           # Required, pattern: ^[a-z0-9-]+$\n  name: string         # Required, human-readable name\n  version: string      # Required, semantic version (X.Y.Z)\n  description: string  # Required, brief description (<200 chars)\n  author: string       # Required\n  repository: string   # Required, valid URL\n  license: string      # Required (e.g., \"MIT\", \"Apache-2.0\")\n  homepage: string     # Optional, valid URL\n\nrequires:\n  speckit_version: string  # Required, version specifier (>=X.Y.Z)\n  tools:                   # Optional, array of tool requirements\n    - name: string         # Tool name\n      version: string      # Optional, version specifier\n      required: boolean    # Optional, default: false\n\nprovides:\n  commands:              # Required, at least one command\n    - name: string       # Required, pattern: ^speckit\\.[a-z0-9-]+\\.[a-z0-9-]+$\n      file: string       # Required, relative path to command file\n      description: string # Required\n      aliases: [string]  # Optional, array of alternate names\n\n  config:                # Optional, array of config files\n    - name: string       # Config file name\n      template: string   # Template file path\n      description: string\n      required: boolean  # Default: false\n\nhooks:                   # Optional, event hooks\n  event_name:            # e.g., \"after_specify\", \"after_plan\", \"after_tasks\", \"after_implement\"\n    command: string      # Command to execute\n    optional: boolean    # Default: true\n    prompt: string       # Prompt text for optional hooks\n    description: string  # Hook description\n    condition: string    # Optional, condition expression\n\ntags:                    # Optional, array of tags (2-10 recommended)\n  - string\n\ndefaults:                # Optional, default configuration values\n  key: value             # Any YAML structure\n```\n\n### Field Specifications\n\n#### `extension.id`\n\n- **Type**: string\n- **Pattern**: `^[a-z0-9-]+$`\n- **Description**: Unique extension identifier\n- **Examples**: `jira`, `linear`, `azure-devops`\n- **Invalid**: `Jira`, `my_extension`, `extension.id`\n\n#### `extension.version`\n\n- **Type**: string\n- **Format**: Semantic versioning (X.Y.Z)\n- **Description**: Extension version\n- **Examples**: `1.0.0`, `0.9.5`, `2.1.3`\n- **Invalid**: `v1.0`, `1.0`, `1.0.0-beta`\n\n#### `requires.speckit_version`\n\n- **Type**: string\n- **Format**: Version specifier\n- **Description**: Required spec-kit version range\n- **Examples**:\n  - `>=0.1.0` - Any version 0.1.0 or higher\n  - `>=0.1.0,<2.0.0` - Version 0.1.x or 1.x\n  - `==0.1.0` - Exactly 0.1.0\n- **Invalid**: `0.1.0`, `>= 0.1.0` (space), `latest`\n\n#### `provides.commands[].name`\n\n- **Type**: string\n- **Pattern**: `^speckit\\.[a-z0-9-]+\\.[a-z0-9-]+$`\n- **Description**: Namespaced command name\n- **Format**:  `speckit.{extension-id}.{command-name}`\n- **Examples**: `speckit.jira.specstoissues`, `speckit.linear.sync`\n- **Invalid**: `jira.specstoissues`, `speckit.command`, `speckit.jira.CreateIssues`\n\n#### `hooks`\n\n- **Type**: object\n- **Keys**: Event names (e.g., `after_specify`, `after_plan`, `after_tasks`, `after_implement`, `before_commit`)\n- **Description**: Hooks that execute at lifecycle events\n- **Events**: Defined by core spec-kit commands\n\n---\n\n## Python API\n\n### ExtensionManifest\n\n**Module**: `specify_cli.extensions`\n\n```python\nfrom specify_cli.extensions import ExtensionManifest\n\nmanifest = ExtensionManifest(Path(\"extension.yml\"))\n```\n\n**Properties**:\n\n```python\nmanifest.id                        # str: Extension ID\nmanifest.name                      # str: Extension name\nmanifest.version                   # str: Version\nmanifest.description               # str: Description\nmanifest.requires_speckit_version  # str: Required spec-kit version\nmanifest.commands                  # List[Dict]: Command definitions\nmanifest.hooks                     # Dict: Hook definitions\n```\n\n**Methods**:\n\n```python\nmanifest.get_hash()  # str: SHA256 hash of manifest file\n```\n\n**Exceptions**:\n\n```python\nValidationError       # Invalid manifest structure\nCompatibilityError    # Incompatible with current spec-kit version\n```\n\n### ExtensionRegistry\n\n**Module**: `specify_cli.extensions`\n\n```python\nfrom specify_cli.extensions import ExtensionRegistry\n\nregistry = ExtensionRegistry(extensions_dir)\n```\n\n**Methods**:\n\n```python\n# Add extension to registry\nregistry.add(extension_id: str, metadata: dict)\n\n# Remove extension from registry\nregistry.remove(extension_id: str)\n\n# Get extension metadata\nmetadata = registry.get(extension_id: str)  # Optional[dict]\n\n# List all extensions\nextensions = registry.list()  # Dict[str, dict]\n\n# Check if installed\nis_installed = registry.is_installed(extension_id: str)  # bool\n```\n\n**Registry Format**:\n\n```json\n{\n  \"schema_version\": \"1.0\",\n  \"extensions\": {\n    \"jira\": {\n      \"version\": \"1.0.0\",\n      \"source\": \"catalog\",\n      \"manifest_hash\": \"sha256...\",\n      \"enabled\": true,\n      \"registered_commands\": [\"speckit.jira.specstoissues\", ...],\n      \"installed_at\": \"2026-01-28T...\"\n    }\n  }\n}\n```\n\n### ExtensionManager\n\n**Module**: `specify_cli.extensions`\n\n```python\nfrom specify_cli.extensions import ExtensionManager\n\nmanager = ExtensionManager(project_root)\n```\n\n**Methods**:\n\n```python\n# Install from directory\nmanifest = manager.install_from_directory(\n    source_dir: Path,\n    speckit_version: str,\n    register_commands: bool = True\n)  # Returns: ExtensionManifest\n\n# Install from ZIP\nmanifest = manager.install_from_zip(\n    zip_path: Path,\n    speckit_version: str\n)  # Returns: ExtensionManifest\n\n# Remove extension\nsuccess = manager.remove(\n    extension_id: str,\n    keep_config: bool = False\n)  # Returns: bool\n\n# List installed extensions\nextensions = manager.list_installed()  # List[Dict]\n\n# Get extension manifest\nmanifest = manager.get_extension(extension_id: str)  # Optional[ExtensionManifest]\n\n# Check compatibility\nmanager.check_compatibility(\n    manifest: ExtensionManifest,\n    speckit_version: str\n)  # Raises: CompatibilityError if incompatible\n```\n\n### CatalogEntry\n\n**Module**: `specify_cli.extensions`\n\nRepresents a single catalog in the active catalog stack.\n\n```python\nfrom specify_cli.extensions import CatalogEntry\n\nentry = CatalogEntry(\n    url=\"https://example.com/catalog.json\",\n    name=\"default\",\n    priority=1,\n    install_allowed=True,\n    description=\"Built-in catalog of installable extensions\",\n)\n```\n\n**Fields**:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `url` | `str` | Catalog URL (must use HTTPS, or HTTP for localhost) |\n| `name` | `str` | Human-readable catalog name |\n| `priority` | `int` | Sort order (lower = higher priority, wins on conflicts) |\n| `install_allowed` | `bool` | Whether extensions from this catalog can be installed |\n| `description` | `str` | Optional human-readable description of the catalog (default: empty) |\n\n### ExtensionCatalog\n\n**Module**: `specify_cli.extensions`\n\n```python\nfrom specify_cli.extensions import ExtensionCatalog\n\ncatalog = ExtensionCatalog(project_root)\n```\n\n**Class attributes**:\n\n```python\nExtensionCatalog.DEFAULT_CATALOG_URL    # default catalog URL\nExtensionCatalog.COMMUNITY_CATALOG_URL  # community catalog URL\n```\n\n**Methods**:\n\n```python\n# Get the ordered list of active catalogs\nentries = catalog.get_active_catalogs()  # List[CatalogEntry]\n\n# Fetch catalog (primary catalog, backward compat)\ncatalog_data = catalog.fetch_catalog(force_refresh: bool = False)  # Dict\n\n# Search extensions across all active catalogs\n# Each result includes _catalog_name and _install_allowed\nresults = catalog.search(\n    query: Optional[str] = None,\n    tag: Optional[str] = None,\n    author: Optional[str] = None,\n    verified_only: bool = False\n)  # Returns: List[Dict]  — each dict includes _catalog_name, _install_allowed\n\n# Get extension info (searches all active catalogs)\n# Returns None if not found; includes _catalog_name and _install_allowed\next_info = catalog.get_extension_info(extension_id: str)  # Optional[Dict]\n\n# Check cache validity (primary catalog)\nis_valid = catalog.is_cache_valid()  # bool\n\n# Clear all catalog caches\ncatalog.clear_cache()\n```\n\n**Result annotation fields**:\n\nEach extension dict returned by `search()` and `get_extension_info()` includes:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `_catalog_name` | `str` | Name of the source catalog |\n| `_install_allowed` | `bool` | Whether installation is allowed from this catalog |\n\n**Catalog config file** (`.specify/extension-catalogs.yml`):\n\n```yaml\ncatalogs:\n  - name: \"default\"\n    url: \"https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json\"\n    priority: 1\n    install_allowed: true\n    description: \"Built-in catalog of installable extensions\"\n  - name: \"community\"\n    url: \"https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json\"\n    priority: 2\n    install_allowed: false\n    description: \"Community-contributed extensions (discovery only)\"\n```\n\n### HookExecutor\n\n**Module**: `specify_cli.extensions`\n\n```python\nfrom specify_cli.extensions import HookExecutor\n\nhook_executor = HookExecutor(project_root)\n```\n\n**Methods**:\n\n```python\n# Get project config\nconfig = hook_executor.get_project_config()  # Dict\n\n# Save project config\nhook_executor.save_project_config(config: Dict)\n\n# Register hooks\nhook_executor.register_hooks(manifest: ExtensionManifest)\n\n# Unregister hooks\nhook_executor.unregister_hooks(extension_id: str)\n\n# Get hooks for event\nhooks = hook_executor.get_hooks_for_event(event_name: str)  # List[Dict]\n\n# Check if hook should execute\nshould_run = hook_executor.should_execute_hook(hook: Dict)  # bool\n\n# Format hook message\nmessage = hook_executor.format_hook_message(\n    event_name: str,\n    hooks: List[Dict]\n)  # str\n```\n\n### CommandRegistrar\n\n**Module**: `specify_cli.extensions`\n\n```python\nfrom specify_cli.extensions import CommandRegistrar\n\nregistrar = CommandRegistrar()\n```\n\n**Methods**:\n\n```python\n# Register commands for Claude Code\nregistered = registrar.register_commands_for_claude(\n    manifest: ExtensionManifest,\n    extension_dir: Path,\n    project_root: Path\n)  # Returns: List[str] (command names)\n\n# Parse frontmatter\nfrontmatter, body = registrar.parse_frontmatter(content: str)\n\n# Render frontmatter\nyaml_text = registrar.render_frontmatter(frontmatter: Dict)  # str\n```\n\n---\n\n## Command File Format\n\n### Universal Command Format\n\n**File**: `commands/{command-name}.md`\n\n```markdown\n---\ndescription: \"Command description\"\ntools:\n  - 'mcp-server/tool_name'\n  - 'other-mcp-server/other_tool'\n---\n\n# Command Title\n\nCommand documentation in Markdown.\n\n## Prerequisites\n\n1. Requirement 1\n2. Requirement 2\n\n## User Input\n\n$ARGUMENTS\n\n## Steps\n\n### Step 1: Description\n\nInstruction text...\n\n\\`\\`\\`bash\n# Shell commands\n\\`\\`\\`\n\n### Step 2: Another Step\n\nMore instructions...\n\n## Configuration Reference\n\nInformation about configuration options.\n\n## Notes\n\nAdditional notes and tips.\n```\n\n### Frontmatter Fields\n\n```yaml\ndescription: string   # Required, brief command description\ntools: [string]       # Optional, MCP tools required\n```\n\n### Special Variables\n\n- `$ARGUMENTS` - Placeholder for user-provided arguments\n- Extension context automatically injected:\n\n  ```markdown\n  <!-- Extension: {extension-id} -->\n  <!-- Config: .specify/extensions/{extension-id}/ -->\n  ```\n\n---\n\n## Configuration Schema\n\n### Extension Config File\n\n**File**: `.specify/extensions/{extension-id}/{extension-id}-config.yml`\n\nExtensions define their own config schema. Common patterns:\n\n```yaml\n# Connection settings\nconnection:\n  url: string\n  api_key: string\n\n# Project settings\nproject:\n  key: string\n  workspace: string\n\n# Feature flags\nfeatures:\n  enabled: boolean\n  auto_sync: boolean\n\n# Defaults\ndefaults:\n  labels: [string]\n  assignee: string\n\n# Custom fields\nfield_mappings:\n  internal_name: \"external_field_id\"\n```\n\n### Config Layers\n\n1. **Extension Defaults** (from `extension.yml` `defaults` section)\n2. **Project Config** (`{extension-id}-config.yml`)\n3. **Local Override** (`{extension-id}-config.local.yml`, gitignored)\n4. **Environment Variables** (`SPECKIT_{EXTENSION}_*`)\n\n### Environment Variable Pattern\n\nFormat: `SPECKIT_{EXTENSION}_{KEY}`\n\nExamples:\n\n- `SPECKIT_JIRA_PROJECT_KEY`\n- `SPECKIT_LINEAR_API_KEY`\n- `SPECKIT_GITHUB_TOKEN`\n\n---\n\n## Hook System\n\n### Hook Definition\n\n**In extension.yml**:\n\n```yaml\nhooks:\n  after_tasks:\n    command: \"speckit.jira.specstoissues\"\n    optional: true\n    prompt: \"Create Jira issues from tasks?\"\n    description: \"Automatically create Jira hierarchy\"\n    condition: null\n```\n\n### Hook Events\n\nStandard events (defined by core):\n\n- `before_specify` - Before specification generation\n- `after_specify` - After specification generation\n- `before_plan` - Before implementation planning\n- `after_plan` - After implementation planning\n- `before_tasks` - Before task generation\n- `after_tasks` - After task generation\n- `before_implement` - Before implementation\n- `after_implement` - After implementation\n- `before_commit` - Before git commit *(planned - not yet wired into core templates)*\n- `after_commit` - After git commit *(planned - not yet wired into core templates)*\n\n### Hook Configuration\n\n**In `.specify/extensions.yml`**:\n\n```yaml\nhooks:\n  after_tasks:\n    - extension: jira\n      command: speckit.jira.specstoissues\n      enabled: true\n      optional: true\n      prompt: \"Create Jira issues from tasks?\"\n      description: \"...\"\n      condition: null\n```\n\n### Hook Message Format\n\n```markdown\n## Extension Hooks\n\n**Optional Hook**: {extension}\nCommand: `/{command}`\nDescription: {description}\n\nPrompt: {prompt}\nTo execute: `/{command}`\n```\n\nOr for mandatory hooks:\n\n```markdown\n**Automatic Hook**: {extension}\nExecuting: `/{command}`\nEXECUTE_COMMAND: {command}\n```\n\n---\n\n## CLI Commands\n\n### extension list\n\n**Usage**: `specify extension list [OPTIONS]`\n\n**Options**:\n\n- `--available` - Show available extensions from catalog\n- `--all` - Show both installed and available\n\n**Output**: List of installed extensions with metadata\n\n### extension catalog list\n\n**Usage**: `specify extension catalog list`\n\nLists all active catalogs in the current catalog stack, showing name, description, URL, priority, and `install_allowed` status.\n\n### extension catalog add\n\n**Usage**: `specify extension catalog add URL [OPTIONS]`\n\n**Options**:\n\n- `--name NAME` - Catalog name (required)\n- `--priority INT` - Priority (lower = higher priority, default: 10)\n- `--install-allowed / --no-install-allowed` - Allow installs from this catalog (default: false)\n- `--description TEXT` - Optional description of the catalog\n\n**Arguments**:\n\n- `URL` - Catalog URL (must use HTTPS)\n\nAdds a catalog entry to `.specify/extension-catalogs.yml`.\n\n### extension catalog remove\n\n**Usage**: `specify extension catalog remove NAME`\n\n**Arguments**:\n\n- `NAME` - Catalog name to remove\n\nRemoves a catalog entry from `.specify/extension-catalogs.yml`.\n\n### extension add\n\n**Usage**: `specify extension add EXTENSION [OPTIONS]`\n\n**Options**:\n\n- `--from URL` - Install from custom URL\n- `--dev PATH` - Install from local directory\n\n**Arguments**:\n\n- `EXTENSION` - Extension name or URL\n\n**Note**: Extensions from catalogs with `install_allowed: false` cannot be installed via this command.\n\n### extension remove\n\n**Usage**: `specify extension remove EXTENSION [OPTIONS]`\n\n**Options**:\n\n- `--keep-config` - Preserve config files\n- `--force` - Skip confirmation\n\n**Arguments**:\n\n- `EXTENSION` - Extension ID\n\n### extension search\n\n**Usage**: `specify extension search [QUERY] [OPTIONS]`\n\nSearches all active catalogs simultaneously. Results include source catalog name and install_allowed status.\n\n**Options**:\n\n- `--tag TAG` - Filter by tag\n- `--author AUTHOR` - Filter by author\n- `--verified` - Show only verified extensions\n\n**Arguments**:\n\n- `QUERY` - Optional search query\n\n### extension info\n\n**Usage**: `specify extension info EXTENSION`\n\nShows source catalog and install_allowed status.\n\n**Arguments**:\n\n- `EXTENSION` - Extension ID\n\n### extension update\n\n**Usage**: `specify extension update [EXTENSION]`\n\n**Arguments**:\n\n- `EXTENSION` - Optional, extension ID (default: all)\n\n### extension enable\n\n**Usage**: `specify extension enable EXTENSION`\n\n**Arguments**:\n\n- `EXTENSION` - Extension ID\n\n### extension disable\n\n**Usage**: `specify extension disable EXTENSION`\n\n**Arguments**:\n\n- `EXTENSION` - Extension ID\n\n---\n\n## Exceptions\n\n### ValidationError\n\nRaised when extension manifest validation fails.\n\n```python\nfrom specify_cli.extensions import ValidationError\n\ntry:\n    manifest = ExtensionManifest(path)\nexcept ValidationError as e:\n    print(f\"Invalid manifest: {e}\")\n```\n\n### CompatibilityError\n\nRaised when extension is incompatible with current spec-kit version.\n\n```python\nfrom specify_cli.extensions import CompatibilityError\n\ntry:\n    manager.check_compatibility(manifest, \"0.1.0\")\nexcept CompatibilityError as e:\n    print(f\"Incompatible: {e}\")\n```\n\n### ExtensionError\n\nBase exception for all extension-related errors.\n\n```python\nfrom specify_cli.extensions import ExtensionError\n\ntry:\n    manager.install_from_directory(path, \"0.1.0\")\nexcept ExtensionError as e:\n    print(f\"Extension error: {e}\")\n```\n\n---\n\n## Version Functions\n\n### version_satisfies\n\nCheck if a version satisfies a specifier.\n\n```python\nfrom specify_cli.extensions import version_satisfies\n\n# True if 1.2.3 satisfies >=1.0.0,<2.0.0\nsatisfied = version_satisfies(\"1.2.3\", \">=1.0.0,<2.0.0\")  # bool\n```\n\n---\n\n## File System Layout\n\n```text\n.specify/\n├── extensions/\n│   ├── .registry               # Extension registry (JSON)\n│   ├── .cache/                 # Catalog cache\n│   │   ├── catalog.json\n│   │   └── catalog-metadata.json\n│   ├── .backup/                # Config backups\n│   │   └── {ext}-{config}.yml\n│   ├── {extension-id}/         # Extension directory\n│   │   ├── extension.yml       # Manifest\n│   │   ├── {ext}-config.yml    # User config\n│   │   ├── {ext}-config.local.yml  # Local overrides (gitignored)\n│   │   ├── {ext}-config.template.yml  # Template\n│   │   ├── commands/           # Command files\n│   │   │   └── *.md\n│   │   ├── scripts/            # Helper scripts\n│   │   │   └── *.sh\n│   │   ├── docs/               # Documentation\n│   │   └── README.md\n│   └── extensions.yml          # Project extension config\n└── scripts/                    # (existing spec-kit)\n\n.claude/\n└── commands/\n    └── speckit.{ext}.{cmd}.md  # Registered commands\n```\n\n---\n\n*Last Updated: 2026-01-28*\n*API Version: 1.0*\n*Spec Kit Version: 0.1.0*\n"
  },
  {
    "path": "extensions/EXTENSION-DEVELOPMENT-GUIDE.md",
    "content": "# Extension Development Guide\n\nA guide for creating Spec Kit extensions.\n\n---\n\n## Quick Start\n\n### 1. Create Extension Directory\n\n```bash\nmkdir my-extension\ncd my-extension\n```\n\n### 2. Create `extension.yml` Manifest\n\n```yaml\nschema_version: \"1.0\"\n\nextension:\n  id: \"my-ext\"                          # Lowercase, alphanumeric + hyphens only\n  name: \"My Extension\"\n  version: \"1.0.0\"                      # Semantic versioning\n  description: \"My custom extension\"\n  author: \"Your Name\"\n  repository: \"https://github.com/you/spec-kit-my-ext\"\n  license: \"MIT\"\n\nrequires:\n  speckit_version: \">=0.1.0\"            # Minimum spec-kit version\n  tools:                                # Optional: External tools required\n    - name: \"my-tool\"\n      required: true\n      version: \">=1.0.0\"\n  commands:                             # Optional: Core commands needed\n    - \"speckit.tasks\"\n\nprovides:\n  commands:\n    - name: \"speckit.my-ext.hello\"      # Must follow pattern: speckit.{ext-id}.{cmd}\n      file: \"commands/hello.md\"\n      description: \"Say hello\"\n      aliases: [\"speckit.hello\"]        # Optional aliases\n\n  config:                               # Optional: Config files\n    - name: \"my-ext-config.yml\"\n      template: \"my-ext-config.template.yml\"\n      description: \"Extension configuration\"\n      required: false\n\nhooks:                                  # Optional: Integration hooks\n  after_tasks:\n    command: \"speckit.my-ext.hello\"\n    optional: true\n    prompt: \"Run hello command?\"\n\ntags:                                   # Optional: For catalog search\n  - \"example\"\n  - \"utility\"\n```\n\n### 3. Create Commands Directory\n\n```bash\nmkdir commands\n```\n\n### 4. Create Command File\n\n**File**: `commands/hello.md`\n\n```markdown\n---\ndescription: \"Say hello command\"\ntools:                              # Optional: AI tools this command uses\n  - 'some-tool/function'\nscripts:                            # Optional: Helper scripts\n  sh: ../../scripts/bash/helper.sh\n  ps: ../../scripts/powershell/helper.ps1\n---\n\n# Hello Command\n\nThis command says hello!\n\n## User Input\n\n$ARGUMENTS\n\n## Steps\n\n1. Greet the user\n2. Show extension is working\n\n```bash\necho \"Hello from my extension!\"\necho \"Arguments: $ARGUMENTS\"\n```\n\n## Extension Configuration\n\nLoad extension config from `.specify/extensions/my-ext/my-ext-config.yml`.\n\n### 5. Test Locally\n\n```bash\ncd /path/to/spec-kit-project\nspecify extension add --dev /path/to/my-extension\n```\n\n### 6. Verify Installation\n\n```bash\nspecify extension list\n\n# Should show:\n#  ✓ My Extension (v1.0.0)\n#     My custom extension\n#     Commands: 1 | Hooks: 1 | Status: Enabled\n```\n\n### 7. Test Command\n\nIf using Claude:\n\n```bash\nclaude\n> /speckit.my-ext.hello world\n```\n\nThe command will be available in `.claude/commands/speckit.my-ext.hello.md`.\n\n---\n\n## Manifest Schema Reference\n\n### Required Fields\n\n#### `schema_version`\n\nExtension manifest schema version. Currently: `\"1.0\"`\n\n#### `extension`\n\nExtension metadata block.\n\n**Required sub-fields**:\n\n- `id`: Extension identifier (lowercase, alphanumeric, hyphens)\n- `name`: Human-readable name\n- `version`: Semantic version (e.g., \"1.0.0\")\n- `description`: Short description\n\n**Optional sub-fields**:\n\n- `author`: Extension author\n- `repository`: Source code URL\n- `license`: SPDX license identifier\n- `homepage`: Extension homepage URL\n\n#### `requires`\n\nCompatibility requirements.\n\n**Required sub-fields**:\n\n- `speckit_version`: Semantic version specifier (e.g., \">=0.1.0,<2.0.0\")\n\n**Optional sub-fields**:\n\n- `tools`: External tools required (array of tool objects)\n- `commands`: Core spec-kit commands needed (array of command names)\n- `scripts`: Core scripts required (array of script names)\n\n#### `provides`\n\nWhat the extension provides.\n\n**Required sub-fields**:\n\n- `commands`: Array of command objects (must have at least one)\n\n**Command object**:\n\n- `name`: Command name (must match `speckit.{ext-id}.{command}`)\n- `file`: Path to command file (relative to extension root)\n- `description`: Command description (optional)\n- `aliases`: Alternative command names (optional, array)\n\n### Optional Fields\n\n#### `hooks`\n\nIntegration hooks for automatic execution.\n\nAvailable hook points:\n\n- `after_tasks`: After `/speckit.tasks` completes\n- `after_implement`: After `/speckit.implement` completes (future)\n\nHook object:\n\n- `command`: Command to execute (must be in `provides.commands`)\n- `optional`: If true, prompt user before executing\n- `prompt`: Prompt text for optional hooks\n- `description`: Hook description\n- `condition`: Execution condition (future)\n\n#### `tags`\n\nArray of tags for catalog discovery.\n\n#### `defaults`\n\nDefault extension configuration values.\n\n#### `config_schema`\n\nJSON Schema for validating extension configuration.\n\n---\n\n## Command File Format\n\n### Frontmatter (YAML)\n\n```yaml\n---\ndescription: \"Command description\"          # Required\ntools:                                      # Optional\n  - 'tool-name/function'\nscripts:                                    # Optional\n  sh: ../../scripts/bash/helper.sh\n  ps: ../../scripts/powershell/helper.ps1\n---\n```\n\n### Body (Markdown)\n\nUse standard Markdown with special placeholders:\n\n- `$ARGUMENTS`: User-provided arguments\n- `{SCRIPT}`: Replaced with script path during registration\n\n**Example**:\n\n````markdown\n## Steps\n\n1. Parse arguments\n2. Execute logic\n\n```bash\nargs=\"$ARGUMENTS\"\necho \"Running with args: $args\"\n```\n````\n\n### Script Path Rewriting\n\nExtension commands use relative paths that get rewritten during registration:\n\n**In extension**:\n\n```yaml\nscripts:\n  sh: ../../scripts/bash/helper.sh\n```\n\n**After registration**:\n\n```yaml\nscripts:\n  sh: .specify/scripts/bash/helper.sh\n```\n\nThis allows scripts to reference core spec-kit scripts.\n\n---\n\n## Configuration Files\n\n### Config Template\n\n**File**: `my-ext-config.template.yml`\n\n```yaml\n# My Extension Configuration\n# Copy this to my-ext-config.yml and customize\n\n# Example configuration\napi:\n  endpoint: \"https://api.example.com\"\n  timeout: 30\n\nfeatures:\n  feature_a: true\n  feature_b: false\n\ncredentials:\n  # DO NOT commit credentials!\n  # Use environment variables instead\n  api_key: \"${MY_EXT_API_KEY}\"\n```\n\n### Config Loading\n\nIn your command, load config with layered precedence:\n\n1. Extension defaults (`extension.yml` → `defaults`)\n2. Project config (`.specify/extensions/my-ext/my-ext-config.yml`)\n3. Local overrides (`.specify/extensions/my-ext/my-ext-config.local.yml` - gitignored)\n4. Environment variables (`SPECKIT_MY_EXT_*`)\n\n**Example loading script**:\n\n```bash\n#!/usr/bin/env bash\nEXT_DIR=\".specify/extensions/my-ext\"\n\n# Load and merge config\nconfig=$(yq eval '.' \"$EXT_DIR/my-ext-config.yml\" -o=json)\n\n# Apply env overrides\nif [ -n \"${SPECKIT_MY_EXT_API_KEY:-}\" ]; then\n  config=$(echo \"$config\" | jq \".api.api_key = \\\"$SPECKIT_MY_EXT_API_KEY\\\"\")\nfi\n\necho \"$config\"\n```\n\n---\n\n## Excluding Files with `.extensionignore`\n\nExtension authors can create a `.extensionignore` file in the extension root to exclude files and folders from being copied when a user installs the extension with `specify extension add`. This is useful for keeping development-only files (tests, CI configs, docs source, etc.) out of the installed copy.\n\n### Format\n\nThe file uses `.gitignore`-compatible patterns (one per line), powered by the [`pathspec`](https://pypi.org/project/pathspec/) library:\n\n- Blank lines are ignored\n- Lines starting with `#` are comments\n- `*` matches anything **except** `/` (does not cross directory boundaries)\n- `**` matches zero or more directories (e.g., `docs/**/*.draft.md`)\n- `?` matches any single character except `/`\n- A trailing `/` restricts a pattern to directories only\n- Patterns containing `/` (other than a trailing slash) are anchored to the extension root\n- Patterns without `/` match at any depth in the tree\n- `!` negates a previously excluded pattern (re-includes a file)\n- Backslashes in patterns are normalised to forward slashes for cross-platform compatibility\n- The `.extensionignore` file itself is always excluded automatically\n\n### Example\n\n```gitignore\n# .extensionignore\n\n# Development files\ntests/\n.github/\n.gitignore\n\n# Build artifacts\n__pycache__/\n*.pyc\ndist/\n\n# Documentation source (keep only the built README)\ndocs/\nCONTRIBUTING.md\n```\n\n### Pattern Matching\n\n| Pattern | Matches | Does NOT match |\n|---------|---------|----------------|\n| `*.pyc` | Any `.pyc` file in any directory | — |\n| `tests/` | The `tests` directory (and all its contents) | A file named `tests` |\n| `docs/*.draft.md` | `docs/api.draft.md` (directly inside `docs/`) | `docs/sub/api.draft.md` (nested) |\n| `.env` | The `.env` file at any level | — |\n| `!README.md` | Re-includes `README.md` even if matched by an earlier pattern | — |\n| `docs/**/*.draft.md` | `docs/api.draft.md`, `docs/sub/api.draft.md` | — |\n\n### Unsupported Features\n\nThe following `.gitignore` features are **not applicable** in this context:\n\n- **Multiple `.extensionignore` files**: Only a single file at the extension root is supported (`.gitignore` supports files in subdirectories)\n- **`$GIT_DIR/info/exclude` and `core.excludesFile`**: These are Git-specific and have no equivalent here\n- **Negation inside excluded directories**: Because file copying uses `shutil.copytree`, excluding a directory prevents recursion into it entirely. A negation pattern cannot re-include a file inside a directory that was itself excluded. For example, the combination `tests/` followed by `!tests/important.py` will **not** preserve `tests/important.py` — the `tests/` directory is skipped at the root level and its contents are never evaluated. To work around this, exclude the directory's contents individually instead of the directory itself (e.g., `tests/*.pyc` and `tests/.cache/` rather than `tests/`).\n\n---\n\n## Validation Rules\n\n### Extension ID\n\n- **Pattern**: `^[a-z0-9-]+$`\n- **Valid**: `my-ext`, `tool-123`, `awesome-plugin`\n- **Invalid**: `MyExt` (uppercase), `my_ext` (underscore), `my ext` (space)\n\n### Extension Version\n\n- **Format**: Semantic versioning (MAJOR.MINOR.PATCH)\n- **Valid**: `1.0.0`, `0.1.0`, `2.5.3`\n- **Invalid**: `1.0`, `v1.0.0`, `1.0.0-beta`\n\n### Command Name\n\n- **Pattern**: `^speckit\\.[a-z0-9-]+\\.[a-z0-9-]+$`\n- **Valid**: `speckit.my-ext.hello`, `speckit.tool.cmd`\n- **Invalid**: `my-ext.hello` (missing prefix), `speckit.hello` (no extension namespace)\n\n### Command File Path\n\n- **Must be** relative to extension root\n- **Valid**: `commands/hello.md`, `commands/subdir/cmd.md`\n- **Invalid**: `/absolute/path.md`, `../outside.md`\n\n---\n\n## Testing Extensions\n\n### Manual Testing\n\n1. **Create test extension**\n2. **Install locally**:\n\n   ```bash\n   specify extension add --dev /path/to/extension\n   ```\n\n3. **Verify installation**:\n\n   ```bash\n   specify extension list\n   ```\n\n4. **Test commands** with your AI agent\n5. **Check command registration**:\n\n   ```bash\n   ls .claude/commands/speckit.my-ext.*\n   ```\n\n6. **Remove extension**:\n\n   ```bash\n   specify extension remove my-ext\n   ```\n\n### Automated Testing\n\nCreate tests for your extension:\n\n```python\n# tests/test_my_extension.py\nimport pytest\nfrom pathlib import Path\nfrom specify_cli.extensions import ExtensionManifest\n\ndef test_manifest_valid():\n    \"\"\"Test extension manifest is valid.\"\"\"\n    manifest = ExtensionManifest(Path(\"extension.yml\"))\n    assert manifest.id == \"my-ext\"\n    assert len(manifest.commands) >= 1\n\ndef test_command_files_exist():\n    \"\"\"Test all command files exist.\"\"\"\n    manifest = ExtensionManifest(Path(\"extension.yml\"))\n    for cmd in manifest.commands:\n        cmd_file = Path(cmd[\"file\"])\n        assert cmd_file.exists(), f\"Command file not found: {cmd_file}\"\n```\n\n---\n\n## Distribution\n\n### Option 1: GitHub Repository\n\n1. **Create repository**: `spec-kit-my-ext`\n2. **Add files**:\n\n   ```text\n   spec-kit-my-ext/\n   ├── extension.yml\n   ├── commands/\n   ├── scripts/\n   ├── docs/\n   ├── README.md\n   ├── LICENSE\n   └── CHANGELOG.md\n   ```\n\n3. **Create release**: Tag with version (e.g., `v1.0.0`)\n4. **Install from repo**:\n\n   ```bash\n   git clone https://github.com/you/spec-kit-my-ext\n   specify extension add --dev spec-kit-my-ext/\n   ```\n\n### Option 2: ZIP Archive (Future)\n\nCreate ZIP archive and host on GitHub Releases:\n\n```bash\nzip -r spec-kit-my-ext-1.0.0.zip extension.yml commands/ scripts/ docs/\n```\n\nUsers install with:\n\n```bash\nspecify extension add --from https://github.com/.../spec-kit-my-ext-1.0.0.zip\n```\n\n### Option 3: Community Reference Catalog\n\nSubmit to the community catalog for public discovery:\n\n1. **Fork** spec-kit repository\n2. **Add entry** to `extensions/catalog.community.json`\n3. **Update** `extensions/README.md` with your extension\n4. **Create PR** following the [Extension Publishing Guide](EXTENSION-PUBLISHING-GUIDE.md)\n5. **After merge**, your extension becomes available:\n   - Users can browse `catalog.community.json` to discover your extension\n   - Users copy the entry to their own `catalog.json`\n   - Users install with: `specify extension add my-ext` (from their catalog)\n\nSee the [Extension Publishing Guide](EXTENSION-PUBLISHING-GUIDE.md) for detailed submission instructions.\n\n---\n\n## Best Practices\n\n### Naming Conventions\n\n- **Extension ID**: Use descriptive, hyphenated names (`jira-integration`, not `ji`)\n- **Commands**: Use verb-noun pattern (`create-issue`, `sync-status`)\n- **Config files**: Match extension ID (`jira-config.yml`)\n\n### Documentation\n\n- **README.md**: Overview, installation, usage\n- **CHANGELOG.md**: Version history\n- **docs/**: Detailed guides\n- **Command descriptions**: Clear, concise\n\n### Versioning\n\n- **Follow SemVer**: `MAJOR.MINOR.PATCH`\n- **MAJOR**: Breaking changes\n- **MINOR**: New features\n- **PATCH**: Bug fixes\n\n### Security\n\n- **Never commit secrets**: Use environment variables\n- **Validate input**: Sanitize user arguments\n- **Document permissions**: What files/APIs are accessed\n\n### Compatibility\n\n- **Specify version range**: Don't require exact version\n- **Test with multiple versions**: Ensure compatibility\n- **Graceful degradation**: Handle missing features\n\n---\n\n## Example Extensions\n\n### Minimal Extension\n\nSmallest possible extension:\n\n```yaml\n# extension.yml\nschema_version: \"1.0\"\nextension:\n  id: \"minimal\"\n  name: \"Minimal Extension\"\n  version: \"1.0.0\"\n  description: \"Minimal example\"\nrequires:\n  speckit_version: \">=0.1.0\"\nprovides:\n  commands:\n    - name: \"speckit.minimal.hello\"\n      file: \"commands/hello.md\"\n```\n\n````markdown\n<!-- commands/hello.md -->\n---\ndescription: \"Hello command\"\n---\n\n# Hello World\n\n```bash\necho \"Hello, $ARGUMENTS!\"\n```\n````\n\n### Extension with Config\n\nExtension using configuration:\n\n```yaml\n# extension.yml\n# ... metadata ...\nprovides:\n  config:\n    - name: \"tool-config.yml\"\n      template: \"tool-config.template.yml\"\n      required: true\n```\n\n```yaml\n# tool-config.template.yml\napi_endpoint: \"https://api.example.com\"\ntimeout: 30\n```\n\n````markdown\n<!-- commands/use-config.md -->\n# Use Config\n\nLoad config:\n```bash\nconfig_file=\".specify/extensions/tool/tool-config.yml\"\nendpoint=$(yq eval '.api_endpoint' \"$config_file\")\necho \"Using endpoint: $endpoint\"\n```\n````\n\n### Extension with Hooks\n\nExtension that runs automatically:\n\n```yaml\n# extension.yml\nhooks:\n  after_tasks:\n    command: \"speckit.auto.analyze\"\n    optional: false  # Always run\n    description: \"Analyze tasks after generation\"\n```\n\n---\n\n## Troubleshooting\n\n### Extension won't install\n\n**Error**: `Invalid extension ID`\n\n- **Fix**: Use lowercase, alphanumeric + hyphens only\n\n**Error**: `Extension requires spec-kit >=0.2.0`\n\n- **Fix**: Update spec-kit with `uv tool install specify-cli --force`\n\n**Error**: `Command file not found`\n\n- **Fix**: Ensure command files exist at paths specified in manifest\n\n### Commands not registered\n\n**Symptom**: Commands don't appear in AI agent\n\n**Check**:\n\n1. `.claude/commands/` directory exists\n2. Extension installed successfully\n3. Commands registered in registry:\n\n   ```bash\n   cat .specify/extensions/.registry\n   ```\n\n**Fix**: Reinstall extension to trigger registration\n\n### Config not loading\n\n**Check**:\n\n1. Config file exists: `.specify/extensions/{ext-id}/{ext-id}-config.yml`\n2. YAML syntax is valid: `yq eval '.' config.yml`\n3. Environment variables set correctly\n\n---\n\n## Getting Help\n\n- **Issues**: Report bugs at GitHub repository\n- **Discussions**: Ask questions in GitHub Discussions\n- **Examples**: See `spec-kit-jira` for full-featured example (Phase B)\n\n---\n\n## Next Steps\n\n1. **Create your extension** following this guide\n2. **Test locally** with `--dev` flag\n3. **Share with community** (GitHub, catalog)\n4. **Iterate** based on feedback\n\nHappy extending! 🚀\n"
  },
  {
    "path": "extensions/EXTENSION-PUBLISHING-GUIDE.md",
    "content": "# Extension Publishing Guide\n\nThis guide explains how to publish your extension to the Spec Kit extension catalog, making it discoverable by `specify extension search`.\n\n## Table of Contents\n\n1. [Prerequisites](#prerequisites)\n2. [Prepare Your Extension](#prepare-your-extension)\n3. [Submit to Catalog](#submit-to-catalog)\n4. [Verification Process](#verification-process)\n5. [Release Workflow](#release-workflow)\n6. [Best Practices](#best-practices)\n\n---\n\n## Prerequisites\n\nBefore publishing an extension, ensure you have:\n\n1. **Valid Extension**: A working extension with a valid `extension.yml` manifest\n2. **Git Repository**: Extension hosted on GitHub (or other public git hosting)\n3. **Documentation**: README.md with installation and usage instructions\n4. **License**: Open source license file (MIT, Apache 2.0, etc.)\n5. **Versioning**: Semantic versioning (e.g., 1.0.0)\n6. **Testing**: Extension tested on real projects\n\n---\n\n## Prepare Your Extension\n\n### 1. Extension Structure\n\nEnsure your extension follows the standard structure:\n\n```text\nyour-extension/\n├── extension.yml              # Required: Extension manifest\n├── README.md                  # Required: Documentation\n├── LICENSE                    # Required: License file\n├── CHANGELOG.md               # Recommended: Version history\n├── .gitignore                 # Recommended: Git ignore rules\n│\n├── commands/                  # Extension commands\n│   ├── command1.md\n│   └── command2.md\n│\n├── config-template.yml        # Config template (if needed)\n│\n└── docs/                      # Additional documentation\n    ├── usage.md\n    └── examples/\n```\n\n### 2. extension.yml Validation\n\nVerify your manifest is valid:\n\n```yaml\nschema_version: \"1.0\"\n\nextension:\n  id: \"your-extension\"           # Unique lowercase-hyphenated ID\n  name: \"Your Extension Name\"     # Human-readable name\n  version: \"1.0.0\"                # Semantic version\n  description: \"Brief description (one sentence)\"\n  author: \"Your Name or Organization\"\n  repository: \"https://github.com/your-org/spec-kit-your-extension\"\n  license: \"MIT\"\n  homepage: \"https://github.com/your-org/spec-kit-your-extension\"\n\nrequires:\n  speckit_version: \">=0.1.0\"    # Required spec-kit version\n\nprovides:\n  commands:                       # List all commands\n    - name: \"speckit.your-extension.command\"\n      file: \"commands/command.md\"\n      description: \"Command description\"\n\ntags:                             # 2-5 relevant tags\n  - \"category\"\n  - \"tool-name\"\n```\n\n**Validation Checklist**:\n\n- ✅ `id` is lowercase with hyphens only (no underscores, spaces, or special characters)\n- ✅ `version` follows semantic versioning (X.Y.Z)\n- ✅ `description` is concise (under 100 characters)\n- ✅ `repository` URL is valid and public\n- ✅ All command files exist in the extension directory\n- ✅ Tags are lowercase and descriptive\n\n### 3. Create GitHub Release\n\nCreate a GitHub release for your extension version:\n\n```bash\n# Tag the release\ngit tag v1.0.0\ngit push origin v1.0.0\n\n# Create release on GitHub\n# Go to: https://github.com/your-org/spec-kit-your-extension/releases/new\n# - Tag: v1.0.0\n# - Title: v1.0.0 - Release Name\n# - Description: Changelog/release notes\n```\n\nThe release archive URL will be:\n\n```text\nhttps://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip\n```\n\n### 4. Test Installation\n\nTest that users can install from your release:\n\n```bash\n# Test dev installation\nspecify extension add --dev /path/to/your-extension\n\n# Test from GitHub archive\nspecify extension add --from https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip\n```\n\n---\n\n## Submit to Catalog\n\n### Understanding the Catalogs\n\nSpec Kit uses a dual-catalog system. For details about how catalogs work, see the main [Extensions README](README.md#extension-catalogs).\n\n**For extension publishing**: All community extensions should be added to `catalog.community.json`. Users browse this catalog and copy extensions they trust into their own `catalog.json`.\n\n### 1. Fork the spec-kit Repository\n\n```bash\n# Fork on GitHub\n# https://github.com/github/spec-kit/fork\n\n# Clone your fork\ngit clone https://github.com/YOUR-USERNAME/spec-kit.git\ncd spec-kit\n```\n\n### 2. Add Extension to Community Catalog\n\nEdit `extensions/catalog.community.json` and add your extension:\n\n```json\n{\n  \"schema_version\": \"1.0\",\n  \"updated_at\": \"2026-01-28T15:54:00Z\",\n  \"catalog_url\": \"https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json\",\n  \"extensions\": {\n    \"your-extension\": {\n      \"name\": \"Your Extension Name\",\n      \"id\": \"your-extension\",\n      \"description\": \"Brief description of your extension\",\n      \"author\": \"Your Name\",\n      \"version\": \"1.0.0\",\n      \"download_url\": \"https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip\",\n      \"repository\": \"https://github.com/your-org/spec-kit-your-extension\",\n      \"homepage\": \"https://github.com/your-org/spec-kit-your-extension\",\n      \"documentation\": \"https://github.com/your-org/spec-kit-your-extension/blob/main/docs/\",\n      \"changelog\": \"https://github.com/your-org/spec-kit-your-extension/blob/main/CHANGELOG.md\",\n      \"license\": \"MIT\",\n      \"requires\": {\n        \"speckit_version\": \">=0.1.0\",\n        \"tools\": [\n          {\n            \"name\": \"required-mcp-tool\",\n            \"version\": \">=1.0.0\",\n            \"required\": true\n          }\n        ]\n      },\n      \"provides\": {\n        \"commands\": 3,\n        \"hooks\": 1\n      },\n      \"tags\": [\n        \"category\",\n        \"tool-name\",\n        \"feature\"\n      ],\n      \"verified\": false,\n      \"downloads\": 0,\n      \"stars\": 0,\n      \"created_at\": \"2026-01-28T00:00:00Z\",\n      \"updated_at\": \"2026-01-28T00:00:00Z\"\n    }\n  }\n}\n```\n\n**Important**:\n\n- Set `verified: false` (maintainers will verify)\n- Set `downloads: 0` and `stars: 0` (auto-updated later)\n- Use current timestamp for `created_at` and `updated_at`\n- Update the top-level `updated_at` to current time\n\n### 3. Update Extensions README\n\nAdd your extension to the Available Extensions table in `extensions/README.md`:\n\n```markdown\n| Your Extension Name | Brief description of what it does | [repo-name](https://github.com/your-org/spec-kit-your-extension) |\n```\n\nInsert your extension in alphabetical order in the table.\n\n### 4. Submit Pull Request\n\n```bash\n# Create a branch\ngit checkout -b add-your-extension\n\n# Commit your changes\ngit add extensions/catalog.community.json extensions/README.md\ngit commit -m \"Add your-extension to community catalog\n\n- Extension ID: your-extension\n- Version: 1.0.0\n- Author: Your Name\n- Description: Brief description\n\"\n\n# Push to your fork\ngit push origin add-your-extension\n\n# Create Pull Request on GitHub\n# https://github.com/github/spec-kit/compare\n```\n\n**Pull Request Template**:\n\n```markdown\n## Extension Submission\n\n**Extension Name**: Your Extension Name\n**Extension ID**: your-extension\n**Version**: 1.0.0\n**Author**: Your Name\n**Repository**: https://github.com/your-org/spec-kit-your-extension\n\n### Description\nBrief description of what your extension does.\n\n### Checklist\n- [x] Valid extension.yml manifest\n- [x] README.md with installation and usage docs\n- [x] LICENSE file included\n- [x] GitHub release created (v1.0.0)\n- [x] Extension tested on real project\n- [x] All commands working\n- [x] No security vulnerabilities\n- [x] Added to extensions/catalog.community.json\n- [x] Added to extensions/README.md Available Extensions table\n\n### Testing\nTested on:\n- macOS 13.0+ with spec-kit 0.1.0\n- Project: [Your test project]\n\n### Additional Notes\nAny additional context or notes for reviewers.\n```\n\n---\n\n## Verification Process\n\n### What Happens After Submission\n\n1. **Automated Checks** (if available):\n   - Manifest validation\n   - Download URL accessibility\n   - Repository existence\n   - License file presence\n\n2. **Manual Review**:\n   - Code quality review\n   - Security audit\n   - Functionality testing\n   - Documentation review\n\n3. **Verification**:\n   - If approved, `verified: true` is set\n   - Extension appears in `specify extension search --verified`\n\n### Verification Criteria\n\nTo be verified, your extension must:\n\n✅ **Functionality**:\n\n- Works as described in documentation\n- All commands execute without errors\n- No breaking changes to user workflows\n\n✅ **Security**:\n\n- No known vulnerabilities\n- No malicious code\n- Safe handling of user data\n- Proper validation of inputs\n\n✅ **Code Quality**:\n\n- Clean, readable code\n- Follows extension best practices\n- Proper error handling\n- Helpful error messages\n\n✅ **Documentation**:\n\n- Clear installation instructions\n- Usage examples\n- Troubleshooting section\n- Accurate description\n\n✅ **Maintenance**:\n\n- Active repository\n- Responsive to issues\n- Regular updates\n- Semantic versioning followed\n\n### Typical Review Timeline\n\n- **Automated checks**: Immediate (if implemented)\n- **Manual review**: 3-7 business days\n- **Verification**: After successful review\n\n---\n\n## Release Workflow\n\n### Publishing New Versions\n\nWhen releasing a new version:\n\n1. **Update version** in `extension.yml`:\n\n   ```yaml\n   extension:\n     version: \"1.1.0\"  # Updated version\n   ```\n\n2. **Update CHANGELOG.md**:\n\n   ```markdown\n   ## [1.1.0] - 2026-02-15\n\n   ### Added\n   - New feature X\n\n   ### Fixed\n   - Bug fix Y\n   ```\n\n3. **Create GitHub release**:\n\n   ```bash\n   git tag v1.1.0\n   git push origin v1.1.0\n   # Create release on GitHub\n   ```\n\n4. **Update catalog**:\n\n   ```bash\n   # Fork spec-kit repo (or update existing fork)\n   cd spec-kit\n\n   # Update extensions/catalog.json\n   jq '.extensions[\"your-extension\"].version = \"1.1.0\"' extensions/catalog.json > tmp.json && mv tmp.json extensions/catalog.json\n   jq '.extensions[\"your-extension\"].download_url = \"https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.1.0.zip\"' extensions/catalog.json > tmp.json && mv tmp.json extensions/catalog.json\n   jq '.extensions[\"your-extension\"].updated_at = \"2026-02-15T00:00:00Z\"' extensions/catalog.json > tmp.json && mv tmp.json extensions/catalog.json\n   jq '.updated_at = \"2026-02-15T00:00:00Z\"' extensions/catalog.json > tmp.json && mv tmp.json extensions/catalog.json\n\n   # Submit PR\n   git checkout -b update-your-extension-v1.1.0\n   git add extensions/catalog.json\n   git commit -m \"Update your-extension to v1.1.0\"\n   git push origin update-your-extension-v1.1.0\n   ```\n\n5. **Submit update PR** with changelog in description\n\n---\n\n## Best Practices\n\n### Extension Design\n\n1. **Single Responsibility**: Each extension should focus on one tool/integration\n2. **Clear Naming**: Use descriptive, unambiguous names\n3. **Minimal Dependencies**: Avoid unnecessary dependencies\n4. **Backward Compatibility**: Follow semantic versioning strictly\n\n### Documentation\n\n1. **README.md Structure**:\n   - Overview and features\n   - Installation instructions\n   - Configuration guide\n   - Usage examples\n   - Troubleshooting\n   - Contributing guidelines\n\n2. **Command Documentation**:\n   - Clear description\n   - Prerequisites listed\n   - Step-by-step instructions\n   - Error handling guidance\n   - Examples\n\n3. **Configuration**:\n   - Provide template file\n   - Document all options\n   - Include examples\n   - Explain defaults\n\n### Security\n\n1. **Input Validation**: Validate all user inputs\n2. **No Hardcoded Secrets**: Never include credentials\n3. **Safe Dependencies**: Only use trusted dependencies\n4. **Audit Regularly**: Check for vulnerabilities\n\n### Maintenance\n\n1. **Respond to Issues**: Address issues within 1-2 weeks\n2. **Regular Updates**: Keep dependencies updated\n3. **Changelog**: Maintain detailed changelog\n4. **Deprecation**: Give advance notice for breaking changes\n\n### Community\n\n1. **License**: Use permissive open-source license (MIT, Apache 2.0)\n2. **Contributing**: Welcome contributions\n3. **Code of Conduct**: Be respectful and inclusive\n4. **Support**: Provide ways to get help (issues, discussions, email)\n\n---\n\n## FAQ\n\n### Q: Can I publish private/proprietary extensions?\n\nA: The main catalog is for public extensions only. For private extensions:\n\n- Host your own catalog.json file\n- Users add your catalog: `specify extension add-catalog https://your-domain.com/catalog.json`\n- Not yet implemented - coming in Phase 4\n\n### Q: How long does verification take?\n\nA: Typically 3-7 business days for initial review. Updates to verified extensions are usually faster.\n\n### Q: What if my extension is rejected?\n\nA: You'll receive feedback on what needs to be fixed. Make the changes and resubmit.\n\n### Q: Can I update my extension anytime?\n\nA: Yes, submit a PR to update the catalog with your new version. Verified status may be re-evaluated for major changes.\n\n### Q: Do I need to be verified to be in the catalog?\n\nA: No, unverified extensions are still searchable. Verification just adds trust and visibility.\n\n### Q: Can extensions have paid features?\n\nA: Extensions should be free and open-source. Commercial support/services are allowed, but core functionality must be free.\n\n---\n\n## Support\n\n- **Catalog Issues**: <https://github.com/statsperform/spec-kit/issues>\n- **Extension Template**: <https://github.com/statsperform/spec-kit-extension-template> (coming soon)\n- **Development Guide**: See EXTENSION-DEVELOPMENT-GUIDE.md\n- **Community**: Discussions and Q&A\n\n---\n\n## Appendix: Catalog Schema\n\n### Complete Catalog Entry Schema\n\n```json\n{\n  \"name\": \"string (required)\",\n  \"id\": \"string (required, unique)\",\n  \"description\": \"string (required, <200 chars)\",\n  \"author\": \"string (required)\",\n  \"version\": \"string (required, semver)\",\n  \"download_url\": \"string (required, valid URL)\",\n  \"repository\": \"string (required, valid URL)\",\n  \"homepage\": \"string (optional, valid URL)\",\n  \"documentation\": \"string (optional, valid URL)\",\n  \"changelog\": \"string (optional, valid URL)\",\n  \"license\": \"string (required)\",\n  \"requires\": {\n    \"speckit_version\": \"string (required, version specifier)\",\n    \"tools\": [\n      {\n        \"name\": \"string (required)\",\n        \"version\": \"string (optional, version specifier)\",\n        \"required\": \"boolean (default: false)\"\n      }\n    ]\n  },\n  \"provides\": {\n    \"commands\": \"integer (optional)\",\n    \"hooks\": \"integer (optional)\"\n  },\n  \"tags\": [\"array of strings (2-10 tags)\"],\n  \"verified\": \"boolean (default: false)\",\n  \"downloads\": \"integer (auto-updated)\",\n  \"stars\": \"integer (auto-updated)\",\n  \"created_at\": \"string (ISO 8601 datetime)\",\n  \"updated_at\": \"string (ISO 8601 datetime)\"\n}\n```\n\n### Valid Tags\n\nRecommended tag categories:\n\n- **Integration**: jira, linear, github, gitlab, azure-devops\n- **Category**: issue-tracking, vcs, ci-cd, documentation, testing\n- **Platform**: atlassian, microsoft, google\n- **Feature**: automation, reporting, deployment, monitoring\n\nUse 2-5 tags that best describe your extension.\n\n---\n\n*Last Updated: 2026-01-28*\n*Catalog Format Version: 1.0*\n"
  },
  {
    "path": "extensions/EXTENSION-USER-GUIDE.md",
    "content": "# Extension User Guide\n\nComplete guide for using Spec Kit extensions to enhance your workflow.\n\n## Table of Contents\n\n1. [Introduction](#introduction)\n2. [Getting Started](#getting-started)\n3. [Finding Extensions](#finding-extensions)\n4. [Installing Extensions](#installing-extensions)\n5. [Using Extensions](#using-extensions)\n6. [Managing Extensions](#managing-extensions)\n7. [Configuration](#configuration)\n8. [Troubleshooting](#troubleshooting)\n9. [Best Practices](#best-practices)\n\n---\n\n## Introduction\n\n### What are Extensions?\n\nExtensions are modular packages that add new commands and functionality to Spec Kit without bloating the core framework. They allow you to:\n\n- **Integrate** with external tools (Jira, Linear, GitHub, etc.)\n- **Automate** repetitive tasks with hooks\n- **Customize** workflows for your team\n- **Share** solutions across projects\n\n### Why Use Extensions?\n\n- **Clean Core**: Keeps spec-kit lightweight and focused\n- **Optional Features**: Only install what you need\n- **Community Driven**: Anyone can create and share extensions\n- **Version Controlled**: Extensions are versioned independently\n\n---\n\n## Getting Started\n\n### Prerequisites\n\n- Spec Kit version 0.1.0 or higher\n- A spec-kit project (directory with `.specify/` folder)\n\n### Check Your Version\n\n```bash\nspecify version\n# Should show 0.1.0 or higher\n```\n\n### First Extension\n\nLet's install the Jira extension as an example:\n\n```bash\n# 1. Search for the extension\nspecify extension search jira\n\n# 2. Get detailed information\nspecify extension info jira\n\n# 3. Install it\nspecify extension add jira\n\n# 4. Configure it\nvim .specify/extensions/jira/jira-config.yml\n\n# 5. Use it\n# (Commands are now available in Claude Code)\n/speckit.jira.specstoissues\n```\n\n---\n\n## Finding Extensions\n\n`specify extension search` searches **all active catalogs** simultaneously, including the community catalog by default. Results are annotated with their source catalog and install status.\n\n### Browse All Extensions\n\n```bash\nspecify extension search\n```\n\nShows all extensions across all active catalogs (default and community by default).\n\n### Search by Keyword\n\n```bash\n# Search for \"jira\"\nspecify extension search jira\n\n# Search for \"issue tracking\"\nspecify extension search issue\n```\n\n### Filter by Tag\n\n```bash\n# Find all issue-tracking extensions\nspecify extension search --tag issue-tracking\n\n# Find all Atlassian tools\nspecify extension search --tag atlassian\n```\n\n### Filter by Author\n\n```bash\n# Extensions by Stats Perform\nspecify extension search --author \"Stats Perform\"\n```\n\n### Show Verified Only\n\n```bash\n# Only show verified extensions\nspecify extension search --verified\n```\n\n### Get Extension Details\n\n```bash\n# Detailed information\nspecify extension info jira\n```\n\nShows:\n\n- Description\n- Requirements\n- Commands provided\n- Hooks available\n- Links (documentation, repository, changelog)\n- Installation status\n\n---\n\n## Installing Extensions\n\n### Install from Catalog\n\n```bash\n# By name (from catalog)\nspecify extension add jira\n```\n\nThis will:\n\n1. Download the extension from GitHub\n2. Validate the manifest\n3. Check compatibility with your spec-kit version\n4. Install to `.specify/extensions/jira/`\n5. Register commands with your AI agent\n6. Create config template\n\n### Install from URL\n\n```bash\n# From GitHub release\nspecify extension add --from https://github.com/org/spec-kit-ext/archive/refs/tags/v1.0.0.zip\n```\n\n### Install from Local Directory (Development)\n\n```bash\n# For testing or development\nspecify extension add --dev /path/to/extension\n```\n\n### Installation Output\n\n```text\n✓ Extension installed successfully!\n\nJira Integration (v1.0.0)\n  Create Jira Epics, Stories, and Issues from spec-kit artifacts\n\nProvided commands:\n  • speckit.jira.specstoissues - Create Jira hierarchy from spec and tasks\n  • speckit.jira.discover-fields - Discover Jira custom fields for configuration\n  • speckit.jira.sync-status - Sync task completion status to Jira\n\n⚠  Configuration may be required\n   Check: .specify/extensions/jira/\n```\n\n---\n\n## Using Extensions\n\n### Using Extension Commands\n\nExtensions add commands that appear in your AI agent (Claude Code):\n\n```text\n# In Claude Code\n> /speckit.jira.specstoissues\n\n# Or use short alias (if provided)\n> /speckit.specstoissues\n```\n\n### Extension Configuration\n\nMost extensions require configuration:\n\n```bash\n# 1. Find the config file\nls .specify/extensions/jira/\n\n# 2. Copy template to config\ncp .specify/extensions/jira/jira-config.template.yml \\\n   .specify/extensions/jira/jira-config.yml\n\n# 3. Edit configuration\nvim .specify/extensions/jira/jira-config.yml\n\n# 4. Use the extension\n# (Commands will now work with your config)\n```\n\n### Extension Hooks\n\nSome extensions provide hooks that execute after core commands:\n\n**Example**: Jira extension hooks into `/speckit.tasks`\n\n```text\n# Run core command\n> /speckit.tasks\n\n# Output includes:\n## Extension Hooks\n\n**Optional Hook**: jira\nCommand: `/speckit.jira.specstoissues`\nDescription: Automatically create Jira hierarchy after task generation\n\nPrompt: Create Jira issues from tasks?\nTo execute: `/speckit.jira.specstoissues`\n```\n\nYou can then choose to run the hook or skip it.\n\n---\n\n## Managing Extensions\n\n### List Installed Extensions\n\n```bash\nspecify extension list\n```\n\nOutput:\n\n```text\nInstalled Extensions:\n\n  ✓ Jira Integration (v1.0.0)\n     Create Jira Epics, Stories, and Issues from spec-kit artifacts\n     Commands: 3 | Hooks: 1 | Status: Enabled\n```\n\n### Update Extensions\n\n```bash\n# Check for updates (all extensions)\nspecify extension update\n\n# Update specific extension\nspecify extension update jira\n```\n\nOutput:\n\n```text\n🔄 Checking for updates...\n\nUpdates available:\n\n  • jira: 1.0.0 → 1.1.0\n\nUpdate these extensions? [y/N]:\n```\n\n### Disable Extension Temporarily\n\n```bash\n# Disable without removing\nspecify extension disable jira\n\n✓ Extension 'jira' disabled\n\nCommands will no longer be available. Hooks will not execute.\nTo re-enable: specify extension enable jira\n```\n\n### Re-enable Extension\n\n```bash\nspecify extension enable jira\n\n✓ Extension 'jira' enabled\n```\n\n### Remove Extension\n\n```bash\n# Remove extension (with confirmation)\nspecify extension remove jira\n\n# Keep configuration when removing\nspecify extension remove jira --keep-config\n\n# Force removal (no confirmation)\nspecify extension remove jira --force\n```\n\n---\n\n## Configuration\n\n### Configuration Files\n\nExtensions can have multiple configuration files:\n\n```text\n.specify/extensions/jira/\n├── jira-config.yml           # Main config (version controlled)\n├── jira-config.local.yml     # Local overrides (gitignored)\n└── jira-config.template.yml  # Template (reference)\n```\n\n### Configuration Layers\n\nConfiguration is merged in this order (highest priority last):\n\n1. **Extension defaults** (from `extension.yml`)\n2. **Project config** (`jira-config.yml`)\n3. **Local overrides** (`jira-config.local.yml`)\n4. **Environment variables** (`SPECKIT_JIRA_*`)\n\n### Example: Jira Configuration\n\n**Project config** (`.specify/extensions/jira/jira-config.yml`):\n\n```yaml\nproject:\n  key: \"MSATS\"\n\ndefaults:\n  epic:\n    labels: [\"spec-driven\"]\n```\n\n**Local override** (`.specify/extensions/jira/jira-config.local.yml`):\n\n```yaml\nproject:\n  key: \"MYTEST\"  # Override for local development\n```\n\n**Environment variable**:\n\n```bash\nexport SPECKIT_JIRA_PROJECT_KEY=\"DEVTEST\"\n```\n\nFinal resolved config uses `DEVTEST` from environment variable.\n\n### Project-Wide Extension Settings\n\nFile: `.specify/extensions.yml`\n\n```yaml\n# Extensions installed in this project\ninstalled:\n  - jira\n  - linear\n\n# Global settings\nsettings:\n  auto_execute_hooks: true\n\n# Hook configuration\n# Available events: before_specify, after_specify, before_plan, after_plan,\n#                   before_tasks, after_tasks, before_implement, after_implement\n# Planned (not yet wired into core templates): before_commit, after_commit\nhooks:\n  after_tasks:\n    - extension: jira\n      command: speckit.jira.specstoissues\n      enabled: true\n      optional: true\n      prompt: \"Create Jira issues from tasks?\"\n```\n\n### Core Environment Variables\n\nIn addition to extension-specific environment variables (`SPECKIT_{EXT_ID}_*`), spec-kit supports core environment variables:\n\n| Variable | Description | Default |\n|----------|-------------|---------|\n| `SPECKIT_CATALOG_URL`       | Override the full catalog stack with a single URL (backward compat) | Built-in default stack |\n| `GH_TOKEN` / `GITHUB_TOKEN` | GitHub API token for downloads     | None                  |\n\n#### Example: Using a custom catalog for testing\n\n```bash\n# Point to a local or alternative catalog (replaces the full stack)\nexport SPECKIT_CATALOG_URL=\"http://localhost:8000/catalog.json\"\n\n# Or use a staging catalog\nexport SPECKIT_CATALOG_URL=\"https://example.com/staging/catalog.json\"\n```\n\n---\n\n## Extension Catalogs\n\nSpec Kit uses a **catalog stack** — an ordered list of catalogs searched simultaneously. By default, two catalogs are active:\n\n| Priority | Catalog | Install Allowed | Purpose |\n|----------|---------|-----------------|---------|\n| 1 | `catalog.json` (default) | ✅ Yes | Curated extensions available for installation |\n| 2 | `catalog.community.json` (community) | ❌ No (discovery only) | Browse community extensions |\n\n### Listing Active Catalogs\n\n```bash\nspecify extension catalog list\n```\n\n### Managing Catalogs via CLI\n\nYou can view the main catalog management commands using `--help`:\n\n```text\nspecify extension catalog --help\n\n Usage: specify extension catalog [OPTIONS] COMMAND [ARGS]...\n\n Manage extension catalogs\n╭─ Options ────────────────────────────────────────────────────────────────────────╮\n│ --help          Show this message and exit.                                      │\n╰──────────────────────────────────────────────────────────────────────────────────╯\n╭─ Commands ───────────────────────────────────────────────────────────────────────╮\n│ list     List all active extension catalogs.                                     │\n│ add      Add a catalog to .specify/extension-catalogs.yml.                       │\n│ remove   Remove a catalog from .specify/extension-catalogs.yml.                  │\n╰──────────────────────────────────────────────────────────────────────────────────╯\n```\n\n### Adding a Catalog (Project-scoped)\n\n```bash\n# Add an internal catalog that allows installs\nspecify extension catalog add \\\n  --name \"internal\" \\\n  --priority 2 \\\n  --install-allowed \\\n  https://internal.company.com/spec-kit/catalog.json\n\n# Add a discovery-only catalog\nspecify extension catalog add \\\n  --name \"partner\" \\\n  --priority 5 \\\n  https://partner.example.com/spec-kit/catalog.json\n```\n\nThis creates or updates `.specify/extension-catalogs.yml`.\n\n### Removing a Catalog\n\n```bash\nspecify extension catalog remove internal\n```\n\n### Manual Config File\n\nYou can also edit `.specify/extension-catalogs.yml` directly:\n\n```yaml\ncatalogs:\n  - name: \"default\"\n    url: \"https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json\"\n    priority: 1\n    install_allowed: true\n    description: \"Built-in catalog of installable extensions\"\n\n  - name: \"internal\"\n    url: \"https://internal.company.com/spec-kit/catalog.json\"\n    priority: 2\n    install_allowed: true\n    description: \"Internal company extensions\"\n\n  - name: \"community\"\n    url: \"https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json\"\n    priority: 3\n    install_allowed: false\n    description: \"Community-contributed extensions (discovery only)\"\n```\n\nA user-level equivalent lives at `~/.specify/extension-catalogs.yml`. Project-level config takes full precedence when it contains one or more catalog entries. An empty `catalogs: []` list falls back to built-in defaults.\n\n## Organization Catalog Customization\n\n### Why Customize Your Catalog\n\nOrganizations customize their catalogs to:\n\n- **Control available extensions** - Curate which extensions your team can install\n- **Host private extensions** - Internal tools that shouldn't be public\n- **Customize for compliance** - Meet security/audit requirements\n- **Support air-gapped environments** - Work without internet access\n\n### Setting Up a Custom Catalog\n\n#### 1. Create Your Catalog File\n\nCreate a `catalog.json` file with your extensions:\n\n```json\n{\n  \"schema_version\": \"1.0\",\n  \"updated_at\": \"2026-02-03T00:00:00Z\",\n  \"catalog_url\": \"https://your-org.com/spec-kit/catalog.json\",\n  \"extensions\": {\n    \"jira\": {\n      \"name\": \"Jira Integration\",\n      \"id\": \"jira\",\n      \"description\": \"Create Jira issues from spec-kit artifacts\",\n      \"author\": \"Your Organization\",\n      \"version\": \"2.1.0\",\n      \"download_url\": \"https://github.com/your-org/spec-kit-jira/archive/refs/tags/v2.1.0.zip\",\n      \"repository\": \"https://github.com/your-org/spec-kit-jira\",\n      \"license\": \"MIT\",\n      \"requires\": {\n        \"speckit_version\": \">=0.1.0\",\n        \"tools\": [\n          {\"name\": \"atlassian-mcp-server\", \"required\": true}\n        ]\n      },\n      \"provides\": {\n        \"commands\": 3,\n        \"hooks\": 1\n      },\n      \"tags\": [\"jira\", \"atlassian\", \"issue-tracking\"],\n      \"verified\": true\n    },\n    \"internal-tool\": {\n      \"name\": \"Internal Tool Integration\",\n      \"id\": \"internal-tool\",\n      \"description\": \"Connect to internal company systems\",\n      \"author\": \"Your Organization\",\n      \"version\": \"1.0.0\",\n      \"download_url\": \"https://internal.your-org.com/extensions/internal-tool-1.0.0.zip\",\n      \"repository\": \"https://github.internal.your-org.com/spec-kit-internal\",\n      \"license\": \"Proprietary\",\n      \"requires\": {\n        \"speckit_version\": \">=0.1.0\"\n      },\n      \"provides\": {\n        \"commands\": 2\n      },\n      \"tags\": [\"internal\", \"proprietary\"],\n      \"verified\": true\n    }\n  }\n}\n```\n\n#### 2. Host the Catalog\n\nOptions for hosting your catalog:\n\n| Method | URL Example | Use Case |\n| ------ | ----------- | -------- |\n| GitHub Pages | `https://your-org.github.io/spec-kit-catalog/catalog.json` | Public or org-visible |\n| Internal web server | `https://internal.company.com/spec-kit/catalog.json` | Corporate network |\n| S3/Cloud storage | `https://s3.amazonaws.com/your-bucket/catalog.json` | Cloud-hosted teams |\n| Local file server | `http://localhost:8000/catalog.json` | Development/testing |\n\n**Security requirement**: URLs must use HTTPS (except `localhost` for testing).\n\n#### 3. Configure Your Environment\n\n##### Option A: Catalog stack config file (recommended)\n\nAdd to `.specify/extension-catalogs.yml` in your project:\n\n```yaml\ncatalogs:\n  - name: \"my-org\"\n    url: \"https://your-org.com/spec-kit/catalog.json\"\n    priority: 1\n    install_allowed: true\n```\n\nOr use the CLI:\n\n```bash\nspecify extension catalog add \\\n  --name \"my-org\" \\\n  --install-allowed \\\n  https://your-org.com/spec-kit/catalog.json\n```\n\n##### Option B: Environment variable (recommended for CI/CD, single-catalog)\n\n```bash\n# In ~/.bashrc, ~/.zshrc, or CI pipeline\nexport SPECKIT_CATALOG_URL=\"https://your-org.com/spec-kit/catalog.json\"\n```\n\n#### 4. Verify Configuration\n\n```bash\n# List active catalogs\nspecify extension catalog list\n\n# Search should now show your catalog's extensions\nspecify extension search\n\n# Install from your catalog\nspecify extension add jira\n```\n\n### Catalog JSON Schema\n\nRequired fields for each extension entry:\n\n| Field | Type | Required | Description |\n| ----- | ---- | -------- | ----------- |\n| `name` | string | Yes | Human-readable name |\n| `id` | string | Yes | Unique identifier (lowercase, hyphens) |\n| `version` | string | Yes | Semantic version (X.Y.Z) |\n| `download_url` | string | Yes | URL to ZIP archive |\n| `repository` | string | Yes | Source code URL |\n| `description` | string | No | Brief description |\n| `author` | string | No | Author/organization |\n| `license` | string | No | SPDX license identifier |\n| `requires.speckit_version` | string | No | Version constraint |\n| `requires.tools` | array | No | Required external tools |\n| `provides.commands` | number | No | Number of commands |\n| `provides.hooks` | number | No | Number of hooks |\n| `tags` | array | No | Search tags |\n| `verified` | boolean | No | Verification status |\n\n### Use Cases\n\n#### Private/Internal Extensions\n\nHost proprietary extensions that integrate with internal systems:\n\n```json\n{\n  \"internal-auth\": {\n    \"name\": \"Internal SSO Integration\",\n    \"download_url\": \"https://artifactory.company.com/spec-kit/internal-auth-1.0.0.zip\",\n    \"verified\": true\n  }\n}\n```\n\n#### Curated Team Catalog\n\nLimit which extensions your team can install:\n\n```json\n{\n  \"extensions\": {\n    \"jira\": { \"...\" },\n    \"github\": { \"...\" }\n  }\n}\n```\n\nOnly `jira` and `github` will appear in `specify extension search`.\n\n#### Air-Gapped Environments\n\nFor networks without internet access:\n\n1. Download extension ZIPs to internal file server\n2. Create catalog pointing to internal URLs\n3. Host catalog on internal web server\n\n```json\n{\n  \"jira\": {\n    \"download_url\": \"https://files.internal/spec-kit/jira-2.1.0.zip\"\n  }\n}\n```\n\n#### Development/Testing\n\nTest new extensions before publishing:\n\n```bash\n# Start local server\npython -m http.server 8000 --directory ./my-catalog/\n\n# Point spec-kit to local catalog\nexport SPECKIT_CATALOG_URL=\"http://localhost:8000/catalog.json\"\n\n# Test installation\nspecify extension add my-new-extension\n```\n\n### Combining with Direct Installation\n\nYou can still install extensions not in your catalog using `--from`:\n\n```bash\n# From catalog\nspecify extension add jira\n\n# Direct URL (bypasses catalog)\nspecify extension add --from https://github.com/someone/spec-kit-ext/archive/v1.0.0.zip\n\n# Local development\nspecify extension add --dev /path/to/extension\n```\n\n**Note**: Direct URL installation shows a security warning since the extension isn't from your configured catalog.\n\n---\n\n## Troubleshooting\n\n### Extension Not Found\n\n**Error**: `Extension 'jira' not found in catalog\n\n**Solutions**:\n\n1. Check spelling: `specify extension search jira`\n2. Refresh catalog: `specify extension search --help`\n3. Check internet connection\n4. Extension may not be published yet\n\n### Configuration Not Found\n\n**Error**: `Jira configuration not found`\n\n**Solutions**:\n\n1. Check if extension is installed: `specify extension list`\n2. Create config from template:\n\n   ```bash\n   cp .specify/extensions/jira/jira-config.template.yml \\\n      .specify/extensions/jira/jira-config.yml\n   ```\n\n3. Reinstall extension: `specify extension remove jira && specify extension add jira`\n\n### Command Not Available\n\n**Issue**: Extension command not appearing in AI agent\n\n**Solutions**:\n\n1. Check extension is enabled: `specify extension list`\n2. Restart AI agent (Claude Code)\n3. Check command file exists:\n\n   ```bash\n   ls .claude/commands/speckit.jira.*.md\n   ```\n\n4. Reinstall extension\n\n### Incompatible Version\n\n**Error**: `Extension requires spec-kit >=0.2.0, but you have 0.1.0`\n\n**Solutions**:\n\n1. Upgrade spec-kit:\n\n   ```bash\n   uv tool upgrade specify-cli\n   ```\n\n2. Install older version of extension:\n\n   ```bash\n   specify extension add --from https://github.com/org/ext/archive/v1.0.0.zip\n   ```\n\n### MCP Tool Not Available\n\n**Error**: `Tool 'jira-mcp-server/epic_create' not found`\n\n**Solutions**:\n\n1. Check MCP server is installed\n2. Check AI agent MCP configuration\n3. Restart AI agent\n4. Check extension requirements: `specify extension info jira`\n\n### Permission Denied\n\n**Error**: `Permission denied` when accessing Jira\n\n**Solutions**:\n\n1. Check Jira credentials in MCP server config\n2. Verify project permissions in Jira\n3. Test MCP server connection independently\n\n---\n\n## Best Practices\n\n### 1. Version Control\n\n**Do commit**:\n\n- `.specify/extensions.yml` (project extension config)\n- `.specify/extensions/*/jira-config.yml` (project config)\n\n**Don't commit**:\n\n- `.specify/extensions/.cache/` (catalog cache)\n- `.specify/extensions/.backup/` (config backups)\n- `.specify/extensions/*/*.local.yml` (local overrides)\n- `.specify/extensions/.registry` (installation state)\n\nAdd to `.gitignore`:\n\n```gitignore\n.specify/extensions/.cache/\n.specify/extensions/.backup/\n.specify/extensions/*/*.local.yml\n.specify/extensions/.registry\n```\n\n### 2. Team Workflows\n\n**For teams**:\n\n1. Agree on which extensions to use\n2. Commit extension configuration\n3. Document extension usage in README\n4. Keep extensions updated together\n\n**Example README section**:\n\n```markdown\n## Extensions\n\nThis project uses:\n- **jira** (v1.0.0) - Jira integration\n  - Config: `.specify/extensions/jira/jira-config.yml`\n  - Requires: jira-mcp-server\n\nTo install: `specify extension add jira`\n```\n\n### 3. Local Development\n\nUse local config for development:\n\n```yaml\n# .specify/extensions/jira/jira-config.local.yml\nproject:\n  key: \"DEVTEST\"  # Your test project\n\ndefaults:\n  task:\n    custom_fields:\n      customfield_10002: 1  # Lower story points for testing\n```\n\n### 4. Environment-Specific Config\n\nUse environment variables for CI/CD:\n\n```bash\n# .github/workflows/deploy.yml\nenv:\n  SPECKIT_JIRA_PROJECT_KEY: ${{ secrets.JIRA_PROJECT }}\n\n- name: Create Jira Issues\n  run: specify extension add jira && ...\n```\n\n### 5. Extension Updates\n\n**Check for updates regularly**:\n\n```bash\n# Weekly or before major releases\nspecify extension update\n```\n\n**Pin versions for stability**:\n\n```yaml\n# .specify/extensions.yml\ninstalled:\n  - id: jira\n    version: \"1.0.0\"  # Pin to specific version\n```\n\n### 6. Minimal Extensions\n\nOnly install extensions you actively use:\n\n- Reduces complexity\n- Faster command loading\n- Less configuration\n\n### 7. Documentation\n\nDocument extension usage in your project:\n\n```markdown\n# PROJECT.md\n\n## Working with Jira\n\nAfter creating tasks, sync to Jira:\n1. Run `/speckit.tasks` to generate tasks\n2. Run `/speckit.jira.specstoissues` to create Jira issues\n3. Run `/speckit.jira.sync-status` to update status\n```\n\n---\n\n## FAQ\n\n### Q: Can I use multiple extensions at once?\n\n**A**: Yes! Extensions are designed to work together. Install as many as you need.\n\n### Q: Do extensions slow down spec-kit?\n\n**A**: No. Extensions are loaded on-demand and only when their commands are used.\n\n### Q: Can I create private extensions?\n\n**A**: Yes. Install with `--dev` or `--from` and keep private. Public catalog submission is optional.\n\n### Q: How do I know if an extension is safe?\n\n**A**: Look for the ✓ Verified badge. Verified extensions are reviewed by maintainers. Always review extension code before installing.\n\n### Q: Can extensions modify spec-kit core?\n\n**A**: No. Extensions can only add commands and hooks. They cannot modify core functionality.\n\n### Q: What happens if two extensions have the same command name?\n\n**A**: Extensions use namespaced commands (`speckit.{extension}.{command}`), so conflicts are very rare. The extension system will warn you if conflicts occur.\n\n### Q: Can I contribute to existing extensions?\n\n**A**: Yes! Most extensions are open source. Check the repository link in `specify extension info {extension}`.\n\n### Q: How do I report extension bugs?\n\n**A**: Go to the extension's repository (shown in `specify extension info`) and create an issue.\n\n### Q: Can extensions work offline?\n\n**A**: Once installed, extensions work offline. However, some extensions may require internet for their functionality (e.g., Jira requires Jira API access).\n\n### Q: How do I backup my extension configuration?\n\n**A**: Extension configs are in `.specify/extensions/{extension}/`. Back up this directory or commit configs to git.\n\n---\n\n## Support\n\n- **Extension Issues**: Report to extension repository (see `specify extension info`)\n- **Spec Kit Issues**: <https://github.com/statsperform/spec-kit/issues>\n- **Extension Catalog**: <https://github.com/statsperform/spec-kit/tree/main/extensions>\n- **Documentation**: See EXTENSION-DEVELOPMENT-GUIDE.md and EXTENSION-PUBLISHING-GUIDE.md\n\n---\n\n*Last Updated: 2026-01-28*\n*Spec Kit Version: 0.1.0*\n"
  },
  {
    "path": "extensions/README.md",
    "content": "# Spec Kit Extensions\n\nExtension system for [Spec Kit](https://github.com/github/spec-kit) - add new functionality without bloating the core framework.\n\n## Extension Catalogs\n\nSpec Kit provides two catalog files with different purposes:\n\n### Your Catalog (`catalog.json`)\n\n- **Purpose**: Default upstream catalog of extensions used by the Spec Kit CLI\n- **Default State**: Empty by design in the upstream project - you or your organization populate a fork/copy with extensions you trust\n- **Location (upstream)**: `extensions/catalog.json` in the GitHub-hosted spec-kit repo\n- **CLI Default**: The `specify extension` commands use the upstream catalog URL by default, unless overridden\n- **Org Catalog**: Point `SPECKIT_CATALOG_URL` at your organization's fork or hosted catalog JSON to use it instead of the upstream default\n- **Customization**: Copy entries from the community catalog into your org catalog, or add your own extensions directly\n\n**Example override:**\n```bash\n# Override the default upstream catalog with your organization's catalog\nexport SPECKIT_CATALOG_URL=\"https://your-org.com/spec-kit/catalog.json\"\nspecify extension search  # Now uses your organization's catalog instead of the upstream default\n```\n\n### Community Reference Catalog (`catalog.community.json`)\n\n- **Purpose**: Browse available community-contributed extensions\n- **Status**: Active - contains extensions submitted by the community\n- **Location**: `extensions/catalog.community.json`\n- **Usage**: Reference catalog for discovering available extensions\n- **Submission**: Open to community contributions via Pull Request\n\n**How It Works:**\n\n## Making Extensions Available\n\nYou control which extensions your team can discover and install:\n\n### Option 1: Curated Catalog (Recommended for Organizations)\n\nPopulate your `catalog.json` with approved extensions:\n\n1. **Discover** extensions from various sources:\n   - Browse `catalog.community.json` for community extensions\n   - Find private/internal extensions in your organization's repos\n   - Discover extensions from trusted third parties\n2. **Review** extensions and choose which ones you want to make available\n3. **Add** those extension entries to your own `catalog.json`\n4. **Team members** can now discover and install them:\n   - `specify extension search` shows your curated catalog\n   - `specify extension add <name>` installs from your catalog\n\n**Benefits**: Full control over available extensions, team consistency, organizational approval workflow\n\n**Example**: Copy an entry from `catalog.community.json` to your `catalog.json`, then your team can discover and install it by name.\n\n### Option 2: Direct URLs (For Ad-hoc Use)\n\nSkip catalog curation - team members install directly using URLs:\n\n```bash\nspecify extension add --from https://github.com/org/spec-kit-ext/archive/refs/tags/v1.0.0.zip\n```\n\n**Benefits**: Quick for one-off testing or private extensions\n\n**Tradeoff**: Extensions installed this way won't appear in `specify extension search` for other team members unless you also add them to your `catalog.json`.\n\n## Available Community Extensions\n\nThe following community-contributed extensions are available in [`catalog.community.json`](catalog.community.json):\n\n**Categories:** `docs` — reads, validates, or generates spec artifacts · `code` — reviews, validates, or modifies source code · `process` — orchestrates workflow across phases · `integration` — syncs with external platforms · `visibility` — reports on project health or progress\n\n**Effect:** `Read-only` — produces reports without modifying files · `Read+Write` — modifies files, creates artifacts, or updates specs\n\n| Extension | Purpose | Category | Effect | URL |\n|-----------|---------|----------|--------|-----|\n| Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) |\n| Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) |\n| Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) |\n| Cognitive Squad | Multi-agent cognitive system with Triadic Model: understanding, internalization, application — with quality gates, backpropagation verification, and self-healing | `docs` | Read+Write | [cognitive-squad](https://github.com/Testimonial/cognitive-squad) |\n| Conduct Extension | Orchestrates spec-kit phases via sub-agent delegation to reduce context pollution. | `process` | Read+Write | [spec-kit-conduct-ext](https://github.com/twbrandon7/spec-kit-conduct-ext) |\n| DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero NPM runtime dependencies. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |\n| Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) |\n| Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) |\n| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |\n| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |\n| Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) |\n| Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | `visibility` | Read-only | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) |\n| Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss/spec-kit-ralph) |\n| Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | `docs` | Read+Write | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) |\n| Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | `docs` | Read+Write | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) |\n| Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) |\n| SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | `process` | Read+Write | [speckit-utils](https://github.com/mvanhorn/speckit-utils) |\n| Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) |\n| Understanding | Automated requirements quality analysis — 31 deterministic metrics against IEEE/ISO standards with experimental energy-based ambiguity detection | `docs` | Read-only | [understanding](https://github.com/Testimonial/understanding) |\n| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) |\n| Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) |\n| Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) |\n\n\n## Adding Your Extension\n\n### Submission Process\n\nTo add your extension to the community catalog:\n\n1. **Prepare your extension** following the [Extension Development Guide](EXTENSION-DEVELOPMENT-GUIDE.md)\n2. **Create a GitHub release** for your extension\n3. **Submit a Pull Request** that:\n   - Adds your extension to `extensions/catalog.community.json`\n   - Updates this README with your extension in the Available Extensions table\n4. **Wait for review** - maintainers will review and merge if criteria are met\n\nSee the [Extension Publishing Guide](EXTENSION-PUBLISHING-GUIDE.md) for detailed step-by-step instructions.\n\n### Submission Checklist\n\nBefore submitting, ensure:\n\n- ✅ Valid `extension.yml` manifest\n- ✅ Complete README with installation and usage instructions\n- ✅ LICENSE file included\n- ✅ GitHub release created with semantic version (e.g., v1.0.0)\n- ✅ Extension tested on a real project\n- ✅ All commands working as documented\n\n## Installing Extensions\nOnce extensions are available (either in your catalog or via direct URL), install them:\n\n```bash\n# From your curated catalog (by name)\nspecify extension search                  # See what's in your catalog\nspecify extension add <extension-name>    # Install by name\n\n# Direct from URL (bypasses catalog)\nspecify extension add --from https://github.com/<org>/<repo>/archive/refs/tags/<version>.zip\n\n# List installed extensions\nspecify extension list\n```\n\nFor more information, see the [Extension User Guide](EXTENSION-USER-GUIDE.md).\n"
  },
  {
    "path": "extensions/RFC-EXTENSION-SYSTEM.md",
    "content": "# RFC: Spec Kit Extension System\n\n**Status**: Implemented\n**Author**: Stats Perform Engineering\n**Created**: 2026-01-28\n**Updated**: 2026-03-11\n\n---\n\n## Table of Contents\n\n1. [Summary](#summary)\n2. [Motivation](#motivation)\n3. [Design Principles](#design-principles)\n4. [Architecture Overview](#architecture-overview)\n5. [Extension Manifest Specification](#extension-manifest-specification)\n6. [Extension Lifecycle](#extension-lifecycle)\n7. [Command Registration](#command-registration)\n8. [Configuration Management](#configuration-management)\n9. [Hook System](#hook-system)\n10. [Extension Discovery & Catalog](#extension-discovery--catalog)\n11. [CLI Commands](#cli-commands)\n12. [Compatibility & Versioning](#compatibility--versioning)\n13. [Security Considerations](#security-considerations)\n14. [Migration Strategy](#migration-strategy)\n15. [Implementation Phases](#implementation-phases)\n16. [Resolved Questions](#resolved-questions)\n17. [Open Questions (Remaining)](#open-questions-remaining)\n18. [Appendices](#appendices)\n\n---\n\n## Summary\n\nIntroduce an extension system to Spec Kit that allows modular integration with external tools (Jira, Linear, Azure DevOps, etc.) without bloating the core framework. Extensions are self-contained packages installed into `.specify/extensions/` with declarative manifests, versioned independently, and discoverable through a central catalog.\n\n---\n\n## Motivation\n\n### Current Problems\n\n1. **Monolithic Growth**: Adding Jira integration to core spec-kit creates:\n   - Large configuration files affecting all users\n   - Dependencies on Jira MCP server for everyone\n   - Merge conflicts as features accumulate\n\n2. **Limited Flexibility**: Different organizations use different tools:\n   - GitHub Issues vs Jira vs Linear vs Azure DevOps\n   - Custom internal tools\n   - No way to support all without bloat\n\n3. **Maintenance Burden**: Every integration adds:\n   - Documentation complexity\n   - Testing matrix expansion\n   - Breaking change surface area\n\n4. **Community Friction**: External contributors can't easily add integrations without core repo PR approval and release cycles.\n\n### Goals\n\n1. **Modularity**: Core spec-kit remains lean, extensions are opt-in\n2. **Extensibility**: Clear API for building new integrations\n3. **Independence**: Extensions version/release separately from core\n4. **Discoverability**: Central catalog for finding extensions\n5. **Safety**: Validation, compatibility checks, sandboxing\n\n---\n\n## Design Principles\n\n### 1. Convention Over Configuration\n\n- Standard directory structure (`.specify/extensions/{name}/`)\n- Declarative manifest (`extension.yml`)\n- Predictable command naming (`speckit.{extension}.{command}`)\n\n### 2. Fail-Safe Defaults\n\n- Missing extensions gracefully degrade (skip hooks)\n- Invalid extensions warn but don't break core functionality\n- Extension failures isolated from core operations\n\n### 3. Backward Compatibility\n\n- Core commands remain unchanged\n- Extensions additive only (no core modifications)\n- Old projects work without extensions\n\n### 4. Developer Experience\n\n- Simple installation: `specify extension add jira`\n- Clear error messages for compatibility issues\n- Local development mode for testing extensions\n\n### 5. Security First\n\n- Extensions run in same context as AI agent (trust boundary)\n- Manifest validation prevents malicious code\n- Verify signatures for official extensions (future)\n\n---\n\n## Architecture Overview\n\n### Directory Structure\n\n```text\nproject/\n├── .specify/\n│   ├── scripts/                 # Core scripts (unchanged)\n│   ├── templates/               # Core templates (unchanged)\n│   ├── memory/                  # Session memory\n│   ├── extensions/              # Extensions directory (NEW)\n│   │   ├── .registry            # Installed extensions metadata (NEW)\n│   │   ├── jira/                # Jira extension\n│   │   │   ├── extension.yml    # Manifest\n│   │   │   ├── jira-config.yml  # Extension config\n│   │   │   ├── commands/        # Command files\n│   │   │   ├── scripts/         # Helper scripts\n│   │   │   └── docs/            # Documentation\n│   │   └── linear/              # Linear extension (example)\n│   └── extensions.yml           # Project extension configuration (NEW)\n└── .gitignore                   # Ignore local extension configs\n```\n\n### Component Diagram\n\n```text\n┌─────────────────────────────────────────────────────────┐\n│                    Spec Kit Core                        │\n│  ┌──────────────────────────────────────────────────┐   │\n│  │  CLI (specify)                                   │   │\n│  │  - init, check                                   │   │\n│  │  - extension add/remove/list/update  ← NEW       │   │\n│  └──────────────────────────────────────────────────┘   │\n│  ┌──────────────────────────────────────────────────┐   │\n│  │  Extension Manager  ← NEW                        │   │\n│  │  - Discovery, Installation, Validation           │   │\n│  │  - Command Registration, Hook Execution          │   │\n│  └──────────────────────────────────────────────────┘   │\n│  ┌──────────────────────────────────────────────────┐   │\n│  │  Core Commands                                   │   │\n│  │  - /speckit.specify                              │   │\n│  │  - /speckit.tasks                                │   │\n│  │  - /speckit.implement                            │   │\n│  └─────────┬────────────────────────────────────────┘   │\n└────────────┼────────────────────────────────────────────┘\n             │ Hook Points (after_tasks, after_implement)\n             ↓\n┌─────────────────────────────────────────────────────────┐\n│                    Extensions                           │\n│  ┌──────────────────────────────────────────────────┐   │\n│  │  Jira Extension                                  │   │\n│  │  - /speckit.jira.specstoissues                   │   │\n│  │  - /speckit.jira.discover-fields                 │   │\n│  └──────────────────────────────────────────────────┘   │\n│  ┌──────────────────────────────────────────────────┐   │\n│  │  Linear Extension                                │   │\n│  │  - /speckit.linear.sync                          │   │\n│  └──────────────────────────────────────────────────┘   │\n└─────────────────────────────────────────────────────────┘\n             │ Calls external tools\n             ↓\n┌─────────────────────────────────────────────────────────┐\n│                    External Tools                       │\n│  - Jira MCP Server                                      │\n│  - Linear API                                           │\n│  - GitHub API                                           │\n└─────────────────────────────────────────────────────────┘\n```\n\n---\n\n## Extension Manifest Specification\n\n### Schema: `extension.yml`\n\n```yaml\n# Extension Manifest Schema v1.0\n# All extensions MUST include this file at root\n\n# Schema version for compatibility\nschema_version: \"1.0\"\n\n# Extension metadata (REQUIRED)\nextension:\n  id: \"jira\"                    # Unique identifier (lowercase, alphanumeric, hyphens)\n  name: \"Jira Integration\"      # Human-readable name\n  version: \"1.0.0\"              # Semantic version\n  description: \"Create Jira Epics, Stories, and Issues from spec-kit artifacts\"\n  author: \"Stats Perform\"       # Author/organization\n  repository: \"https://github.com/statsperform/spec-kit-jira\"\n  license: \"MIT\"                # SPDX license identifier\n  homepage: \"https://github.com/statsperform/spec-kit-jira/blob/main/README.md\"\n\n# Compatibility requirements (REQUIRED)\nrequires:\n  # Spec-kit version (semantic version range)\n  speckit_version: \">=0.1.0,<2.0.0\"\n\n  # External tools required by extension\n  tools:\n    - name: \"jira-mcp-server\"\n      required: true\n      version: \">=1.0.0\"          # Optional: version constraint\n      description: \"Jira MCP server for API access\"\n      install_url: \"https://github.com/your-org/jira-mcp-server\"\n      check_command: \"jira --version\"  # Optional: CLI command to verify\n\n  # Core spec-kit commands this extension depends on\n  commands:\n    - \"speckit.tasks\"             # Extension needs tasks command\n\n  # Core scripts required\n  scripts:\n    - \"check-prerequisites.sh\"\n\n# What this extension provides (REQUIRED)\nprovides:\n  # Commands added to AI agent\n  commands:\n    - name: \"speckit.jira.specstoissues\"\n      file: \"commands/specstoissues.md\"\n      description: \"Create Jira hierarchy from spec and tasks\"\n      aliases: [\"speckit.specstoissues\"]  # Alternate names\n\n    - name: \"speckit.jira.discover-fields\"\n      file: \"commands/discover-fields.md\"\n      description: \"Discover Jira custom fields for configuration\"\n\n    - name: \"speckit.jira.sync-status\"\n      file: \"commands/sync-status.md\"\n      description: \"Sync task completion status to Jira\"\n\n  # Configuration files\n  config:\n    - name: \"jira-config.yml\"\n      template: \"jira-config.template.yml\"\n      description: \"Jira integration configuration\"\n      required: true              # User must configure before use\n\n  # Helper scripts\n  scripts:\n    - name: \"parse-jira-config.sh\"\n      file: \"scripts/parse-jira-config.sh\"\n      description: \"Parse jira-config.yml to JSON\"\n      executable: true            # Make executable on install\n\n# Extension configuration defaults (OPTIONAL)\ndefaults:\n  project:\n    key: null                     # No default, user must configure\n  hierarchy:\n    issue_type: \"subtask\"\n  update_behavior:\n    mode: \"update\"\n    sync_completion: true\n\n# Configuration schema for validation (OPTIONAL)\nconfig_schema:\n  type: \"object\"\n  required: [\"project\"]\n  properties:\n    project:\n      type: \"object\"\n      required: [\"key\"]\n      properties:\n        key:\n          type: \"string\"\n          pattern: \"^[A-Z]{2,10}$\"\n          description: \"Jira project key (e.g., MSATS)\"\n\n# Integration hooks (OPTIONAL)\nhooks:\n  # Hook fired after /speckit.tasks completes\n  after_tasks:\n    command: \"speckit.jira.specstoissues\"\n    optional: true\n    prompt: \"Create Jira issues from tasks?\"\n    description: \"Automatically create Jira hierarchy after task generation\"\n\n  # Hook fired after /speckit.implement completes\n  after_implement:\n    command: \"speckit.jira.sync-status\"\n    optional: true\n    prompt: \"Sync completion status to Jira?\"\n\n# Tags for discovery (OPTIONAL)\ntags:\n  - \"issue-tracking\"\n  - \"jira\"\n  - \"atlassian\"\n  - \"project-management\"\n\n# Changelog URL (OPTIONAL)\nchangelog: \"https://github.com/statsperform/spec-kit-jira/blob/main/CHANGELOG.md\"\n\n# Support information (OPTIONAL)\nsupport:\n  documentation: \"https://github.com/statsperform/spec-kit-jira/blob/main/docs/\"\n  issues: \"https://github.com/statsperform/spec-kit-jira/issues\"\n  discussions: \"https://github.com/statsperform/spec-kit-jira/discussions\"\n  email: \"support@statsperform.com\"\n```\n\n### Validation Rules\n\n1. **MUST have** `schema_version`, `extension`, `requires`, `provides`\n2. **MUST follow** semantic versioning for `version`\n3. **MUST have** unique `id` (no conflicts with other extensions)\n4. **MUST declare** all external tool dependencies\n5. **SHOULD include** `config_schema` if extension uses config\n6. **SHOULD include** `support` information\n7. Command `file` paths **MUST be** relative to extension root\n8. Hook `command` names **MUST match** a command in `provides.commands`\n\n---\n\n## Extension Lifecycle\n\n### 1. Discovery\n\n```bash\nspecify extension search jira\n# Searches catalog for extensions matching \"jira\"\n```\n\n**Process:**\n\n1. Fetch extension catalog from GitHub\n2. Filter by search term (name, tags, description)\n3. Display results with metadata\n\n### 2. Installation\n\n```bash\nspecify extension add jira\n```\n\n**Process:**\n\n1. **Resolve**: Look up extension in catalog\n2. **Download**: Fetch extension package (ZIP from GitHub release)\n3. **Validate**: Check manifest schema, compatibility\n4. **Extract**: Unpack to `.specify/extensions/jira/`\n5. **Configure**: Copy config templates\n6. **Register**: Add commands to AI agent config\n7. **Record**: Update `.specify/extensions/.registry`\n\n**Registry Format** (`.specify/extensions/.registry`):\n\n```json\n{\n  \"schema_version\": \"1.0\",\n  \"extensions\": {\n    \"jira\": {\n      \"version\": \"1.0.0\",\n      \"installed_at\": \"2026-01-28T14:30:00Z\",\n      \"source\": \"catalog\",\n      \"manifest_hash\": \"sha256:abc123...\",\n      \"enabled\": true,\n      \"priority\": 10\n    }\n  }\n}\n```\n\n**Priority Field**: Extensions are ordered by `priority` (lower = higher precedence). Default is 10. Used for template resolution when multiple extensions provide the same template.\n\n### 3. Configuration\n\n```bash\n# User edits extension config\nvim .specify/extensions/jira/jira-config.yml\n```\n\n**Config discovery order:**\n\n1. Extension defaults (`extension.yml` → `defaults`)\n2. Project config (`jira-config.yml`)\n3. Local overrides (`jira-config.local.yml` - gitignored)\n4. Environment variables (`SPECKIT_JIRA_*`)\n\n### 4. Usage\n\n```bash\nclaude\n> /speckit.jira.specstoissues\n```\n\n**Command resolution:**\n\n1. AI agent finds command in `.claude/commands/speckit.jira.specstoissues.md`\n2. Command file references extension scripts/config\n3. Extension executes with full context\n\n### 5. Update\n\n```bash\nspecify extension update jira\n```\n\n**Process:**\n\n1. Check catalog for newer version\n2. Download new version\n3. Validate compatibility\n4. Back up current config\n5. Extract new version (preserve config)\n6. Re-register commands\n7. Update registry\n\n### 6. Removal\n\n```bash\nspecify extension remove jira\n```\n\n**Process:**\n\n1. Confirm with user (show what will be removed)\n2. Unregister commands from AI agent\n3. Remove from `.specify/extensions/jira/`\n4. Update registry\n5. Optionally preserve config for reinstall\n\n---\n\n## Command Registration\n\n### Per-Agent Registration\n\nExtensions provide **universal command format** (Markdown-based), and CLI converts to agent-specific format during registration.\n\n#### Universal Command Format\n\n**Location**: Extension's `commands/specstoissues.md`\n\n```markdown\n---\n# Universal metadata (parsed by all agents)\ndescription: \"Create Jira hierarchy from spec and tasks\"\ntools:\n  - 'jira-mcp-server/epic_create'\n  - 'jira-mcp-server/story_create'\nscripts:\n  sh: ../../scripts/bash/check-prerequisites.sh --json\n  ps: ../../scripts/powershell/check-prerequisites.ps1 -Json\n---\n\n# Command implementation\n## User Input\n$ARGUMENTS\n\n## Steps\n1. Load jira-config.yml\n2. Parse spec.md and tasks.md\n3. Create Jira items\n```\n\n#### Claude Code Registration\n\n**Output**: `.claude/commands/speckit.jira.specstoissues.md`\n\n```markdown\n---\ndescription: \"Create Jira hierarchy from spec and tasks\"\ntools:\n  - 'jira-mcp-server/epic_create'\n  - 'jira-mcp-server/story_create'\nscripts:\n  sh: .specify/scripts/bash/check-prerequisites.sh --json\n  ps: .specify/scripts/powershell/check-prerequisites.ps1 -Json\n---\n\n# Command implementation (copied from extension)\n## User Input\n$ARGUMENTS\n\n## Steps\n1. Load jira-config.yml from .specify/extensions/jira/\n2. Parse spec.md and tasks.md\n3. Create Jira items\n```\n\n**Transformation:**\n\n- Copy frontmatter with adjustments\n- Rewrite script paths (relative to repo root)\n- Add extension context (config location)\n\n#### Gemini CLI Registration\n\n**Output**: `.gemini/commands/speckit.jira.specstoissues.toml`\n\n```toml\n[command]\nname = \"speckit.jira.specstoissues\"\ndescription = \"Create Jira hierarchy from spec and tasks\"\n\n[command.tools]\ntools = [\n  \"jira-mcp-server/epic_create\",\n  \"jira-mcp-server/story_create\"\n]\n\n[command.script]\nsh = \".specify/scripts/bash/check-prerequisites.sh --json\"\nps = \".specify/scripts/powershell/check-prerequisites.ps1 -Json\"\n\n[command.template]\ncontent = \"\"\"\n# Command implementation\n## User Input\n{{args}}\n\n## Steps\n1. Load jira-config.yml from .specify/extensions/jira/\n2. Parse spec.md and tasks.md\n3. Create Jira items\n\"\"\"\n```\n\n**Transformation:**\n\n- Convert Markdown frontmatter to TOML\n- Convert `$ARGUMENTS` to `{{args}}`\n- Rewrite script paths\n\n### Registration Code\n\n**Location**: `src/specify_cli/extensions.py`\n\n```python\ndef register_extension_commands(\n    project_path: Path,\n    ai_assistant: str,\n    manifest: dict\n) -> None:\n    \"\"\"Register extension commands with AI agent.\"\"\"\n\n    agent_config = AGENT_CONFIG.get(ai_assistant)\n    if not agent_config:\n        console.print(f\"[yellow]Unknown agent: {ai_assistant}[/yellow]\")\n        return\n\n    ext_id = manifest['extension']['id']\n    ext_dir = project_path / \".specify\" / \"extensions\" / ext_id\n    agent_commands_dir = project_path / agent_config['folder'].rstrip('/') / \"commands\"\n    agent_commands_dir.mkdir(parents=True, exist_ok=True)\n\n    for cmd_info in manifest['provides']['commands']:\n        cmd_name = cmd_info['name']\n        source_file = ext_dir / cmd_info['file']\n\n        if not source_file.exists():\n            console.print(f\"[red]Command file not found:[/red] {cmd_info['file']}\")\n            continue\n\n        # Convert to agent-specific format\n        if ai_assistant == \"claude\":\n            dest_file = agent_commands_dir / f\"{cmd_name}.md\"\n            convert_to_claude(source_file, dest_file, ext_dir)\n        elif ai_assistant == \"gemini\":\n            dest_file = agent_commands_dir / f\"{cmd_name}.toml\"\n            convert_to_gemini(source_file, dest_file, ext_dir)\n        elif ai_assistant == \"copilot\":\n            dest_file = agent_commands_dir / f\"{cmd_name}.md\"\n            convert_to_copilot(source_file, dest_file, ext_dir)\n        # ... other agents\n\n        console.print(f\"  ✓ Registered: {cmd_name}\")\n\ndef convert_to_claude(\n    source: Path,\n    dest: Path,\n    ext_dir: Path\n) -> None:\n    \"\"\"Convert universal command to Claude format.\"\"\"\n\n    # Parse universal command\n    content = source.read_text()\n    frontmatter, body = parse_frontmatter(content)\n\n    # Adjust script paths (relative to repo root)\n    if 'scripts' in frontmatter:\n        for key in frontmatter['scripts']:\n            frontmatter['scripts'][key] = adjust_path_for_repo_root(\n                frontmatter['scripts'][key]\n            )\n\n    # Inject extension context\n    body = inject_extension_context(body, ext_dir)\n\n    # Write Claude command\n    dest.write_text(render_frontmatter(frontmatter) + \"\\n\" + body)\n```\n\n---\n\n## Configuration Management\n\n### Configuration File Hierarchy\n\n```yaml\n# .specify/extensions/jira/jira-config.yml (Project config)\nproject:\n  key: \"MSATS\"\n\nhierarchy:\n  issue_type: \"subtask\"\n\ndefaults:\n  epic:\n    labels: [\"spec-driven\", \"typescript\"]\n```\n\n```yaml\n# .specify/extensions/jira/jira-config.local.yml (Local overrides - gitignored)\nproject:\n  key: \"MYTEST\"  # Override for local testing\n```\n\n```bash\n# Environment variables (highest precedence)\nexport SPECKIT_JIRA_PROJECT_KEY=\"DEVTEST\"\n```\n\n### Config Loading Function\n\n**Location**: Extension command (e.g., `commands/specstoissues.md`)\n\n````markdown\n## Load Configuration\n\n1. Run helper script to load and merge config:\n\n```bash\nconfig_json=$(bash .specify/extensions/jira/scripts/parse-jira-config.sh)\necho \"$config_json\"\n```\n\n1. Parse JSON and use in subsequent steps\n````\n\n**Script**: `.specify/extensions/jira/scripts/parse-jira-config.sh`\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n\nEXT_DIR=\".specify/extensions/jira\"\nCONFIG_FILE=\"$EXT_DIR/jira-config.yml\"\nLOCAL_CONFIG=\"$EXT_DIR/jira-config.local.yml\"\n\n# Start with defaults from extension.yml\ndefaults=$(yq eval '.defaults' \"$EXT_DIR/extension.yml\" -o=json)\n\n# Merge project config\nif [ -f \"$CONFIG_FILE\" ]; then\n  project_config=$(yq eval '.' \"$CONFIG_FILE\" -o=json)\n  defaults=$(echo \"$defaults $project_config\" | jq -s '.[0] * .[1]')\nfi\n\n# Merge local config\nif [ -f \"$LOCAL_CONFIG\" ]; then\n  local_config=$(yq eval '.' \"$LOCAL_CONFIG\" -o=json)\n  defaults=$(echo \"$defaults $local_config\" | jq -s '.[0] * .[1]')\nfi\n\n# Apply environment variable overrides\nif [ -n \"${SPECKIT_JIRA_PROJECT_KEY:-}\" ]; then\n  defaults=$(echo \"$defaults\" | jq \".project.key = \\\"$SPECKIT_JIRA_PROJECT_KEY\\\"\")\nfi\n\n# Output merged config as JSON\necho \"$defaults\"\n```\n\n### Config Validation\n\n**In command file**:\n\n````markdown\n## Validate Configuration\n\n1. Load config (from previous step)\n2. Validate against schema from extension.yml:\n\n```python\nimport jsonschema\n\nschema = load_yaml(\".specify/extensions/jira/extension.yml\")['config_schema']\nconfig = json.loads(config_json)\n\ntry:\n    jsonschema.validate(config, schema)\nexcept jsonschema.ValidationError as e:\n    print(f\"❌ Invalid jira-config.yml: {e.message}\")\n    print(f\"   Path: {'.'.join(str(p) for p in e.path)}\")\n    exit(1)\n```\n\n1. Proceed with validated config\n````\n\n---\n\n## Hook System\n\n### Hook Definition\n\n**In extension.yml:**\n\n```yaml\nhooks:\n  after_tasks:\n    command: \"speckit.jira.specstoissues\"\n    optional: true\n    prompt: \"Create Jira issues from tasks?\"\n    description: \"Automatically create Jira hierarchy\"\n    condition: \"config.project.key is set\"\n```\n\n### Hook Registration\n\n**During extension installation**, record hooks in project config:\n\n**File**: `.specify/extensions.yml` (project-level extension config)\n\n```yaml\n# Extensions installed in this project\ninstalled:\n  - jira\n  - linear\n\n# Global extension settings\nsettings:\n  auto_execute_hooks: true  # Prompt for optional hooks after commands\n\n# Hook configuration\nhooks:\n  after_tasks:\n    - extension: jira\n      command: speckit.jira.specstoissues\n      enabled: true\n      optional: true\n      prompt: \"Create Jira issues from tasks?\"\n\n  after_implement:\n    - extension: jira\n      command: speckit.jira.sync-status\n      enabled: true\n      optional: true\n      prompt: \"Sync completion status to Jira?\"\n```\n\n### Hook Execution\n\n**In core command** (e.g., `templates/commands/tasks.md`):\n\nAdd at end of command:\n\n````markdown\n## Extension Hooks\n\nAfter task generation completes, check for registered hooks:\n\n```bash\n# Check if extensions.yml exists and has after_tasks hooks\nif [ -f \".specify/extensions.yml\" ]; then\n  # Parse hooks for after_tasks\n  hooks=$(yq eval '.hooks.after_tasks[] | select(.enabled == true)' .specify/extensions.yml -o=json)\n\n  if [ -n \"$hooks\" ]; then\n    echo \"\"\n    echo \"📦 Extension hooks available:\"\n\n    # Iterate hooks\n    echo \"$hooks\" | jq -c '.' | while read -r hook; do\n      extension=$(echo \"$hook\" | jq -r '.extension')\n      command=$(echo \"$hook\" | jq -r '.command')\n      optional=$(echo \"$hook\" | jq -r '.optional')\n      prompt_text=$(echo \"$hook\" | jq -r '.prompt')\n\n      if [ \"$optional\" = \"true\" ]; then\n        # Prompt user\n        echo \"\"\n        read -p \"$prompt_text (y/n) \" -n 1 -r\n        echo\n        if [[ $REPLY =~ ^[Yy]$ ]]; then\n          echo \"▶ Executing: $command\"\n          # Let AI agent execute the command\n          # (AI agent will see this and execute)\n          echo \"EXECUTE_COMMAND: $command\"\n        fi\n      else\n        # Auto-execute mandatory hooks\n        echo \"▶ Executing: $command (required)\"\n        echo \"EXECUTE_COMMAND: $command\"\n      fi\n    done\n  fi\nfi\n```\n````\n\n**AI Agent Handling:**\n\nThe AI agent sees `EXECUTE_COMMAND: speckit.jira.specstoissues` in output and automatically invokes that command.\n\n**Alternative**: Direct call in agent context (if agent supports it):\n\n```python\n# In AI agent's command execution engine\ndef execute_command_with_hooks(command_name: str, args: str):\n    # Execute main command\n    result = execute_command(command_name, args)\n\n    # Check for hooks\n    hooks = load_hooks_for_phase(f\"after_{command_name}\")\n    for hook in hooks:\n        if hook.optional:\n            if confirm(hook.prompt):\n                execute_command(hook.command, args)\n        else:\n            execute_command(hook.command, args)\n\n    return result\n```\n\n### Hook Conditions\n\nExtensions can specify **conditions** for hooks:\n\n```yaml\nhooks:\n  after_tasks:\n    command: \"speckit.jira.specstoissues\"\n    optional: true\n    condition: \"config.project.key is set and config.enabled == true\"\n```\n\n**Condition evaluation** (in hook executor):\n\n```python\ndef should_execute_hook(hook: dict, config: dict) -> bool:\n    \"\"\"Evaluate hook condition.\"\"\"\n    condition = hook.get('condition')\n    if not condition:\n        return True  # No condition = always eligible\n\n    # Simple expression evaluator\n    # \"config.project.key is set\" → check if config['project']['key'] exists\n    # \"config.enabled == true\" → check if config['enabled'] is True\n\n    return eval_condition(condition, config)\n```\n\n---\n\n## Extension Discovery & Catalog\n\n### Dual Catalog System\n\nSpec Kit uses two catalog files with different purposes:\n\n#### User Catalog (`catalog.json`)\n\n**URL**: `https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json`\n\n- **Purpose**: Organization's curated catalog of approved extensions\n- **Default State**: Empty by design - users populate with extensions they trust\n- **Usage**: Primary catalog (priority 1, `install_allowed: true`) in the default stack\n- **Control**: Organizations maintain their own fork/version for their teams\n\n#### Community Reference Catalog (`catalog.community.json`)\n\n**URL**: `https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json`\n\n- **Purpose**: Reference catalog of available community-contributed extensions\n- **Verification**: Community extensions may have `verified: false` initially\n- **Status**: Active - open for community contributions\n- **Submission**: Via Pull Request following the Extension Publishing Guide\n- **Usage**: Secondary catalog (priority 2, `install_allowed: false`) in the default stack — discovery only\n\n**How It Works (default stack):**\n\n1. **Discover**: `specify extension search` searches both catalogs — community extensions appear automatically\n2. **Review**: Evaluate community extensions for security, quality, and organizational fit\n3. **Curate**: Copy approved entries from community catalog to your `catalog.json`, or add to `.specify/extension-catalogs.yml` with `install_allowed: true`\n4. **Install**: Use `specify extension add <name>` — only allowed from `install_allowed: true` catalogs\n\nThis approach gives organizations full control over which extensions can be installed while still providing community discoverability out of the box.\n\n### Catalog Format\n\n**Format** (same for both catalogs):\n\n```json\n{\n  \"schema_version\": \"1.0\",\n  \"updated_at\": \"2026-01-28T14:30:00Z\",\n  \"extensions\": {\n    \"jira\": {\n      \"name\": \"Jira Integration\",\n      \"id\": \"jira\",\n      \"description\": \"Create Jira Epics, Stories, and Issues from spec-kit artifacts\",\n      \"author\": \"Stats Perform\",\n      \"version\": \"1.0.0\",\n      \"download_url\": \"https://github.com/statsperform/spec-kit-jira/releases/download/v1.0.0/spec-kit-jira-1.0.0.zip\",\n      \"repository\": \"https://github.com/statsperform/spec-kit-jira\",\n      \"homepage\": \"https://github.com/statsperform/spec-kit-jira/blob/main/README.md\",\n      \"documentation\": \"https://github.com/statsperform/spec-kit-jira/blob/main/docs/\",\n      \"changelog\": \"https://github.com/statsperform/spec-kit-jira/blob/main/CHANGELOG.md\",\n      \"license\": \"MIT\",\n      \"requires\": {\n        \"speckit_version\": \">=0.1.0,<2.0.0\",\n        \"tools\": [\n          {\n            \"name\": \"jira-mcp-server\",\n            \"version\": \">=1.0.0\"\n          }\n        ]\n      },\n      \"tags\": [\"issue-tracking\", \"jira\", \"atlassian\", \"project-management\"],\n      \"verified\": true,\n      \"downloads\": 1250,\n      \"stars\": 45\n    },\n    \"linear\": {\n      \"name\": \"Linear Integration\",\n      \"id\": \"linear\",\n      \"description\": \"Sync spec-kit tasks with Linear issues\",\n      \"author\": \"Community\",\n      \"version\": \"0.9.0\",\n      \"download_url\": \"https://github.com/example/spec-kit-linear/releases/download/v0.9.0/spec-kit-linear-0.9.0.zip\",\n      \"repository\": \"https://github.com/example/spec-kit-linear\",\n      \"requires\": {\n        \"speckit_version\": \">=0.1.0\"\n      },\n      \"tags\": [\"issue-tracking\", \"linear\"],\n      \"verified\": false\n    }\n  }\n}\n```\n\n### Catalog Discovery Commands\n\n```bash\n# List all available extensions\nspecify extension search\n\n# Search by keyword\nspecify extension search jira\n\n# Search by tag\nspecify extension search --tag issue-tracking\n\n# Show extension details\nspecify extension info jira\n```\n\n### Custom Catalogs\n\nSpec Kit supports a **catalog stack** — an ordered list of catalogs that the CLI merges and searches across. This allows organizations to maintain their own org-approved extensions alongside an internal catalog and community discovery, all at once.\n\n#### Catalog Stack Resolution\n\nThe active catalog stack is resolved in this order (first match wins):\n\n1. **`SPECKIT_CATALOG_URL` environment variable** — single catalog replacing all defaults (backward compat)\n2. **Project-level `.specify/extension-catalogs.yml`** — full control for the project\n3. **User-level `~/.specify/extension-catalogs.yml`** — personal defaults\n4. **Built-in default stack** — `catalog.json` (install_allowed: true) + `catalog.community.json` (install_allowed: false)\n\n#### Default Built-in Stack\n\nWhen no config file exists, the CLI uses:\n\n| Priority | Catalog | install_allowed | Purpose |\n|----------|---------|-----------------|---------|\n| 1 | `catalog.json` (default) | `true` | Curated extensions available for installation |\n| 2 | `catalog.community.json` (community) | `false` | Discovery only — browse but not install |\n\nThis means `specify extension search` surfaces community extensions out of the box, while `specify extension add` is still restricted to entries from catalogs with `install_allowed: true`.\n\n#### `.specify/extension-catalogs.yml` Config File\n\n```yaml\ncatalogs:\n  - name: \"default\"\n    url: \"https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json\"\n    priority: 1          # Highest — only approved entries can be installed\n    install_allowed: true\n    description: \"Built-in catalog of installable extensions\"\n\n  - name: \"internal\"\n    url: \"https://internal.company.com/spec-kit/catalog.json\"\n    priority: 2\n    install_allowed: true\n    description: \"Internal company extensions\"\n\n  - name: \"community\"\n    url: \"https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json\"\n    priority: 3          # Lowest — discovery only, not installable\n    install_allowed: false\n    description: \"Community-contributed extensions (discovery only)\"\n```\n\nA user-level equivalent lives at `~/.specify/extension-catalogs.yml`. When a project-level config is present with one or more catalog entries, it takes full control and the built-in defaults are not applied. An empty `catalogs: []` list is treated the same as no config file, falling back to defaults.\n\n#### Catalog CLI Commands\n\n```bash\n# List active catalogs with name, URL, priority, and install_allowed\nspecify extension catalog list\n\n# Add a catalog (project-scoped)\nspecify extension catalog add --name \"internal\" --install-allowed \\\n  https://internal.company.com/spec-kit/catalog.json\n\n# Add a discovery-only catalog\nspecify extension catalog add --name \"community\" \\\n  https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json\n\n# Remove a catalog\nspecify extension catalog remove internal\n\n# Show which catalog an extension came from\nspecify extension info jira\n# → Source catalog: default\n```\n\n#### Merge Conflict Resolution\n\nWhen the same extension `id` appears in multiple catalogs, the higher-priority (lower priority number) catalog wins. Extensions from lower-priority catalogs with the same `id` are ignored.\n\n#### `install_allowed: false` Behavior\n\nExtensions from discovery-only catalogs are shown in `specify extension search` results but cannot be installed directly:\n\n```\n⚠  'linear' is available in the 'community' catalog but installation is not allowed from that catalog.\n\nTo enable installation, add 'linear' to an approved catalog (install_allowed: true) in .specify/extension-catalogs.yml.\n```\n\n#### `SPECKIT_CATALOG_URL` (Backward Compatibility)\n\nThe `SPECKIT_CATALOG_URL` environment variable still works — it is treated as a single `install_allowed: true` catalog, **replacing both defaults** for full backward compatibility:\n\n```bash\n# Point to your organization's catalog\nexport SPECKIT_CATALOG_URL=\"https://internal.company.com/spec-kit/catalog.json\"\n\n# All extension commands now use your custom catalog\nspecify extension search       # Uses custom catalog\nspecify extension add jira     # Installs from custom catalog\n```\n\n**Requirements:**\n- URL must use HTTPS (HTTP only allowed for localhost testing)\n- Catalog must follow the standard catalog.json schema\n- Must be publicly accessible or accessible within your network\n\n**Example for testing:**\n```bash\n# Test with localhost during development\nexport SPECKIT_CATALOG_URL=\"http://localhost:8000/catalog.json\"\nspecify extension search\n```\n\n---\n\n## CLI Commands\n\n### `specify extension` Subcommands\n\n#### `specify extension list`\n\nList installed extensions in current project.\n\n```bash\n$ specify extension list\n\nInstalled Extensions:\n  ✓ Jira Integration (v1.0.0)\n     jira\n     Create Jira issues from spec-kit artifacts\n     Commands: 3 | Hooks: 2 | Priority: 10 | Status: Enabled\n\n  ✓ Linear Integration (v0.9.0)\n     linear\n     Create Linear issues from spec-kit artifacts\n     Commands: 1 | Hooks: 1 | Priority: 10 | Status: Enabled\n```\n\n**Options:**\n\n- `--available`: Show available (not installed) extensions from catalog\n- `--all`: Show both installed and available\n\n#### `specify extension search [QUERY]`\n\nSearch extension catalog.\n\n```bash\n$ specify extension search jira\n\nFound 1 extension:\n\n┌─────────────────────────────────────────────────────────┐\n│ jira (v1.0.0) ✓ Verified                                │\n│ Jira Integration                                        │\n│                                                         │\n│ Create Jira Epics, Stories, and Issues from spec-kit   │\n│ artifacts                                               │\n│                                                         │\n│ Author: Stats Perform                                   │\n│ Tags: issue-tracking, jira, atlassian                   │\n│ Downloads: 1,250                                        │\n│                                                         │\n│ Repository: github.com/statsperform/spec-kit-jira       │\n│ Documentation: github.com/.../docs                      │\n└─────────────────────────────────────────────────────────┘\n\nInstall: specify extension add jira\n```\n\n**Options:**\n\n- `--tag TAG`: Filter by tag\n- `--author AUTHOR`: Filter by author\n- `--verified`: Show only verified extensions\n\n#### `specify extension info NAME`\n\nShow detailed information about an extension.\n\n```bash\n$ specify extension info jira\n\nJira Integration (jira) v1.0.0\n\nDescription:\n  Create Jira Epics, Stories, and Issues from spec-kit artifacts\n\nAuthor: Stats Perform\nLicense: MIT\nRepository: https://github.com/statsperform/spec-kit-jira\nDocumentation: https://github.com/statsperform/spec-kit-jira/blob/main/docs/\n\nRequirements:\n  • Spec Kit: >=0.1.0,<2.0.0\n  • Tools: jira-mcp-server (>=1.0.0)\n\nProvides:\n  Commands:\n    • speckit.jira.specstoissues - Create Jira hierarchy from spec and tasks\n    • speckit.jira.discover-fields - Discover Jira custom fields\n    • speckit.jira.sync-status - Sync task completion status\n\n  Hooks:\n    • after_tasks - Prompt to create Jira issues\n    • after_implement - Prompt to sync status\n\nTags: issue-tracking, jira, atlassian, project-management\n\nDownloads: 1,250 | Stars: 45 | Verified: ✓\n\nInstall: specify extension add jira\n```\n\n#### `specify extension add NAME`\n\nInstall an extension.\n\n```bash\n$ specify extension add jira\n\nInstalling extension: Jira Integration\n\n✓ Downloaded spec-kit-jira-1.0.0.zip (245 KB)\n✓ Validated manifest\n✓ Checked compatibility (spec-kit 0.1.0 ≥ 0.1.0)\n✓ Extracted to .specify/extensions/jira/\n✓ Registered 3 commands with claude\n✓ Installed config template (jira-config.yml)\n\n⚠  Configuration required:\n   Edit .specify/extensions/jira/jira-config.yml to set your Jira project key\n\nExtension installed successfully!\n\nNext steps:\n  1. Configure: vim .specify/extensions/jira/jira-config.yml\n  2. Discover fields: /speckit.jira.discover-fields\n  3. Use commands: /speckit.jira.specstoissues\n```\n\n**Options:**\n\n- `--from URL`: Install from a remote URL (archive). Does not accept Git repositories directly.\n- `--dev`: Install from a local path in development mode (the PATH is the positional `extension` argument).\n- `--priority NUMBER`: Set resolution priority (lower = higher precedence, default 10)\n\n#### `specify extension remove NAME`\n\nUninstall an extension.\n\n```bash\n$ specify extension remove jira\n\n⚠  This will remove:\n   • 3 commands from AI agent\n   • Extension directory: .specify/extensions/jira/\n   • Config file: jira-config.yml (will be backed up)\n\nContinue? (yes/no): yes\n\n✓ Unregistered commands\n✓ Backed up config to .specify/extensions/.backup/jira-config.yml\n✓ Removed extension directory\n✓ Updated registry\n\nExtension removed successfully.\n\nTo reinstall: specify extension add jira\n```\n\n**Options:**\n\n- `--keep-config`: Don't remove config file\n- `--force`: Skip confirmation\n\n#### `specify extension update [NAME]`\n\nUpdate extension(s) to latest version.\n\n```bash\n$ specify extension update jira\n\nChecking for updates...\n\njira: 1.0.0 → 1.1.0 available\n\nChanges in v1.1.0:\n  • Added support for custom workflows\n  • Fixed issue with parallel tasks\n  • Improved error messages\n\nUpdate? (yes/no): yes\n\n✓ Downloaded spec-kit-jira-1.1.0.zip\n✓ Validated manifest\n✓ Backed up current version\n✓ Extracted new version\n✓ Preserved config file\n✓ Re-registered commands\n\nExtension updated successfully!\n\nChangelog: https://github.com/statsperform/spec-kit-jira/blob/main/CHANGELOG.md#v110\n```\n\n**Options:**\n\n- `--all`: Update all extensions\n- `--check`: Check for updates without installing\n- `--force`: Force update even if already latest\n\n#### `specify extension enable/disable NAME`\n\nEnable or disable an extension without removing it.\n\n```bash\n$ specify extension disable jira\n\n✓ Disabled extension: jira\n  • Commands unregistered (but files preserved)\n  • Hooks will not execute\n\nTo re-enable: specify extension enable jira\n```\n\n#### `specify extension set-priority NAME PRIORITY`\n\nChange the resolution priority of an installed extension.\n\n```bash\n$ specify extension set-priority jira 5\n\n✓ Extension 'Jira Integration' priority changed: 10 → 5\n\nLower priority = higher precedence in template resolution\n```\n\n**Priority Values:**\n\n- Lower numbers = higher precedence (checked first in resolution)\n- Default priority is 10\n- Must be a positive integer (1 or higher)\n\n**Use Cases:**\n\n- Ensure a critical extension's templates take precedence\n- Override default resolution order when multiple extensions provide similar templates\n\n---\n\n## Compatibility & Versioning\n\n### Semantic Versioning\n\nExtensions follow [SemVer 2.0.0](https://semver.org/):\n\n- **MAJOR**: Breaking changes (command API changes, config schema changes)\n- **MINOR**: New features (new commands, new config options)\n- **PATCH**: Bug fixes (no API changes)\n\n### Compatibility Checks\n\n**At installation:**\n\n```python\ndef check_compatibility(extension_manifest: dict) -> bool:\n    \"\"\"Check if extension is compatible with current environment.\"\"\"\n\n    requires = extension_manifest['requires']\n\n    # 1. Check spec-kit version\n    current_speckit = get_speckit_version()  # e.g., \"0.1.5\"\n    required_speckit = requires['speckit_version']  # e.g., \">=0.1.0,<2.0.0\"\n\n    if not version_satisfies(current_speckit, required_speckit):\n        raise IncompatibleVersionError(\n            f\"Extension requires spec-kit {required_speckit}, \"\n            f\"but {current_speckit} is installed. \"\n            f\"Upgrade spec-kit with: uv tool install specify-cli --force\"\n        )\n\n    # 2. Check required tools\n    for tool in requires.get('tools', []):\n        tool_name = tool['name']\n        tool_version = tool.get('version')\n\n        if tool.get('required', True):\n            if not check_tool(tool_name):\n                raise MissingToolError(\n                    f\"Extension requires tool: {tool_name}\\n\"\n                    f\"Install from: {tool.get('install_url', 'N/A')}\"\n                )\n\n            if tool_version:\n                installed = get_tool_version(tool_name, tool.get('check_command'))\n                if not version_satisfies(installed, tool_version):\n                    raise IncompatibleToolVersionError(\n                        f\"Extension requires {tool_name} {tool_version}, \"\n                        f\"but {installed} is installed\"\n                    )\n\n    # 3. Check required commands\n    for cmd in requires.get('commands', []):\n        if not command_exists(cmd):\n            raise MissingCommandError(\n                f\"Extension requires core command: {cmd}\\n\"\n                f\"Update spec-kit to latest version\"\n            )\n\n    return True\n```\n\n### Deprecation Policy\n\n**Extension manifest can mark features as deprecated:**\n\n```yaml\nprovides:\n  commands:\n    - name: \"speckit.jira.old-command\"\n      file: \"commands/old-command.md\"\n      deprecated: true\n      deprecated_message: \"Use speckit.jira.new-command instead\"\n      removal_version: \"2.0.0\"\n```\n\n**At runtime, show warning:**\n\n```text\n⚠️  Warning: /speckit.jira.old-command is deprecated\n   Use /speckit.jira.new-command instead\n   This command will be removed in v2.0.0\n```\n\n---\n\n## Security Considerations\n\n### Trust Model\n\nExtensions run with **same privileges as AI agent**:\n\n- Can execute shell commands\n- Can read/write files in project\n- Can make network requests\n\n**Trust boundary**: User must trust extension author.\n\n### Verification\n\n**Verified Extensions** (in catalog):\n\n- Published by known organizations (GitHub, Stats Perform, etc.)\n- Code reviewed by spec-kit maintainers\n- Marked with ✓ badge in catalog\n\n**Community Extensions**:\n\n- Not verified, use at own risk\n- Show warning during installation:\n\n  ```text\n  ⚠️  This extension is not verified.\n     Review code before installing: https://github.com/...\n\n     Continue? (yes/no):\n  ```\n\n### Sandboxing (Future)\n\n**Phase 2** (not in initial release):\n\n- Extensions declare required permissions in manifest\n- CLI enforces permission boundaries\n- Example permissions: `filesystem:read`, `network:external`, `env:read`\n\n```yaml\n# Future extension.yml\npermissions:\n  - \"filesystem:read:.specify/extensions/jira/\"  # Can only read own config\n  - \"filesystem:write:.specify/memory/\"          # Can write to memory\n  - \"network:external:*.atlassian.net\"           # Can call Jira API\n  - \"env:read:SPECKIT_JIRA_*\"                    # Can read own env vars\n```\n\n### Package Integrity\n\n**Future**: Sign extension packages with GPG/Sigstore\n\n```yaml\n# catalog.json\n\"jira\": {\n  \"download_url\": \"...\",\n  \"checksum\": \"sha256:abc123...\",\n  \"signature\": \"https://github.com/.../spec-kit-jira-1.0.0.sig\",\n  \"signing_key\": \"https://github.com/statsperform.gpg\"\n}\n```\n\nCLI verifies signature before extraction.\n\n---\n\n## Migration Strategy\n\n### Backward Compatibility\n\n**Goal**: Existing spec-kit projects work without changes.\n\n**Strategy**:\n\n1. **Core commands unchanged**: `/speckit.tasks`, `/speckit.implement`, etc. remain in core\n\n2. **Optional extensions**: Users opt-in to extensions\n\n3. **Gradual migration**: Existing `taskstoissues` stays in core, Jira extension is alternative\n\n4. **Deprecation timeline**:\n   - **v0.2.0**: Introduce extension system, keep core `taskstoissues`\n   - **v0.3.0**: Mark core `taskstoissues` as \"legacy\" (still works)\n   - **v1.0.0**: Consider removing core `taskstoissues` in favor of extension\n\n### Migration Path for Users\n\n**Scenario 1**: User has no `taskstoissues` usage\n\n- No migration needed, extensions are opt-in\n\n**Scenario 2**: User uses core `taskstoissues` (GitHub Issues)\n\n- Works as before\n- Optional: Migrate to `github-projects` extension for more features\n\n**Scenario 3**: User wants Jira (new requirement)\n\n- `specify extension add jira`\n- Configure and use\n\n**Scenario 4**: User has custom scripts calling `taskstoissues`\n\n- Scripts still work (core command preserved)\n- Migration guide shows how to call extension commands instead\n\n### Extension Migration Guide\n\n**For extension authors** (if core command becomes extension):\n\n```bash\n# Old (core command)\n/speckit.taskstoissues\n\n# New (extension command)\nspecify extension add github-projects\n/speckit.github.taskstoissues\n```\n\n**Compatibility shim** (if needed):\n\n```yaml\n# extension.yml\nprovides:\n  commands:\n    - name: \"speckit.github.taskstoissues\"\n      file: \"commands/taskstoissues.md\"\n      aliases: [\"speckit.taskstoissues\"]  # Backward compatibility\n```\n\nAI agent registers both names, so old scripts work.\n\n---\n\n## Implementation Phases\n\n### Phase 1: Core Extension System ✅ COMPLETED\n\n**Goal**: Basic extension infrastructure\n\n**Deliverables**:\n\n- [x] Extension manifest schema (`extension.yml`)\n- [x] Extension directory structure\n- [x] CLI commands:\n  - [x] `specify extension list`\n  - [x] `specify extension add` (from URL and local `--dev`)\n  - [x] `specify extension remove`\n- [x] Extension registry (`.specify/extensions/.registry`)\n- [x] Command registration (Claude and 15+ other agents)\n- [x] Basic validation (manifest schema, compatibility)\n- [x] Documentation (extension development guide)\n\n**Testing**:\n\n- [x] Unit tests for manifest parsing\n- [x] Integration test: Install dummy extension\n- [x] Integration test: Register commands with Claude\n\n### Phase 2: Jira Extension ✅ COMPLETED\n\n**Goal**: First production extension\n\n**Deliverables**:\n\n- [x] Create `spec-kit-jira` repository\n- [x] Port Jira functionality to extension\n- [x] Create `jira-config.yml` template\n- [x] Commands:\n  - [x] `specstoissues.md`\n  - [x] `discover-fields.md`\n  - [x] `sync-status.md`\n- [x] Helper scripts\n- [x] Documentation (README, configuration guide, examples)\n- [x] Release v3.0.0\n\n**Testing**:\n\n- [x] Test on `eng-msa-ts` project\n- [x] Verify spec→Epic, phase→Story, task→Issue mapping\n- [x] Test configuration loading and validation\n- [x] Test custom field application\n\n### Phase 3: Extension Catalog ✅ COMPLETED\n\n**Goal**: Discovery and distribution\n\n**Deliverables**:\n\n- [x] Central catalog (`extensions/catalog.json` in spec-kit repo)\n- [x] Community catalog (`extensions/catalog.community.json`)\n- [x] Catalog fetch and parsing with multi-catalog support\n- [x] CLI commands:\n  - [x] `specify extension search`\n  - [x] `specify extension info`\n  - [x] `specify extension catalog list`\n  - [x] `specify extension catalog add`\n  - [x] `specify extension catalog remove`\n- [x] Documentation (how to publish extensions)\n\n**Testing**:\n\n- [x] Test catalog fetch\n- [x] Test extension search/filtering\n- [x] Test catalog caching\n- [x] Test multi-catalog merge with priority\n\n### Phase 4: Advanced Features ✅ COMPLETED\n\n**Goal**: Hooks, updates, multi-agent support\n\n**Deliverables**:\n\n- [x] Hook system (`hooks` in extension.yml)\n- [x] Hook registration and execution\n- [x] Project extensions config (`.specify/extensions.yml`)\n- [x] CLI commands:\n  - [x] `specify extension update` (with atomic backup/restore)\n  - [x] `specify extension enable/disable`\n- [x] Command registration for multiple agents (15+ agents including Claude, Copilot, Gemini, Cursor, etc.)\n- [x] Extension update notifications (version comparison)\n- [x] Configuration layer resolution (project, local, env)\n\n**Additional features implemented beyond original RFC**:\n\n- [x] **Display name resolution**: All commands accept extension display names in addition to IDs\n- [x] **Ambiguous name handling**: User-friendly tables when multiple extensions match a name\n- [x] **Atomic update with rollback**: Full backup of extension dir, commands, hooks, and registry with automatic rollback on failure\n- [x] **Pre-install ID validation**: Validates extension ID from ZIP before installing (security)\n- [x] **Enabled state preservation**: Disabled extensions stay disabled after update\n- [x] **Registry update/restore methods**: Clean API for enable/disable and rollback operations\n- [x] **Catalog error fallback**: `extension info` falls back to local info when catalog unavailable\n- [x] **`_install_allowed` flag**: Discovery-only catalogs can't be used for installation\n- [x] **Cache invalidation**: Cache invalidated when `SPECKIT_CATALOG_URL` changes\n\n**Testing**:\n\n- [x] Test hooks in core commands\n- [x] Test extension updates (preserve config)\n- [x] Test multi-agent registration\n- [x] Test atomic rollback on update failure\n- [x] Test enabled state preservation\n- [x] Test display name resolution\n\n### Phase 5: Polish & Documentation ✅ COMPLETED\n\n**Goal**: Production ready\n\n**Deliverables**:\n\n- [x] Comprehensive documentation:\n  - [x] User guide (EXTENSION-USER-GUIDE.md)\n  - [x] Extension development guide (EXTENSION-DEV-GUIDE.md)\n  - [x] Extension API reference (EXTENSION-API-REFERENCE.md)\n- [x] Error messages and validation improvements\n- [x] CLI help text updates\n\n**Testing**:\n\n- [x] End-to-end testing on multiple projects\n- [x] 163 unit tests passing\n\n---\n\n## Resolved Questions\n\nThe following questions from the original RFC have been resolved during implementation:\n\n### 1. Extension Namespace ✅ RESOLVED\n\n**Question**: Should extension commands use namespace prefix?\n\n**Decision**: **Option C** - Both prefixed and aliases are supported. Commands use `speckit.{extension}.{command}` as canonical name, with optional aliases defined in manifest.\n\n**Implementation**: The `aliases` field in `extension.yml` allows extensions to register additional command names.\n\n---\n\n### 2. Config File Location ✅ RESOLVED\n\n**Question**: Where should extension configs live?\n\n**Decision**: **Option A** - Extension directory (`.specify/extensions/{ext-id}/{ext-id}-config.yml`). This keeps extensions self-contained and easier to manage.\n\n**Implementation**: Each extension has its own config file within its directory, with layered resolution (defaults → project → local → env vars).\n\n---\n\n### 3. Command File Format ✅ RESOLVED\n\n**Question**: Should extensions use universal format or agent-specific?\n\n**Decision**: **Option A** - Universal Markdown format. Extensions write commands once, CLI converts to agent-specific format during registration.\n\n**Implementation**: `CommandRegistrar` class handles conversion to 15+ agent formats (Claude, Copilot, Gemini, Cursor, etc.).\n\n---\n\n### 4. Hook Execution Model ✅ RESOLVED\n\n**Question**: How should hooks execute?\n\n**Decision**: **Option A** - Hooks are registered in `.specify/extensions.yml` and executed by the AI agent when it sees the hook trigger. Hook state (enabled/disabled) is managed per-extension.\n\n**Implementation**: `HookExecutor` class manages hook registration and state in `extensions.yml`.\n\n---\n\n### 5. Extension Distribution ✅ RESOLVED\n\n**Question**: How should extensions be packaged?\n\n**Decision**: **Option A** - ZIP archives downloaded from GitHub releases (via catalog `download_url`). Local development uses `--dev` flag with directory path.\n\n**Implementation**: `ExtensionManager.install_from_zip()` handles ZIP extraction and validation.\n\n---\n\n### 6. Multi-Version Support ✅ RESOLVED\n\n**Question**: Can multiple versions of same extension coexist?\n\n**Decision**: **Option A** - Single version only. Updates replace the existing version with atomic rollback on failure.\n\n**Implementation**: `extension update` performs atomic backup/restore to ensure safe updates.\n\n---\n\n## Open Questions (Remaining)\n\n### 1. Sandboxing / Permissions (Future)\n\n**Question**: Should extensions declare required permissions?\n\n**Options**:\n\n- A) No sandboxing (current): Extensions run with same privileges as AI agent\n- B) Permission declarations: Extensions declare `filesystem:read`, `network:external`, etc.\n- C) Opt-in sandboxing: Organizations can enable permission enforcement\n\n**Status**: Deferred to future version. Currently using trust-based model where users trust extension authors.\n\n---\n\n### 2. Package Signatures (Future)\n\n**Question**: Should extensions be cryptographically signed?\n\n**Options**:\n\n- A) No signatures (current): Trust based on catalog source\n- B) GPG/Sigstore signatures: Verify package integrity\n- C) Catalog-level verification: Catalog maintainers verify packages\n\n**Status**: Deferred to future version. `checksum` field is available in catalog schema but not enforced.\n\n---\n\n## Appendices\n\n### Appendix A: Example Extension Structure\n\n**Complete structure of `spec-kit-jira` extension:**\n\n```text\nspec-kit-jira/\n├── README.md                        # Overview, features, installation\n├── LICENSE                          # MIT license\n├── CHANGELOG.md                     # Version history\n├── .gitignore                       # Ignore local configs\n│\n├── extension.yml                    # Extension manifest (required)\n├── jira-config.template.yml         # Config template\n│\n├── commands/                        # Command files\n│   ├── specstoissues.md            # Main command\n│   ├── discover-fields.md          # Helper: Discover custom fields\n│   └── sync-status.md              # Helper: Sync completion status\n│\n├── scripts/                         # Helper scripts\n│   ├── parse-jira-config.sh        # Config loader (bash)\n│   ├── parse-jira-config.ps1       # Config loader (PowerShell)\n│   └── validate-jira-connection.sh # Connection test\n│\n├── docs/                            # Documentation\n│   ├── installation.md             # Installation guide\n│   ├── configuration.md            # Configuration reference\n│   ├── usage.md                    # Usage examples\n│   ├── troubleshooting.md          # Common issues\n│   └── examples/\n│       ├── eng-msa-ts-config.yml   # Real-world config example\n│       └── simple-project.yml      # Minimal config example\n│\n├── tests/                           # Tests (optional)\n│   ├── test-extension.sh           # Extension validation\n│   └── test-commands.sh            # Command execution tests\n│\n└── .github/                         # GitHub integration\n    └── workflows/\n        └── release.yml              # Automated releases\n```\n\n### Appendix B: Extension Development Guide (Outline)\n\n**Documentation for creating new extensions:**\n\n1. **Getting Started**\n   - Prerequisites (tools needed)\n   - Extension template (cookiecutter)\n   - Directory structure\n\n2. **Extension Manifest**\n   - Schema reference\n   - Required vs optional fields\n   - Versioning guidelines\n\n3. **Command Development**\n   - Universal command format\n   - Frontmatter specification\n   - Template variables\n   - Script references\n\n4. **Configuration**\n   - Config file structure\n   - Schema validation\n   - Layered config resolution\n   - Environment variable overrides\n\n5. **Hooks**\n   - Available hook points\n   - Hook registration\n   - Conditional execution\n   - Best practices\n\n6. **Testing**\n   - Local development setup\n   - Testing with `--dev` flag\n   - Validation checklist\n   - Integration testing\n\n7. **Publishing**\n   - Packaging (ZIP format)\n   - GitHub releases\n   - Catalog submission\n   - Versioning strategy\n\n8. **Examples**\n   - Minimal extension\n   - Extension with hooks\n   - Extension with configuration\n   - Extension with multiple commands\n\n### Appendix C: Compatibility Matrix\n\n**Planned support matrix:**\n\n| Extension Feature | Spec Kit Version | AI Agent Support |\n|-------------------|------------------|------------------|\n| Basic commands | 0.2.0+ | Claude, Gemini, Copilot |\n| Hooks (after_tasks) | 0.3.0+ | Claude, Gemini |\n| Config validation | 0.2.0+ | All |\n| Multiple catalogs | 0.4.0+ | All |\n| Permissions (sandboxing) | 1.0.0+ | TBD |\n\n### Appendix D: Extension Catalog Schema\n\n**Full schema for `catalog.json`:**\n\n```json\n{\n  \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n  \"type\": \"object\",\n  \"required\": [\"schema_version\", \"updated_at\", \"extensions\"],\n  \"properties\": {\n    \"schema_version\": {\n      \"type\": \"string\",\n      \"pattern\": \"^\\\\d+\\\\.\\\\d+$\"\n    },\n    \"updated_at\": {\n      \"type\": \"string\",\n      \"format\": \"date-time\"\n    },\n    \"extensions\": {\n      \"type\": \"object\",\n      \"patternProperties\": {\n        \"^[a-z0-9-]+$\": {\n          \"type\": \"object\",\n          \"required\": [\"name\", \"id\", \"version\", \"download_url\", \"repository\"],\n          \"properties\": {\n            \"name\": { \"type\": \"string\" },\n            \"id\": { \"type\": \"string\", \"pattern\": \"^[a-z0-9-]+$\" },\n            \"description\": { \"type\": \"string\" },\n            \"author\": { \"type\": \"string\" },\n            \"version\": { \"type\": \"string\", \"pattern\": \"^\\\\d+\\\\.\\\\d+\\\\.\\\\d+$\" },\n            \"download_url\": { \"type\": \"string\", \"format\": \"uri\" },\n            \"repository\": { \"type\": \"string\", \"format\": \"uri\" },\n            \"homepage\": { \"type\": \"string\", \"format\": \"uri\" },\n            \"documentation\": { \"type\": \"string\", \"format\": \"uri\" },\n            \"changelog\": { \"type\": \"string\", \"format\": \"uri\" },\n            \"license\": { \"type\": \"string\" },\n            \"requires\": {\n              \"type\": \"object\",\n              \"properties\": {\n                \"speckit_version\": { \"type\": \"string\" },\n                \"tools\": {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"type\": \"object\",\n                    \"required\": [\"name\"],\n                    \"properties\": {\n                      \"name\": { \"type\": \"string\" },\n                      \"version\": { \"type\": \"string\" }\n                    }\n                  }\n                }\n              }\n            },\n            \"tags\": {\n              \"type\": \"array\",\n              \"items\": { \"type\": \"string\" }\n            },\n            \"verified\": { \"type\": \"boolean\" },\n            \"downloads\": { \"type\": \"integer\" },\n            \"stars\": { \"type\": \"integer\" },\n            \"checksum\": { \"type\": \"string\" }\n          }\n        }\n      }\n    }\n  }\n}\n```\n\n---\n\n## Summary & Next Steps\n\nThis RFC proposes a comprehensive extension system for Spec Kit that:\n\n1. **Keeps core lean** while enabling unlimited integrations\n2. **Supports multiple agents** (Claude, Gemini, Copilot, etc.)\n3. **Provides clear extension API** for community contributions\n4. **Enables independent versioning** of extensions and core\n5. **Includes safety mechanisms** (validation, compatibility checks)\n\n### Immediate Next Steps\n\n1. **Review this RFC** with stakeholders\n2. **Gather feedback** on open questions\n3. **Refine design** based on feedback\n4. **Proceed to Phase A**: Implement core extension system\n5. **Then Phase B**: Build Jira extension as proof-of-concept\n\n---\n\n## Questions for Discussion\n\n1. Does the extension architecture meet your needs for Jira integration?\n2. Are there additional hook points we should consider?\n3. Should we support extension dependencies (extension A requires extension B)?\n4. How should we handle extension deprecation/removal from catalog?\n5. What level of sandboxing/permissions do we need in v1.0?\n"
  },
  {
    "path": "extensions/catalog.community.json",
    "content": "{\n  \"schema_version\": \"1.0\",\n  \"updated_at\": \"2026-03-19T12:08:20Z\",\n  \"catalog_url\": \"https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json\",\n  \"extensions\": {\n    \"archive\": {\n      \"name\": \"Archive Extension\",\n      \"id\": \"archive\",\n      \"description\": \"Archive merged features into main project memory, resolving gaps and conflicts.\",\n      \"author\": \"Stanislav Deviatov\",\n      \"version\": \"1.0.0\",\n      \"download_url\": \"https://github.com/stn1slv/spec-kit-archive/archive/refs/tags/v1.0.0.zip\",\n      \"repository\": \"https://github.com/stn1slv/spec-kit-archive\",\n      \"homepage\": \"https://github.com/stn1slv/spec-kit-archive\",\n      \"documentation\": \"https://github.com/stn1slv/spec-kit-archive/blob/main/README.md\",\n      \"changelog\": \"https://github.com/stn1slv/spec-kit-archive/blob/main/CHANGELOG.md\",\n      \"license\": \"MIT\",\n      \"requires\": {\n        \"speckit_version\": \">=0.1.0\"\n      },\n      \"provides\": {\n        \"commands\": 1,\n        \"hooks\": 0\n      },\n      \"tags\": [\n        \"archive\",\n        \"memory\",\n        \"merge\",\n        \"changelog\"\n      ],\n      \"verified\": false,\n      \"downloads\": 0,\n      \"stars\": 0,\n      \"created_at\": \"2026-03-14T00:00:00Z\",\n      \"updated_at\": \"2026-03-14T00:00:00Z\"\n    },\n    \"azure-devops\": {\n      \"name\": \"Azure DevOps Integration\",\n      \"id\": \"azure-devops\",\n      \"description\": \"Sync user stories and tasks to Azure DevOps work items using OAuth authentication.\",\n      \"author\": \"pragya247\",\n      \"version\": \"1.0.0\",\n      \"download_url\": \"https://github.com/pragya247/spec-kit-azure-devops/archive/refs/tags/v1.0.0.zip\",\n      \"repository\": \"https://github.com/pragya247/spec-kit-azure-devops\",\n      \"homepage\": \"https://github.com/pragya247/spec-kit-azure-devops\",\n      \"documentation\": \"https://github.com/pragya247/spec-kit-azure-devops/blob/main/README.md\",\n      \"changelog\": \"https://github.com/pragya247/spec-kit-azure-devops/blob/main/CHANGELOG.md\",\n      \"license\": \"MIT\",\n      \"requires\": {\n        \"speckit_version\": \">=0.1.0\",\n        \"tools\": [\n          {\n            \"name\": \"az\",\n            \"version\": \">=2.0.0\",\n            \"required\": true\n          }\n        ]\n      },\n      \"provides\": {\n        \"commands\": 1,\n        \"hooks\": 1\n      },\n      \"tags\": [\n        \"azure\",\n        \"devops\",\n        \"project-management\",\n        \"work-items\",\n        \"issue-tracking\"\n      ],\n      \"verified\": false,\n      \"downloads\": 0,\n      \"stars\": 0,\n      \"created_at\": \"2026-03-03T00:00:00Z\",\n      \"updated_at\": \"2026-03-03T00:00:00Z\"\n    },\n    \"cleanup\": {\n      \"name\": \"Cleanup Extension\",\n      \"id\": \"cleanup\",\n      \"description\": \"Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues.\",\n      \"author\": \"dsrednicki\",\n      \"version\": \"1.0.0\",\n      \"download_url\": \"https://github.com/dsrednicki/spec-kit-cleanup/archive/refs/tags/v1.0.0.zip\",\n      \"repository\": \"https://github.com/dsrednicki/spec-kit-cleanup\",\n      \"homepage\": \"https://github.com/dsrednicki/spec-kit-cleanup\",\n      \"documentation\": \"https://github.com/dsrednicki/spec-kit-cleanup/blob/main/README.md\",\n      \"changelog\": \"https://github.com/dsrednicki/spec-kit-cleanup/blob/main/CHANGELOG.md\",\n      \"license\": \"MIT\",\n      \"requires\": {\n        \"speckit_version\": \">=0.1.0\"\n      },\n      \"provides\": {\n        \"commands\": 1,\n        \"hooks\": 1\n      },\n      \"tags\": [\n        \"quality\",\n        \"tech-debt\",\n        \"review\",\n        \"cleanup\",\n        \"scout-rule\"\n      ],\n      \"verified\": false,\n      \"downloads\": 0,\n      \"stars\": 0,\n      \"created_at\": \"2026-02-22T00:00:00Z\",\n      \"updated_at\": \"2026-02-22T00:00:00Z\"\n    },\n    \"cognitive-squad\": {\n      \"name\": \"Cognitive Squad\",\n      \"id\": \"cognitive-squad\",\n      \"description\": \"Multi-agent cognitive system with Triadic Model: understanding, internalization, application — with quality gates, backpropagation verification, and self-healing\",\n      \"author\": \"Testimonial\",\n      \"version\": \"0.1.0\",\n      \"download_url\": \"https://github.com/Testimonial/cognitive-squad/archive/refs/tags/v0.1.0.zip\",\n      \"repository\": \"https://github.com/Testimonial/cognitive-squad\",\n      \"homepage\": \"https://github.com/Testimonial/cognitive-squad\",\n      \"documentation\": \"https://github.com/Testimonial/cognitive-squad/blob/main/README.md\",\n      \"changelog\": \"https://github.com/Testimonial/cognitive-squad/blob/main/CHANGELOG.md\",\n      \"license\": \"MIT\",\n      \"requires\": {\n        \"speckit_version\": \">=0.3.0\",\n        \"tools\": [\n          {\n            \"name\": \"understanding\",\n            \"version\": \">=3.4.0\",\n            \"required\": false\n          },\n          {\n            \"name\": \"spec-kit-reverse-eng\",\n            \"version\": \">=1.0.0\",\n            \"required\": false\n          }\n        ]\n      },\n      \"provides\": {\n        \"commands\": 10,\n        \"hooks\": 1\n      },\n      \"tags\": [\n        \"ai-agents\",\n        \"cognitive\",\n        \"full-lifecycle\",\n        \"verification\",\n        \"multi-agent\"\n      ],\n      \"verified\": false,\n      \"downloads\": 0,\n      \"stars\": 0,\n      \"created_at\": \"2026-03-16T00:00:00Z\",\n      \"updated_at\": \"2026-03-18T00:00:00Z\"\n    },\n    \"conduct\": {\n      \"name\": \"Conduct Extension\",\n      \"id\": \"conduct\",\n      \"description\": \"Executes a single spec-kit phase via sub-agent delegation to reduce context pollution.\",\n      \"author\": \"twbrandon7\",\n      \"version\": \"1.0.0\",\n      \"download_url\": \"https://github.com/twbrandon7/spec-kit-conduct-ext/archive/refs/tags/v1.0.0.zip\",\n      \"repository\": \"https://github.com/twbrandon7/spec-kit-conduct-ext\",\n      \"homepage\": \"https://github.com/twbrandon7/spec-kit-conduct-ext\",\n      \"documentation\": \"https://github.com/twbrandon7/spec-kit-conduct-ext/blob/main/README.md\",\n      \"changelog\": \"https://github.com/twbrandon7/spec-kit-conduct-ext/blob/main/CHANGELOG.md\",\n      \"license\": \"MIT\",\n      \"requires\": {\n        \"speckit_version\": \">=0.3.1\"\n      },\n      \"provides\": {\n        \"commands\": 1,\n        \"hooks\": 0\n      },\n      \"tags\": [\n        \"conduct\",\n        \"workflow\",\n        \"automation\"\n      ],\n      \"verified\": false,\n      \"downloads\": 0,\n      \"stars\": 0,\n      \"created_at\": \"2026-03-19T12:08:20Z\",\n      \"updated_at\": \"2026-03-19T12:08:20Z\"\n    },\n    \"docguard\": {\n      \"name\": \"DocGuard \\u2014 CDD Enforcement\",\n      \"id\": \"docguard\",\n      \"description\": \"Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero NPM runtime dependencies.\",\n      \"author\": \"raccioly\",\n      \"version\": \"0.9.11\",\n      \"download_url\": \"https://github.com/raccioly/docguard/releases/download/v0.9.11/spec-kit-docguard-v0.9.11.zip\",\n      \"repository\": \"https://github.com/raccioly/docguard\",\n      \"homepage\": \"https://www.npmjs.com/package/docguard-cli\",\n      \"documentation\": \"https://github.com/raccioly/docguard/blob/main/extensions/spec-kit-docguard/README.md\",\n      \"changelog\": \"https://github.com/raccioly/docguard/blob/main/CHANGELOG.md\",\n      \"license\": \"MIT\",\n      \"requires\": {\n        \"speckit_version\": \">=0.1.0\",\n        \"tools\": [\n          {\n            \"name\": \"node\",\n            \"version\": \">=18.0.0\",\n            \"required\": true\n          }\n        ]\n      },\n      \"provides\": {\n        \"commands\": 6,\n        \"hooks\": 3\n      },\n      \"tags\": [\n        \"documentation\",\n        \"validation\",\n        \"quality\",\n        \"cdd\",\n        \"traceability\",\n        \"ai-agents\",\n        \"enforcement\",\n        \"spec-kit\"\n      ],\n      \"verified\": false,\n      \"downloads\": 0,\n      \"stars\": 0,\n      \"created_at\": \"2026-03-13T00:00:00Z\",\n      \"updated_at\": \"2026-03-18T18:53:31Z\"\n    },\n    \"doctor\": {\n      \"name\": \"Project Health Check\",\n      \"id\": \"doctor\",\n      \"description\": \"Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git.\",\n      \"author\": \"KhawarHabibKhan\",\n      \"version\": \"1.0.0\",\n      \"download_url\": \"https://github.com/KhawarHabibKhan/spec-kit-doctor/archive/refs/tags/v1.0.0.zip\",\n      \"repository\": \"https://github.com/KhawarHabibKhan/spec-kit-doctor\",\n      \"homepage\": \"https://github.com/KhawarHabibKhan/spec-kit-doctor\",\n      \"documentation\": \"https://github.com/KhawarHabibKhan/spec-kit-doctor/blob/main/README.md\",\n      \"changelog\": \"https://github.com/KhawarHabibKhan/spec-kit-doctor/blob/main/CHANGELOG.md\",\n      \"license\": \"MIT\",\n      \"requires\": {\n        \"speckit_version\": \">=0.1.0\"\n      },\n      \"provides\": {\n        \"commands\": 1,\n        \"hooks\": 0\n      },\n      \"tags\": [\n        \"diagnostics\",\n        \"health-check\",\n        \"validation\",\n        \"project-structure\"\n      ],\n      \"verified\": false,\n      \"downloads\": 0,\n      \"stars\": 0,\n      \"created_at\": \"2026-03-13T00:00:00Z\",\n      \"updated_at\": \"2026-03-13T00:00:00Z\"\n    },\n    \"fleet\": {\n      \"name\": \"Fleet Orchestrator\",\n      \"id\": \"fleet\",\n      \"description\": \"Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases.\",\n      \"author\": \"sharathsatish\",\n      \"version\": \"1.0.0\",\n      \"download_url\": \"https://github.com/sharathsatish/spec-kit-fleet/archive/refs/tags/v1.0.0.zip\",\n      \"repository\": \"https://github.com/sharathsatish/spec-kit-fleet\",\n      \"homepage\": \"https://github.com/sharathsatish/spec-kit-fleet\",\n      \"documentation\": \"https://github.com/sharathsatish/spec-kit-fleet/blob/main/README.md\",\n      \"changelog\": \"https://github.com/sharathsatish/spec-kit-fleet/blob/main/CHANGELOG.md\",\n      \"license\": \"MIT\",\n      \"requires\": {\n        \"speckit_version\": \">=0.1.0\"\n      },\n      \"provides\": {\n        \"commands\": 2,\n        \"hooks\": 1\n      },\n      \"tags\": [\n        \"orchestration\",\n        \"workflow\",\n        \"human-in-the-loop\",\n        \"parallel\"\n      ],\n      \"verified\": false,\n      \"downloads\": 0,\n      \"stars\": 0,\n      \"created_at\": \"2026-03-06T00:00:00Z\",\n      \"updated_at\": \"2026-03-06T00:00:00Z\"\n    },\n    \"iterate\": {\n      \"name\": \"Iterate\",\n      \"id\": \"iterate\",\n      \"description\": \"Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building\",\n      \"author\": \"Vianca Martinez\",\n      \"version\": \"2.0.0\",\n      \"download_url\": \"https://github.com/imviancagrace/spec-kit-iterate/archive/refs/tags/v2.0.0.zip\",\n      \"repository\": \"https://github.com/imviancagrace/spec-kit-iterate\",\n      \"homepage\": \"https://github.com/imviancagrace/spec-kit-iterate\",\n      \"documentation\": \"https://github.com/imviancagrace/spec-kit-iterate/blob/main/README.md\",\n      \"changelog\": \"https://github.com/imviancagrace/spec-kit-iterate/blob/main/CHANGELOG.md\",\n      \"license\": \"MIT\",\n      \"requires\": {\n        \"speckit_version\": \">=0.1.0\"\n      },\n      \"provides\": {\n        \"commands\": 2,\n        \"hooks\": 0\n      },\n      \"tags\": [\n        \"iteration\",\n        \"change-management\",\n        \"spec-maintenance\"\n      ],\n      \"verified\": false,\n      \"downloads\": 0,\n      \"stars\": 0,\n      \"created_at\": \"2026-03-17T00:00:00Z\",\n      \"updated_at\": \"2026-03-17T00:00:00Z\"\n    },\n    \"jira\": {\n      \"name\": \"Jira Integration\",\n      \"id\": \"jira\",\n      \"description\": \"Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support.\",\n      \"author\": \"mbachorik\",\n      \"version\": \"2.1.0\",\n      \"download_url\": \"https://github.com/mbachorik/spec-kit-jira/archive/refs/tags/v2.1.0.zip\",\n      \"repository\": \"https://github.com/mbachorik/spec-kit-jira\",\n      \"homepage\": \"https://github.com/mbachorik/spec-kit-jira\",\n      \"documentation\": \"https://github.com/mbachorik/spec-kit-jira/blob/main/README.md\",\n      \"changelog\": \"https://github.com/mbachorik/spec-kit-jira/blob/main/CHANGELOG.md\",\n      \"license\": \"MIT\",\n      \"requires\": {\n        \"speckit_version\": \">=0.1.0\"\n      },\n      \"provides\": {\n        \"commands\": 3,\n        \"hooks\": 1\n      },\n      \"tags\": [\n        \"issue-tracking\",\n        \"jira\",\n        \"atlassian\",\n        \"project-management\"\n      ],\n      \"verified\": false,\n      \"downloads\": 0,\n      \"stars\": 0,\n      \"created_at\": \"2026-03-05T00:00:00Z\",\n      \"updated_at\": \"2026-03-05T00:00:00Z\"\n    },\n    \"ralph\": {\n      \"name\": \"Ralph Loop\",\n      \"id\": \"ralph\",\n      \"description\": \"Autonomous implementation loop using AI agent CLI.\",\n      \"author\": \"Rubiss\",\n      \"version\": \"1.0.0\",\n      \"download_url\": \"https://github.com/Rubiss/spec-kit-ralph/archive/refs/tags/v1.0.0.zip\",\n      \"repository\": \"https://github.com/Rubiss/spec-kit-ralph\",\n      \"homepage\": \"https://github.com/Rubiss/spec-kit-ralph\",\n      \"documentation\": \"https://github.com/Rubiss/spec-kit-ralph/blob/main/README.md\",\n      \"changelog\": \"https://github.com/Rubiss/spec-kit-ralph/blob/main/CHANGELOG.md\",\n      \"license\": \"MIT\",\n      \"requires\": {\n        \"speckit_version\": \">=0.1.0\",\n        \"tools\": [\n          {\n            \"name\": \"copilot\",\n            \"required\": true\n          },\n          {\n            \"name\": \"git\",\n            \"required\": true\n          }\n        ]\n      },\n      \"provides\": {\n        \"commands\": 2,\n        \"hooks\": 1\n      },\n      \"tags\": [\n        \"implementation\",\n        \"automation\",\n        \"loop\",\n        \"copilot\"\n      ],\n      \"verified\": false,\n      \"downloads\": 0,\n      \"stars\": 0,\n      \"created_at\": \"2026-03-09T00:00:00Z\",\n      \"updated_at\": \"2026-03-09T00:00:00Z\"\n    },\n    \"reconcile\": {\n      \"name\": \"Reconcile Extension\",\n      \"id\": \"reconcile\",\n      \"description\": \"Reconcile implementation drift by surgically updating the feature's own spec, plan, and tasks.\",\n      \"author\": \"Stanislav Deviatov\",\n      \"version\": \"1.0.0\",\n      \"download_url\": \"https://github.com/stn1slv/spec-kit-reconcile/archive/refs/tags/v1.0.0.zip\",\n      \"repository\": \"https://github.com/stn1slv/spec-kit-reconcile\",\n      \"homepage\": \"https://github.com/stn1slv/spec-kit-reconcile\",\n      \"documentation\": \"https://github.com/stn1slv/spec-kit-reconcile/blob/main/README.md\",\n      \"changelog\": \"https://github.com/stn1slv/spec-kit-reconcile/blob/main/CHANGELOG.md\",\n      \"license\": \"MIT\",\n      \"requires\": {\n        \"speckit_version\": \">=0.1.0\"\n      },\n      \"provides\": {\n        \"commands\": 1,\n        \"hooks\": 0\n      },\n      \"tags\": [\n        \"reconcile\",\n        \"drift\",\n        \"tasks\",\n        \"remediation\"\n      ],\n      \"verified\": false,\n      \"downloads\": 0,\n      \"stars\": 0,\n      \"created_at\": \"2026-03-14T00:00:00Z\",\n      \"updated_at\": \"2026-03-14T00:00:00Z\"\n    },\n    \"retrospective\": {\n      \"name\": \"Retrospective Extension\",\n      \"id\": \"retrospective\",\n      \"description\": \"Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates.\",\n      \"author\": \"emi-dm\",\n      \"version\": \"1.0.0\",\n      \"download_url\": \"https://github.com/emi-dm/spec-kit-retrospective/archive/refs/tags/v1.0.0.zip\",\n      \"repository\": \"https://github.com/emi-dm/spec-kit-retrospective\",\n      \"homepage\": \"https://github.com/emi-dm/spec-kit-retrospective\",\n      \"documentation\": \"https://github.com/emi-dm/spec-kit-retrospective/blob/main/README.md\",\n      \"changelog\": \"https://github.com/emi-dm/spec-kit-retrospective/blob/main/CHANGELOG.md\",\n      \"license\": \"MIT\",\n      \"requires\": {\n        \"speckit_version\": \">=0.1.0\"\n      },\n      \"provides\": {\n        \"commands\": 1,\n        \"hooks\": 1\n      },\n      \"tags\": [\n        \"retrospective\",\n        \"spec-drift\",\n        \"quality\",\n        \"analysis\",\n        \"governance\"\n      ],\n      \"verified\": false,\n      \"downloads\": 0,\n      \"stars\": 0,\n      \"created_at\": \"2026-02-24T00:00:00Z\",\n      \"updated_at\": \"2026-02-24T00:00:00Z\"\n    },\n    \"review\": {\n      \"name\": \"Review Extension\",\n      \"id\": \"review\",\n      \"description\": \"Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification.\",\n      \"author\": \"ismaelJimenez\",\n      \"version\": \"1.0.0\",\n      \"download_url\": \"https://github.com/ismaelJimenez/spec-kit-review/archive/refs/tags/v1.0.0.zip\",\n      \"repository\": \"https://github.com/ismaelJimenez/spec-kit-review\",\n      \"homepage\": \"https://github.com/ismaelJimenez/spec-kit-review\",\n      \"documentation\": \"https://github.com/ismaelJimenez/spec-kit-review/blob/main/README.md\",\n      \"changelog\": \"https://github.com/ismaelJimenez/spec-kit-review/blob/main/CHANGELOG.md\",\n      \"license\": \"MIT\",\n      \"requires\": {\n        \"speckit_version\": \">=0.1.0\"\n      },\n      \"provides\": {\n        \"commands\": 7,\n        \"hooks\": 1\n      },\n      \"tags\": [\n        \"code-review\",\n        \"quality\",\n        \"review\",\n        \"testing\",\n        \"error-handling\",\n        \"type-design\",\n        \"simplification\"\n      ],\n      \"verified\": false,\n      \"downloads\": 0,\n      \"stars\": 0,\n      \"created_at\": \"2026-03-06T00:00:00Z\",\n      \"updated_at\": \"2026-03-06T00:00:00Z\"\n    },\n    \"speckit-utils\": {\n      \"name\": \"SDD Utilities\",\n      \"id\": \"speckit-utils\",\n      \"description\": \"Resume interrupted workflows, validate project health, and verify spec-to-task traceability.\",\n      \"author\": \"mvanhorn\",\n      \"version\": \"1.0.0\",\n      \"download_url\": \"https://github.com/mvanhorn/speckit-utils/archive/refs/tags/v1.0.0.zip\",\n      \"repository\": \"https://github.com/mvanhorn/speckit-utils\",\n      \"homepage\": \"https://github.com/mvanhorn/speckit-utils\",\n      \"documentation\": \"https://github.com/mvanhorn/speckit-utils/blob/main/README.md\",\n      \"changelog\": \"https://github.com/mvanhorn/speckit-utils/blob/main/CHANGELOG.md\",\n      \"license\": \"MIT\",\n      \"requires\": {\n        \"speckit_version\": \">=0.1.0\"\n      },\n      \"provides\": {\n        \"commands\": 3,\n        \"hooks\": 2\n      },\n      \"tags\": [\n        \"resume\",\n        \"doctor\",\n        \"validate\",\n        \"workflow\",\n        \"health-check\"\n      ],\n      \"verified\": false,\n      \"downloads\": 0,\n      \"stars\": 0,\n      \"created_at\": \"2026-03-18T00:00:00Z\",\n      \"updated_at\": \"2026-03-18T00:00:00Z\"\n    },\n    \"sync\": {\n      \"name\": \"Spec Sync\",\n      \"id\": \"sync\",\n      \"description\": \"Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval.\",\n      \"author\": \"bgervin\",\n      \"version\": \"0.1.0\",\n      \"download_url\": \"https://github.com/bgervin/spec-kit-sync/archive/refs/tags/v0.1.0.zip\",\n      \"repository\": \"https://github.com/bgervin/spec-kit-sync\",\n      \"homepage\": \"https://github.com/bgervin/spec-kit-sync\",\n      \"documentation\": \"https://github.com/bgervin/spec-kit-sync/blob/main/README.md\",\n      \"changelog\": \"https://github.com/bgervin/spec-kit-sync/blob/main/CHANGELOG.md\",\n      \"license\": \"MIT\",\n      \"requires\": {\n        \"speckit_version\": \">=0.1.0\"\n      },\n      \"provides\": {\n        \"commands\": 5,\n        \"hooks\": 1\n      },\n      \"tags\": [\n        \"sync\",\n        \"drift\",\n        \"validation\",\n        \"bidirectional\",\n        \"backfill\"\n      ],\n      \"verified\": false,\n      \"downloads\": 0,\n      \"stars\": 0,\n      \"created_at\": \"2026-03-02T00:00:00Z\",\n      \"updated_at\": \"2026-03-02T00:00:00Z\"\n    },\n    \"understanding\": {\n      \"name\": \"Understanding\",\n      \"id\": \"understanding\",\n      \"description\": \"Automated requirements quality analysis \\u2014 validates specs against IEEE/ISO standards using 31 deterministic metrics. Catches ambiguity, missing testability, and structural issues before they reach implementation. Includes experimental energy-based ambiguity detection using local LM token perplexity.\",\n      \"author\": \"Ladislav Bihari\",\n      \"version\": \"3.4.0\",\n      \"download_url\": \"https://github.com/Testimonial/understanding/archive/refs/tags/v3.4.0.zip\",\n      \"repository\": \"https://github.com/Testimonial/understanding\",\n      \"homepage\": \"https://github.com/Testimonial/understanding\",\n      \"documentation\": \"https://github.com/Testimonial/understanding/blob/main/extension/README.md\",\n      \"changelog\": \"https://github.com/Testimonial/understanding/blob/main/extension/CHANGELOG.md\",\n      \"license\": \"MIT\",\n      \"requires\": {\n        \"speckit_version\": \">=0.1.0\",\n        \"tools\": [\n          {\n            \"name\": \"understanding\",\n            \"version\": \">=3.4.0\",\n            \"required\": true\n          }\n        ]\n      },\n      \"provides\": {\n        \"commands\": 3,\n        \"hooks\": 1\n      },\n      \"tags\": [\n        \"quality\",\n        \"metrics\",\n        \"requirements\",\n        \"validation\",\n        \"readability\",\n        \"IEEE-830\",\n        \"ISO-29148\"\n      ],\n      \"verified\": false,\n      \"downloads\": 0,\n      \"stars\": 0,\n      \"created_at\": \"2026-03-07T00:00:00Z\",\n      \"updated_at\": \"2026-03-07T00:00:00Z\"\n    },\n    \"status\": {\n      \"name\": \"Project Status\",\n      \"id\": \"status\",\n      \"description\": \"Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary.\",\n      \"author\": \"KhawarHabibKhan\",\n      \"version\": \"1.0.0\",\n      \"download_url\": \"https://github.com/KhawarHabibKhan/spec-kit-status/archive/refs/tags/v1.0.0.zip\",\n      \"repository\": \"https://github.com/KhawarHabibKhan/spec-kit-status\",\n      \"homepage\": \"https://github.com/KhawarHabibKhan/spec-kit-status\",\n      \"documentation\": \"https://github.com/KhawarHabibKhan/spec-kit-status/blob/main/README.md\",\n      \"changelog\": \"https://github.com/KhawarHabibKhan/spec-kit-status/blob/main/CHANGELOG.md\",\n      \"license\": \"MIT\",\n      \"requires\": {\n        \"speckit_version\": \">=0.1.0\"\n      },\n      \"provides\": {\n        \"commands\": 1,\n        \"hooks\": 0\n      },\n      \"tags\": [\n        \"status\",\n        \"workflow\",\n        \"progress\",\n        \"feature-tracking\",\n        \"task-progress\"\n      ],\n      \"verified\": false,\n      \"downloads\": 0,\n      \"stars\": 0,\n      \"created_at\": \"2026-03-16T00:00:00Z\",\n      \"updated_at\": \"2026-03-16T00:00:00Z\"\n    },\n    \"v-model\": {\n      \"name\": \"V-Model Extension Pack\",\n      \"id\": \"v-model\",\n      \"description\": \"Enforces V-Model paired generation of development specs and test specs with full traceability.\",\n      \"author\": \"leocamello\",\n      \"version\": \"0.4.0\",\n      \"download_url\": \"https://github.com/leocamello/spec-kit-v-model/archive/refs/tags/v0.4.0.zip\",\n      \"repository\": \"https://github.com/leocamello/spec-kit-v-model\",\n      \"homepage\": \"https://github.com/leocamello/spec-kit-v-model\",\n      \"documentation\": \"https://github.com/leocamello/spec-kit-v-model/blob/main/README.md\",\n      \"changelog\": \"https://github.com/leocamello/spec-kit-v-model/blob/main/CHANGELOG.md\",\n      \"license\": \"MIT\",\n      \"requires\": {\n        \"speckit_version\": \">=0.1.0\"\n      },\n      \"provides\": {\n        \"commands\": 9,\n        \"hooks\": 1\n      },\n      \"tags\": [\n        \"v-model\",\n        \"traceability\",\n        \"testing\",\n        \"compliance\",\n        \"safety-critical\"\n      ],\n      \"verified\": false,\n      \"downloads\": 0,\n      \"stars\": 0,\n      \"created_at\": \"2026-02-20T00:00:00Z\",\n      \"updated_at\": \"2026-02-22T00:00:00Z\"\n    },\n    \"learn\": {\n      \"name\": \"Learning Extension\",\n      \"id\": \"learn\",\n      \"description\": \"Generate educational guides from implementations and enhance clarifications with mentoring context.\",\n      \"author\": \"Vianca Martinez\",\n      \"version\": \"1.0.0\",\n      \"download_url\": \"https://github.com/imviancagrace/spec-kit-learn/archive/refs/tags/v1.0.0.zip\",\n      \"repository\": \"https://github.com/imviancagrace/spec-kit-learn\",\n      \"homepage\": \"https://github.com/imviancagrace/spec-kit-learn\",\n      \"documentation\": \"https://github.com/imviancagrace/spec-kit-learn/blob/main/README.md\",\n      \"changelog\": \"https://github.com/imviancagrace/spec-kit-learn/blob/main/CHANGELOG.md\",\n      \"license\": \"MIT\",\n      \"requires\": {\n        \"speckit_version\": \">=0.1.0\"\n      },\n      \"provides\": {\n        \"commands\": 2,\n        \"hooks\": 1\n      },\n      \"tags\": [\n        \"learning\",\n        \"education\",\n        \"mentoring\",\n        \"knowledge-transfer\"\n      ],\n      \"verified\": false,\n      \"downloads\": 0,\n      \"stars\": 0,\n      \"created_at\": \"2026-03-17T00:00:00Z\",\n      \"updated_at\": \"2026-03-17T00:00:00Z\"\n    },\n    \"verify\": {\n      \"name\": \"Verify Extension\",\n      \"id\": \"verify\",\n      \"description\": \"Post-implementation quality gate that validates implemented code against specification artifacts.\",\n      \"author\": \"ismaelJimenez\",\n      \"version\": \"1.0.0\",\n      \"download_url\": \"https://github.com/ismaelJimenez/spec-kit-verify/archive/refs/tags/v1.0.0.zip\",\n      \"repository\": \"https://github.com/ismaelJimenez/spec-kit-verify\",\n      \"homepage\": \"https://github.com/ismaelJimenez/spec-kit-verify\",\n      \"documentation\": \"https://github.com/ismaelJimenez/spec-kit-verify/blob/main/README.md\",\n      \"changelog\": \"https://github.com/ismaelJimenez/spec-kit-verify/blob/main/CHANGELOG.md\",\n      \"license\": \"MIT\",\n      \"requires\": {\n        \"speckit_version\": \">=0.1.0\"\n      },\n      \"provides\": {\n        \"commands\": 1,\n        \"hooks\": 1\n      },\n      \"tags\": [\n        \"verification\",\n        \"quality-gate\",\n        \"implementation\",\n        \"spec-adherence\",\n        \"compliance\"\n      ],\n      \"verified\": false,\n      \"downloads\": 0,\n      \"stars\": 0,\n      \"created_at\": \"2026-03-03T00:00:00Z\",\n      \"updated_at\": \"2026-03-03T00:00:00Z\"\n    },\n    \"verify-tasks\": {\n      \"name\": \"Verify Tasks Extension\",\n      \"id\": \"verify-tasks\",\n      \"description\": \"Detect phantom completions: tasks marked [X] in tasks.md with no real implementation.\",\n      \"author\": \"Dave Sharpe\",\n      \"version\": \"1.0.0\",\n      \"download_url\": \"https://github.com/datastone-inc/spec-kit-verify-tasks/archive/refs/tags/v1.0.0.zip\",\n      \"repository\": \"https://github.com/datastone-inc/spec-kit-verify-tasks\",\n      \"homepage\": \"https://github.com/datastone-inc/spec-kit-verify-tasks\",\n      \"documentation\": \"https://github.com/datastone-inc/spec-kit-verify-tasks/blob/main/README.md\",\n      \"changelog\": \"https://github.com/datastone-inc/spec-kit-verify-tasks/blob/main/CHANGELOG.md\",\n      \"license\": \"MIT\",\n      \"requires\": {\n        \"speckit_version\": \">=0.1.0\"\n      },\n      \"provides\": {\n        \"commands\": 1,\n        \"hooks\": 1\n      },\n      \"tags\": [\n        \"verification\",\n        \"quality\",\n        \"phantom-completion\",\n        \"tasks\"\n      ],\n      \"verified\": false,\n      \"downloads\": 0,\n      \"stars\": 0,\n      \"created_at\": \"2026-03-16T00:00:00Z\",\n      \"updated_at\": \"2026-03-16T00:00:00Z\"\n    }\n  }\n}\n"
  },
  {
    "path": "extensions/catalog.json",
    "content": "{\n  \"schema_version\": \"1.0\",\n  \"updated_at\": \"2026-03-10T00:00:00Z\",\n  \"catalog_url\": \"https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json\",\n  \"extensions\": {\n    \"selftest\": {\n      \"name\": \"Spec Kit Self-Test Utility\",\n      \"id\": \"selftest\",\n      \"version\": \"1.0.0\",\n      \"description\": \"Verifies catalog extensions by programmatically walking through the discovery, installation, and registration lifecycle.\",\n      \"author\": \"spec-kit-core\",\n      \"repository\": \"https://github.com/github/spec-kit\",\n      \"download_url\": \"https://github.com/github/spec-kit/releases/download/selftest-v1.0.0/selftest.zip\",\n      \"tags\": [\n        \"testing\",\n        \"core\",\n        \"utility\"\n      ]\n    }\n  }\n}"
  },
  {
    "path": "extensions/selftest/commands/selftest.md",
    "content": "---\ndescription: \"Validate the lifecycle of an extension from the catalog.\"\n---\n\n# Extension Self-Test: `$ARGUMENTS`\n\nThis command drives a self-test simulating the developer experience with the `$ARGUMENTS` extension.\n\n## Goal\n\nValidate the end-to-end lifecycle (discovery, installation, registration) for the extension: `$ARGUMENTS`.\nIf `$ARGUMENTS` is empty, you must tell the user to provide an extension name, for example: `/speckit.selftest.extension linear`.\n\n## Steps\n\n### Step 1: Catalog Discovery Validation\n\nCheck if the extension exists in the Spec Kit catalog.\nExecute this command and verify that it completes successfully and that the returned extension ID exactly matches `$ARGUMENTS`. If the command fails or the ID does not match `$ARGUMENTS`, fail the test.\n\n```bash\nspecify extension info \"$ARGUMENTS\"\n```\n\n### Step 2: Simulate Installation\n\nFirst, try to add the extension to the current workspace configuration directly. If the catalog provides the extension as `install_allowed: false` (discovery-only), this step is *expected* to fail.\n\n```bash\nspecify extension add \"$ARGUMENTS\"\n```\n\nThen, simulate adding the extension by installing it from its catalog download URL, which should bypass the restriction.\nObtain the extension's `download_url` from the catalog metadata (for example, via a catalog info command or UI), then run:\n\n```bash\nspecify extension add \"$ARGUMENTS\" --from \"<download_url>\"\n```\n\n### Step 3: Registration Verification\n\nOnce the `add` command completes, verify the installation by checking the project configuration.\nUse terminal tools (like `cat`) to verify that the following file contains a record for `$ARGUMENTS`.\n\n```bash\ncat .specify/extensions/.registry/$ARGUMENTS.json\n```\n\n### Step 4: Verification Report\n\nAnalyze the standard output of the three steps. \nGenerate a terminal-style test output format detailing the results of discovery, installation, and registration. Return this directly to the user.\n\nExample output format:\n```text\n============================= test session starts ==============================\ncollected 3 items\n\ntest_selftest_discovery.py::test_catalog_search [PASS/FAIL]\n  Details: [Provide execution result of specify extension search]\n\ntest_selftest_installation.py::test_extension_add [PASS/FAIL]\n  Details: [Provide execution result of specify extension add]\n\ntest_selftest_registration.py::test_config_verification [PASS/FAIL]\n  Details: [Provide execution result of registry record verification]\n\n============================== [X] passed in ... ==============================\n```\n"
  },
  {
    "path": "extensions/selftest/extension.yml",
    "content": "schema_version: \"1.0\"\nextension:\n  id: selftest\n  name: Spec Kit Self-Test Utility\n  version: 1.0.0\n  description: Verifies catalog extensions by programmatically walking through the discovery, installation, and registration lifecycle.\n  author: spec-kit-core\n  repository: https://github.com/github/spec-kit\n  license: MIT\nrequires:\n  speckit_version: \">=0.2.0\"\nprovides:\n  commands:\n    - name: speckit.selftest.extension\n      file: commands/selftest.md\n      description: Validate the lifecycle of an extension from the catalog.\n"
  },
  {
    "path": "extensions/template/.gitignore",
    "content": "# Local configuration overrides\n*-config.local.yml\n\n# Python\n__pycache__/\n*.py[cod]\n*$py.class\n*.so\n.Python\nenv/\nvenv/\n\n# Testing\n.pytest_cache/\n.coverage\nhtmlcov/\n\n# IDEs\n.vscode/\n.idea/\n*.swp\n*.swo\n*~\n\n# OS\n.DS_Store\nThumbs.db\n\n# Logs\n*.log\n\n# Build artifacts\ndist/\nbuild/\n*.egg-info/\n\n# Temporary files\n*.tmp\n.cache/\n"
  },
  {
    "path": "extensions/template/CHANGELOG.md",
    "content": "# Changelog\n\nAll notable changes to this extension will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\nand this project adheres to  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n## [Unreleased]\n\n### Planned\n\n- Feature ideas for future versions\n- Enhancements\n- Bug fixes\n\n## [1.0.0] - YYYY-MM-DD\n\n### Added\n\n- Initial release of extension\n- Command: `/speckit.my-extension.example` - Example command functionality\n- Configuration system with template\n- Documentation and examples\n\n### Features\n\n- Feature 1 description\n- Feature 2 description\n- Feature 3 description\n\n### Requirements\n\n- Spec Kit: >=0.1.0\n- External dependencies (if any)\n\n---\n\n[Unreleased]: https://github.com/your-org/spec-kit-my-extension/compare/v1.0.0...HEAD\n[1.0.0]: https://github.com/your-org/spec-kit-my-extension/releases/tag/v1.0.0\n"
  },
  {
    "path": "extensions/template/EXAMPLE-README.md",
    "content": "# EXAMPLE: Extension README\n\nThis is an example of what your extension README should look like after customization.\n**Delete this file and replace README.md with content similar to this.**\n\n---\n\n# My Extension\n\n<!-- CUSTOMIZE: Replace with your extension description -->\n\nBrief description of what your extension does and why it's useful.\n\n## Features\n\n<!-- CUSTOMIZE: List key features -->\n\n- Feature 1: Description\n- Feature 2: Description\n- Feature 3: Description\n\n## Installation\n\n```bash\n# Install from catalog\nspecify extension add my-extension\n\n# Or install from local development directory\nspecify extension add --dev /path/to/my-extension\n```\n\n## Configuration\n\n1. Create configuration file:\n\n   ```bash\n   cp .specify/extensions/my-extension/config-template.yml \\\n      .specify/extensions/my-extension/my-extension-config.yml\n   ```\n\n2. Edit configuration:\n\n   ```bash\n   vim .specify/extensions/my-extension/my-extension-config.yml\n   ```\n\n3. Set required values:\n   <!-- CUSTOMIZE: List required configuration -->\n   ```yaml\n   connection:\n     url: \"https://api.example.com\"\n     api_key: \"your-api-key\"\n\n   project:\n     id: \"your-project-id\"\n   ```\n\n## Usage\n\n<!-- CUSTOMIZE: Add usage examples -->\n\n### Command: example\n\nDescription of what this command does.\n\n```bash\n# In Claude Code\n> /speckit.my-extension.example\n```\n\n**Prerequisites**:\n\n- Prerequisite 1\n- Prerequisite 2\n\n**Output**:\n\n- What this command produces\n- Where results are saved\n\n## Configuration Reference\n\n<!-- CUSTOMIZE: Document all configuration options -->\n\n### Connection Settings\n\n| Setting | Type | Required | Description |\n|---------|------|----------|-------------|\n| `connection.url` | string | Yes | API endpoint URL |\n| `connection.api_key` | string | Yes | API authentication key |\n\n### Project Settings\n\n| Setting | Type | Required | Description |\n|---------|------|----------|-------------|\n| `project.id` | string | Yes | Project identifier |\n| `project.workspace` | string | No | Workspace or organization |\n\n## Environment Variables\n\nOverride configuration with environment variables:\n\n```bash\n# Override connection settings\nexport SPECKIT_MY_EXTENSION_CONNECTION_URL=\"https://custom-api.com\"\nexport SPECKIT_MY_EXTENSION_CONNECTION_API_KEY=\"custom-key\"\n```\n\n## Examples\n\n<!-- CUSTOMIZE: Add real-world examples -->\n\n### Example 1: Basic Workflow\n\n```bash\n# Step 1: Create specification\n> /speckit.spec\n\n# Step 2: Generate tasks\n> /speckit.tasks\n\n# Step 3: Use extension\n> /speckit.my-extension.example\n```\n\n## Troubleshooting\n\n<!-- CUSTOMIZE: Add common issues -->\n\n### Issue: Configuration not found\n\n**Solution**: Create config from template (see Configuration section)\n\n### Issue: Command not available\n\n**Solutions**:\n\n1. Check extension is installed: `specify extension list`\n2. Restart AI agent\n3. Reinstall extension\n\n## License\n\nMIT License - see LICENSE file\n\n## Support\n\n- **Issues**: <https://github.com/your-org/spec-kit-my-extension/issues>\n- **Spec Kit Docs**: <https://github.com/statsperform/spec-kit>\n\n## Changelog\n\nSee [CHANGELOG.md](CHANGELOG.md) for version history.\n\n---\n\n*Extension Version: 1.0.0*\n*Spec Kit: >=0.1.0*\n"
  },
  {
    "path": "extensions/template/LICENSE",
    "content": "MIT License\n\nCopyright (c) 2026 [Your Name or Organization]\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "extensions/template/README.md",
    "content": "# Extension Template\n\nStarter template for creating a Spec Kit extension.\n\n## Quick Start\n\n1. **Copy this template**:\n\n   ```bash\n   cp -r extensions/template my-extension\n   cd my-extension\n   ```\n\n2. **Customize `extension.yml`**:\n   - Change extension ID, name, description\n   - Update author and repository\n   - Define your commands\n\n3. **Create commands**:\n   - Add command files in `commands/` directory\n   - Use Markdown format with YAML frontmatter\n\n4. **Create config template**:\n   - Define configuration options\n   - Document all settings\n\n5. **Write documentation**:\n   - Update README.md with usage instructions\n   - Add examples\n\n6. **Test locally**:\n\n   ```bash\n   cd /path/to/spec-kit-project\n   specify extension add --dev /path/to/my-extension\n   ```\n\n7. **Publish** (optional):\n   - Create GitHub repository\n   - Create release\n   - Submit to catalog (see EXTENSION-PUBLISHING-GUIDE.md)\n\n## Files in This Template\n\n- `extension.yml` - Extension manifest (CUSTOMIZE THIS)\n- `config-template.yml` - Configuration template (CUSTOMIZE THIS)\n- `commands/example.md` - Example command (REPLACE THIS)\n- `README.md` - Extension documentation (REPLACE THIS)\n- `LICENSE` - MIT License (REVIEW THIS)\n- `CHANGELOG.md` - Version history (UPDATE THIS)\n- `.gitignore` - Git ignore rules\n\n## Customization Checklist\n\n- [ ] Update `extension.yml` with your extension details\n- [ ] Change extension ID to your extension name\n- [ ] Update author information\n- [ ] Define your commands\n- [ ] Create command files in `commands/`\n- [ ] Update config template\n- [ ] Write README with usage instructions\n- [ ] Add examples\n- [ ] Update LICENSE if needed\n- [ ] Test extension locally\n- [ ] Create git repository\n- [ ] Create first release\n\n## Need Help?\n\n- **Development Guide**: See EXTENSION-DEVELOPMENT-GUIDE.md\n- **API Reference**: See EXTENSION-API-REFERENCE.md\n- **Publishing Guide**: See EXTENSION-PUBLISHING-GUIDE.md\n- **User Guide**: See EXTENSION-USER-GUIDE.md\n\n## Template Version\n\n- Version: 1.0.0\n- Last Updated: 2026-01-28\n- Compatible with Spec Kit: >=0.1.0\n"
  },
  {
    "path": "extensions/template/commands/example.md",
    "content": "---\ndescription: \"Example command that demonstrates extension functionality\"\n# CUSTOMIZE: List MCP tools this command uses\ntools:\n  - 'example-mcp-server/example_tool'\n---\n\n# Example Command\n\n<!-- CUSTOMIZE: Replace this entire file with your command documentation -->\n\nThis is an example command that demonstrates how to create commands for Spec Kit extensions.\n\n## Purpose\n\nDescribe what this command does and when to use it.\n\n## Prerequisites\n\nList requirements before using this command:\n\n1. Prerequisite 1 (e.g., \"MCP server configured\")\n2. Prerequisite 2 (e.g., \"Configuration file exists\")\n3. Prerequisite 3 (e.g., \"Valid API credentials\")\n\n## User Input\n\n$ARGUMENTS\n\n## Steps\n\n### Step 1: Load Configuration\n\n<!-- CUSTOMIZE: Replace with your actual steps -->\n\nLoad extension configuration from the project:\n\n``bash\nconfig_file=\".specify/extensions/my-extension/my-extension-config.yml\"\n\nif [ ! -f \"$config_file\" ]; then\n  echo \"❌ Error: Configuration not found at $config_file\"\n  echo \"Run 'specify extension add my-extension' to install and configure\"\n  exit 1\nfi\n\n# Read configuration values\n\nsetting_value=$(yq eval '.settings.key' \"$config_file\")\n\n# Apply environment variable overrides\n\nsetting_value=\"${SPECKIT_MY_EXTENSION_KEY:-$setting_value}\"\n\n# Validate configuration\n\nif [ -z \"$setting_value\" ]; then\n  echo \"❌ Error: Configuration value not set\"\n  echo \"Edit $config_file and set 'settings.key'\"\n  exit 1\nfi\n\necho \"📋 Configuration loaded: $setting_value\"\n``\n\n### Step 2: Perform Main Action\n\n<!-- CUSTOMIZE: Replace with your command logic -->\n\nDescribe what this step does:\n\n``markdown\nUse MCP tools to perform the main action:\n\n- Tool: example-mcp-server example_tool\n- Parameters: { \"key\": \"$setting_value\" }\n\nThis calls the MCP server tool to execute the operation.\n``\n\n### Step 3: Process Results\n\n<!-- CUSTOMIZE: Add more steps as needed -->\n\nProcess the results and provide output:\n\n`` bash\necho \"\"\necho \"✅ Command completed successfully!\"\necho \"\"\necho \"Results:\"\necho \"  • Item 1: Value\"\necho \"  • Item 2: Value\"\necho \"\"\n``\n\n### Step 4: Save Output (Optional)\n\nSave results to a file if needed:\n\n``bash\noutput_file=\".specify/my-extension-output.json\"\n\ncat > \"$output_file\" <<EOF\n{\n  \"timestamp\": \"$(date -u +\"%Y-%m-%dT%H:%M:%SZ\")\",\n  \"setting\": \"$setting_value\",\n  \"results\": []\n}\nEOF\n\necho \"💾 Output saved to $output_file\"\n``\n\n## Configuration Reference\n\n<!-- CUSTOMIZE: Document configuration options -->\n\nThis command uses the following configuration from `my-extension-config.yml`:\n\n- **settings.key**: Description of what this setting does\n  - Type: string\n  - Required: Yes\n  - Example: `\"example-value\"`\n\n- **settings.another_key**: Description of another setting\n  - Type: boolean\n  - Required: No\n  - Default: `false`\n  - Example: `true`\n\n## Environment Variables\n\n<!-- CUSTOMIZE: Document environment variable overrides -->\n\nConfiguration can be overridden with environment variables:\n\n- `SPECKIT_MY_EXTENSION_KEY` - Overrides `settings.key`\n- `SPECKIT_MY_EXTENSION_ANOTHER_KEY` - Overrides `settings.another_key`\n\nExample:\n``bash\nexport SPECKIT_MY_EXTENSION_KEY=\"override-value\"\n``\n\n## Troubleshooting\n\n<!-- CUSTOMIZE: Add common issues and solutions -->\n\n### \"Configuration not found\"\n\n**Solution**: Install the extension and create configuration:\n``bash\nspecify extension add my-extension\ncp .specify/extensions/my-extension/config-template.yml \\\n   .specify/extensions/my-extension/my-extension-config.yml\n``\n\n### \"MCP tool not available\"\n\n**Solution**: Ensure MCP server is configured in your AI agent settings.\n\n### \"Permission denied\"\n\n**Solution**: Check credentials and permissions in the external service.\n\n## Notes\n\n<!-- CUSTOMIZE: Add helpful notes and tips -->\n\n- This command requires an active connection to the external service\n- Results are cached for performance\n- Re-run the command to refresh data\n\n## Examples\n\n<!-- CUSTOMIZE: Add usage examples -->\n\n### Example 1: Basic Usage\n\n``bash\n\n# Run with default configuration\n>\n> /speckit.my-extension.example\n``\n\n### Example 2: With Environment Override\n\n``bash\n\n# Override configuration with environment variable\n\nexport SPECKIT_MY_EXTENSION_KEY=\"custom-value\"\n> /speckit.my-extension.example\n``\n\n### Example 3: After Core Command\n\n``bash\n\n# Use as part of a workflow\n>\n> /speckit.tasks\n> /speckit.my-extension.example\n``\n\n---\n\n*For more information, see the extension README or run `specify extension info my-extension`*\n"
  },
  {
    "path": "extensions/template/config-template.yml",
    "content": "# Extension Configuration Template\n# Copy this to my-extension-config.yml and customize for your project\n\n# CUSTOMIZE: Add your configuration sections below\n\n# Example: Connection settings\nconnection:\n  # URL to external service\n  url: \"\"  # REQUIRED: e.g., \"https://api.example.com\"\n\n  # API key or token\n  api_key: \"\"  # REQUIRED: Your API key\n\n# Example: Project settings\nproject:\n  # Project identifier\n  id: \"\"  # REQUIRED: e.g., \"my-project\"\n\n  # Workspace or organization\n  workspace: \"\"  # OPTIONAL: e.g., \"my-org\"\n\n# Example: Feature flags\nfeatures:\n  # Enable/disable main functionality\n  enabled: true\n\n  # Automatic synchronization\n  auto_sync: false\n\n  # Verbose logging\n  verbose: false\n\n# Example: Default values\ndefaults:\n  # Labels to apply\n  labels: []  # e.g., [\"automated\", \"spec-kit\"]\n\n  # Priority level\n  priority: \"medium\"  # Options: \"low\", \"medium\", \"high\"\n\n  # Assignee\n  assignee: \"\"  # OPTIONAL: Default assignee\n\n# Example: Field mappings\n# Map internal names to external field IDs\nfield_mappings:\n  # Example mappings\n  # internal_field: \"external_field_id\"\n  # status: \"customfield_10001\"\n\n# Example: Advanced settings\nadvanced:\n  # Timeout in seconds\n  timeout: 30\n\n  # Retry attempts\n  retry_count: 3\n\n  # Cache duration in seconds\n  cache_duration: 3600\n\n# Environment Variable Overrides:\n# You can override any setting with environment variables  using this pattern:\n# SPECKIT_MY_EXTENSION_{SECTION}_{KEY}\n#\n# Examples:\n# - SPECKIT_MY_EXTENSION_CONNECTION_API_KEY: Override connection.api_key\n# - SPECKIT_MY_EXTENSION_PROJECT_ID: Override project.id\n# - SPECKIT_MY_EXTENSION_FEATURES_ENABLED: Override features.enabled\n#\n# Note: Use uppercase and replace dots with underscores\n\n# Local Overrides:\n# For local development, create my-extension-config.local.yml (gitignored)\n# to override settings without affecting the team configuration\n"
  },
  {
    "path": "extensions/template/extension.yml",
    "content": "schema_version: \"1.0\"\n\nextension:\n  # CUSTOMIZE: Change 'my-extension' to your extension ID (lowercase, hyphen-separated)\n  id: \"my-extension\"\n\n  # CUSTOMIZE: Human-readable name for your extension\n  name: \"My Extension\"\n\n  # CUSTOMIZE: Update version when releasing (semantic versioning: X.Y.Z)\n  version: \"1.0.0\"\n\n  # CUSTOMIZE: Brief description (under 200 characters)\n  description: \"Brief description of what your extension does\"\n\n  # CUSTOMIZE: Your name or organization name\n  author: \"Your Name\"\n\n  # CUSTOMIZE: GitHub repository URL (create before publishing)\n  repository: \"https://github.com/your-org/spec-kit-my-extension\"\n\n  # REVIEW: License (MIT is recommended for open source)\n  license: \"MIT\"\n\n  # CUSTOMIZE: Extension homepage (can be same as repository)\n  homepage: \"https://github.com/your-org/spec-kit-my-extension\"\n\n# Requirements for this extension\nrequires:\n  # CUSTOMIZE: Minimum spec-kit version required\n  # Use >=X.Y.Z for minimum version\n  # Use >=X.Y.Z,<Y.0.0 for version range\n  speckit_version: \">=0.1.0\"\n\n  # CUSTOMIZE: Add MCP tools or other dependencies\n  # Remove if no external tools required\n  tools:\n    - name: \"example-mcp-server\"\n      version: \">=1.0.0\"\n      required: true\n\n# Commands provided by this extension\nprovides:\n  commands:\n    # CUSTOMIZE: Define your commands\n    # Pattern: speckit.{extension-id}.{command-name}\n    - name: \"speckit.my-extension.example\"\n      file: \"commands/example.md\"\n      description: \"Example command that demonstrates functionality\"\n      # Optional: Add aliases for shorter command names\n      aliases: [\"speckit.example\"]\n\n    # ADD MORE COMMANDS: Copy this block for each command\n    # - name: \"speckit.my-extension.another-command\"\n    #   file: \"commands/another-command.md\"\n    #   description: \"Another command\"\n\n  # CUSTOMIZE: Define configuration files\n  config:\n    - name: \"my-extension-config.yml\"\n      template: \"config-template.yml\"\n      description: \"Extension configuration\"\n      required: true # Set to false if config is optional\n\n# CUSTOMIZE: Define hooks (optional)\n# Remove if no hooks needed\nhooks:\n  # Hook that runs after /speckit.tasks\n  after_tasks:\n    command: \"speckit.my-extension.example\"\n    optional: true # User will be prompted\n    prompt: \"Run example command?\"\n    description: \"Demonstrates hook functionality\"\n    condition: null # Future: conditional execution\n\n  # ADD MORE HOOKS: Copy this block for other events\n  # after_implement:\n  #   command: \"speckit.my-extension.another\"\n  #   optional: false  # Auto-execute without prompting\n  #   description: \"Runs automatically after implementation\"\n\n# CUSTOMIZE: Add relevant tags (2-5 recommended)\n# Used for discovery in catalog\ntags:\n  - \"example\"\n  - \"template\"\n  # ADD MORE: \"category\", \"tool-name\", etc.\n\n# CUSTOMIZE: Default configuration values (optional)\n# These are merged with user config\ndefaults:\n  # Example default values\n  feature:\n    enabled: true\n    auto_sync: false\n\n  # ADD MORE: Any default settings for your extension\n"
  },
  {
    "path": "newsletters/2026-February.md",
    "content": "# Spec Kit - February 2026 Newsletter\n\nThis edition covers Spec Kit activity in February 2026. Versions v0.1.7 through v0.1.13 shipped during the month, addressing bugs and adding features including a dual-catalog extension system and additional agent integrations. Community activity included blog posts, tutorials, and meetup sessions. A category summary is in the table below, followed by details.\n\n| **Spec Kit Core (Feb 2026)** | **Community & Content** | **Roadmap & Next** |\n| --- | --- | --- |\n| Versions **v0.1.7** through **v0.1.13** shipped with bug fixes and features, including a **dual-catalog extension system** and new agent integrations. Over 300 issues were closed (of ~800 filed). The repo reached 71k stars and 6.4k forks. [\\[github.com\\]](https://github.com/github/spec-kit/releases) [\\[github.com\\]](https://github.com/github/spec-kit/issues) [\\[rywalker.com\\]](https://rywalker.com/research/github-spec-kit) | Eduardo Luz published a LinkedIn article on SDD and Spec Kit [\\[linkedin.com\\]](https://www.linkedin.com/pulse/specification-driven-development-sdd-github-spec-kit-elevating-luz-tojmc?tl=en). Erick Matsen blogged a walkthrough of building a bioinformatics pipeline with Spec Kit [\\[matsen.fredhutch.org\\]](https://matsen.fredhutch.org/general/2026/02/10/spec-kit-walkthrough.html). Microsoft MVP [Eric Boyd](https://ericboyd.com/) (not the Microsoft AI Platform VP of the same name) presented at the Cleveland .NET User Group [\\[ericboyd.com\\]](https://ericboyd.com/events/cleveland-csharp-user-group-february-25-2026-spec-driven-development-sdd-github-spec-kit). | **v0.2.0** was released in early March, consolidating February's work. It added extensions for Jira and Azure DevOps, community plugin support, and agents for Tabnine CLI and Kiro CLI [\\[github.com\\]](https://github.com/github/spec-kit/releases). Future work includes spec lifecycle management and progress toward a stable 1.0 release [\\[martinfowler.com\\]](https://martinfowler.com/articles/exploring-gen-ai/sdd-3-tools.html). |\n\n***\n\n## Spec Kit Project Updates\n\nSpec Kit released versions **v0.1.7** through **v0.1.13** during February. Version 0.1.7 (early February) updated documentation for the newly introduced **dual-catalog extension system**, which allows both core and community extension catalogs to coexist. Subsequent patches (0.1.8, 0.1.9, etc.) bumped dependencies such as GitHub Actions versions and resolved minor issues. **v0.1.10** fixed YAML front-matter handling in generated files. By late February, **v0.1.12** and **v0.1.13** shipped with additional fixes in preparation for the next version bump. [\\[github.com\\]](https://github.com/github/spec-kit/releases)\n\nThe main architectural addition was the **modular extension system** with separate \"core\" and \"community\" extension catalogs for third-party add-ons. Multiple community-contributed extensions were merged during the month, including a **Jira extension** for issue tracker integration, an **Azure DevOps extension**, and utility extensions for code review, retrospective documentation, and CI/CD sync. The pending 0.2.0 release changelog lists over a dozen changes from February, including the extension additions and support for **multiple agent catalogs concurrently**. [\\[github.com\\]](https://github.com/github/spec-kit/releases)\n\nBy end of February, **over 330 issues/feature requests had been closed on GitHub** (out of ~870 filed to date). External contributors submitted pull requests including the **Tabnine CLI support**, which was merged in late February. The repository reached ~71k stars and crossed 6,000 forks. [\\[github.com\\]](https://github.com/github/spec-kit/issues) [\\[github.com\\]](https://github.com/github/spec-kit/releases) [\\[rywalker.com\\]](https://rywalker.com/research/github-spec-kit)\n\nOn the stability side, February's work focused on tightening core workflows and fixing edge-case bugs in the specification, planning, and task-generation commands. The team addressed file-handling issues (e.g., clarifying how output files are created/appended) and improved the reliability of the automated release pipeline. The project also added **Kiro CLI** to the supported agent list and updated integration scripts for Cursor and Code Interpreter, bringing the total number of supported AI coding assistants to over 20. [\\[github.com\\]](https://github.com/github/spec-kit/releases) [\\[github.com\\]](https://github.com/github/spec-kit)\n\n## Community & Content\n\n**Eduardo Luz** published a LinkedIn article on Feb 15 titled *\"Specification Driven Development (SDD) and the GitHub Spec Kit: Elevating Software Engineering.\"* The article draws on his experience as a senior engineer to describe common causes of technical debt and inconsistent designs, and how SDD addresses them. It walks through Spec Kit's **four-layer approach** (Constitution, Design, Tasks, Implementation) and discusses treating specifications as a source of truth. The post generated discussion among software architects on LinkedIn about reducing misunderstandings and rework through spec-driven workflows. [\\[linkedin.com\\]](https://www.linkedin.com/pulse/specification-driven-development-sdd-github-spec-kit-elevating-luz-tojmc?tl=en)\n\n**Erick Matsen** (Fred Hutchinson Cancer Center) posted a detailed walkthrough on Feb 10 titled *\"Spec-Driven Development with spec-kit.\"* He describes building a **bioinformatics pipeline** in a single day using Spec Kit's workflow (from `speckit.constitution` to `speckit.implement`). The post includes command outputs and notes on decisions made along the way, such as refining the spec to add domain-specific requirements. He writes: \"I really recommend this approach. This feels like the way software development should be.\" [\\[matsen.fredhutch.org\\]](https://matsen.fredhutch.org/general/2026/02/10/spec-kit-walkthrough.html) [\\[github.com\\]](https://github.com/mnriem/spec-kit-dotnet-cli-demo)\n\nSeveral other tutorials and guides appeared during the month. An article on *IntuitionLabs* (updated Feb 21) provided a guide to Spec Kit covering the philosophy behind SDD and a walkthrough of the four-phase workflow with examples. A piece by Ry Walker (Feb 22) summarized key aspects of Spec Kit, noting its agent-agnostic design and 71k-star count. Microsoft's Developer Blog post from late 2025 (*\"Diving Into Spec-Driven Development with GitHub Spec Kit\"* by Den Delimarsky) continued to circulate among new users. [\\[intuitionlabs.ai\\]](https://intuitionlabs.ai/articles/spec-driven-development-spec-kit) [\\[rywalker.com\\]](https://rywalker.com/research/github-spec-kit)\n\nOn **Feb 25**, the Cleveland C# .NET User Group hosted a session titled *\"Spec Driven Development with GitHub Spec Kit.\"* The talk was delivered by Microsoft MVP **[Eric Boyd](https://ericboyd.com/)** (Cleveland-based .NET developer; not to be confused with the Microsoft AI Platform VP of the same name). Boyd covered how specs change an AI coding assistant's output, patterns for iterating and refining specs over multiple cycles, and moving from ad-hoc prompting to a repeatable spec-driven workflow. Other groups, including GDG Madison, also listed sessions on spec-driven development in late February and early March. [\\[ericboyd.com\\]](https://ericboyd.com/events/cleveland-csharp-user-group-february-25-2026-spec-driven-development-sdd-github-spec-kit)\n\nOn GitHub, the **Spec Kit Discussions forum** saw activity around installation troubleshooting, handling multi-feature projects with Spec Kit's branching model, and feature suggestions. One thread discussed how Spec Kit treats each spec as a short-lived artifact tied to a feature branch, which led to discussion about future support for long-running \"spec of record\" use cases. [\\[martinfowler.com\\]](https://martinfowler.com/articles/exploring-gen-ai/sdd-3-tools.html)\n\n## SDD Ecosystem\n\nOther spec-driven development tools also saw activity in February.\n\nAWS **Kiro** released version 0.10 on Feb 18 with two new spec workflows: a **Design-First** mode (starting from architecture/pseudocode to derive requirements) and a **Bugfix** mode (structured root-cause analysis producing a `bugfix.md` spec file). Kiro also added hunk-level code review for AI-generated changes and pre/post task hooks for custom automation. AWS expanded Kiro to GovCloud regions on Feb 17 for government compliance use cases. [\\[kiro.dev\\]](https://kiro.dev/changelog/)\n\n**OpenSpec** (by Fission AI), a lightweight SDD framework, reached ~29.3k stars and nearly 2k forks. Its community published guides and comparisons during the month, including *\"Spec-Driven Development Made Easy: A Practical Guide with OpenSpec.\"* OpenSpec emphasizes simplicity and flexibility, integrating with multiple AI coding assistants via YAML configs.\n\n**Tessl** remained in private beta. As described by Thoughtworks writer Birgitta Boeckeler, Tessl pursues a **spec-as-source** model where specifications are maintained long-term and directly generate code files one-to-one, with generated code labeled as \"do not edit.\" This contrasts with Spec Kit's current approach of creating specs per feature/branch. [\\[martinfowler.com\\]](https://martinfowler.com/articles/exploring-gen-ai/sdd-3-tools.html)\n\nAn **arXiv preprint** (January 2026) categorized SDD implementations into three levels: *spec-first*, *spec-anchored*, and *spec-as-source*. Spec Kit was identified as primarily spec-first with elements of spec-anchored. Tech media published reviews including a *Vibe Coding* \"GitHub Spec Kit Review (2026)\" and a blog post titled *\"Putting Spec Kit Through Its Paces: Radical Idea or Reinvented Waterfall?\"* which concluded that SDD with AI assistance is more iterative than traditional Waterfall. [\\[intuitionlabs.ai\\]](https://intuitionlabs.ai/articles/spec-driven-development-spec-kit) [\\[martinfowler.com\\]](https://martinfowler.com/articles/exploring-gen-ai/sdd-3-tools.html)\n\n## Roadmap\n\n**v0.2.0** was released on March 10, 2026, consolidating the month's work. It includes new extensions (Jira, Azure DevOps, review, sync), support for multiple extension catalogs and community plugins, and additional agent integrations (Tabnine CLI, Kiro CLI). [\\[github.com\\]](https://github.com/github/spec-kit/releases)\n\nAreas under discussion or in progress for future development:\n\n- **Spec lifecycle management** -- supporting longer-lived specifications that can evolve across multiple iterations, rather than being tied to a single feature branch. Users have raised this in GitHub Discussions, and the concept of \"spec-anchored\" development is under consideration. [\\[martinfowler.com\\]](https://martinfowler.com/articles/exploring-gen-ai/sdd-3-tools.html)\n- **CI/CD integration** -- incorporating Spec Kit verification (e.g., `speckit.checklist` or `speckit.verify`) into pull request workflows and project management tools. February's Jira and Azure DevOps extensions are a step in this direction. [\\[github.com\\]](https://github.com/github/spec-kit/releases)\n- **Continued agent support** -- adding integrations as new AI coding assistants emerge. The project currently supports over 20 agents and has been adding new ones (Kiro CLI, Tabnine CLI) as they become available. [\\[github.com\\]](https://github.com/github/spec-kit)\n- **Community ecosystem** -- the open extension model allows external contributors to add functionality directly. February's Jira and Azure DevOps plugins were community-contributed. The Spec Kit README now links to community walkthrough demos for .NET, Spring Boot, and other stacks. [\\[github.com\\]](https://github.com/github/spec-kit)\n"
  },
  {
    "path": "presets/ARCHITECTURE.md",
    "content": "# Preset System Architecture\n\nThis document describes the internal architecture of the preset system — how template resolution, command registration, and catalog management work under the hood.\n\nFor usage instructions, see [README.md](README.md).\n\n## Template Resolution\n\nWhen Spec Kit needs a template (e.g. `spec-template`), the `PresetResolver` walks a priority stack and returns the first match:\n\n```mermaid\nflowchart TD\n    A[\"resolve_template('spec-template')\"] --> B{Override exists?}\n    B -- Yes --> C[\".specify/templates/overrides/spec-template.md\"]\n    B -- No --> D{Preset provides it?}\n    D -- Yes --> E[\".specify/presets/‹preset-id›/templates/spec-template.md\"]\n    D -- No --> F{Extension provides it?}\n    F -- Yes --> G[\".specify/extensions/‹ext-id›/templates/spec-template.md\"]\n    F -- No --> H[\".specify/templates/spec-template.md\"]\n\n    E -- \"multiple presets?\" --> I[\"lowest priority number wins\"]\n    I --> E\n\n    style C fill:#4caf50,color:#fff\n    style E fill:#2196f3,color:#fff\n    style G fill:#ff9800,color:#fff\n    style H fill:#9e9e9e,color:#fff\n```\n\n| Priority | Source | Path | Use case |\n|----------|--------|------|----------|\n| 1 (highest) | Override | `.specify/templates/overrides/` | One-off project-local tweaks |\n| 2 | Preset | `.specify/presets/<id>/templates/` | Shareable, stackable customizations |\n| 3 | Extension | `.specify/extensions/<id>/templates/` | Extension-provided templates |\n| 4 (lowest) | Core | `.specify/templates/` | Shipped defaults |\n\nWhen multiple presets are installed, they're sorted by their `priority` field (lower number = higher precedence). This is set via `--priority` on `specify preset add`.\n\nThe resolution is implemented three times to ensure consistency:\n- **Python**: `PresetResolver` in `src/specify_cli/presets.py`\n- **Bash**: `resolve_template()` in `scripts/bash/common.sh`\n- **PowerShell**: `Resolve-Template` in `scripts/powershell/common.ps1`\n\n## Command Registration\n\nWhen a preset is installed with `type: \"command\"` entries, the `PresetManager` registers them into all detected agent directories using the shared `CommandRegistrar` from `src/specify_cli/agents.py`.\n\n```mermaid\nflowchart TD\n    A[\"specify preset add my-preset\"] --> B{Preset has type: command?}\n    B -- No --> Z[\"done (templates only)\"]\n    B -- Yes --> C{Extension command?}\n    C -- \"speckit.myext.cmd\\n(3+ dot segments)\" --> D{Extension installed?}\n    D -- No --> E[\"skip (extension not active)\"]\n    D -- Yes --> F[\"register command\"]\n    C -- \"speckit.specify\\n(core command)\" --> F\n    F --> G[\"detect agent directories\"]\n    G --> H[\".claude/commands/\"]\n    G --> I[\".gemini/commands/\"]\n    G --> J[\".github/agents/\"]\n    G --> K[\"... (17+ agents)\"]\n    H --> L[\"write .md (Markdown format)\"]\n    I --> M[\"write .toml (TOML format)\"]\n    J --> N[\"write .agent.md + .prompt.md\"]\n\n    style E fill:#ff5722,color:#fff\n    style L fill:#4caf50,color:#fff\n    style M fill:#4caf50,color:#fff\n    style N fill:#4caf50,color:#fff\n```\n\n### Extension safety check\n\nCommand names follow the pattern `speckit.<ext-id>.<cmd-name>`. When a command has 3+ dot segments, the system extracts the extension ID and checks if `.specify/extensions/<ext-id>/` exists. If the extension isn't installed, the command is skipped — preventing orphan files referencing non-existent extensions.\n\nCore commands (e.g. `speckit.specify`, with only 2 segments) are always registered.\n\n### Agent format rendering\n\nThe `CommandRegistrar` renders commands differently per agent:\n\n| Agent | Format | Extension | Arg placeholder |\n|-------|--------|-----------|-----------------|\n| Claude, Cursor, opencode, Windsurf, etc. | Markdown | `.md` | `$ARGUMENTS` |\n| Copilot | Markdown | `.agent.md` + `.prompt.md` | `$ARGUMENTS` |\n| Gemini, Qwen, Tabnine | TOML | `.toml` | `{{args}}` |\n\n### Cleanup on removal\n\nWhen `specify preset remove` is called, the registered commands are read from the registry metadata and the corresponding files are deleted from each agent directory, including Copilot companion `.prompt.md` files.\n\n## Catalog System\n\n```mermaid\nflowchart TD\n    A[\"specify preset search\"] --> B[\"PresetCatalog.get_active_catalogs()\"]\n    B --> C{SPECKIT_PRESET_CATALOG_URL set?}\n    C -- Yes --> D[\"single custom catalog\"]\n    C -- No --> E{.specify/preset-catalogs.yml exists?}\n    E -- Yes --> F[\"project-level catalog stack\"]\n    E -- No --> G{\"~/.specify/preset-catalogs.yml exists?\"}\n    G -- Yes --> H[\"user-level catalog stack\"]\n    G -- No --> I[\"built-in defaults\"]\n    I --> J[\"default (install allowed)\"]\n    I --> K[\"community (discovery only)\"]\n\n    style D fill:#ff9800,color:#fff\n    style F fill:#2196f3,color:#fff\n    style H fill:#2196f3,color:#fff\n    style J fill:#4caf50,color:#fff\n    style K fill:#9e9e9e,color:#fff\n```\n\nCatalogs are fetched with a 1-hour cache (per-URL, SHA256-hashed cache files). Each catalog entry has a `priority` (for merge ordering) and `install_allowed` flag.\n\n## Repository Layout\n\n```\npresets/\n├── ARCHITECTURE.md                         # This file\n├── PUBLISHING.md                           # Guide for submitting presets to the catalog\n├── README.md                               # User guide\n├── catalog.json                            # Official preset catalog\n├── catalog.community.json                  # Community preset catalog\n├── scaffold/                               # Scaffold for creating new presets\n│   ├── preset.yml                          # Example manifest\n│   ├── README.md                           # Guide for customizing the scaffold\n│   ├── commands/\n│   │   ├── speckit.specify.md              # Core command override example\n│   │   └── speckit.myext.myextcmd.md       # Extension command override example\n│   └── templates/\n│       ├── spec-template.md                # Core template override example\n│       └── myext-template.md               # Extension template override example\n└── self-test/                              # Self-test preset (overrides all core templates)\n    ├── preset.yml\n    ├── commands/\n    │   └── speckit.specify.md\n    └── templates/\n        ├── spec-template.md\n        ├── plan-template.md\n        ├── tasks-template.md\n        ├── checklist-template.md\n        ├── constitution-template.md\n        └── agent-file-template.md\n```\n\n## Module Structure\n\n```\nsrc/specify_cli/\n├── agents.py       # CommandRegistrar — shared infrastructure for writing\n│                    #   command files to agent directories\n├── presets.py       # PresetManifest, PresetRegistry, PresetManager,\n│                    #   PresetCatalog, PresetCatalogEntry, PresetResolver\n└── __init__.py      # CLI commands: specify preset list/add/remove/search/\n                     #   resolve/info, specify preset catalog list/add/remove\n```\n"
  },
  {
    "path": "presets/PUBLISHING.md",
    "content": "# Preset Publishing Guide\n\nThis guide explains how to publish your preset to the Spec Kit preset catalog, making it discoverable by `specify preset search`.\n\n## Table of Contents\n\n1. [Prerequisites](#prerequisites)\n2. [Prepare Your Preset](#prepare-your-preset)\n3. [Submit to Catalog](#submit-to-catalog)\n4. [Verification Process](#verification-process)\n5. [Release Workflow](#release-workflow)\n6. [Best Practices](#best-practices)\n\n---\n\n## Prerequisites\n\nBefore publishing a preset, ensure you have:\n\n1. **Valid Preset**: A working preset with a valid `preset.yml` manifest\n2. **Git Repository**: Preset hosted on GitHub (or other public git hosting)\n3. **Documentation**: README.md with description and usage instructions\n4. **License**: Open source license file (MIT, Apache 2.0, etc.)\n5. **Versioning**: Semantic versioning (e.g., 1.0.0)\n6. **Testing**: Preset tested on real projects with `specify preset add --dev`\n\n---\n\n## Prepare Your Preset\n\n### 1. Preset Structure\n\nEnsure your preset follows the standard structure:\n\n```text\nyour-preset/\n├── preset.yml                 # Required: Preset manifest\n├── README.md                  # Required: Documentation\n├── LICENSE                    # Required: License file\n├── CHANGELOG.md               # Recommended: Version history\n│\n├── templates/                 # Template overrides\n│   ├── spec-template.md\n│   ├── plan-template.md\n│   └── ...\n│\n└── commands/                  # Command overrides (optional)\n    └── speckit.specify.md\n```\n\nStart from the [scaffold](scaffold/) if you're creating a new preset.\n\n### 2. preset.yml Validation\n\nVerify your manifest is valid:\n\n```yaml\nschema_version: \"1.0\"\n\npreset:\n  id: \"your-preset\"               # Unique lowercase-hyphenated ID\n  name: \"Your Preset Name\"        # Human-readable name\n  version: \"1.0.0\"                # Semantic version\n  description: \"Brief description (one sentence)\"\n  author: \"Your Name or Organization\"\n  repository: \"https://github.com/your-org/spec-kit-preset-your-preset\"\n  license: \"MIT\"\n\nrequires:\n  speckit_version: \">=0.1.0\"      # Required spec-kit version\n\nprovides:\n  templates:\n    - type: \"template\"\n      name: \"spec-template\"\n      file: \"templates/spec-template.md\"\n      description: \"Custom spec template\"\n      replaces: \"spec-template\"\n\ntags:                              # 2-5 relevant tags\n  - \"category\"\n  - \"workflow\"\n```\n\n**Validation Checklist**:\n\n- ✅ `id` is lowercase with hyphens only (no underscores, spaces, or special characters)\n- ✅ `version` follows semantic versioning (X.Y.Z)\n- ✅ `description` is concise (under 200 characters)\n- ✅ `repository` URL is valid and public\n- ✅ All template and command files exist in the preset directory\n- ✅ Template names are lowercase with hyphens only\n- ✅ Command names use dot notation (e.g. `speckit.specify`)\n- ✅ Tags are lowercase and descriptive\n\n### 3. Test Locally\n\n```bash\n# Install from local directory\nspecify preset add --dev /path/to/your-preset\n\n# Verify templates resolve from your preset\nspecify preset resolve spec-template\n\n# Verify preset info\nspecify preset info your-preset\n\n# List installed presets\nspecify preset list\n\n# Remove when done testing\nspecify preset remove your-preset\n```\n\nIf your preset includes command overrides, verify they appear in the agent directories:\n\n```bash\n# Check Claude commands (if using Claude)\nls .claude/commands/speckit.*.md\n\n# Check Copilot commands (if using Copilot)\nls .github/agents/speckit.*.agent.md\n\n# Check Gemini commands (if using Gemini)\nls .gemini/commands/speckit.*.toml\n```\n\n### 4. Create GitHub Release\n\nCreate a GitHub release for your preset version:\n\n```bash\n# Tag the release\ngit tag v1.0.0\ngit push origin v1.0.0\n```\n\nThe release archive URL will be:\n\n```text\nhttps://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip\n```\n\n### 5. Test Installation from Archive\n\n```bash\nspecify preset add --from https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip\n```\n\n---\n\n## Submit to Catalog\n\n### Understanding the Catalogs\n\nSpec Kit uses a dual-catalog system:\n\n- **`catalog.json`** — Official, verified presets (install allowed by default)\n- **`catalog.community.json`** — Community-contributed presets (discovery only by default)\n\nAll community presets should be submitted to `catalog.community.json`.\n\n### 1. Fork the spec-kit Repository\n\n```bash\ngit clone https://github.com/YOUR-USERNAME/spec-kit.git\ncd spec-kit\n```\n\n### 2. Add Preset to Community Catalog\n\nEdit `presets/catalog.community.json` and add your preset.\n\n> **⚠️ Entries must be sorted alphabetically by preset ID.** Insert your preset in the correct position within the `\"presets\"` object.\n\n```json\n{\n  \"schema_version\": \"1.0\",\n  \"updated_at\": \"2026-03-10T00:00:00Z\",\n  \"catalog_url\": \"https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json\",\n  \"presets\": {\n    \"your-preset\": {\n      \"name\": \"Your Preset Name\",\n      \"description\": \"Brief description of what your preset provides\",\n      \"author\": \"Your Name\",\n      \"version\": \"1.0.0\",\n      \"download_url\": \"https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip\",\n      \"repository\": \"https://github.com/your-org/spec-kit-preset-your-preset\",\n      \"license\": \"MIT\",\n      \"requires\": {\n        \"speckit_version\": \">=0.1.0\"\n      },\n      \"provides\": {\n        \"templates\": 3,\n        \"commands\": 1\n      },\n      \"tags\": [\n        \"category\",\n        \"workflow\"\n      ],\n      \"created_at\": \"2026-03-10T00:00:00Z\",\n      \"updated_at\": \"2026-03-10T00:00:00Z\"\n    }\n  }\n}\n```\n\n### 3. Submit Pull Request\n\n```bash\ngit checkout -b add-your-preset\ngit add presets/catalog.community.json\ngit commit -m \"Add your-preset to community catalog\n\n- Preset ID: your-preset\n- Version: 1.0.0\n- Author: Your Name\n- Description: Brief description\n\"\ngit push origin add-your-preset\n```\n\n**Pull Request Checklist**:\n\n```markdown\n## Preset Submission\n\n**Preset Name**: Your Preset Name\n**Preset ID**: your-preset\n**Version**: 1.0.0\n**Repository**: https://github.com/your-org/spec-kit-preset-your-preset\n\n### Checklist\n- [ ] Valid preset.yml manifest\n- [ ] README.md with description and usage\n- [ ] LICENSE file included\n- [ ] GitHub release created\n- [ ] Preset tested with `specify preset add --dev`\n- [ ] Templates resolve correctly (`specify preset resolve`)\n- [ ] Commands register to agent directories (if applicable)\n- [ ] Commands match template sections (command + template are coherent)\n- [ ] Added to presets/catalog.community.json\n```\n\n---\n\n## Verification Process\n\nAfter submission, maintainers will review:\n\n1. **Manifest validation** — valid `preset.yml`, all files exist\n2. **Template quality** — templates are useful and well-structured\n3. **Command coherence** — commands reference sections that exist in templates\n4. **Security** — no malicious content, safe file operations\n5. **Documentation** — clear README explaining what the preset does\n\nOnce verified, `verified: true` is set and the preset appears in `specify preset search`.\n\n---\n\n## Release Workflow\n\nWhen releasing a new version:\n\n1. Update `version` in `preset.yml`\n2. Update CHANGELOG.md\n3. Tag and push: `git tag v1.1.0 && git push origin v1.1.0`\n4. Submit PR to update `version` and `download_url` in `presets/catalog.community.json`\n\n---\n\n## Best Practices\n\n### Template Design\n\n- **Keep sections clear** — use headings and placeholder text the LLM can replace\n- **Match commands to templates** — if your preset overrides a command, make sure it references the sections in your template\n- **Document customization points** — use HTML comments to guide users on what to change\n\n### Naming\n\n- Preset IDs should be descriptive: `healthcare-compliance`, `enterprise-safe`, `startup-lean`\n- Avoid generic names: `my-preset`, `custom`, `test`\n\n### Stacking\n\n- Design presets to work well when stacked with others\n- Only override templates you need to change\n- Document which templates and commands your preset modifies\n\n### Command Overrides\n\n- Only override commands when the workflow needs to change, not just the output format\n- If you only need different template sections, a template override is sufficient\n- Test command overrides with multiple agents (Claude, Gemini, Copilot)\n"
  },
  {
    "path": "presets/README.md",
    "content": "# Presets\n\nPresets are stackable, priority-ordered collections of template and command overrides for Spec Kit. They let you customize both the artifacts produced by the Spec-Driven Development workflow (specs, plans, tasks, checklists, constitutions) and the commands that guide the LLM in creating them — without forking or modifying core files.\n\n## How It Works\n\nWhen Spec Kit needs a template (e.g. `spec-template`), it walks a resolution stack:\n\n1. `.specify/templates/overrides/` — project-local one-off overrides\n2. `.specify/presets/<preset-id>/templates/` — installed presets (sorted by priority)\n3. `.specify/extensions/<ext-id>/templates/` — extension-provided templates\n4. `.specify/templates/` — core templates shipped with Spec Kit\n\nIf no preset is installed, core templates are used — exactly the same behavior as before presets existed.\n\nTemplate resolution happens **at runtime** — although preset files are copied into `.specify/presets/<id>/` during installation, Spec Kit walks the resolution stack on every template lookup rather than merging templates into a single location.\n\nFor detailed resolution and command registration flows, see [ARCHITECTURE.md](ARCHITECTURE.md).\n\n## Command Overrides\n\nPresets can also override the commands that guide the SDD workflow. Templates define *what* gets produced (specs, plans, constitutions); commands define *how* the LLM produces them (the step-by-step instructions).\n\nUnlike templates, command overrides are applied **at install time**. When a preset includes `type: \"command\"` entries, the commands are registered into all detected agent directories (`.claude/commands/`, `.gemini/commands/`, etc.) in the correct format (Markdown or TOML with appropriate argument placeholders). When the preset is removed, the registered commands are cleaned up.\n\n## Quick Start\n\n```bash\n# Search available presets\nspecify preset search\n\n# Install a preset from the catalog\nspecify preset add healthcare-compliance\n\n# Install from a local directory (for development)\nspecify preset add --dev ./my-preset\n\n# Install with a specific priority (lower = higher precedence)\nspecify preset add healthcare-compliance --priority 5\n\n# List installed presets\nspecify preset list\n\n# See which template a name resolves to\nspecify preset resolve spec-template\n\n# Get detailed info about a preset\nspecify preset info healthcare-compliance\n\n# Remove a preset\nspecify preset remove healthcare-compliance\n```\n\n## Stacking Presets\n\nMultiple presets can be installed simultaneously. The `--priority` flag controls which one wins when two presets provide the same template (lower number = higher precedence):\n\n```bash\nspecify preset add enterprise-safe --priority 10      # base layer\nspecify preset add healthcare-compliance --priority 5  # overrides enterprise-safe\nspecify preset add pm-workflow --priority 1            # overrides everything\n```\n\nPresets **override**, they don't merge. If two presets both provide `spec-template`, the one with the lowest priority number wins entirely.\n\n## Catalog Management\n\nPresets are discovered through catalogs. By default, Spec Kit uses the official and community catalogs:\n\n```bash\n# List active catalogs\nspecify preset catalog list\n\n# Add a custom catalog\nspecify preset catalog add https://example.com/catalog.json --name my-org --install-allowed\n\n# Remove a catalog\nspecify preset catalog remove my-org\n```\n\n## Creating a Preset\n\nSee [scaffold/](scaffold/) for a scaffold you can copy to create your own preset.\n\n1. Copy `scaffold/` to a new directory\n2. Edit `preset.yml` with your preset's metadata\n3. Add or replace templates in `templates/`\n4. Test locally with `specify preset add --dev .`\n5. Verify with `specify preset resolve spec-template`\n\n## Environment Variables\n\n| Variable | Description |\n|----------|-------------|\n| `SPECKIT_PRESET_CATALOG_URL` | Override the catalog URL (replaces all defaults) |\n\n## Configuration Files\n\n| File | Scope | Description |\n|------|-------|-------------|\n| `.specify/preset-catalogs.yml` | Project | Custom catalog stack for this project |\n| `~/.specify/preset-catalogs.yml` | User | Custom catalog stack for all projects |\n\n## Future Considerations\n\nThe following enhancements are under consideration for future releases:\n\n- **Composition strategies** — Allow presets to declare a `strategy` per template instead of the default `replace`:\n\n  | Type | `replace` | `prepend` | `append` | `wrap` |\n  |------|-----------|-----------|----------|--------|\n  | **template** | ✓ (default) | ✓ | ✓ | ✓ |\n  | **command** | ✓ (default) | ✓ | ✓ | ✓ |\n  | **script** | ✓ (default) | — | — | ✓ |\n\n  For artifacts and commands (which are LLM directives), `wrap` would inject preset content before and after the core template using a `{CORE_TEMPLATE}` placeholder. For scripts, `wrap` would run custom logic before/after the core script via a `$CORE_SCRIPT` variable.\n- **Script overrides** — Enable presets to provide alternative versions of core scripts (e.g. `create-new-feature.sh`) for workflow customization. A `strategy: \"wrap\"` option could allow presets to run custom logic before/after the core script without fully replacing it.\n"
  },
  {
    "path": "presets/catalog.community.json",
    "content": "{\n  \"schema_version\": \"1.0\",\n  \"updated_at\": \"2026-03-09T00:00:00Z\",\n  \"catalog_url\": \"https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json\",\n  \"presets\": {}\n}\n"
  },
  {
    "path": "presets/catalog.json",
    "content": "{\n  \"schema_version\": \"1.0\",\n  \"updated_at\": \"2026-03-10T00:00:00Z\",\n  \"catalog_url\": \"https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.json\",\n  \"presets\": {}\n}\n"
  },
  {
    "path": "presets/scaffold/README.md",
    "content": "# My Preset\n\nA custom preset for Spec Kit. Copy this directory and customize it to create your own.\n\n## Templates Included\n\n| Template | Type | Description |\n|----------|------|-------------|\n| `spec-template` | template | Custom feature specification template (overrides core and extensions) |\n| `myext-template` | template | Override of the myext extension's report template |\n| `speckit.specify` | command | Custom specification command (overrides core) |\n| `speckit.myext.myextcmd` | command | Override of the myext extension's myextcmd command |\n\n## Development\n\n1. Copy this directory: `cp -r presets/scaffold my-preset`\n2. Edit `preset.yml` — set your preset's ID, name, description, and templates\n3. Add or modify templates in `templates/`\n4. Test locally: `specify preset add --dev ./my-preset`\n5. Verify resolution: `specify preset resolve spec-template`\n6. Remove when done testing: `specify preset remove my-preset`\n\n## Manifest Reference (`preset.yml`)\n\nRequired fields:\n- `schema_version` — always `\"1.0\"`\n- `preset.id` — lowercase alphanumeric with hyphens\n- `preset.name` — human-readable name\n- `preset.version` — semantic version (e.g. `1.0.0`)\n- `preset.description` — brief description\n- `requires.speckit_version` — version constraint (e.g. `>=0.1.0`)\n- `provides.templates` — list of templates with `type`, `name`, and `file`\n\n## Template Types\n\n- **template** — Document scaffolds (spec-template.md, plan-template.md, tasks-template.md, etc.)\n- **command** — AI agent workflow prompts (e.g. speckit.specify, speckit.plan)\n- **script** — Custom scripts (reserved for future use)\n\n## Publishing\n\nSee the [Preset Publishing Guide](../PUBLISHING.md) for details on submitting to the catalog.\n\n## License\n\nMIT\n"
  },
  {
    "path": "presets/scaffold/commands/speckit.myext.myextcmd.md",
    "content": "---\ndescription: \"Override of the myext extension's myextcmd command\"\n---\n\n<!-- Preset override for speckit.myext.myextcmd -->\n\nYou are following a customized version of the myext extension's myextcmd command.\n\nWhen executing this command:\n\n1. Read the user's input from $ARGUMENTS\n2. Follow the standard myextcmd workflow\n3. Additionally, apply the following customizations from this preset:\n   - Add compliance checks before proceeding\n   - Include audit trail entries in the output\n\n> CUSTOMIZE: Replace the instructions above with your own.\n> This file overrides the command that the \"myext\" extension provides.\n> When this preset is installed, all agents (Claude, Gemini, Copilot, etc.)\n> will use this version instead of the extension's original.\n"
  },
  {
    "path": "presets/scaffold/commands/speckit.specify.md",
    "content": "---\ndescription: \"Create a feature specification (preset override)\"\nscripts:\n  sh: scripts/bash/create-new-feature.sh \"{ARGS}\"\n  ps: scripts/powershell/create-new-feature.ps1 \"{ARGS}\"\n---\n\n## User Input\n\n```text\n$ARGUMENTS\n```\n\nGiven the feature description above:\n\n1. **Create the feature branch** by running the script:\n   - Bash: `{SCRIPT} --json --short-name \"<short-name>\" \"<description>\"`\n   - The JSON output contains BRANCH_NAME and SPEC_FILE paths.\n\n2. **Read the spec-template** to see the sections you need to fill.\n\n3. **Write the specification** to SPEC_FILE, replacing the placeholders in each section\n   (Overview, Requirements, Acceptance Criteria) with details from the user's description.\n"
  },
  {
    "path": "presets/scaffold/preset.yml",
    "content": "schema_version: \"1.0\"\n\npreset:\n  # CUSTOMIZE: Change 'my-preset' to your preset ID (lowercase, hyphen-separated)\n  id: \"my-preset\"\n\n  # CUSTOMIZE: Human-readable name for your preset\n  name: \"My Preset\"\n\n  # CUSTOMIZE: Update version when releasing (semantic versioning: X.Y.Z)\n  version: \"1.0.0\"\n\n  # CUSTOMIZE: Brief description (under 200 characters)\n  description: \"Brief description of what your preset provides\"\n\n  # CUSTOMIZE: Your name or organization name\n  author: \"Your Name\"\n\n  # CUSTOMIZE: GitHub repository URL (create before publishing)\n  repository: \"https://github.com/your-org/spec-kit-preset-my-preset\"\n\n  # REVIEW: License (MIT is recommended for open source)\n  license: \"MIT\"\n\n# Requirements for this preset\nrequires:\n  # CUSTOMIZE: Minimum spec-kit version required\n  speckit_version: \">=0.1.0\"\n\n# Templates provided by this preset\nprovides:\n  templates:\n    # CUSTOMIZE: Define your template overrides\n    # Templates are document scaffolds (spec-template.md, plan-template.md, etc.)\n    - type: \"template\"\n      name: \"spec-template\"\n      file: \"templates/spec-template.md\"\n      description: \"Custom feature specification template\"\n      replaces: \"spec-template\"  # Which core template this overrides (optional)\n\n    # ADD MORE TEMPLATES: Copy this block for each template\n    # - type: \"template\"\n    #   name: \"plan-template\"\n    #   file: \"templates/plan-template.md\"\n    #   description: \"Custom plan template\"\n    #   replaces: \"plan-template\"\n\n    # OVERRIDE EXTENSION TEMPLATES:\n    # Presets sit above extensions in the resolution stack, so you can\n    # override templates provided by any installed extension.\n    # For example, if the \"myext\" extension provides a spec-template,\n    # the preset's version above will take priority automatically.\n\n    # Override a template provided by the \"myext\" extension:\n    - type: \"template\"\n      name: \"myext-template\"\n      file: \"templates/myext-template.md\"\n      description: \"Override myext's report template\"\n      replaces: \"myext-template\"\n\n    # Command overrides (AI agent workflow prompts)\n    # Presets can override both core and extension commands.\n    # Commands are automatically registered into all detected agent\n    # directories (.claude/commands/, .gemini/commands/, etc.)\n\n    # Override a core command:\n    - type: \"command\"\n      name: \"speckit.specify\"\n      file: \"commands/speckit.specify.md\"\n      description: \"Custom specification command\"\n      replaces: \"speckit.specify\"\n\n    # Override an extension command (e.g. from the \"myext\" extension):\n    - type: \"command\"\n      name: \"speckit.myext.myextcmd\"\n      file: \"commands/speckit.myext.myextcmd.md\"\n      description: \"Override myext's myextcmd command with custom workflow\"\n      replaces: \"speckit.myext.myextcmd\"\n\n    # Script templates (reserved for future use)\n    # - type: \"script\"\n    #   name: \"create-new-feature\"\n    #   file: \"scripts/bash/create-new-feature.sh\"\n    #   description: \"Custom feature creation script\"\n    #   replaces: \"create-new-feature\"\n\n# CUSTOMIZE: Add relevant tags (2-5 recommended)\n# Used for discovery in catalog\ntags:\n  - \"example\"\n  - \"preset\"\n"
  },
  {
    "path": "presets/scaffold/templates/myext-template.md",
    "content": "# MyExt Report\n\n> This template overrides the one provided by the \"myext\" extension.\n> Customize it to match your needs.\n\n## Summary\n\nBrief summary of the report.\n\n## Details\n\n- Detail 1\n- Detail 2\n\n## Actions\n\n- [ ] Action 1\n- [ ] Action 2\n\n<!--\n  CUSTOMIZE: This template takes priority over the myext extension's\n  version of myext-template. The extension's original is still available\n  if you remove this preset.\n-->\n"
  },
  {
    "path": "presets/scaffold/templates/spec-template.md",
    "content": "# Feature Specification: [FEATURE NAME]\n\n**Created**: [DATE]\n**Status**: Draft\n\n## Overview\n\n[Brief description of the feature]\n\n## Requirements\n\n- [ ] Requirement 1\n- [ ] Requirement 2\n\n## Acceptance Criteria\n\n- [ ] Criterion 1\n- [ ] Criterion 2\n"
  },
  {
    "path": "presets/self-test/commands/speckit.specify.md",
    "content": "---\ndescription: \"Self-test override of the specify command\"\n---\n\n<!-- preset:self-test -->\n\nYou are following the self-test preset's version of the specify command.\n\nWhen creating a specification, follow this process:\n\n1. Read the user's requirements from $ARGUMENTS\n2. Create a specification document using the spec-template\n3. Include all standard sections plus the self-test marker\n\n> This command is provided by the self-test preset.\n"
  },
  {
    "path": "presets/self-test/preset.yml",
    "content": "schema_version: \"1.0\"\n\npreset:\n  id: \"self-test\"\n  name: \"Self-Test Preset\"\n  version: \"1.0.0\"\n  description: \"A preset that overrides all core templates for testing purposes\"\n  author: \"github\"\n  repository: \"https://github.com/github/spec-kit\"\n  license: \"MIT\"\n\nrequires:\n  speckit_version: \">=0.1.0\"\n\nprovides:\n  templates:\n    - type: \"template\"\n      name: \"spec-template\"\n      file: \"templates/spec-template.md\"\n      description: \"Self-test spec template\"\n      replaces: \"spec-template\"\n\n    - type: \"template\"\n      name: \"plan-template\"\n      file: \"templates/plan-template.md\"\n      description: \"Self-test plan template\"\n      replaces: \"plan-template\"\n\n    - type: \"template\"\n      name: \"tasks-template\"\n      file: \"templates/tasks-template.md\"\n      description: \"Self-test tasks template\"\n      replaces: \"tasks-template\"\n\n    - type: \"template\"\n      name: \"checklist-template\"\n      file: \"templates/checklist-template.md\"\n      description: \"Self-test checklist template\"\n      replaces: \"checklist-template\"\n\n    - type: \"template\"\n      name: \"constitution-template\"\n      file: \"templates/constitution-template.md\"\n      description: \"Self-test constitution template\"\n      replaces: \"constitution-template\"\n\n    - type: \"template\"\n      name: \"agent-file-template\"\n      file: \"templates/agent-file-template.md\"\n      description: \"Self-test agent file template\"\n      replaces: \"agent-file-template\"\n\n    - type: \"command\"\n      name: \"speckit.specify\"\n      file: \"commands/speckit.specify.md\"\n      description: \"Self-test override of the specify command\"\n      replaces: \"speckit.specify\"\n\ntags:\n  - \"testing\"\n  - \"self-test\"\n"
  },
  {
    "path": "presets/self-test/templates/agent-file-template.md",
    "content": "# Agent File (Self-Test Preset)\n\n<!-- preset:self-test -->\n\n> This template is provided by the self-test preset.\n\n## Agent Instructions\n\nFollow these guidelines when working on this project.\n"
  },
  {
    "path": "presets/self-test/templates/checklist-template.md",
    "content": "# Checklist (Self-Test Preset)\n\n<!-- preset:self-test -->\n\n> This template is provided by the self-test preset.\n\n## Pre-Implementation\n\n- [ ] Spec reviewed\n- [ ] Plan approved\n\n## Post-Implementation\n\n- [ ] Tests passing\n- [ ] Documentation updated\n"
  },
  {
    "path": "presets/self-test/templates/constitution-template.md",
    "content": "# Constitution (Self-Test Preset)\n\n<!-- preset:self-test -->\n\n> This template is provided by the self-test preset.\n\n## Principles\n\n1. Principle 1\n2. Principle 2\n\n## Guidelines\n\n- Guideline 1\n- Guideline 2\n"
  },
  {
    "path": "presets/self-test/templates/plan-template.md",
    "content": "# Implementation Plan (Self-Test Preset)\n\n<!-- preset:self-test -->\n\n> This template is provided by the self-test preset.\n\n## Approach\n\nDescribe the implementation approach.\n\n## Steps\n\n1. Step 1\n2. Step 2\n\n## Dependencies\n\n- Dependency 1\n\n## Risks\n\n- Risk 1\n"
  },
  {
    "path": "presets/self-test/templates/spec-template.md",
    "content": "# Feature Specification (Self-Test Preset)\n\n<!-- preset:self-test -->\n\n> This template is provided by the self-test preset.\n\n## Overview\n\nBrief description of the feature.\n\n## Requirements\n\n- Requirement 1\n- Requirement 2\n\n## Design\n\nDescribe the design approach.\n\n## Acceptance Criteria\n\n- [ ] Criterion 1\n- [ ] Criterion 2\n"
  },
  {
    "path": "presets/self-test/templates/tasks-template.md",
    "content": "# Tasks (Self-Test Preset)\n\n<!-- preset:self-test -->\n\n> This template is provided by the self-test preset.\n\n## Task List\n\n- [ ] Task 1\n- [ ] Task 2\n\n## Estimation\n\n| Task | Estimate |\n|------|----------|\n| Task 1 | TBD |\n| Task 2 | TBD |\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[project]\nname = \"specify-cli\"\nversion = \"0.3.2\"\ndescription = \"Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD).\"\nrequires-python = \">=3.11\"\ndependencies = [\n    \"typer\",\n    \"click>=8.1\",\n    \"rich\",\n    \"httpx[socks]\",\n    \"platformdirs\",\n    \"readchar\",\n    \"truststore>=0.10.4\",\n    \"pyyaml>=6.0\",\n    \"packaging>=23.0\",\n    \"pathspec>=0.12.0\",\n    \"json5>=0.13.0\",\n]\n\n[project.scripts]\nspecify = \"specify_cli:main\"\n\n[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[tool.hatch.build.targets.wheel]\npackages = [\"src/specify_cli\"]\n\n[project.optional-dependencies]\ntest = [\n    \"pytest>=7.0\",\n    \"pytest-cov>=4.0\",\n]\n\n[tool.pytest.ini_options]\ntestpaths = [\"tests\"]\npython_files = [\"test_*.py\"]\npython_classes = [\"Test*\"]\npython_functions = [\"test_*\"]\naddopts = [\n    \"-v\",\n    \"--strict-markers\",\n    \"--tb=short\",\n]\n\n[tool.coverage.run]\nsource = [\"src\"]\nomit = [\"*/tests/*\", \"*/__pycache__/*\"]\n\n[tool.coverage.report]\nprecision = 2\nshow_missing = true\nskip_covered = false\n\n"
  },
  {
    "path": "scripts/bash/check-prerequisites.sh",
    "content": "#!/usr/bin/env bash\n\n# Consolidated prerequisite checking script\n#\n# This script provides unified prerequisite checking for Spec-Driven Development workflow.\n# It replaces the functionality previously spread across multiple scripts.\n#\n# Usage: ./check-prerequisites.sh [OPTIONS]\n#\n# OPTIONS:\n#   --json              Output in JSON format\n#   --require-tasks     Require tasks.md to exist (for implementation phase)\n#   --include-tasks     Include tasks.md in AVAILABLE_DOCS list\n#   --paths-only        Only output path variables (no validation)\n#   --help, -h          Show help message\n#\n# OUTPUTS:\n#   JSON mode: {\"FEATURE_DIR\":\"...\", \"AVAILABLE_DOCS\":[\"...\"]}\n#   Text mode: FEATURE_DIR:... \\n AVAILABLE_DOCS: \\n ✓/✗ file.md\n#   Paths only: REPO_ROOT: ... \\n BRANCH: ... \\n FEATURE_DIR: ... etc.\n\nset -e\n\n# Parse command line arguments\nJSON_MODE=false\nREQUIRE_TASKS=false\nINCLUDE_TASKS=false\nPATHS_ONLY=false\n\nfor arg in \"$@\"; do\n    case \"$arg\" in\n        --json)\n            JSON_MODE=true\n            ;;\n        --require-tasks)\n            REQUIRE_TASKS=true\n            ;;\n        --include-tasks)\n            INCLUDE_TASKS=true\n            ;;\n        --paths-only)\n            PATHS_ONLY=true\n            ;;\n        --help|-h)\n            cat << 'EOF'\nUsage: check-prerequisites.sh [OPTIONS]\n\nConsolidated prerequisite checking for Spec-Driven Development workflow.\n\nOPTIONS:\n  --json              Output in JSON format\n  --require-tasks     Require tasks.md to exist (for implementation phase)\n  --include-tasks     Include tasks.md in AVAILABLE_DOCS list\n  --paths-only        Only output path variables (no prerequisite validation)\n  --help, -h          Show this help message\n\nEXAMPLES:\n  # Check task prerequisites (plan.md required)\n  ./check-prerequisites.sh --json\n  \n  # Check implementation prerequisites (plan.md + tasks.md required)\n  ./check-prerequisites.sh --json --require-tasks --include-tasks\n  \n  # Get feature paths only (no validation)\n  ./check-prerequisites.sh --paths-only\n  \nEOF\n            exit 0\n            ;;\n        *)\n            echo \"ERROR: Unknown option '$arg'. Use --help for usage information.\" >&2\n            exit 1\n            ;;\n    esac\ndone\n\n# Source common functions\nSCRIPT_DIR=\"$(CDPATH=\"\" cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nsource \"$SCRIPT_DIR/common.sh\"\n\n# Get feature paths and validate branch\n_paths_output=$(get_feature_paths) || { echo \"ERROR: Failed to resolve feature paths\" >&2; exit 1; }\neval \"$_paths_output\"\nunset _paths_output\ncheck_feature_branch \"$CURRENT_BRANCH\" \"$HAS_GIT\" || exit 1\n\n# If paths-only mode, output paths and exit (support JSON + paths-only combined)\nif $PATHS_ONLY; then\n    if $JSON_MODE; then\n        # Minimal JSON paths payload (no validation performed)\n        if has_jq; then\n            jq -cn \\\n                --arg repo_root \"$REPO_ROOT\" \\\n                --arg branch \"$CURRENT_BRANCH\" \\\n                --arg feature_dir \"$FEATURE_DIR\" \\\n                --arg feature_spec \"$FEATURE_SPEC\" \\\n                --arg impl_plan \"$IMPL_PLAN\" \\\n                --arg tasks \"$TASKS\" \\\n                '{REPO_ROOT:$repo_root,BRANCH:$branch,FEATURE_DIR:$feature_dir,FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,TASKS:$tasks}'\n        else\n            printf '{\"REPO_ROOT\":\"%s\",\"BRANCH\":\"%s\",\"FEATURE_DIR\":\"%s\",\"FEATURE_SPEC\":\"%s\",\"IMPL_PLAN\":\"%s\",\"TASKS\":\"%s\"}\\n' \\\n                \"$(json_escape \"$REPO_ROOT\")\" \"$(json_escape \"$CURRENT_BRANCH\")\" \"$(json_escape \"$FEATURE_DIR\")\" \"$(json_escape \"$FEATURE_SPEC\")\" \"$(json_escape \"$IMPL_PLAN\")\" \"$(json_escape \"$TASKS\")\"\n        fi\n    else\n        echo \"REPO_ROOT: $REPO_ROOT\"\n        echo \"BRANCH: $CURRENT_BRANCH\"\n        echo \"FEATURE_DIR: $FEATURE_DIR\"\n        echo \"FEATURE_SPEC: $FEATURE_SPEC\"\n        echo \"IMPL_PLAN: $IMPL_PLAN\"\n        echo \"TASKS: $TASKS\"\n    fi\n    exit 0\nfi\n\n# Validate required directories and files\nif [[ ! -d \"$FEATURE_DIR\" ]]; then\n    echo \"ERROR: Feature directory not found: $FEATURE_DIR\" >&2\n    echo \"Run /speckit.specify first to create the feature structure.\" >&2\n    exit 1\nfi\n\nif [[ ! -f \"$IMPL_PLAN\" ]]; then\n    echo \"ERROR: plan.md not found in $FEATURE_DIR\" >&2\n    echo \"Run /speckit.plan first to create the implementation plan.\" >&2\n    exit 1\nfi\n\n# Check for tasks.md if required\nif $REQUIRE_TASKS && [[ ! -f \"$TASKS\" ]]; then\n    echo \"ERROR: tasks.md not found in $FEATURE_DIR\" >&2\n    echo \"Run /speckit.tasks first to create the task list.\" >&2\n    exit 1\nfi\n\n# Build list of available documents\ndocs=()\n\n# Always check these optional docs\n[[ -f \"$RESEARCH\" ]] && docs+=(\"research.md\")\n[[ -f \"$DATA_MODEL\" ]] && docs+=(\"data-model.md\")\n\n# Check contracts directory (only if it exists and has files)\nif [[ -d \"$CONTRACTS_DIR\" ]] && [[ -n \"$(ls -A \"$CONTRACTS_DIR\" 2>/dev/null)\" ]]; then\n    docs+=(\"contracts/\")\nfi\n\n[[ -f \"$QUICKSTART\" ]] && docs+=(\"quickstart.md\")\n\n# Include tasks.md if requested and it exists\nif $INCLUDE_TASKS && [[ -f \"$TASKS\" ]]; then\n    docs+=(\"tasks.md\")\nfi\n\n# Output results\nif $JSON_MODE; then\n    # Build JSON array of documents\n    if has_jq; then\n        if [[ ${#docs[@]} -eq 0 ]]; then\n            json_docs=\"[]\"\n        else\n            json_docs=$(printf '%s\\n' \"${docs[@]}\" | jq -R . | jq -s .)\n        fi\n        jq -cn \\\n            --arg feature_dir \"$FEATURE_DIR\" \\\n            --argjson docs \"$json_docs\" \\\n            '{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs}'\n    else\n        if [[ ${#docs[@]} -eq 0 ]]; then\n            json_docs=\"[]\"\n        else\n            json_docs=$(for d in \"${docs[@]}\"; do printf '\"%s\",' \"$(json_escape \"$d\")\"; done)\n            json_docs=\"[${json_docs%,}]\"\n        fi\n        printf '{\"FEATURE_DIR\":\"%s\",\"AVAILABLE_DOCS\":%s}\\n' \"$(json_escape \"$FEATURE_DIR\")\" \"$json_docs\"\n    fi\nelse\n    # Text output\n    echo \"FEATURE_DIR:$FEATURE_DIR\"\n    echo \"AVAILABLE_DOCS:\"\n    \n    # Show status of each potential document\n    check_file \"$RESEARCH\" \"research.md\"\n    check_file \"$DATA_MODEL\" \"data-model.md\"\n    check_dir \"$CONTRACTS_DIR\" \"contracts/\"\n    check_file \"$QUICKSTART\" \"quickstart.md\"\n    \n    if $INCLUDE_TASKS; then\n        check_file \"$TASKS\" \"tasks.md\"\n    fi\nfi\n"
  },
  {
    "path": "scripts/bash/common.sh",
    "content": "#!/usr/bin/env bash\n# Common functions and variables for all scripts\n\n# Get repository root, with fallback for non-git repositories\nget_repo_root() {\n    if git rev-parse --show-toplevel >/dev/null 2>&1; then\n        git rev-parse --show-toplevel\n    else\n        # Fall back to script location for non-git repos\n        local script_dir=\"$(CDPATH=\"\" cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n        (cd \"$script_dir/../../..\" && pwd)\n    fi\n}\n\n# Get current branch, with fallback for non-git repositories\nget_current_branch() {\n    # First check if SPECIFY_FEATURE environment variable is set\n    if [[ -n \"${SPECIFY_FEATURE:-}\" ]]; then\n        echo \"$SPECIFY_FEATURE\"\n        return\n    fi\n\n    # Then check git if available\n    if git rev-parse --abbrev-ref HEAD >/dev/null 2>&1; then\n        git rev-parse --abbrev-ref HEAD\n        return\n    fi\n\n    # For non-git repos, try to find the latest feature directory\n    local repo_root=$(get_repo_root)\n    local specs_dir=\"$repo_root/specs\"\n\n    if [[ -d \"$specs_dir\" ]]; then\n        local latest_feature=\"\"\n        local highest=0\n\n        for dir in \"$specs_dir\"/*; do\n            if [[ -d \"$dir\" ]]; then\n                local dirname=$(basename \"$dir\")\n                if [[ \"$dirname\" =~ ^([0-9]{3})- ]]; then\n                    local number=${BASH_REMATCH[1]}\n                    number=$((10#$number))\n                    if [[ \"$number\" -gt \"$highest\" ]]; then\n                        highest=$number\n                        latest_feature=$dirname\n                    fi\n                fi\n            fi\n        done\n\n        if [[ -n \"$latest_feature\" ]]; then\n            echo \"$latest_feature\"\n            return\n        fi\n    fi\n\n    echo \"main\"  # Final fallback\n}\n\n# Check if we have git available\nhas_git() {\n    git rev-parse --show-toplevel >/dev/null 2>&1\n}\n\ncheck_feature_branch() {\n    local branch=\"$1\"\n    local has_git_repo=\"$2\"\n\n    # For non-git repos, we can't enforce branch naming but still provide output\n    if [[ \"$has_git_repo\" != \"true\" ]]; then\n        echo \"[specify] Warning: Git repository not detected; skipped branch validation\" >&2\n        return 0\n    fi\n\n    if [[ ! \"$branch\" =~ ^[0-9]{3}- ]]; then\n        echo \"ERROR: Not on a feature branch. Current branch: $branch\" >&2\n        echo \"Feature branches should be named like: 001-feature-name\" >&2\n        return 1\n    fi\n\n    return 0\n}\n\nget_feature_dir() { echo \"$1/specs/$2\"; }\n\n# Find feature directory by numeric prefix instead of exact branch match\n# This allows multiple branches to work on the same spec (e.g., 004-fix-bug, 004-add-feature)\nfind_feature_dir_by_prefix() {\n    local repo_root=\"$1\"\n    local branch_name=\"$2\"\n    local specs_dir=\"$repo_root/specs\"\n\n    # Extract numeric prefix from branch (e.g., \"004\" from \"004-whatever\")\n    if [[ ! \"$branch_name\" =~ ^([0-9]{3})- ]]; then\n        # If branch doesn't have numeric prefix, fall back to exact match\n        echo \"$specs_dir/$branch_name\"\n        return\n    fi\n\n    local prefix=\"${BASH_REMATCH[1]}\"\n\n    # Search for directories in specs/ that start with this prefix\n    local matches=()\n    if [[ -d \"$specs_dir\" ]]; then\n        for dir in \"$specs_dir\"/\"$prefix\"-*; do\n            if [[ -d \"$dir\" ]]; then\n                matches+=(\"$(basename \"$dir\")\")\n            fi\n        done\n    fi\n\n    # Handle results\n    if [[ ${#matches[@]} -eq 0 ]]; then\n        # No match found - return the branch name path (will fail later with clear error)\n        echo \"$specs_dir/$branch_name\"\n    elif [[ ${#matches[@]} -eq 1 ]]; then\n        # Exactly one match - perfect!\n        echo \"$specs_dir/${matches[0]}\"\n    else\n        # Multiple matches - this shouldn't happen with proper naming convention\n        echo \"ERROR: Multiple spec directories found with prefix '$prefix': ${matches[*]}\" >&2\n        echo \"Please ensure only one spec directory exists per numeric prefix.\" >&2\n        return 1\n    fi\n}\n\nget_feature_paths() {\n    local repo_root=$(get_repo_root)\n    local current_branch=$(get_current_branch)\n    local has_git_repo=\"false\"\n\n    if has_git; then\n        has_git_repo=\"true\"\n    fi\n\n    # Use prefix-based lookup to support multiple branches per spec\n    local feature_dir\n    if ! feature_dir=$(find_feature_dir_by_prefix \"$repo_root\" \"$current_branch\"); then\n        echo \"ERROR: Failed to resolve feature directory\" >&2\n        return 1\n    fi\n\n    # Use printf '%q' to safely quote values, preventing shell injection\n    # via crafted branch names or paths containing special characters\n    printf 'REPO_ROOT=%q\\n' \"$repo_root\"\n    printf 'CURRENT_BRANCH=%q\\n' \"$current_branch\"\n    printf 'HAS_GIT=%q\\n' \"$has_git_repo\"\n    printf 'FEATURE_DIR=%q\\n' \"$feature_dir\"\n    printf 'FEATURE_SPEC=%q\\n' \"$feature_dir/spec.md\"\n    printf 'IMPL_PLAN=%q\\n' \"$feature_dir/plan.md\"\n    printf 'TASKS=%q\\n' \"$feature_dir/tasks.md\"\n    printf 'RESEARCH=%q\\n' \"$feature_dir/research.md\"\n    printf 'DATA_MODEL=%q\\n' \"$feature_dir/data-model.md\"\n    printf 'QUICKSTART=%q\\n' \"$feature_dir/quickstart.md\"\n    printf 'CONTRACTS_DIR=%q\\n' \"$feature_dir/contracts\"\n}\n\n# Check if jq is available for safe JSON construction\nhas_jq() {\n    command -v jq >/dev/null 2>&1\n}\n\n# Escape a string for safe embedding in a JSON value (fallback when jq is unavailable).\n# Handles backslash, double-quote, and JSON-required control character escapes (RFC 8259).\njson_escape() {\n    local s=\"$1\"\n    s=\"${s//\\\\/\\\\\\\\}\"\n    s=\"${s//\\\"/\\\\\\\"}\"\n    s=\"${s//$'\\n'/\\\\n}\"\n    s=\"${s//$'\\t'/\\\\t}\"\n    s=\"${s//$'\\r'/\\\\r}\"\n    s=\"${s//$'\\b'/\\\\b}\"\n    s=\"${s//$'\\f'/\\\\f}\"\n    # Escape any remaining U+0001-U+001F control characters as \\uXXXX.\n    # (U+0000/NUL cannot appear in bash strings and is excluded.)\n    # LC_ALL=C ensures ${#s} counts bytes and ${s:$i:1} yields single bytes,\n    # so multi-byte UTF-8 sequences (first byte >= 0xC0) pass through intact.\n    local LC_ALL=C\n    local i char code\n    for (( i=0; i<${#s}; i++ )); do\n        char=\"${s:$i:1}\"\n        printf -v code '%d' \"'$char\" 2>/dev/null || code=256\n        if (( code >= 1 && code <= 31 )); then\n            printf '\\\\u%04x' \"$code\"\n        else\n            printf '%s' \"$char\"\n        fi\n    done\n}\n\ncheck_file() { [[ -f \"$1\" ]] && echo \"  ✓ $2\" || echo \"  ✗ $2\"; }\ncheck_dir() { [[ -d \"$1\" && -n $(ls -A \"$1\" 2>/dev/null) ]] && echo \"  ✓ $2\" || echo \"  ✗ $2\"; }\n\n# Resolve a template name to a file path using the priority stack:\n#   1. .specify/templates/overrides/\n#   2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)\n#   3. .specify/extensions/<ext-id>/templates/\n#   4. .specify/templates/ (core)\nresolve_template() {\n    local template_name=\"$1\"\n    local repo_root=\"$2\"\n    local base=\"$repo_root/.specify/templates\"\n\n    # Priority 1: Project overrides\n    local override=\"$base/overrides/${template_name}.md\"\n    [ -f \"$override\" ] && echo \"$override\" && return 0\n\n    # Priority 2: Installed presets (sorted by priority from .registry)\n    local presets_dir=\"$repo_root/.specify/presets\"\n    if [ -d \"$presets_dir\" ]; then\n        local registry_file=\"$presets_dir/.registry\"\n        if [ -f \"$registry_file\" ] && command -v python3 >/dev/null 2>&1; then\n            # Read preset IDs sorted by priority (lower number = higher precedence).\n            # The python3 call is wrapped in an if-condition so that set -e does not\n            # abort the function when python3 exits non-zero (e.g. invalid JSON).\n            local sorted_presets=\"\"\n            if sorted_presets=$(SPECKIT_REGISTRY=\"$registry_file\" python3 -c \"\nimport json, sys, os\ntry:\n    with open(os.environ['SPECKIT_REGISTRY']) as f:\n        data = json.load(f)\n    presets = data.get('presets', {})\n    for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10)):\n        print(pid)\nexcept Exception:\n    sys.exit(1)\n\" 2>/dev/null); then\n                if [ -n \"$sorted_presets\" ]; then\n                    # python3 succeeded and returned preset IDs — search in priority order\n                    while IFS= read -r preset_id; do\n                        local candidate=\"$presets_dir/$preset_id/templates/${template_name}.md\"\n                        [ -f \"$candidate\" ] && echo \"$candidate\" && return 0\n                    done <<< \"$sorted_presets\"\n                fi\n                # python3 succeeded but registry has no presets — nothing to search\n            else\n                # python3 failed (missing, or registry parse error) — fall back to unordered directory scan\n                for preset in \"$presets_dir\"/*/; do\n                    [ -d \"$preset\" ] || continue\n                    local candidate=\"$preset/templates/${template_name}.md\"\n                    [ -f \"$candidate\" ] && echo \"$candidate\" && return 0\n                done\n            fi\n        else\n            # Fallback: alphabetical directory order (no python3 available)\n            for preset in \"$presets_dir\"/*/; do\n                [ -d \"$preset\" ] || continue\n                local candidate=\"$preset/templates/${template_name}.md\"\n                [ -f \"$candidate\" ] && echo \"$candidate\" && return 0\n            done\n        fi\n    fi\n\n    # Priority 3: Extension-provided templates\n    local ext_dir=\"$repo_root/.specify/extensions\"\n    if [ -d \"$ext_dir\" ]; then\n        for ext in \"$ext_dir\"/*/; do\n            [ -d \"$ext\" ] || continue\n            # Skip hidden directories (e.g. .backup, .cache)\n            case \"$(basename \"$ext\")\" in .*) continue;; esac\n            local candidate=\"$ext/templates/${template_name}.md\"\n            [ -f \"$candidate\" ] && echo \"$candidate\" && return 0\n        done\n    fi\n\n    # Priority 4: Core templates\n    local core=\"$base/${template_name}.md\"\n    [ -f \"$core\" ] && echo \"$core\" && return 0\n\n    # Template not found in any location.\n    # Return 1 so callers can distinguish \"not found\" from \"found\".\n    # Callers running under set -e should use: TEMPLATE=$(resolve_template ...) || true\n    return 1\n}\n\n"
  },
  {
    "path": "scripts/bash/create-new-feature.sh",
    "content": "#!/usr/bin/env bash\n\nset -e\n\nJSON_MODE=false\nSHORT_NAME=\"\"\nBRANCH_NUMBER=\"\"\nARGS=()\ni=1\nwhile [ $i -le $# ]; do\n    arg=\"${!i}\"\n    case \"$arg\" in\n        --json) \n            JSON_MODE=true \n            ;;\n        --short-name)\n            if [ $((i + 1)) -gt $# ]; then\n                echo 'Error: --short-name requires a value' >&2\n                exit 1\n            fi\n            i=$((i + 1))\n            next_arg=\"${!i}\"\n            # Check if the next argument is another option (starts with --)\n            if [[ \"$next_arg\" == --* ]]; then\n                echo 'Error: --short-name requires a value' >&2\n                exit 1\n            fi\n            SHORT_NAME=\"$next_arg\"\n            ;;\n        --number)\n            if [ $((i + 1)) -gt $# ]; then\n                echo 'Error: --number requires a value' >&2\n                exit 1\n            fi\n            i=$((i + 1))\n            next_arg=\"${!i}\"\n            if [[ \"$next_arg\" == --* ]]; then\n                echo 'Error: --number requires a value' >&2\n                exit 1\n            fi\n            BRANCH_NUMBER=\"$next_arg\"\n            ;;\n        --help|-h) \n            echo \"Usage: $0 [--json] [--short-name <name>] [--number N] <feature_description>\"\n            echo \"\"\n            echo \"Options:\"\n            echo \"  --json              Output in JSON format\"\n            echo \"  --short-name <name> Provide a custom short name (2-4 words) for the branch\"\n            echo \"  --number N          Specify branch number manually (overrides auto-detection)\"\n            echo \"  --help, -h          Show this help message\"\n            echo \"\"\n            echo \"Examples:\"\n            echo \"  $0 'Add user authentication system' --short-name 'user-auth'\"\n            echo \"  $0 'Implement OAuth2 integration for API' --number 5\"\n            exit 0\n            ;;\n        *) \n            ARGS+=(\"$arg\") \n            ;;\n    esac\n    i=$((i + 1))\ndone\n\nFEATURE_DESCRIPTION=\"${ARGS[*]}\"\nif [ -z \"$FEATURE_DESCRIPTION\" ]; then\n    echo \"Usage: $0 [--json] [--short-name <name>] [--number N] <feature_description>\" >&2\n    exit 1\nfi\n\n# Trim whitespace and validate description is not empty (e.g., user passed only whitespace)\nFEATURE_DESCRIPTION=$(echo \"$FEATURE_DESCRIPTION\" | xargs)\nif [ -z \"$FEATURE_DESCRIPTION\" ]; then\n    echo \"Error: Feature description cannot be empty or contain only whitespace\" >&2\n    exit 1\nfi\n\n# Function to find the repository root by searching for existing project markers\nfind_repo_root() {\n    local dir=\"$1\"\n    while [ \"$dir\" != \"/\" ]; do\n        if [ -d \"$dir/.git\" ] || [ -d \"$dir/.specify\" ]; then\n            echo \"$dir\"\n            return 0\n        fi\n        dir=\"$(dirname \"$dir\")\"\n    done\n    return 1\n}\n\n# Function to get highest number from specs directory\nget_highest_from_specs() {\n    local specs_dir=\"$1\"\n    local highest=0\n    \n    if [ -d \"$specs_dir\" ]; then\n        for dir in \"$specs_dir\"/*; do\n            [ -d \"$dir\" ] || continue\n            dirname=$(basename \"$dir\")\n            number=$(echo \"$dirname\" | grep -o '^[0-9]\\+' || echo \"0\")\n            number=$((10#$number))\n            if [ \"$number\" -gt \"$highest\" ]; then\n                highest=$number\n            fi\n        done\n    fi\n    \n    echo \"$highest\"\n}\n\n# Function to get highest number from git branches\nget_highest_from_branches() {\n    local highest=0\n    \n    # Get all branches (local and remote)\n    branches=$(git branch -a 2>/dev/null || echo \"\")\n    \n    if [ -n \"$branches\" ]; then\n        while IFS= read -r branch; do\n            # Clean branch name: remove leading markers and remote prefixes\n            clean_branch=$(echo \"$branch\" | sed 's/^[* ]*//; s|^remotes/[^/]*/||')\n            \n            # Extract feature number if branch matches pattern ###-*\n            if echo \"$clean_branch\" | grep -q '^[0-9]\\{3\\}-'; then\n                number=$(echo \"$clean_branch\" | grep -o '^[0-9]\\{3\\}' || echo \"0\")\n                number=$((10#$number))\n                if [ \"$number\" -gt \"$highest\" ]; then\n                    highest=$number\n                fi\n            fi\n        done <<< \"$branches\"\n    fi\n    \n    echo \"$highest\"\n}\n\n# Function to check existing branches (local and remote) and return next available number\ncheck_existing_branches() {\n    local specs_dir=\"$1\"\n\n    # Fetch all remotes to get latest branch info (suppress errors if no remotes)\n    git fetch --all --prune >/dev/null 2>&1 || true\n\n    # Get highest number from ALL branches (not just matching short name)\n    local highest_branch=$(get_highest_from_branches)\n\n    # Get highest number from ALL specs (not just matching short name)\n    local highest_spec=$(get_highest_from_specs \"$specs_dir\")\n\n    # Take the maximum of both\n    local max_num=$highest_branch\n    if [ \"$highest_spec\" -gt \"$max_num\" ]; then\n        max_num=$highest_spec\n    fi\n\n    # Return next number\n    echo $((max_num + 1))\n}\n\n# Function to clean and format a branch name\nclean_branch_name() {\n    local name=\"$1\"\n    echo \"$name\" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\\+/-/g' | sed 's/^-//' | sed 's/-$//'\n}\n\n# Resolve repository root. Prefer git information when available, but fall back\n# to searching for repository markers so the workflow still functions in repositories that\n# were initialised with --no-git.\nSCRIPT_DIR=\"$(CDPATH=\"\" cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nsource \"$SCRIPT_DIR/common.sh\"\n\nif git rev-parse --show-toplevel >/dev/null 2>&1; then\n    REPO_ROOT=$(git rev-parse --show-toplevel)\n    HAS_GIT=true\nelse\n    REPO_ROOT=\"$(find_repo_root \"$SCRIPT_DIR\")\"\n    if [ -z \"$REPO_ROOT\" ]; then\n        echo \"Error: Could not determine repository root. Please run this script from within the repository.\" >&2\n        exit 1\n    fi\n    HAS_GIT=false\nfi\n\ncd \"$REPO_ROOT\"\n\nSPECS_DIR=\"$REPO_ROOT/specs\"\nmkdir -p \"$SPECS_DIR\"\n\n# Function to generate branch name with stop word filtering and length filtering\ngenerate_branch_name() {\n    local description=\"$1\"\n    \n    # Common stop words to filter out\n    local stop_words=\"^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$\"\n    \n    # Convert to lowercase and split into words\n    local clean_name=$(echo \"$description\" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g')\n    \n    # Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original)\n    local meaningful_words=()\n    for word in $clean_name; do\n        # Skip empty words\n        [ -z \"$word\" ] && continue\n        \n        # Keep words that are NOT stop words AND (length >= 3 OR are potential acronyms)\n        if ! echo \"$word\" | grep -qiE \"$stop_words\"; then\n            if [ ${#word} -ge 3 ]; then\n                meaningful_words+=(\"$word\")\n            elif echo \"$description\" | grep -q \"\\b${word^^}\\b\"; then\n                # Keep short words if they appear as uppercase in original (likely acronyms)\n                meaningful_words+=(\"$word\")\n            fi\n        fi\n    done\n    \n    # If we have meaningful words, use first 3-4 of them\n    if [ ${#meaningful_words[@]} -gt 0 ]; then\n        local max_words=3\n        if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi\n        \n        local result=\"\"\n        local count=0\n        for word in \"${meaningful_words[@]}\"; do\n            if [ $count -ge $max_words ]; then break; fi\n            if [ -n \"$result\" ]; then result=\"$result-\"; fi\n            result=\"$result$word\"\n            count=$((count + 1))\n        done\n        echo \"$result\"\n    else\n        # Fallback to original logic if no meaningful words found\n        local cleaned=$(clean_branch_name \"$description\")\n        echo \"$cleaned\" | tr '-' '\\n' | grep -v '^$' | head -3 | tr '\\n' '-' | sed 's/-$//'\n    fi\n}\n\n# Generate branch name\nif [ -n \"$SHORT_NAME\" ]; then\n    # Use provided short name, just clean it up\n    BRANCH_SUFFIX=$(clean_branch_name \"$SHORT_NAME\")\nelse\n    # Generate from description with smart filtering\n    BRANCH_SUFFIX=$(generate_branch_name \"$FEATURE_DESCRIPTION\")\nfi\n\n# Determine branch number\nif [ -z \"$BRANCH_NUMBER\" ]; then\n    if [ \"$HAS_GIT\" = true ]; then\n        # Check existing branches on remotes\n        BRANCH_NUMBER=$(check_existing_branches \"$SPECS_DIR\")\n    else\n        # Fall back to local directory check\n        HIGHEST=$(get_highest_from_specs \"$SPECS_DIR\")\n        BRANCH_NUMBER=$((HIGHEST + 1))\n    fi\nfi\n\n# Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal)\nFEATURE_NUM=$(printf \"%03d\" \"$((10#$BRANCH_NUMBER))\")\nBRANCH_NAME=\"${FEATURE_NUM}-${BRANCH_SUFFIX}\"\n\n# GitHub enforces a 244-byte limit on branch names\n# Validate and truncate if necessary\nMAX_BRANCH_LENGTH=244\nif [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then\n    # Calculate how much we need to trim from suffix\n    # Account for: feature number (3) + hyphen (1) = 4 chars\n    MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - 4))\n    \n    # Truncate suffix at word boundary if possible\n    TRUNCATED_SUFFIX=$(echo \"$BRANCH_SUFFIX\" | cut -c1-$MAX_SUFFIX_LENGTH)\n    # Remove trailing hyphen if truncation created one\n    TRUNCATED_SUFFIX=$(echo \"$TRUNCATED_SUFFIX\" | sed 's/-$//')\n    \n    ORIGINAL_BRANCH_NAME=\"$BRANCH_NAME\"\n    BRANCH_NAME=\"${FEATURE_NUM}-${TRUNCATED_SUFFIX}\"\n    \n    >&2 echo \"[specify] Warning: Branch name exceeded GitHub's 244-byte limit\"\n    >&2 echo \"[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)\"\n    >&2 echo \"[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)\"\nfi\n\nif [ \"$HAS_GIT\" = true ]; then\n    if ! git checkout -b \"$BRANCH_NAME\" 2>/dev/null; then\n        # Check if branch already exists\n        if git branch --list \"$BRANCH_NAME\" | grep -q .; then\n            >&2 echo \"Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number.\"\n            exit 1\n        else\n            >&2 echo \"Error: Failed to create git branch '$BRANCH_NAME'. Please check your git configuration and try again.\"\n            exit 1\n        fi\n    fi\nelse\n    >&2 echo \"[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME\"\nfi\n\nFEATURE_DIR=\"$SPECS_DIR/$BRANCH_NAME\"\nmkdir -p \"$FEATURE_DIR\"\n\nTEMPLATE=$(resolve_template \"spec-template\" \"$REPO_ROOT\") || true\nSPEC_FILE=\"$FEATURE_DIR/spec.md\"\nif [ -n \"$TEMPLATE\" ] && [ -f \"$TEMPLATE\" ]; then\n    cp \"$TEMPLATE\" \"$SPEC_FILE\"\nelse\n    echo \"Warning: Spec template not found; created empty spec file\" >&2\n    touch \"$SPEC_FILE\"\nfi\n\n# Inform the user how to persist the feature variable in their own shell\nprintf '# To persist: export SPECIFY_FEATURE=%q\\n' \"$BRANCH_NAME\" >&2\n\nif $JSON_MODE; then\n    if command -v jq >/dev/null 2>&1; then\n        jq -cn \\\n            --arg branch_name \"$BRANCH_NAME\" \\\n            --arg spec_file \"$SPEC_FILE\" \\\n            --arg feature_num \"$FEATURE_NUM\" \\\n            '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}'\n    else\n        printf '{\"BRANCH_NAME\":\"%s\",\"SPEC_FILE\":\"%s\",\"FEATURE_NUM\":\"%s\"}\\n' \"$(json_escape \"$BRANCH_NAME\")\" \"$(json_escape \"$SPEC_FILE\")\" \"$(json_escape \"$FEATURE_NUM\")\"\n    fi\nelse\n    echo \"BRANCH_NAME: $BRANCH_NAME\"\n    echo \"SPEC_FILE: $SPEC_FILE\"\n    echo \"FEATURE_NUM: $FEATURE_NUM\"\n    printf '# To persist in your shell: export SPECIFY_FEATURE=%q\\n' \"$BRANCH_NAME\"\nfi\n"
  },
  {
    "path": "scripts/bash/setup-plan.sh",
    "content": "#!/usr/bin/env bash\n\nset -e\n\n# Parse command line arguments\nJSON_MODE=false\nARGS=()\n\nfor arg in \"$@\"; do\n    case \"$arg\" in\n        --json) \n            JSON_MODE=true \n            ;;\n        --help|-h) \n            echo \"Usage: $0 [--json]\"\n            echo \"  --json    Output results in JSON format\"\n            echo \"  --help    Show this help message\"\n            exit 0 \n            ;;\n        *) \n            ARGS+=(\"$arg\") \n            ;;\n    esac\ndone\n\n# Get script directory and load common functions\nSCRIPT_DIR=\"$(CDPATH=\"\" cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nsource \"$SCRIPT_DIR/common.sh\"\n\n# Get all paths and variables from common functions\n_paths_output=$(get_feature_paths) || { echo \"ERROR: Failed to resolve feature paths\" >&2; exit 1; }\neval \"$_paths_output\"\nunset _paths_output\n\n# Check if we're on a proper feature branch (only for git repos)\ncheck_feature_branch \"$CURRENT_BRANCH\" \"$HAS_GIT\" || exit 1\n\n# Ensure the feature directory exists\nmkdir -p \"$FEATURE_DIR\"\n\n# Copy plan template if it exists\nTEMPLATE=$(resolve_template \"plan-template\" \"$REPO_ROOT\") || true\nif [[ -n \"$TEMPLATE\" ]] && [[ -f \"$TEMPLATE\" ]]; then\n    cp \"$TEMPLATE\" \"$IMPL_PLAN\"\n    echo \"Copied plan template to $IMPL_PLAN\"\nelse\n    echo \"Warning: Plan template not found\"\n    # Create a basic plan file if template doesn't exist\n    touch \"$IMPL_PLAN\"\nfi\n\n# Output results\nif $JSON_MODE; then\n    if has_jq; then\n        jq -cn \\\n            --arg feature_spec \"$FEATURE_SPEC\" \\\n            --arg impl_plan \"$IMPL_PLAN\" \\\n            --arg specs_dir \"$FEATURE_DIR\" \\\n            --arg branch \"$CURRENT_BRANCH\" \\\n            --arg has_git \"$HAS_GIT\" \\\n            '{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,SPECS_DIR:$specs_dir,BRANCH:$branch,HAS_GIT:$has_git}'\n    else\n        printf '{\"FEATURE_SPEC\":\"%s\",\"IMPL_PLAN\":\"%s\",\"SPECS_DIR\":\"%s\",\"BRANCH\":\"%s\",\"HAS_GIT\":\"%s\"}\\n' \\\n            \"$(json_escape \"$FEATURE_SPEC\")\" \"$(json_escape \"$IMPL_PLAN\")\" \"$(json_escape \"$FEATURE_DIR\")\" \"$(json_escape \"$CURRENT_BRANCH\")\" \"$(json_escape \"$HAS_GIT\")\"\n    fi\nelse\n    echo \"FEATURE_SPEC: $FEATURE_SPEC\"\n    echo \"IMPL_PLAN: $IMPL_PLAN\" \n    echo \"SPECS_DIR: $FEATURE_DIR\"\n    echo \"BRANCH: $CURRENT_BRANCH\"\n    echo \"HAS_GIT: $HAS_GIT\"\nfi\n\n"
  },
  {
    "path": "scripts/bash/update-agent-context.sh",
    "content": "#!/usr/bin/env bash\n\n# Update agent context files with information from plan.md\n#\n# This script maintains AI agent context files by parsing feature specifications \n# and updating agent-specific configuration files with project information.\n#\n# MAIN FUNCTIONS:\n# 1. Environment Validation\n#    - Verifies git repository structure and branch information\n#    - Checks for required plan.md files and templates\n#    - Validates file permissions and accessibility\n#\n# 2. Plan Data Extraction\n#    - Parses plan.md files to extract project metadata\n#    - Identifies language/version, frameworks, databases, and project types\n#    - Handles missing or incomplete specification data gracefully\n#\n# 3. Agent File Management\n#    - Creates new agent context files from templates when needed\n#    - Updates existing agent files with new project information\n#    - Preserves manual additions and custom configurations\n#    - Supports multiple AI agent formats and directory structures\n#\n# 4. Content Generation\n#    - Generates language-specific build/test commands\n#    - Creates appropriate project directory structures\n#    - Updates technology stacks and recent changes sections\n#    - Maintains consistent formatting and timestamps\n#\n# 5. Multi-Agent Support\n#    - Handles agent-specific file paths and naming conventions\n#    - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Junie, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe, Kimi Code, Pi Coding Agent, iFlow CLI, Antigravity or Generic\n#    - Can update single agents or all existing agent files\n#    - Creates default Claude file if no agent files exist\n#\n# Usage: ./update-agent-context.sh [agent_type]\n# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic\n# Leave empty to update all existing agent files\n\nset -e\n\n# Enable strict error handling\nset -u\nset -o pipefail\n\n#==============================================================================\n# Configuration and Global Variables\n#==============================================================================\n\n# Get script directory and load common functions\nSCRIPT_DIR=\"$(CDPATH=\"\" cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nsource \"$SCRIPT_DIR/common.sh\"\n\n# Get all paths and variables from common functions\n_paths_output=$(get_feature_paths) || { echo \"ERROR: Failed to resolve feature paths\" >&2; exit 1; }\neval \"$_paths_output\"\nunset _paths_output\n\nNEW_PLAN=\"$IMPL_PLAN\"  # Alias for compatibility with existing code\nAGENT_TYPE=\"${1:-}\"\n\n# Agent-specific file paths  \nCLAUDE_FILE=\"$REPO_ROOT/CLAUDE.md\"\nGEMINI_FILE=\"$REPO_ROOT/GEMINI.md\"\nCOPILOT_FILE=\"$REPO_ROOT/.github/agents/copilot-instructions.md\"\nCURSOR_FILE=\"$REPO_ROOT/.cursor/rules/specify-rules.mdc\"\nQWEN_FILE=\"$REPO_ROOT/QWEN.md\"\nAGENTS_FILE=\"$REPO_ROOT/AGENTS.md\"\nWINDSURF_FILE=\"$REPO_ROOT/.windsurf/rules/specify-rules.md\"\nJUNIE_FILE=\"$REPO_ROOT/.junie/AGENTS.md\"\nKILOCODE_FILE=\"$REPO_ROOT/.kilocode/rules/specify-rules.md\"\nAUGGIE_FILE=\"$REPO_ROOT/.augment/rules/specify-rules.md\"\nROO_FILE=\"$REPO_ROOT/.roo/rules/specify-rules.md\"\nCODEBUDDY_FILE=\"$REPO_ROOT/CODEBUDDY.md\"\nQODER_FILE=\"$REPO_ROOT/QODER.md\"\n# Amp, Kiro CLI, IBM Bob, and Pi all share AGENTS.md — use AGENTS_FILE to avoid\n# updating the same file multiple times.\nAMP_FILE=\"$AGENTS_FILE\"\nSHAI_FILE=\"$REPO_ROOT/SHAI.md\"\nTABNINE_FILE=\"$REPO_ROOT/TABNINE.md\"\nKIRO_FILE=\"$AGENTS_FILE\"\nAGY_FILE=\"$REPO_ROOT/.agent/rules/specify-rules.md\"\nBOB_FILE=\"$AGENTS_FILE\"\nVIBE_FILE=\"$REPO_ROOT/.vibe/agents/specify-agents.md\"\nKIMI_FILE=\"$REPO_ROOT/KIMI.md\"\nTRAE_FILE=\"$REPO_ROOT/.trae/rules/AGENTS.md\"\nIFLOW_FILE=\"$REPO_ROOT/IFLOW.md\"\n\n# Template file\nTEMPLATE_FILE=\"$REPO_ROOT/.specify/templates/agent-file-template.md\"\n\n# Global variables for parsed plan data\nNEW_LANG=\"\"\nNEW_FRAMEWORK=\"\"\nNEW_DB=\"\"\nNEW_PROJECT_TYPE=\"\"\n\n#==============================================================================\n# Utility Functions\n#==============================================================================\n\nlog_info() {\n    echo \"INFO: $1\"\n}\n\nlog_success() {\n    echo \"✓ $1\"\n}\n\nlog_error() {\n    echo \"ERROR: $1\" >&2\n}\n\nlog_warning() {\n    echo \"WARNING: $1\" >&2\n}\n\n# Cleanup function for temporary files\ncleanup() {\n    local exit_code=$?\n    # Disarm traps to prevent re-entrant loop\n    trap - EXIT INT TERM\n    rm -f /tmp/agent_update_*_$$\n    rm -f /tmp/manual_additions_$$\n    exit $exit_code\n}\n\n# Set up cleanup trap\ntrap cleanup EXIT INT TERM\n\n#==============================================================================\n# Validation Functions\n#==============================================================================\n\nvalidate_environment() {\n    # Check if we have a current branch/feature (git or non-git)\n    if [[ -z \"$CURRENT_BRANCH\" ]]; then\n        log_error \"Unable to determine current feature\"\n        if [[ \"$HAS_GIT\" == \"true\" ]]; then\n            log_info \"Make sure you're on a feature branch\"\n        else\n            log_info \"Set SPECIFY_FEATURE environment variable or create a feature first\"\n        fi\n        exit 1\n    fi\n    \n    # Check if plan.md exists\n    if [[ ! -f \"$NEW_PLAN\" ]]; then\n        log_error \"No plan.md found at $NEW_PLAN\"\n        log_info \"Make sure you're working on a feature with a corresponding spec directory\"\n        if [[ \"$HAS_GIT\" != \"true\" ]]; then\n            log_info \"Use: export SPECIFY_FEATURE=your-feature-name or create a new feature first\"\n        fi\n        exit 1\n    fi\n    \n    # Check if template exists (needed for new files)\n    if [[ ! -f \"$TEMPLATE_FILE\" ]]; then\n        log_warning \"Template file not found at $TEMPLATE_FILE\"\n        log_warning \"Creating new agent files will fail\"\n    fi\n}\n\n#==============================================================================\n# Plan Parsing Functions\n#==============================================================================\n\nextract_plan_field() {\n    local field_pattern=\"$1\"\n    local plan_file=\"$2\"\n    \n    grep \"^\\*\\*${field_pattern}\\*\\*: \" \"$plan_file\" 2>/dev/null | \\\n        head -1 | \\\n        sed \"s|^\\*\\*${field_pattern}\\*\\*: ||\" | \\\n        sed 's/^[ \\t]*//;s/[ \\t]*$//' | \\\n        grep -v \"NEEDS CLARIFICATION\" | \\\n        grep -v \"^N/A$\" || echo \"\"\n}\n\nparse_plan_data() {\n    local plan_file=\"$1\"\n    \n    if [[ ! -f \"$plan_file\" ]]; then\n        log_error \"Plan file not found: $plan_file\"\n        return 1\n    fi\n    \n    if [[ ! -r \"$plan_file\" ]]; then\n        log_error \"Plan file is not readable: $plan_file\"\n        return 1\n    fi\n    \n    log_info \"Parsing plan data from $plan_file\"\n    \n    NEW_LANG=$(extract_plan_field \"Language/Version\" \"$plan_file\")\n    NEW_FRAMEWORK=$(extract_plan_field \"Primary Dependencies\" \"$plan_file\")\n    NEW_DB=$(extract_plan_field \"Storage\" \"$plan_file\")\n    NEW_PROJECT_TYPE=$(extract_plan_field \"Project Type\" \"$plan_file\")\n    \n    # Log what we found\n    if [[ -n \"$NEW_LANG\" ]]; then\n        log_info \"Found language: $NEW_LANG\"\n    else\n        log_warning \"No language information found in plan\"\n    fi\n    \n    if [[ -n \"$NEW_FRAMEWORK\" ]]; then\n        log_info \"Found framework: $NEW_FRAMEWORK\"\n    fi\n    \n    if [[ -n \"$NEW_DB\" ]] && [[ \"$NEW_DB\" != \"N/A\" ]]; then\n        log_info \"Found database: $NEW_DB\"\n    fi\n    \n    if [[ -n \"$NEW_PROJECT_TYPE\" ]]; then\n        log_info \"Found project type: $NEW_PROJECT_TYPE\"\n    fi\n}\n\nformat_technology_stack() {\n    local lang=\"$1\"\n    local framework=\"$2\"\n    local parts=()\n    \n    # Add non-empty parts\n    [[ -n \"$lang\" && \"$lang\" != \"NEEDS CLARIFICATION\" ]] && parts+=(\"$lang\")\n    [[ -n \"$framework\" && \"$framework\" != \"NEEDS CLARIFICATION\" && \"$framework\" != \"N/A\" ]] && parts+=(\"$framework\")\n    \n    # Join with proper formatting\n    if [[ ${#parts[@]} -eq 0 ]]; then\n        echo \"\"\n    elif [[ ${#parts[@]} -eq 1 ]]; then\n        echo \"${parts[0]}\"\n    else\n        # Join multiple parts with \" + \"\n        local result=\"${parts[0]}\"\n        for ((i=1; i<${#parts[@]}; i++)); do\n            result=\"$result + ${parts[i]}\"\n        done\n        echo \"$result\"\n    fi\n}\n\n#==============================================================================\n# Template and Content Generation Functions\n#==============================================================================\n\nget_project_structure() {\n    local project_type=\"$1\"\n    \n    if [[ \"$project_type\" == *\"web\"* ]]; then\n        echo \"backend/\\\\nfrontend/\\\\ntests/\"\n    else\n        echo \"src/\\\\ntests/\"\n    fi\n}\n\nget_commands_for_language() {\n    local lang=\"$1\"\n    \n    case \"$lang\" in\n        *\"Python\"*)\n            echo \"cd src && pytest && ruff check .\"\n            ;;\n        *\"Rust\"*)\n            echo \"cargo test && cargo clippy\"\n            ;;\n        *\"JavaScript\"*|*\"TypeScript\"*)\n            echo \"npm test \\\\&\\\\& npm run lint\"\n            ;;\n        *)\n            echo \"# Add commands for $lang\"\n            ;;\n    esac\n}\n\nget_language_conventions() {\n    local lang=\"$1\"\n    echo \"$lang: Follow standard conventions\"\n}\n\ncreate_new_agent_file() {\n    local target_file=\"$1\"\n    local temp_file=\"$2\"\n    local project_name=\"$3\"\n    local current_date=\"$4\"\n    \n    if [[ ! -f \"$TEMPLATE_FILE\" ]]; then\n        log_error \"Template not found at $TEMPLATE_FILE\"\n        return 1\n    fi\n    \n    if [[ ! -r \"$TEMPLATE_FILE\" ]]; then\n        log_error \"Template file is not readable: $TEMPLATE_FILE\"\n        return 1\n    fi\n    \n    log_info \"Creating new agent context file from template...\"\n    \n    if ! cp \"$TEMPLATE_FILE\" \"$temp_file\"; then\n        log_error \"Failed to copy template file\"\n        return 1\n    fi\n    \n    # Replace template placeholders\n    local project_structure\n    project_structure=$(get_project_structure \"$NEW_PROJECT_TYPE\")\n    \n    local commands\n    commands=$(get_commands_for_language \"$NEW_LANG\")\n    \n    local language_conventions\n    language_conventions=$(get_language_conventions \"$NEW_LANG\")\n    \n    # Perform substitutions with error checking using safer approach\n    # Escape special characters for sed by using a different delimiter or escaping\n    local escaped_lang=$(printf '%s\\n' \"$NEW_LANG\" | sed 's/[\\[\\.*^$()+{}|]/\\\\&/g')\n    local escaped_framework=$(printf '%s\\n' \"$NEW_FRAMEWORK\" | sed 's/[\\[\\.*^$()+{}|]/\\\\&/g')\n    local escaped_branch=$(printf '%s\\n' \"$CURRENT_BRANCH\" | sed 's/[\\[\\.*^$()+{}|]/\\\\&/g')\n    \n    # Build technology stack and recent change strings conditionally\n    local tech_stack\n    if [[ -n \"$escaped_lang\" && -n \"$escaped_framework\" ]]; then\n        tech_stack=\"- $escaped_lang + $escaped_framework ($escaped_branch)\"\n    elif [[ -n \"$escaped_lang\" ]]; then\n        tech_stack=\"- $escaped_lang ($escaped_branch)\"\n    elif [[ -n \"$escaped_framework\" ]]; then\n        tech_stack=\"- $escaped_framework ($escaped_branch)\"\n    else\n        tech_stack=\"- ($escaped_branch)\"\n    fi\n\n    local recent_change\n    if [[ -n \"$escaped_lang\" && -n \"$escaped_framework\" ]]; then\n        recent_change=\"- $escaped_branch: Added $escaped_lang + $escaped_framework\"\n    elif [[ -n \"$escaped_lang\" ]]; then\n        recent_change=\"- $escaped_branch: Added $escaped_lang\"\n    elif [[ -n \"$escaped_framework\" ]]; then\n        recent_change=\"- $escaped_branch: Added $escaped_framework\"\n    else\n        recent_change=\"- $escaped_branch: Added\"\n    fi\n\n    local substitutions=(\n        \"s|\\[PROJECT NAME\\]|$project_name|\"\n        \"s|\\[DATE\\]|$current_date|\"\n        \"s|\\[EXTRACTED FROM ALL PLAN.MD FILES\\]|$tech_stack|\"\n        \"s|\\[ACTUAL STRUCTURE FROM PLANS\\]|$project_structure|g\"\n        \"s|\\[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES\\]|$commands|\"\n        \"s|\\[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE\\]|$language_conventions|\"\n        \"s|\\[LAST 3 FEATURES AND WHAT THEY ADDED\\]|$recent_change|\"\n    )\n    \n    for substitution in \"${substitutions[@]}\"; do\n        if ! sed -i.bak -e \"$substitution\" \"$temp_file\"; then\n            log_error \"Failed to perform substitution: $substitution\"\n            rm -f \"$temp_file\" \"$temp_file.bak\"\n            return 1\n        fi\n    done\n    \n    # Convert \\n sequences to actual newlines\n    newline=$(printf '\\n')\n    sed -i.bak2 \"s/\\\\\\\\n/${newline}/g\" \"$temp_file\"\n\n    # Clean up backup files\n    rm -f \"$temp_file.bak\" \"$temp_file.bak2\"\n\n    # Prepend Cursor frontmatter for .mdc files so rules are auto-included\n    if [[ \"$target_file\" == *.mdc ]]; then\n        local frontmatter_file\n        frontmatter_file=$(mktemp) || return 1\n        printf '%s\\n' \"---\" \"description: Project Development Guidelines\" \"globs: [\\\"**/*\\\"]\" \"alwaysApply: true\" \"---\" \"\" > \"$frontmatter_file\"\n        cat \"$temp_file\" >> \"$frontmatter_file\"\n        mv \"$frontmatter_file\" \"$temp_file\"\n    fi\n\n    return 0\n}\n\n\n\n\nupdate_existing_agent_file() {\n    local target_file=\"$1\"\n    local current_date=\"$2\"\n    \n    log_info \"Updating existing agent context file...\"\n    \n    # Use a single temporary file for atomic update\n    local temp_file\n    temp_file=$(mktemp) || {\n        log_error \"Failed to create temporary file\"\n        return 1\n    }\n    \n    # Process the file in one pass\n    local tech_stack=$(format_technology_stack \"$NEW_LANG\" \"$NEW_FRAMEWORK\")\n    local new_tech_entries=()\n    local new_change_entry=\"\"\n    \n    # Prepare new technology entries\n    if [[ -n \"$tech_stack\" ]] && ! grep -q \"$tech_stack\" \"$target_file\"; then\n        new_tech_entries+=(\"- $tech_stack ($CURRENT_BRANCH)\")\n    fi\n    \n    if [[ -n \"$NEW_DB\" ]] && [[ \"$NEW_DB\" != \"N/A\" ]] && [[ \"$NEW_DB\" != \"NEEDS CLARIFICATION\" ]] && ! grep -q \"$NEW_DB\" \"$target_file\"; then\n        new_tech_entries+=(\"- $NEW_DB ($CURRENT_BRANCH)\")\n    fi\n    \n    # Prepare new change entry\n    if [[ -n \"$tech_stack\" ]]; then\n        new_change_entry=\"- $CURRENT_BRANCH: Added $tech_stack\"\n    elif [[ -n \"$NEW_DB\" ]] && [[ \"$NEW_DB\" != \"N/A\" ]] && [[ \"$NEW_DB\" != \"NEEDS CLARIFICATION\" ]]; then\n        new_change_entry=\"- $CURRENT_BRANCH: Added $NEW_DB\"\n    fi\n    \n    # Check if sections exist in the file\n    local has_active_technologies=0\n    local has_recent_changes=0\n    \n    if grep -q \"^## Active Technologies\" \"$target_file\" 2>/dev/null; then\n        has_active_technologies=1\n    fi\n    \n    if grep -q \"^## Recent Changes\" \"$target_file\" 2>/dev/null; then\n        has_recent_changes=1\n    fi\n    \n    # Process file line by line\n    local in_tech_section=false\n    local in_changes_section=false\n    local tech_entries_added=false\n    local changes_entries_added=false\n    local existing_changes_count=0\n    local file_ended=false\n    \n    while IFS= read -r line || [[ -n \"$line\" ]]; do\n        # Handle Active Technologies section\n        if [[ \"$line\" == \"## Active Technologies\" ]]; then\n            echo \"$line\" >> \"$temp_file\"\n            in_tech_section=true\n            continue\n        elif [[ $in_tech_section == true ]] && [[ \"$line\" =~ ^##[[:space:]] ]]; then\n            # Add new tech entries before closing the section\n            if [[ $tech_entries_added == false ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then\n                printf '%s\\n' \"${new_tech_entries[@]}\" >> \"$temp_file\"\n                tech_entries_added=true\n            fi\n            echo \"$line\" >> \"$temp_file\"\n            in_tech_section=false\n            continue\n        elif [[ $in_tech_section == true ]] && [[ -z \"$line\" ]]; then\n            # Add new tech entries before empty line in tech section\n            if [[ $tech_entries_added == false ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then\n                printf '%s\\n' \"${new_tech_entries[@]}\" >> \"$temp_file\"\n                tech_entries_added=true\n            fi\n            echo \"$line\" >> \"$temp_file\"\n            continue\n        fi\n        \n        # Handle Recent Changes section\n        if [[ \"$line\" == \"## Recent Changes\" ]]; then\n            echo \"$line\" >> \"$temp_file\"\n            # Add new change entry right after the heading\n            if [[ -n \"$new_change_entry\" ]]; then\n                echo \"$new_change_entry\" >> \"$temp_file\"\n            fi\n            in_changes_section=true\n            changes_entries_added=true\n            continue\n        elif [[ $in_changes_section == true ]] && [[ \"$line\" =~ ^##[[:space:]] ]]; then\n            echo \"$line\" >> \"$temp_file\"\n            in_changes_section=false\n            continue\n        elif [[ $in_changes_section == true ]] && [[ \"$line\" == \"- \"* ]]; then\n            # Keep only first 2 existing changes\n            if [[ $existing_changes_count -lt 2 ]]; then\n                echo \"$line\" >> \"$temp_file\"\n                ((existing_changes_count++))\n            fi\n            continue\n        fi\n        \n        # Update timestamp\n        if [[ \"$line\" =~ (\\*\\*)?Last\\ updated(\\*\\*)?:.*[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] ]]; then\n            echo \"$line\" | sed \"s/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/$current_date/\" >> \"$temp_file\"\n        else\n            echo \"$line\" >> \"$temp_file\"\n        fi\n    done < \"$target_file\"\n    \n    # Post-loop check: if we're still in the Active Technologies section and haven't added new entries\n    if [[ $in_tech_section == true ]] && [[ $tech_entries_added == false ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then\n        printf '%s\\n' \"${new_tech_entries[@]}\" >> \"$temp_file\"\n        tech_entries_added=true\n    fi\n    \n    # If sections don't exist, add them at the end of the file\n    if [[ $has_active_technologies -eq 0 ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then\n        echo \"\" >> \"$temp_file\"\n        echo \"## Active Technologies\" >> \"$temp_file\"\n        printf '%s\\n' \"${new_tech_entries[@]}\" >> \"$temp_file\"\n        tech_entries_added=true\n    fi\n    \n    if [[ $has_recent_changes -eq 0 ]] && [[ -n \"$new_change_entry\" ]]; then\n        echo \"\" >> \"$temp_file\"\n        echo \"## Recent Changes\" >> \"$temp_file\"\n        echo \"$new_change_entry\" >> \"$temp_file\"\n        changes_entries_added=true\n    fi\n    \n    # Ensure Cursor .mdc files have YAML frontmatter for auto-inclusion\n    if [[ \"$target_file\" == *.mdc ]]; then\n        if ! head -1 \"$temp_file\" | grep -q '^---'; then\n            local frontmatter_file\n            frontmatter_file=$(mktemp) || { rm -f \"$temp_file\"; return 1; }\n            printf '%s\\n' \"---\" \"description: Project Development Guidelines\" \"globs: [\\\"**/*\\\"]\" \"alwaysApply: true\" \"---\" \"\" > \"$frontmatter_file\"\n            cat \"$temp_file\" >> \"$frontmatter_file\"\n            mv \"$frontmatter_file\" \"$temp_file\"\n        fi\n    fi\n\n    # Move temp file to target atomically\n    if ! mv \"$temp_file\" \"$target_file\"; then\n        log_error \"Failed to update target file\"\n        rm -f \"$temp_file\"\n        return 1\n    fi\n\n    return 0\n}\n#==============================================================================\n# Main Agent File Update Function\n#==============================================================================\n\nupdate_agent_file() {\n    local target_file=\"$1\"\n    local agent_name=\"$2\"\n    \n    if [[ -z \"$target_file\" ]] || [[ -z \"$agent_name\" ]]; then\n        log_error \"update_agent_file requires target_file and agent_name parameters\"\n        return 1\n    fi\n    \n    log_info \"Updating $agent_name context file: $target_file\"\n    \n    local project_name\n    project_name=$(basename \"$REPO_ROOT\")\n    local current_date\n    current_date=$(date +%Y-%m-%d)\n    \n    # Create directory if it doesn't exist\n    local target_dir\n    target_dir=$(dirname \"$target_file\")\n    if [[ ! -d \"$target_dir\" ]]; then\n        if ! mkdir -p \"$target_dir\"; then\n            log_error \"Failed to create directory: $target_dir\"\n            return 1\n        fi\n    fi\n    \n    if [[ ! -f \"$target_file\" ]]; then\n        # Create new file from template\n        local temp_file\n        temp_file=$(mktemp) || {\n            log_error \"Failed to create temporary file\"\n            return 1\n        }\n        \n        if create_new_agent_file \"$target_file\" \"$temp_file\" \"$project_name\" \"$current_date\"; then\n            if mv \"$temp_file\" \"$target_file\"; then\n                log_success \"Created new $agent_name context file\"\n            else\n                log_error \"Failed to move temporary file to $target_file\"\n                rm -f \"$temp_file\"\n                return 1\n            fi\n        else\n            log_error \"Failed to create new agent file\"\n            rm -f \"$temp_file\"\n            return 1\n        fi\n    else\n        # Update existing file\n        if [[ ! -r \"$target_file\" ]]; then\n            log_error \"Cannot read existing file: $target_file\"\n            return 1\n        fi\n        \n        if [[ ! -w \"$target_file\" ]]; then\n            log_error \"Cannot write to existing file: $target_file\"\n            return 1\n        fi\n        \n        if update_existing_agent_file \"$target_file\" \"$current_date\"; then\n            log_success \"Updated existing $agent_name context file\"\n        else\n            log_error \"Failed to update existing agent file\"\n            return 1\n        fi\n    fi\n    \n    return 0\n}\n\n#==============================================================================\n# Agent Selection and Processing\n#==============================================================================\n\nupdate_specific_agent() {\n    local agent_type=\"$1\"\n    \n    case \"$agent_type\" in\n        claude)\n            update_agent_file \"$CLAUDE_FILE\" \"Claude Code\" || return 1\n            ;;\n        gemini)\n            update_agent_file \"$GEMINI_FILE\" \"Gemini CLI\" || return 1\n            ;;\n        copilot)\n            update_agent_file \"$COPILOT_FILE\" \"GitHub Copilot\" || return 1\n            ;;\n        cursor-agent)\n            update_agent_file \"$CURSOR_FILE\" \"Cursor IDE\" || return 1\n            ;;\n        qwen)\n            update_agent_file \"$QWEN_FILE\" \"Qwen Code\" || return 1\n            ;;\n        opencode)\n            update_agent_file \"$AGENTS_FILE\" \"opencode\" || return 1\n            ;;\n        codex)\n            update_agent_file \"$AGENTS_FILE\" \"Codex CLI\" || return 1\n            ;;\n        windsurf)\n            update_agent_file \"$WINDSURF_FILE\" \"Windsurf\" || return 1\n            ;;\n        junie)\n            update_agent_file \"$JUNIE_FILE\" \"Junie\" || return 1\n            ;;\n        kilocode)\n            update_agent_file \"$KILOCODE_FILE\" \"Kilo Code\" || return 1\n            ;;\n        auggie)\n            update_agent_file \"$AUGGIE_FILE\" \"Auggie CLI\" || return 1\n            ;;\n        roo)\n            update_agent_file \"$ROO_FILE\" \"Roo Code\" || return 1\n            ;;\n        codebuddy)\n            update_agent_file \"$CODEBUDDY_FILE\" \"CodeBuddy CLI\" || return 1\n            ;;\n        qodercli)\n            update_agent_file \"$QODER_FILE\" \"Qoder CLI\" || return 1\n            ;;\n        amp)\n            update_agent_file \"$AMP_FILE\" \"Amp\" || return 1\n            ;;\n        shai)\n            update_agent_file \"$SHAI_FILE\" \"SHAI\" || return 1\n            ;;\n        tabnine)\n            update_agent_file \"$TABNINE_FILE\" \"Tabnine CLI\" || return 1\n            ;;\n        kiro-cli)\n            update_agent_file \"$KIRO_FILE\" \"Kiro CLI\" || return 1\n            ;;\n        agy)\n            update_agent_file \"$AGY_FILE\" \"Antigravity\" || return 1\n            ;;\n        bob)\n            update_agent_file \"$BOB_FILE\" \"IBM Bob\" || return 1\n            ;;\n        vibe)\n            update_agent_file \"$VIBE_FILE\" \"Mistral Vibe\" || return 1\n            ;;\n        kimi)\n            update_agent_file \"$KIMI_FILE\" \"Kimi Code\" || return 1\n            ;;\n        trae)\n            update_agent_file \"$TRAE_FILE\" \"Trae\" || return 1\n            ;;\n        pi)\n            update_agent_file \"$AGENTS_FILE\" \"Pi Coding Agent\" || return 1\n            ;;\n        iflow)\n            update_agent_file \"$IFLOW_FILE\" \"iFlow CLI\" || return 1\n            ;;\n        generic)\n            log_info \"Generic agent: no predefined context file. Use the agent-specific update script for your agent.\"\n            ;;\n        *)\n            log_error \"Unknown agent type '$agent_type'\"\n            log_error \"Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic\"\n            exit 1\n            ;;\n    esac\n}\n\n# Helper: skip non-existent files and files already updated (dedup by\n# realpath so that variables pointing to the same file — e.g. AMP_FILE,\n# KIRO_FILE, BOB_FILE all resolving to AGENTS_FILE — are only written once).\n# Uses a linear array instead of associative array for bash 3.2 compatibility.\n# Note: defined at top level because bash 3.2 does not support true\n# nested/local functions. _updated_paths, _found_agent, and _all_ok are\n# initialised exclusively inside update_all_existing_agents so that\n# sourcing this script has no side effects on the caller's environment.\n\n_update_if_new() {\n    local file=\"$1\" name=\"$2\"\n    [[ -f \"$file\" ]] || return 0\n    local real_path\n    real_path=$(realpath \"$file\" 2>/dev/null || echo \"$file\")\n    local p\n    if [[ ${#_updated_paths[@]} -gt 0 ]]; then\n        for p in \"${_updated_paths[@]}\"; do\n            [[ \"$p\" == \"$real_path\" ]] && return 0\n        done\n    fi\n    # Record the file as seen before attempting the update so that:\n    # (a) aliases pointing to the same path are not retried on failure\n    # (b) _found_agent reflects file existence, not update success\n    _updated_paths+=(\"$real_path\")\n    _found_agent=true\n    update_agent_file \"$file\" \"$name\"\n}\n\nupdate_all_existing_agents() {\n    _found_agent=false\n    _updated_paths=()\n    local _all_ok=true\n\n    _update_if_new \"$CLAUDE_FILE\" \"Claude Code\"           || _all_ok=false\n    _update_if_new \"$GEMINI_FILE\" \"Gemini CLI\"             || _all_ok=false\n    _update_if_new \"$COPILOT_FILE\" \"GitHub Copilot\"        || _all_ok=false\n    _update_if_new \"$CURSOR_FILE\" \"Cursor IDE\"             || _all_ok=false\n    _update_if_new \"$QWEN_FILE\" \"Qwen Code\"                || _all_ok=false\n    _update_if_new \"$AGENTS_FILE\" \"Codex/opencode\"         || _all_ok=false\n    _update_if_new \"$AMP_FILE\" \"Amp\"                       || _all_ok=false\n    _update_if_new \"$KIRO_FILE\" \"Kiro CLI\"                 || _all_ok=false\n    _update_if_new \"$BOB_FILE\" \"IBM Bob\"                   || _all_ok=false\n    _update_if_new \"$WINDSURF_FILE\" \"Windsurf\"             || _all_ok=false\n    _update_if_new \"$JUNIE_FILE\" \"Junie\"                || _all_ok=false\n    _update_if_new \"$KILOCODE_FILE\" \"Kilo Code\"            || _all_ok=false\n    _update_if_new \"$AUGGIE_FILE\" \"Auggie CLI\"             || _all_ok=false\n    _update_if_new \"$ROO_FILE\" \"Roo Code\"                  || _all_ok=false\n    _update_if_new \"$CODEBUDDY_FILE\" \"CodeBuddy CLI\"       || _all_ok=false\n    _update_if_new \"$SHAI_FILE\" \"SHAI\"                     || _all_ok=false\n    _update_if_new \"$TABNINE_FILE\" \"Tabnine CLI\"           || _all_ok=false\n    _update_if_new \"$QODER_FILE\" \"Qoder CLI\"               || _all_ok=false\n    _update_if_new \"$AGY_FILE\" \"Antigravity\"               || _all_ok=false\n    _update_if_new \"$VIBE_FILE\" \"Mistral Vibe\"             || _all_ok=false\n    _update_if_new \"$KIMI_FILE\" \"Kimi Code\"                || _all_ok=false\n    _update_if_new \"$TRAE_FILE\" \"Trae\"                     || _all_ok=false\n    _update_if_new \"$IFLOW_FILE\" \"iFlow CLI\"               || _all_ok=false\n\n    # If no agent files exist, create a default Claude file\n    if [[ \"$_found_agent\" == false ]]; then\n        log_info \"No existing agent files found, creating default Claude file...\"\n        update_agent_file \"$CLAUDE_FILE\" \"Claude Code\" || return 1\n    fi\n\n    [[ \"$_all_ok\" == true ]]\n}\nprint_summary() {\n    echo\n    log_info \"Summary of changes:\"\n    \n    if [[ -n \"$NEW_LANG\" ]]; then\n        echo \"  - Added language: $NEW_LANG\"\n    fi\n    \n    if [[ -n \"$NEW_FRAMEWORK\" ]]; then\n        echo \"  - Added framework: $NEW_FRAMEWORK\"\n    fi\n    \n    if [[ -n \"$NEW_DB\" ]] && [[ \"$NEW_DB\" != \"N/A\" ]]; then\n        echo \"  - Added database: $NEW_DB\"\n    fi\n    \n    echo\n    log_info \"Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic]\"\n}\n\n#==============================================================================\n# Main Execution\n#==============================================================================\n\nmain() {\n    # Validate environment before proceeding\n    validate_environment\n    \n    log_info \"=== Updating agent context files for feature $CURRENT_BRANCH ===\"\n    \n    # Parse the plan file to extract project information\n    if ! parse_plan_data \"$NEW_PLAN\"; then\n        log_error \"Failed to parse plan data\"\n        exit 1\n    fi\n    \n    # Process based on agent type argument\n    local success=true\n    \n    if [[ -z \"$AGENT_TYPE\" ]]; then\n        # No specific agent provided - update all existing agent files\n        log_info \"No agent specified, updating all existing agent files...\"\n        if ! update_all_existing_agents; then\n            success=false\n        fi\n    else\n        # Specific agent provided - update only that agent\n        log_info \"Updating specific agent: $AGENT_TYPE\"\n        if ! update_specific_agent \"$AGENT_TYPE\"; then\n            success=false\n        fi\n    fi\n    \n    # Print summary\n    print_summary\n    \n    if [[ \"$success\" == true ]]; then\n        log_success \"Agent context update completed successfully\"\n        exit 0\n    else\n        log_error \"Agent context update completed with errors\"\n        exit 1\n    fi\n}\n\n# Execute main function if script is run directly\nif [[ \"${BASH_SOURCE[0]}\" == \"${0}\" ]]; then\n    main \"$@\"\nfi\n"
  },
  {
    "path": "scripts/powershell/check-prerequisites.ps1",
    "content": "#!/usr/bin/env pwsh\n\n# Consolidated prerequisite checking script (PowerShell)\n#\n# This script provides unified prerequisite checking for Spec-Driven Development workflow.\n# It replaces the functionality previously spread across multiple scripts.\n#\n# Usage: ./check-prerequisites.ps1 [OPTIONS]\n#\n# OPTIONS:\n#   -Json               Output in JSON format\n#   -RequireTasks       Require tasks.md to exist (for implementation phase)\n#   -IncludeTasks       Include tasks.md in AVAILABLE_DOCS list\n#   -PathsOnly          Only output path variables (no validation)\n#   -Help, -h           Show help message\n\n[CmdletBinding()]\nparam(\n    [switch]$Json,\n    [switch]$RequireTasks,\n    [switch]$IncludeTasks,\n    [switch]$PathsOnly,\n    [switch]$Help\n)\n\n$ErrorActionPreference = 'Stop'\n\n# Show help if requested\nif ($Help) {\n    Write-Output @\"\nUsage: check-prerequisites.ps1 [OPTIONS]\n\nConsolidated prerequisite checking for Spec-Driven Development workflow.\n\nOPTIONS:\n  -Json               Output in JSON format\n  -RequireTasks       Require tasks.md to exist (for implementation phase)\n  -IncludeTasks       Include tasks.md in AVAILABLE_DOCS list\n  -PathsOnly          Only output path variables (no prerequisite validation)\n  -Help, -h           Show this help message\n\nEXAMPLES:\n  # Check task prerequisites (plan.md required)\n  .\\check-prerequisites.ps1 -Json\n  \n  # Check implementation prerequisites (plan.md + tasks.md required)\n  .\\check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks\n  \n  # Get feature paths only (no validation)\n  .\\check-prerequisites.ps1 -PathsOnly\n\n\"@\n    exit 0\n}\n\n# Source common functions\n. \"$PSScriptRoot/common.ps1\"\n\n# Get feature paths and validate branch\n$paths = Get-FeaturePathsEnv\n\nif (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit:$paths.HAS_GIT)) { \n    exit 1 \n}\n\n# If paths-only mode, output paths and exit (support combined -Json -PathsOnly)\nif ($PathsOnly) {\n    if ($Json) {\n        [PSCustomObject]@{\n            REPO_ROOT    = $paths.REPO_ROOT\n            BRANCH       = $paths.CURRENT_BRANCH\n            FEATURE_DIR  = $paths.FEATURE_DIR\n            FEATURE_SPEC = $paths.FEATURE_SPEC\n            IMPL_PLAN    = $paths.IMPL_PLAN\n            TASKS        = $paths.TASKS\n        } | ConvertTo-Json -Compress\n    } else {\n        Write-Output \"REPO_ROOT: $($paths.REPO_ROOT)\"\n        Write-Output \"BRANCH: $($paths.CURRENT_BRANCH)\"\n        Write-Output \"FEATURE_DIR: $($paths.FEATURE_DIR)\"\n        Write-Output \"FEATURE_SPEC: $($paths.FEATURE_SPEC)\"\n        Write-Output \"IMPL_PLAN: $($paths.IMPL_PLAN)\"\n        Write-Output \"TASKS: $($paths.TASKS)\"\n    }\n    exit 0\n}\n\n# Validate required directories and files\nif (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) {\n    Write-Output \"ERROR: Feature directory not found: $($paths.FEATURE_DIR)\"\n    Write-Output \"Run /speckit.specify first to create the feature structure.\"\n    exit 1\n}\n\nif (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {\n    Write-Output \"ERROR: plan.md not found in $($paths.FEATURE_DIR)\"\n    Write-Output \"Run /speckit.plan first to create the implementation plan.\"\n    exit 1\n}\n\n# Check for tasks.md if required\nif ($RequireTasks -and -not (Test-Path $paths.TASKS -PathType Leaf)) {\n    Write-Output \"ERROR: tasks.md not found in $($paths.FEATURE_DIR)\"\n    Write-Output \"Run /speckit.tasks first to create the task list.\"\n    exit 1\n}\n\n# Build list of available documents\n$docs = @()\n\n# Always check these optional docs\nif (Test-Path $paths.RESEARCH) { $docs += 'research.md' }\nif (Test-Path $paths.DATA_MODEL) { $docs += 'data-model.md' }\n\n# Check contracts directory (only if it exists and has files)\nif ((Test-Path $paths.CONTRACTS_DIR) -and (Get-ChildItem -Path $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) { \n    $docs += 'contracts/' \n}\n\nif (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' }\n\n# Include tasks.md if requested and it exists\nif ($IncludeTasks -and (Test-Path $paths.TASKS)) { \n    $docs += 'tasks.md' \n}\n\n# Output results\nif ($Json) {\n    # JSON output\n    [PSCustomObject]@{ \n        FEATURE_DIR = $paths.FEATURE_DIR\n        AVAILABLE_DOCS = $docs \n    } | ConvertTo-Json -Compress\n} else {\n    # Text output\n    Write-Output \"FEATURE_DIR:$($paths.FEATURE_DIR)\"\n    Write-Output \"AVAILABLE_DOCS:\"\n    \n    # Show status of each potential document\n    Test-FileExists -Path $paths.RESEARCH -Description 'research.md' | Out-Null\n    Test-FileExists -Path $paths.DATA_MODEL -Description 'data-model.md' | Out-Null\n    Test-DirHasFiles -Path $paths.CONTRACTS_DIR -Description 'contracts/' | Out-Null\n    Test-FileExists -Path $paths.QUICKSTART -Description 'quickstart.md' | Out-Null\n    \n    if ($IncludeTasks) {\n        Test-FileExists -Path $paths.TASKS -Description 'tasks.md' | Out-Null\n    }\n}\n"
  },
  {
    "path": "scripts/powershell/common.ps1",
    "content": "#!/usr/bin/env pwsh\n# Common PowerShell functions analogous to common.sh\n\nfunction Get-RepoRoot {\n    try {\n        $result = git rev-parse --show-toplevel 2>$null\n        if ($LASTEXITCODE -eq 0) {\n            return $result\n        }\n    } catch {\n        # Git command failed\n    }\n    \n    # Fall back to script location for non-git repos\n    return (Resolve-Path (Join-Path $PSScriptRoot \"../../..\")).Path\n}\n\nfunction Get-CurrentBranch {\n    # First check if SPECIFY_FEATURE environment variable is set\n    if ($env:SPECIFY_FEATURE) {\n        return $env:SPECIFY_FEATURE\n    }\n    \n    # Then check git if available\n    try {\n        $result = git rev-parse --abbrev-ref HEAD 2>$null\n        if ($LASTEXITCODE -eq 0) {\n            return $result\n        }\n    } catch {\n        # Git command failed\n    }\n    \n    # For non-git repos, try to find the latest feature directory\n    $repoRoot = Get-RepoRoot\n    $specsDir = Join-Path $repoRoot \"specs\"\n    \n    if (Test-Path $specsDir) {\n        $latestFeature = \"\"\n        $highest = 0\n        \n        Get-ChildItem -Path $specsDir -Directory | ForEach-Object {\n            if ($_.Name -match '^(\\d{3})-') {\n                $num = [int]$matches[1]\n                if ($num -gt $highest) {\n                    $highest = $num\n                    $latestFeature = $_.Name\n                }\n            }\n        }\n        \n        if ($latestFeature) {\n            return $latestFeature\n        }\n    }\n    \n    # Final fallback\n    return \"main\"\n}\n\nfunction Test-HasGit {\n    try {\n        git rev-parse --show-toplevel 2>$null | Out-Null\n        return ($LASTEXITCODE -eq 0)\n    } catch {\n        return $false\n    }\n}\n\nfunction Test-FeatureBranch {\n    param(\n        [string]$Branch,\n        [bool]$HasGit = $true\n    )\n    \n    # For non-git repos, we can't enforce branch naming but still provide output\n    if (-not $HasGit) {\n        Write-Warning \"[specify] Warning: Git repository not detected; skipped branch validation\"\n        return $true\n    }\n    \n    if ($Branch -notmatch '^[0-9]{3}-') {\n        Write-Output \"ERROR: Not on a feature branch. Current branch: $Branch\"\n        Write-Output \"Feature branches should be named like: 001-feature-name\"\n        return $false\n    }\n    return $true\n}\n\nfunction Get-FeatureDir {\n    param([string]$RepoRoot, [string]$Branch)\n    Join-Path $RepoRoot \"specs/$Branch\"\n}\n\nfunction Get-FeaturePathsEnv {\n    $repoRoot = Get-RepoRoot\n    $currentBranch = Get-CurrentBranch\n    $hasGit = Test-HasGit\n    $featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch\n    \n    [PSCustomObject]@{\n        REPO_ROOT     = $repoRoot\n        CURRENT_BRANCH = $currentBranch\n        HAS_GIT       = $hasGit\n        FEATURE_DIR   = $featureDir\n        FEATURE_SPEC  = Join-Path $featureDir 'spec.md'\n        IMPL_PLAN     = Join-Path $featureDir 'plan.md'\n        TASKS         = Join-Path $featureDir 'tasks.md'\n        RESEARCH      = Join-Path $featureDir 'research.md'\n        DATA_MODEL    = Join-Path $featureDir 'data-model.md'\n        QUICKSTART    = Join-Path $featureDir 'quickstart.md'\n        CONTRACTS_DIR = Join-Path $featureDir 'contracts'\n    }\n}\n\nfunction Test-FileExists {\n    param([string]$Path, [string]$Description)\n    if (Test-Path -Path $Path -PathType Leaf) {\n        Write-Output \"  ✓ $Description\"\n        return $true\n    } else {\n        Write-Output \"  ✗ $Description\"\n        return $false\n    }\n}\n\nfunction Test-DirHasFiles {\n    param([string]$Path, [string]$Description)\n    if ((Test-Path -Path $Path -PathType Container) -and (Get-ChildItem -Path $Path -ErrorAction SilentlyContinue | Where-Object { -not $_.PSIsContainer } | Select-Object -First 1)) {\n        Write-Output \"  ✓ $Description\"\n        return $true\n    } else {\n        Write-Output \"  ✗ $Description\"\n        return $false\n    }\n}\n\n# Resolve a template name to a file path using the priority stack:\n#   1. .specify/templates/overrides/\n#   2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)\n#   3. .specify/extensions/<ext-id>/templates/\n#   4. .specify/templates/ (core)\nfunction Resolve-Template {\n    param(\n        [Parameter(Mandatory=$true)][string]$TemplateName,\n        [Parameter(Mandatory=$true)][string]$RepoRoot\n    )\n\n    $base = Join-Path $RepoRoot '.specify/templates'\n\n    # Priority 1: Project overrides\n    $override = Join-Path $base \"overrides/$TemplateName.md\"\n    if (Test-Path $override) { return $override }\n\n    # Priority 2: Installed presets (sorted by priority from .registry)\n    $presetsDir = Join-Path $RepoRoot '.specify/presets'\n    if (Test-Path $presetsDir) {\n        $registryFile = Join-Path $presetsDir '.registry'\n        $sortedPresets = @()\n        if (Test-Path $registryFile) {\n            try {\n                $registryData = Get-Content $registryFile -Raw | ConvertFrom-Json\n                $presets = $registryData.presets\n                if ($presets) {\n                    $sortedPresets = $presets.PSObject.Properties |\n                        Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } |\n                        ForEach-Object { $_.Name }\n                }\n            } catch {\n                # Fallback: alphabetical directory order\n                $sortedPresets = @()\n            }\n        }\n\n        if ($sortedPresets.Count -gt 0) {\n            foreach ($presetId in $sortedPresets) {\n                $candidate = Join-Path $presetsDir \"$presetId/templates/$TemplateName.md\"\n                if (Test-Path $candidate) { return $candidate }\n            }\n        } else {\n            # Fallback: alphabetical directory order\n            foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' }) {\n                $candidate = Join-Path $preset.FullName \"templates/$TemplateName.md\"\n                if (Test-Path $candidate) { return $candidate }\n            }\n        }\n    }\n\n    # Priority 3: Extension-provided templates\n    $extDir = Join-Path $RepoRoot '.specify/extensions'\n    if (Test-Path $extDir) {\n        foreach ($ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) {\n            $candidate = Join-Path $ext.FullName \"templates/$TemplateName.md\"\n            if (Test-Path $candidate) { return $candidate }\n        }\n    }\n\n    # Priority 4: Core templates\n    $core = Join-Path $base \"$TemplateName.md\"\n    if (Test-Path $core) { return $core }\n\n    return $null\n}\n\n"
  },
  {
    "path": "scripts/powershell/create-new-feature.ps1",
    "content": "#!/usr/bin/env pwsh\n# Create a new feature\n[CmdletBinding()]\nparam(\n    [switch]$Json,\n    [string]$ShortName,\n    [Parameter()]\n    [int]$Number = 0,\n    [switch]$Help,\n    [Parameter(Position = 0, ValueFromRemainingArguments = $true)]\n    [string[]]$FeatureDescription\n)\n$ErrorActionPreference = 'Stop'\n\n# Show help if requested\nif ($Help) {\n    Write-Host \"Usage: ./create-new-feature.ps1 [-Json] [-ShortName <name>] [-Number N] <feature description>\"\n    Write-Host \"\"\n    Write-Host \"Options:\"\n    Write-Host \"  -Json               Output in JSON format\"\n    Write-Host \"  -ShortName <name>   Provide a custom short name (2-4 words) for the branch\"\n    Write-Host \"  -Number N           Specify branch number manually (overrides auto-detection)\"\n    Write-Host \"  -Help               Show this help message\"\n    Write-Host \"\"\n    Write-Host \"Examples:\"\n    Write-Host \"  ./create-new-feature.ps1 'Add user authentication system' -ShortName 'user-auth'\"\n    Write-Host \"  ./create-new-feature.ps1 'Implement OAuth2 integration for API'\"\n    exit 0\n}\n\n# Check if feature description provided\nif (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {\n    Write-Error \"Usage: ./create-new-feature.ps1 [-Json] [-ShortName <name>] <feature description>\"\n    exit 1\n}\n\n$featureDesc = ($FeatureDescription -join ' ').Trim()\n\n# Validate description is not empty after trimming (e.g., user passed only whitespace)\nif ([string]::IsNullOrWhiteSpace($featureDesc)) {\n    Write-Error \"Error: Feature description cannot be empty or contain only whitespace\"\n    exit 1\n}\n\n# Resolve repository root. Prefer git information when available, but fall back\n# to searching for repository markers so the workflow still functions in repositories that\n# were initialized with --no-git.\nfunction Find-RepositoryRoot {\n    param(\n        [string]$StartDir,\n        [string[]]$Markers = @('.git', '.specify')\n    )\n    $current = Resolve-Path $StartDir\n    while ($true) {\n        foreach ($marker in $Markers) {\n            if (Test-Path (Join-Path $current $marker)) {\n                return $current\n            }\n        }\n        $parent = Split-Path $current -Parent\n        if ($parent -eq $current) {\n            # Reached filesystem root without finding markers\n            return $null\n        }\n        $current = $parent\n    }\n}\n\nfunction Get-HighestNumberFromSpecs {\n    param([string]$SpecsDir)\n    \n    $highest = 0\n    if (Test-Path $SpecsDir) {\n        Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object {\n            if ($_.Name -match '^(\\d+)') {\n                $num = [int]$matches[1]\n                if ($num -gt $highest) { $highest = $num }\n            }\n        }\n    }\n    return $highest\n}\n\nfunction Get-HighestNumberFromBranches {\n    param()\n    \n    $highest = 0\n    try {\n        $branches = git branch -a 2>$null\n        if ($LASTEXITCODE -eq 0) {\n            foreach ($branch in $branches) {\n                # Clean branch name: remove leading markers and remote prefixes\n                $cleanBranch = $branch.Trim() -replace '^\\*?\\s+', '' -replace '^remotes/[^/]+/', ''\n                \n                # Extract feature number if branch matches pattern ###-*\n                if ($cleanBranch -match '^(\\d+)-') {\n                    $num = [int]$matches[1]\n                    if ($num -gt $highest) { $highest = $num }\n                }\n            }\n        }\n    } catch {\n        # If git command fails, return 0\n        Write-Verbose \"Could not check Git branches: $_\"\n    }\n    return $highest\n}\n\nfunction Get-NextBranchNumber {\n    param(\n        [string]$SpecsDir\n    )\n\n    # Fetch all remotes to get latest branch info (suppress errors if no remotes)\n    try {\n        git fetch --all --prune 2>$null | Out-Null\n    } catch {\n        # Ignore fetch errors\n    }\n\n    # Get highest number from ALL branches (not just matching short name)\n    $highestBranch = Get-HighestNumberFromBranches\n\n    # Get highest number from ALL specs (not just matching short name)\n    $highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir\n\n    # Take the maximum of both\n    $maxNum = [Math]::Max($highestBranch, $highestSpec)\n\n    # Return next number\n    return $maxNum + 1\n}\n\nfunction ConvertTo-CleanBranchName {\n    param([string]$Name)\n    \n    return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''\n}\n$fallbackRoot = (Find-RepositoryRoot -StartDir $PSScriptRoot)\nif (-not $fallbackRoot) {\n    Write-Error \"Error: Could not determine repository root. Please run this script from within the repository.\"\n    exit 1\n}\n\n# Load common functions (includes Resolve-Template)\n. \"$PSScriptRoot/common.ps1\"\n\ntry {\n    $repoRoot = git rev-parse --show-toplevel 2>$null\n    if ($LASTEXITCODE -eq 0) {\n        $hasGit = $true\n    } else {\n        throw \"Git not available\"\n    }\n} catch {\n    $repoRoot = $fallbackRoot\n    $hasGit = $false\n}\n\nSet-Location $repoRoot\n\n$specsDir = Join-Path $repoRoot 'specs'\nNew-Item -ItemType Directory -Path $specsDir -Force | Out-Null\n\n# Function to generate branch name with stop word filtering and length filtering\nfunction Get-BranchName {\n    param([string]$Description)\n    \n    # Common stop words to filter out\n    $stopWords = @(\n        'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from',\n        'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had',\n        'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must', 'shall',\n        'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their',\n        'want', 'need', 'add', 'get', 'set'\n    )\n    \n    # Convert to lowercase and extract words (alphanumeric only)\n    $cleanName = $Description.ToLower() -replace '[^a-z0-9\\s]', ' '\n    $words = $cleanName -split '\\s+' | Where-Object { $_ }\n    \n    # Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original)\n    $meaningfulWords = @()\n    foreach ($word in $words) {\n        # Skip stop words\n        if ($stopWords -contains $word) { continue }\n        \n        # Keep words that are length >= 3 OR appear as uppercase in original (likely acronyms)\n        if ($word.Length -ge 3) {\n            $meaningfulWords += $word\n        } elseif ($Description -match \"\\b$($word.ToUpper())\\b\") {\n            # Keep short words if they appear as uppercase in original (likely acronyms)\n            $meaningfulWords += $word\n        }\n    }\n    \n    # If we have meaningful words, use first 3-4 of them\n    if ($meaningfulWords.Count -gt 0) {\n        $maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 }\n        $result = ($meaningfulWords | Select-Object -First $maxWords) -join '-'\n        return $result\n    } else {\n        # Fallback to original logic if no meaningful words found\n        $result = ConvertTo-CleanBranchName -Name $Description\n        $fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3\n        return [string]::Join('-', $fallbackWords)\n    }\n}\n\n# Generate branch name\nif ($ShortName) {\n    # Use provided short name, just clean it up\n    $branchSuffix = ConvertTo-CleanBranchName -Name $ShortName\n} else {\n    # Generate from description with smart filtering\n    $branchSuffix = Get-BranchName -Description $featureDesc\n}\n\n# Determine branch number\nif ($Number -eq 0) {\n    if ($hasGit) {\n        # Check existing branches on remotes\n        $Number = Get-NextBranchNumber -SpecsDir $specsDir\n    } else {\n        # Fall back to local directory check\n        $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1\n    }\n}\n\n$featureNum = ('{0:000}' -f $Number)\n$branchName = \"$featureNum-$branchSuffix\"\n\n# GitHub enforces a 244-byte limit on branch names\n# Validate and truncate if necessary\n$maxBranchLength = 244\nif ($branchName.Length -gt $maxBranchLength) {\n    # Calculate how much we need to trim from suffix\n    # Account for: feature number (3) + hyphen (1) = 4 chars\n    $maxSuffixLength = $maxBranchLength - 4\n    \n    # Truncate suffix\n    $truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))\n    # Remove trailing hyphen if truncation created one\n    $truncatedSuffix = $truncatedSuffix -replace '-$', ''\n    \n    $originalBranchName = $branchName\n    $branchName = \"$featureNum-$truncatedSuffix\"\n    \n    Write-Warning \"[specify] Branch name exceeded GitHub's 244-byte limit\"\n    Write-Warning \"[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)\"\n    Write-Warning \"[specify] Truncated to: $branchName ($($branchName.Length) bytes)\"\n}\n\nif ($hasGit) {\n    $branchCreated = $false\n    try {\n        git checkout -q -b $branchName 2>$null | Out-Null\n        if ($LASTEXITCODE -eq 0) {\n            $branchCreated = $true\n        }\n    } catch {\n        # Exception during git command\n    }\n\n    if (-not $branchCreated) {\n        # Check if branch already exists\n        $existingBranch = git branch --list $branchName 2>$null\n        if ($existingBranch) {\n            Write-Error \"Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number.\"\n            exit 1\n        } else {\n            Write-Error \"Error: Failed to create git branch '$branchName'. Please check your git configuration and try again.\"\n            exit 1\n        }\n    }\n} else {\n    Write-Warning \"[specify] Warning: Git repository not detected; skipped branch creation for $branchName\"\n}\n\n$featureDir = Join-Path $specsDir $branchName\nNew-Item -ItemType Directory -Path $featureDir -Force | Out-Null\n\n$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot\n$specFile = Join-Path $featureDir 'spec.md'\nif ($template -and (Test-Path $template)) { \n    Copy-Item $template $specFile -Force \n} else { \n    New-Item -ItemType File -Path $specFile | Out-Null \n}\n\n# Set the SPECIFY_FEATURE environment variable for the current session\n$env:SPECIFY_FEATURE = $branchName\n\nif ($Json) {\n    $obj = [PSCustomObject]@{ \n        BRANCH_NAME = $branchName\n        SPEC_FILE = $specFile\n        FEATURE_NUM = $featureNum\n        HAS_GIT = $hasGit\n    }\n    $obj | ConvertTo-Json -Compress\n} else {\n    Write-Output \"BRANCH_NAME: $branchName\"\n    Write-Output \"SPEC_FILE: $specFile\"\n    Write-Output \"FEATURE_NUM: $featureNum\"\n    Write-Output \"HAS_GIT: $hasGit\"\n    Write-Output \"SPECIFY_FEATURE environment variable set to: $branchName\"\n}\n\n"
  },
  {
    "path": "scripts/powershell/setup-plan.ps1",
    "content": "#!/usr/bin/env pwsh\n# Setup implementation plan for a feature\n\n[CmdletBinding()]\nparam(\n    [switch]$Json,\n    [switch]$Help\n)\n\n$ErrorActionPreference = 'Stop'\n\n# Show help if requested\nif ($Help) {\n    Write-Output \"Usage: ./setup-plan.ps1 [-Json] [-Help]\"\n    Write-Output \"  -Json     Output results in JSON format\"\n    Write-Output \"  -Help     Show this help message\"\n    exit 0\n}\n\n# Load common functions\n. \"$PSScriptRoot/common.ps1\"\n\n# Get all paths and variables from common functions\n$paths = Get-FeaturePathsEnv\n\n# Check if we're on a proper feature branch (only for git repos)\nif (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GIT)) { \n    exit 1 \n}\n\n# Ensure the feature directory exists\nNew-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null\n\n# Copy plan template if it exists, otherwise note it or create empty file\n$template = Resolve-Template -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT\nif ($template -and (Test-Path $template)) { \n    Copy-Item $template $paths.IMPL_PLAN -Force\n    Write-Output \"Copied plan template to $($paths.IMPL_PLAN)\"\n} else {\n    Write-Warning \"Plan template not found\"\n    # Create a basic plan file if template doesn't exist\n    New-Item -ItemType File -Path $paths.IMPL_PLAN -Force | Out-Null\n}\n\n# Output results\nif ($Json) {\n    $result = [PSCustomObject]@{ \n        FEATURE_SPEC = $paths.FEATURE_SPEC\n        IMPL_PLAN = $paths.IMPL_PLAN\n        SPECS_DIR = $paths.FEATURE_DIR\n        BRANCH = $paths.CURRENT_BRANCH\n        HAS_GIT = $paths.HAS_GIT\n    }\n    $result | ConvertTo-Json -Compress\n} else {\n    Write-Output \"FEATURE_SPEC: $($paths.FEATURE_SPEC)\"\n    Write-Output \"IMPL_PLAN: $($paths.IMPL_PLAN)\"\n    Write-Output \"SPECS_DIR: $($paths.FEATURE_DIR)\"\n    Write-Output \"BRANCH: $($paths.CURRENT_BRANCH)\"\n    Write-Output \"HAS_GIT: $($paths.HAS_GIT)\"\n}\n"
  },
  {
    "path": "scripts/powershell/update-agent-context.ps1",
    "content": "#!/usr/bin/env pwsh\n<#!\n.SYNOPSIS\nUpdate agent context files with information from plan.md (PowerShell version)\n\n.DESCRIPTION\nMirrors the behavior of scripts/bash/update-agent-context.sh:\n 1. Environment Validation\n 2. Plan Data Extraction\n 3. Agent File Management (create from template or update existing)\n 4. Content Generation (technology stack, recent changes, timestamp)\n 5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, junie, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, kimi, trae, pi, iflow, generic)\n\n.PARAMETER AgentType\nOptional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist).\n\n.EXAMPLE\n./update-agent-context.ps1 -AgentType claude\n\n.EXAMPLE\n./update-agent-context.ps1   # Updates all existing agent files\n\n.NOTES\nRelies on common helper functions in common.ps1\n#>\nparam(\n    [Parameter(Position=0)]\n    [ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','junie','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','qodercli','vibe','kimi','trae','pi','iflow','generic')]\n    [string]$AgentType\n)\n\n$ErrorActionPreference = 'Stop'\n\n# Import common helpers\n$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path\n. (Join-Path $ScriptDir 'common.ps1')\n\n# Acquire environment paths\n$envData = Get-FeaturePathsEnv\n$REPO_ROOT     = $envData.REPO_ROOT\n$CURRENT_BRANCH = $envData.CURRENT_BRANCH\n$HAS_GIT       = $envData.HAS_GIT\n$IMPL_PLAN     = $envData.IMPL_PLAN\n$NEW_PLAN = $IMPL_PLAN\n\n# Agent file paths\n$CLAUDE_FILE   = Join-Path $REPO_ROOT 'CLAUDE.md'\n$GEMINI_FILE   = Join-Path $REPO_ROOT 'GEMINI.md'\n$COPILOT_FILE  = Join-Path $REPO_ROOT '.github/agents/copilot-instructions.md'\n$CURSOR_FILE   = Join-Path $REPO_ROOT '.cursor/rules/specify-rules.mdc'\n$QWEN_FILE     = Join-Path $REPO_ROOT 'QWEN.md'\n$AGENTS_FILE   = Join-Path $REPO_ROOT 'AGENTS.md'\n$WINDSURF_FILE = Join-Path $REPO_ROOT '.windsurf/rules/specify-rules.md'\n$JUNIE_FILE = Join-Path $REPO_ROOT '.junie/AGENTS.md'\n$KILOCODE_FILE = Join-Path $REPO_ROOT '.kilocode/rules/specify-rules.md'\n$AUGGIE_FILE   = Join-Path $REPO_ROOT '.augment/rules/specify-rules.md'\n$ROO_FILE      = Join-Path $REPO_ROOT '.roo/rules/specify-rules.md'\n$CODEBUDDY_FILE = Join-Path $REPO_ROOT 'CODEBUDDY.md'\n$QODER_FILE    = Join-Path $REPO_ROOT 'QODER.md'\n$AMP_FILE      = Join-Path $REPO_ROOT 'AGENTS.md'\n$SHAI_FILE     = Join-Path $REPO_ROOT 'SHAI.md'\n$TABNINE_FILE  = Join-Path $REPO_ROOT 'TABNINE.md'\n$KIRO_FILE     = Join-Path $REPO_ROOT 'AGENTS.md'\n$AGY_FILE      = Join-Path $REPO_ROOT '.agent/rules/specify-rules.md'\n$BOB_FILE      = Join-Path $REPO_ROOT 'AGENTS.md'\n$VIBE_FILE     = Join-Path $REPO_ROOT '.vibe/agents/specify-agents.md'\n$KIMI_FILE     = Join-Path $REPO_ROOT 'KIMI.md'\n$TRAE_FILE     = Join-Path $REPO_ROOT '.trae/rules/AGENTS.md'\n$IFLOW_FILE    = Join-Path $REPO_ROOT 'IFLOW.md'\n\n$TEMPLATE_FILE = Join-Path $REPO_ROOT '.specify/templates/agent-file-template.md'\n\n# Parsed plan data placeholders\n$script:NEW_LANG = ''\n$script:NEW_FRAMEWORK = ''\n$script:NEW_DB = ''\n$script:NEW_PROJECT_TYPE = ''\n\nfunction Write-Info { \n    param(\n        [Parameter(Mandatory=$true)]\n        [string]$Message\n    )\n    Write-Host \"INFO: $Message\" \n}\n\nfunction Write-Success { \n    param(\n        [Parameter(Mandatory=$true)]\n        [string]$Message\n    )\n    Write-Host \"$([char]0x2713) $Message\" \n}\n\nfunction Write-WarningMsg { \n    param(\n        [Parameter(Mandatory=$true)]\n        [string]$Message\n    )\n    Write-Warning $Message \n}\n\nfunction Write-Err { \n    param(\n        [Parameter(Mandatory=$true)]\n        [string]$Message\n    )\n    Write-Host \"ERROR: $Message\" -ForegroundColor Red \n}\n\nfunction Validate-Environment {\n    if (-not $CURRENT_BRANCH) {\n        Write-Err 'Unable to determine current feature'\n        if ($HAS_GIT) { Write-Info \"Make sure you're on a feature branch\" } else { Write-Info 'Set SPECIFY_FEATURE environment variable or create a feature first' }\n        exit 1\n    }\n    if (-not (Test-Path $NEW_PLAN)) {\n        Write-Err \"No plan.md found at $NEW_PLAN\"\n        Write-Info 'Ensure you are working on a feature with a corresponding spec directory'\n        if (-not $HAS_GIT) { Write-Info 'Use: $env:SPECIFY_FEATURE=your-feature-name or create a new feature first' }\n        exit 1\n    }\n    if (-not (Test-Path $TEMPLATE_FILE)) {\n        Write-Err \"Template file not found at $TEMPLATE_FILE\"\n        Write-Info 'Run specify init to scaffold .specify/templates, or add agent-file-template.md there.'\n        exit 1\n    }\n}\n\nfunction Extract-PlanField {\n    param(\n        [Parameter(Mandatory=$true)]\n        [string]$FieldPattern,\n        [Parameter(Mandatory=$true)]\n        [string]$PlanFile\n    )\n    if (-not (Test-Path $PlanFile)) { return '' }\n    # Lines like **Language/Version**: Python 3.12\n    $regex = \"^\\*\\*$([Regex]::Escape($FieldPattern))\\*\\*: (.+)$\"\n    Get-Content -LiteralPath $PlanFile -Encoding utf8 | ForEach-Object {\n        if ($_ -match $regex) { \n            $val = $Matches[1].Trim()\n            if ($val -notin @('NEEDS CLARIFICATION','N/A')) { return $val }\n        }\n    } | Select-Object -First 1\n}\n\nfunction Parse-PlanData {\n    param(\n        [Parameter(Mandatory=$true)]\n        [string]$PlanFile\n    )\n    if (-not (Test-Path $PlanFile)) { Write-Err \"Plan file not found: $PlanFile\"; return $false }\n    Write-Info \"Parsing plan data from $PlanFile\"\n    $script:NEW_LANG        = Extract-PlanField -FieldPattern 'Language/Version' -PlanFile $PlanFile\n    $script:NEW_FRAMEWORK   = Extract-PlanField -FieldPattern 'Primary Dependencies' -PlanFile $PlanFile\n    $script:NEW_DB          = Extract-PlanField -FieldPattern 'Storage' -PlanFile $PlanFile\n    $script:NEW_PROJECT_TYPE = Extract-PlanField -FieldPattern 'Project Type' -PlanFile $PlanFile\n\n    if ($NEW_LANG) { Write-Info \"Found language: $NEW_LANG\" } else { Write-WarningMsg 'No language information found in plan' }\n    if ($NEW_FRAMEWORK) { Write-Info \"Found framework: $NEW_FRAMEWORK\" }\n    if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Info \"Found database: $NEW_DB\" }\n    if ($NEW_PROJECT_TYPE) { Write-Info \"Found project type: $NEW_PROJECT_TYPE\" }\n    return $true\n}\n\nfunction Format-TechnologyStack {\n    param(\n        [Parameter(Mandatory=$false)]\n        [string]$Lang,\n        [Parameter(Mandatory=$false)]\n        [string]$Framework\n    )\n    $parts = @()\n    if ($Lang -and $Lang -ne 'NEEDS CLARIFICATION') { $parts += $Lang }\n    if ($Framework -and $Framework -notin @('NEEDS CLARIFICATION','N/A')) { $parts += $Framework }\n    if (-not $parts) { return '' }\n    return ($parts -join ' + ')\n}\n\nfunction Get-ProjectStructure { \n    param(\n        [Parameter(Mandatory=$false)]\n        [string]$ProjectType\n    )\n    if ($ProjectType -match 'web') { return \"backend/`nfrontend/`ntests/\" } else { return \"src/`ntests/\" } \n}\n\nfunction Get-CommandsForLanguage { \n    param(\n        [Parameter(Mandatory=$false)]\n        [string]$Lang\n    )\n    switch -Regex ($Lang) {\n        'Python' { return \"cd src; pytest; ruff check .\" }\n        'Rust' { return \"cargo test; cargo clippy\" }\n        'JavaScript|TypeScript' { return \"npm test; npm run lint\" }\n        default { return \"# Add commands for $Lang\" }\n    }\n}\n\nfunction Get-LanguageConventions { \n    param(\n        [Parameter(Mandatory=$false)]\n        [string]$Lang\n    )\n    if ($Lang) { \"${Lang}: Follow standard conventions\" } else { 'General: Follow standard conventions' } \n}\n\nfunction New-AgentFile {\n    param(\n        [Parameter(Mandatory=$true)]\n        [string]$TargetFile,\n        [Parameter(Mandatory=$true)]\n        [string]$ProjectName,\n        [Parameter(Mandatory=$true)]\n        [datetime]$Date\n    )\n    if (-not (Test-Path $TEMPLATE_FILE)) { Write-Err \"Template not found at $TEMPLATE_FILE\"; return $false }\n    $temp = New-TemporaryFile\n    Copy-Item -LiteralPath $TEMPLATE_FILE -Destination $temp -Force\n\n    $projectStructure = Get-ProjectStructure -ProjectType $NEW_PROJECT_TYPE\n    $commands = Get-CommandsForLanguage -Lang $NEW_LANG\n    $languageConventions = Get-LanguageConventions -Lang $NEW_LANG\n\n    $escaped_lang = $NEW_LANG\n    $escaped_framework = $NEW_FRAMEWORK\n    $escaped_branch = $CURRENT_BRANCH\n\n    $content = Get-Content -LiteralPath $temp -Raw -Encoding utf8\n    $content = $content -replace '\\[PROJECT NAME\\]',$ProjectName\n    $content = $content -replace '\\[DATE\\]',$Date.ToString('yyyy-MM-dd')\n    \n    # Build the technology stack string safely\n    $techStackForTemplate = \"\"\n    if ($escaped_lang -and $escaped_framework) {\n        $techStackForTemplate = \"- $escaped_lang + $escaped_framework ($escaped_branch)\"\n    } elseif ($escaped_lang) {\n        $techStackForTemplate = \"- $escaped_lang ($escaped_branch)\"\n    } elseif ($escaped_framework) {\n        $techStackForTemplate = \"- $escaped_framework ($escaped_branch)\"\n    }\n    \n    $content = $content -replace '\\[EXTRACTED FROM ALL PLAN.MD FILES\\]',$techStackForTemplate\n    # For project structure we manually embed (keep newlines)\n    $escapedStructure = [Regex]::Escape($projectStructure)\n    $content = $content -replace '\\[ACTUAL STRUCTURE FROM PLANS\\]',$escapedStructure\n    # Replace escaped newlines placeholder after all replacements\n    $content = $content -replace '\\[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES\\]',$commands\n    $content = $content -replace '\\[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE\\]',$languageConventions\n    \n    # Build the recent changes string safely\n    $recentChangesForTemplate = \"\"\n    if ($escaped_lang -and $escaped_framework) {\n        $recentChangesForTemplate = \"- ${escaped_branch}: Added ${escaped_lang} + ${escaped_framework}\"\n    } elseif ($escaped_lang) {\n        $recentChangesForTemplate = \"- ${escaped_branch}: Added ${escaped_lang}\"\n    } elseif ($escaped_framework) {\n        $recentChangesForTemplate = \"- ${escaped_branch}: Added ${escaped_framework}\"\n    }\n    \n    $content = $content -replace '\\[LAST 3 FEATURES AND WHAT THEY ADDED\\]',$recentChangesForTemplate\n    # Convert literal \\n sequences introduced by Escape to real newlines\n    $content = $content -replace '\\\\n',[Environment]::NewLine\n\n    # Prepend Cursor frontmatter for .mdc files so rules are auto-included\n    if ($TargetFile -match '\\.mdc$') {\n        $frontmatter = @('---','description: Project Development Guidelines','globs: [\"**/*\"]','alwaysApply: true','---','') -join [Environment]::NewLine\n        $content = $frontmatter + $content\n    }\n\n    $parent = Split-Path -Parent $TargetFile\n    if (-not (Test-Path $parent)) { New-Item -ItemType Directory -Path $parent | Out-Null }\n    Set-Content -LiteralPath $TargetFile -Value $content -NoNewline -Encoding utf8\n    Remove-Item $temp -Force\n    return $true\n}\n\nfunction Update-ExistingAgentFile {\n    param(\n        [Parameter(Mandatory=$true)]\n        [string]$TargetFile,\n        [Parameter(Mandatory=$true)]\n        [datetime]$Date\n    )\n    if (-not (Test-Path $TargetFile)) { return (New-AgentFile -TargetFile $TargetFile -ProjectName (Split-Path $REPO_ROOT -Leaf) -Date $Date) }\n\n    $techStack = Format-TechnologyStack -Lang $NEW_LANG -Framework $NEW_FRAMEWORK\n    $newTechEntries = @()\n    if ($techStack) {\n        $escapedTechStack = [Regex]::Escape($techStack)\n        if (-not (Select-String -Pattern $escapedTechStack -Path $TargetFile -Quiet)) { \n            $newTechEntries += \"- $techStack ($CURRENT_BRANCH)\" \n        }\n    }\n    if ($NEW_DB -and $NEW_DB -notin @('N/A','NEEDS CLARIFICATION')) {\n        $escapedDB = [Regex]::Escape($NEW_DB)\n        if (-not (Select-String -Pattern $escapedDB -Path $TargetFile -Quiet)) { \n            $newTechEntries += \"- $NEW_DB ($CURRENT_BRANCH)\" \n        }\n    }\n    $newChangeEntry = ''\n    if ($techStack) { $newChangeEntry = \"- ${CURRENT_BRANCH}: Added ${techStack}\" }\n    elseif ($NEW_DB -and $NEW_DB -notin @('N/A','NEEDS CLARIFICATION')) { $newChangeEntry = \"- ${CURRENT_BRANCH}: Added ${NEW_DB}\" }\n\n    $lines = Get-Content -LiteralPath $TargetFile -Encoding utf8\n    $output = New-Object System.Collections.Generic.List[string]\n    $inTech = $false; $inChanges = $false; $techAdded = $false; $changeAdded = $false; $existingChanges = 0\n\n    for ($i=0; $i -lt $lines.Count; $i++) {\n        $line = $lines[$i]\n        if ($line -eq '## Active Technologies') {\n            $output.Add($line)\n            $inTech = $true\n            continue\n        }\n        if ($inTech -and $line -match '^##\\s') {\n            if (-not $techAdded -and $newTechEntries.Count -gt 0) { $newTechEntries | ForEach-Object { $output.Add($_) }; $techAdded = $true }\n            $output.Add($line); $inTech = $false; continue\n        }\n        if ($inTech -and [string]::IsNullOrWhiteSpace($line)) {\n            if (-not $techAdded -and $newTechEntries.Count -gt 0) { $newTechEntries | ForEach-Object { $output.Add($_) }; $techAdded = $true }\n            $output.Add($line); continue\n        }\n        if ($line -eq '## Recent Changes') {\n            $output.Add($line)\n            if ($newChangeEntry) { $output.Add($newChangeEntry); $changeAdded = $true }\n            $inChanges = $true\n            continue\n        }\n        if ($inChanges -and $line -match '^##\\s') { $output.Add($line); $inChanges = $false; continue }\n        if ($inChanges -and $line -match '^- ') {\n            if ($existingChanges -lt 2) { $output.Add($line); $existingChanges++ }\n            continue\n        }\n        if ($line -match '(\\*\\*)?Last updated(\\*\\*)?: .*\\d{4}-\\d{2}-\\d{2}') {\n            $output.Add(($line -replace '\\d{4}-\\d{2}-\\d{2}',$Date.ToString('yyyy-MM-dd')))\n            continue\n        }\n        $output.Add($line)\n    }\n\n    # Post-loop check: if we're still in the Active Technologies section and haven't added new entries\n    if ($inTech -and -not $techAdded -and $newTechEntries.Count -gt 0) {\n        $newTechEntries | ForEach-Object { $output.Add($_) }\n    }\n\n    # Ensure Cursor .mdc files have YAML frontmatter for auto-inclusion\n    if ($TargetFile -match '\\.mdc$' -and $output.Count -gt 0 -and $output[0] -ne '---') {\n        $frontmatter = @('---','description: Project Development Guidelines','globs: [\"**/*\"]','alwaysApply: true','---','')\n        $output.InsertRange(0, $frontmatter)\n    }\n\n    Set-Content -LiteralPath $TargetFile -Value ($output -join [Environment]::NewLine) -Encoding utf8\n    return $true\n}\n\nfunction Update-AgentFile {\n    param(\n        [Parameter(Mandatory=$true)]\n        [string]$TargetFile,\n        [Parameter(Mandatory=$true)]\n        [string]$AgentName\n    )\n    if (-not $TargetFile -or -not $AgentName) { Write-Err 'Update-AgentFile requires TargetFile and AgentName'; return $false }\n    Write-Info \"Updating $AgentName context file: $TargetFile\"\n    $projectName = Split-Path $REPO_ROOT -Leaf\n    $date = Get-Date\n\n    $dir = Split-Path -Parent $TargetFile\n    if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir | Out-Null }\n\n    if (-not (Test-Path $TargetFile)) {\n        if (New-AgentFile -TargetFile $TargetFile -ProjectName $projectName -Date $date) { Write-Success \"Created new $AgentName context file\" } else { Write-Err 'Failed to create new agent file'; return $false }\n    } else {\n        try {\n            if (Update-ExistingAgentFile -TargetFile $TargetFile -Date $date) { Write-Success \"Updated existing $AgentName context file\" } else { Write-Err 'Failed to update agent file'; return $false }\n        } catch {\n            Write-Err \"Cannot access or update existing file: $TargetFile. $_\"\n            return $false\n        }\n    }\n    return $true\n}\n\nfunction Update-SpecificAgent {\n    param(\n        [Parameter(Mandatory=$true)]\n        [string]$Type\n    )\n    switch ($Type) {\n        'claude'   { Update-AgentFile -TargetFile $CLAUDE_FILE   -AgentName 'Claude Code' }\n        'gemini'   { Update-AgentFile -TargetFile $GEMINI_FILE   -AgentName 'Gemini CLI' }\n        'copilot'  { Update-AgentFile -TargetFile $COPILOT_FILE  -AgentName 'GitHub Copilot' }\n        'cursor-agent' { Update-AgentFile -TargetFile $CURSOR_FILE   -AgentName 'Cursor IDE' }\n        'qwen'     { Update-AgentFile -TargetFile $QWEN_FILE     -AgentName 'Qwen Code' }\n        'opencode' { Update-AgentFile -TargetFile $AGENTS_FILE   -AgentName 'opencode' }\n        'codex'    { Update-AgentFile -TargetFile $AGENTS_FILE   -AgentName 'Codex CLI' }\n        'windsurf' { Update-AgentFile -TargetFile $WINDSURF_FILE -AgentName 'Windsurf' }\n        'junie'    { Update-AgentFile -TargetFile $JUNIE_FILE    -AgentName 'Junie' }\n        'kilocode' { Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code' }\n        'auggie'   { Update-AgentFile -TargetFile $AUGGIE_FILE   -AgentName 'Auggie CLI' }\n        'roo'      { Update-AgentFile -TargetFile $ROO_FILE      -AgentName 'Roo Code' }\n        'codebuddy' { Update-AgentFile -TargetFile $CODEBUDDY_FILE -AgentName 'CodeBuddy CLI' }\n        'qodercli' { Update-AgentFile -TargetFile $QODER_FILE    -AgentName 'Qoder CLI' }\n        'amp'      { Update-AgentFile -TargetFile $AMP_FILE      -AgentName 'Amp' }\n        'shai'     { Update-AgentFile -TargetFile $SHAI_FILE     -AgentName 'SHAI' }\n        'tabnine'  { Update-AgentFile -TargetFile $TABNINE_FILE  -AgentName 'Tabnine CLI' }\n        'kiro-cli' { Update-AgentFile -TargetFile $KIRO_FILE     -AgentName 'Kiro CLI' }\n        'agy'      { Update-AgentFile -TargetFile $AGY_FILE      -AgentName 'Antigravity' }\n        'bob'      { Update-AgentFile -TargetFile $BOB_FILE      -AgentName 'IBM Bob' }\n        'vibe'     { Update-AgentFile -TargetFile $VIBE_FILE     -AgentName 'Mistral Vibe' }\n        'kimi'     { Update-AgentFile -TargetFile $KIMI_FILE     -AgentName 'Kimi Code' }\n        'trae'     { Update-AgentFile -TargetFile $TRAE_FILE     -AgentName 'Trae' }\n        'pi'       { Update-AgentFile -TargetFile $AGENTS_FILE   -AgentName 'Pi Coding Agent' }\n        'iflow'    { Update-AgentFile -TargetFile $IFLOW_FILE    -AgentName 'iFlow CLI' }\n        'generic'  { Write-Info 'Generic agent: no predefined context file. Use the agent-specific update script for your agent.' }\n        default { Write-Err \"Unknown agent type '$Type'\"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic'; return $false }\n    }\n}\n\nfunction Update-AllExistingAgents {\n    $found = $false\n    $ok = $true\n    if (Test-Path $CLAUDE_FILE)   { if (-not (Update-AgentFile -TargetFile $CLAUDE_FILE   -AgentName 'Claude Code')) { $ok = $false }; $found = $true }\n    if (Test-Path $GEMINI_FILE)   { if (-not (Update-AgentFile -TargetFile $GEMINI_FILE   -AgentName 'Gemini CLI')) { $ok = $false }; $found = $true }\n    if (Test-Path $COPILOT_FILE)  { if (-not (Update-AgentFile -TargetFile $COPILOT_FILE  -AgentName 'GitHub Copilot')) { $ok = $false }; $found = $true }\n    if (Test-Path $CURSOR_FILE)   { if (-not (Update-AgentFile -TargetFile $CURSOR_FILE   -AgentName 'Cursor IDE')) { $ok = $false }; $found = $true }\n    if (Test-Path $QWEN_FILE)     { if (-not (Update-AgentFile -TargetFile $QWEN_FILE     -AgentName 'Qwen Code')) { $ok = $false }; $found = $true }\n    if (Test-Path $AGENTS_FILE)   { if (-not (Update-AgentFile -TargetFile $AGENTS_FILE   -AgentName 'Codex/opencode')) { $ok = $false }; $found = $true }\n    if (Test-Path $WINDSURF_FILE) { if (-not (Update-AgentFile -TargetFile $WINDSURF_FILE -AgentName 'Windsurf')) { $ok = $false }; $found = $true }\n    if (Test-Path $JUNIE_FILE)    { if (-not (Update-AgentFile -TargetFile $JUNIE_FILE    -AgentName 'Junie')) { $ok = $false }; $found = $true }\n    if (Test-Path $KILOCODE_FILE) { if (-not (Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code')) { $ok = $false }; $found = $true }\n    if (Test-Path $AUGGIE_FILE)   { if (-not (Update-AgentFile -TargetFile $AUGGIE_FILE   -AgentName 'Auggie CLI')) { $ok = $false }; $found = $true }\n    if (Test-Path $ROO_FILE)      { if (-not (Update-AgentFile -TargetFile $ROO_FILE      -AgentName 'Roo Code')) { $ok = $false }; $found = $true }\n    if (Test-Path $CODEBUDDY_FILE) { if (-not (Update-AgentFile -TargetFile $CODEBUDDY_FILE -AgentName 'CodeBuddy CLI')) { $ok = $false }; $found = $true }\n    if (Test-Path $QODER_FILE)    { if (-not (Update-AgentFile -TargetFile $QODER_FILE    -AgentName 'Qoder CLI')) { $ok = $false }; $found = $true }\n    if (Test-Path $SHAI_FILE)     { if (-not (Update-AgentFile -TargetFile $SHAI_FILE     -AgentName 'SHAI')) { $ok = $false }; $found = $true }\n    if (Test-Path $TABNINE_FILE)  { if (-not (Update-AgentFile -TargetFile $TABNINE_FILE  -AgentName 'Tabnine CLI')) { $ok = $false }; $found = $true }\n    if (Test-Path $KIRO_FILE)     { if (-not (Update-AgentFile -TargetFile $KIRO_FILE     -AgentName 'Kiro CLI')) { $ok = $false }; $found = $true }\n    if (Test-Path $AGY_FILE)      { if (-not (Update-AgentFile -TargetFile $AGY_FILE      -AgentName 'Antigravity')) { $ok = $false }; $found = $true }\n    if (Test-Path $BOB_FILE)      { if (-not (Update-AgentFile -TargetFile $BOB_FILE      -AgentName 'IBM Bob')) { $ok = $false }; $found = $true }\n    if (Test-Path $VIBE_FILE)     { if (-not (Update-AgentFile -TargetFile $VIBE_FILE     -AgentName 'Mistral Vibe')) { $ok = $false }; $found = $true }\n    if (Test-Path $KIMI_FILE)     { if (-not (Update-AgentFile -TargetFile $KIMI_FILE     -AgentName 'Kimi Code')) { $ok = $false }; $found = $true }\n    if (Test-Path $TRAE_FILE)     { if (-not (Update-AgentFile -TargetFile $TRAE_FILE     -AgentName 'Trae')) { $ok = $false }; $found = $true }\n    if (Test-Path $IFLOW_FILE)    { if (-not (Update-AgentFile -TargetFile $IFLOW_FILE    -AgentName 'iFlow CLI')) { $ok = $false }; $found = $true }\n    if (-not $found) {\n        Write-Info 'No existing agent files found, creating default Claude file...'\n        if (-not (Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false }\n    }\n    return $ok\n}\n\nfunction Print-Summary {\n    Write-Host ''\n    Write-Info 'Summary of changes:'\n    if ($NEW_LANG) { Write-Host \"  - Added language: $NEW_LANG\" }\n    if ($NEW_FRAMEWORK) { Write-Host \"  - Added framework: $NEW_FRAMEWORK\" }\n    if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Host \"  - Added database: $NEW_DB\" }\n    Write-Host ''\n    Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic]'\n}\n\nfunction Main {\n    Validate-Environment\n    Write-Info \"=== Updating agent context files for feature $CURRENT_BRANCH ===\"\n    if (-not (Parse-PlanData -PlanFile $NEW_PLAN)) { Write-Err 'Failed to parse plan data'; exit 1 }\n    $success = $true\n    if ($AgentType) {\n        Write-Info \"Updating specific agent: $AgentType\"\n        if (-not (Update-SpecificAgent -Type $AgentType)) { $success = $false }\n    }\n    else {\n        Write-Info 'No agent specified, updating all existing agent files...'\n        if (-not (Update-AllExistingAgents)) { $success = $false }\n    }\n    Print-Summary\n    if ($success) { Write-Success 'Agent context update completed successfully'; exit 0 } else { Write-Err 'Agent context update completed with errors'; exit 1 }\n}\n\nMain\n"
  },
  {
    "path": "spec-driven.md",
    "content": "# Specification-Driven Development (SDD)\n\n## The Power Inversion\n\nFor decades, code has been king. Specifications served code—they were the scaffolding we built and then discarded once the \"real work\" of coding began. We wrote PRDs to guide development, created design docs to inform implementation, drew diagrams to visualize architecture. But these were always subordinate to the code itself. Code was truth. Everything else was, at best, good intentions. Code was the source of truth, and as it moved forward, specs rarely kept pace. As the asset (code) and the implementation are one, it's not easy to have a parallel implementation without trying to build from the code.\n\nSpec-Driven Development (SDD) inverts this power structure. Specifications don't serve code—code serves specifications. The Product Requirements Document (PRD) isn't a guide for implementation; it's the source that generates implementation. Technical plans aren't documents that inform coding; they're precise definitions that produce code. This isn't an incremental improvement to how we build software. It's a fundamental rethinking of what drives development.\n\nThe gap between specification and implementation has plagued software development since its inception. We've tried to bridge it with better documentation, more detailed requirements, stricter processes. These approaches fail because they accept the gap as inevitable. They try to narrow it but never eliminate it. SDD eliminates the gap by making specifications and their concrete implementation plans born from the specification executable. When specifications and implementation plans generate code, there is no gap—only transformation.\n\nThis transformation is now possible because AI can understand and implement complex specifications, and create detailed implementation plans. But raw AI generation without structure produces chaos. SDD provides that structure through specifications and subsequent implementation plans that are precise, complete, and unambiguous enough to generate working systems. The specification becomes the primary artifact. Code becomes its expression (as an implementation from the implementation plan) in a particular language and framework.\n\nIn this new world, maintaining software means evolving specifications. The intent of the development team is expressed in natural language (\"**intent-driven development**\"), design assets, core principles and other guidelines. The **lingua franca** of development moves to a higher level, and code is the last-mile approach.\n\nDebugging means fixing specifications and their implementation plans that generate incorrect code. Refactoring means restructuring for clarity. The entire development workflow reorganizes around specifications as the central source of truth, with implementation plans and code as the continuously regenerated output. Updating apps with new features or creating a new parallel implementation because we are creative beings, means revisiting the specification and creating new implementation plans. This process is therefore a 0 -> 1, (1', ..), 2, 3, N.\n\nThe development team focuses in on their creativity, experimentation, their critical thinking.\n\n## The SDD Workflow in Practice\n\nThe workflow begins with an idea—often vague and incomplete. Through iterative dialogue with AI, this idea becomes a comprehensive PRD. The AI asks clarifying questions, identifies edge cases, and helps define precise acceptance criteria. What might take days of meetings and documentation in traditional development happens in hours of focused specification work. This transforms the traditional SDLC—requirements and design become continuous activities rather than discrete phases. This is supportive of a **team process**, where team-reviewed specifications are expressed and versioned, created in branches, and merged.\n\nWhen a product manager updates acceptance criteria, implementation plans automatically flag affected technical decisions. When an architect discovers a better pattern, the PRD updates to reflect new possibilities.\n\nThroughout this specification process, research agents gather critical context. They investigate library compatibility, performance benchmarks, and security implications. Organizational constraints are discovered and applied automatically—your company's database standards, authentication requirements, and deployment policies seamlessly integrate into every specification.\n\nFrom the PRD, AI generates implementation plans that map requirements to technical decisions. Every technology choice has documented rationale. Every architectural decision traces back to specific requirements. Throughout this process, consistency validation continuously improves quality. AI analyzes specifications for ambiguity, contradictions, and gaps—not as a one-time gate, but as an ongoing refinement.\n\nCode generation begins as soon as specifications and their implementation plans are stable enough, but they do not have to be \"complete.\" Early generations might be exploratory—testing whether the specification makes sense in practice. Domain concepts become data models. User stories become API endpoints. Acceptance scenarios become tests. This merges development and testing through specification—test scenarios aren't written after code, they're part of the specification that generates both implementation and tests.\n\nThe feedback loop extends beyond initial development. Production metrics and incidents don't just trigger hotfixes—they update specifications for the next regeneration. Performance bottlenecks become new non-functional requirements. Security vulnerabilities become constraints that affect all future generations. This iterative dance between specification, implementation, and operational reality is where true understanding emerges and where the traditional SDLC transforms into a continuous evolution.\n\n## Why SDD Matters Now\n\nThree trends make SDD not just possible but necessary:\n\nFirst, AI capabilities have reached a threshold where natural language specifications can reliably generate working code. This isn't about replacing developers—it's about amplifying their effectiveness by automating the mechanical translation from specification to implementation. It can amplify exploration and creativity, support \"start-over\" easily, and support addition, subtraction, and critical thinking.\n\nSecond, software complexity continues to grow exponentially. Modern systems integrate dozens of services, frameworks, and dependencies. Keeping all these pieces aligned with original intent through manual processes becomes increasingly difficult. SDD provides systematic alignment through specification-driven generation. Frameworks may evolve to provide AI-first support, not human-first support, or architect around reusable components.\n\nThird, the pace of change accelerates. Requirements change far more rapidly today than ever before. Pivoting is no longer exceptional—it's expected. Modern product development demands rapid iteration based on user feedback, market conditions, and competitive pressures. Traditional development treats these changes as disruptions. Each pivot requires manually propagating changes through documentation, design, and code. The result is either slow, careful updates that limit velocity, or fast, reckless changes that accumulate technical debt.\n\nSDD can support what-if/simulation experiments: \"If we need to re-implement or change the application to promote a business need to sell more T-shirts, how would we implement and experiment for that?\"\n\nSDD transforms requirement changes from obstacles into normal workflow. When specifications drive implementation, pivots become systematic regenerations rather than manual rewrites. Change a core requirement in the PRD, and affected implementation plans update automatically. Modify a user story, and corresponding API endpoints regenerate. This isn't just about initial development—it's about maintaining engineering velocity through inevitable changes.\n\n## Core Principles\n\n**Specifications as the Lingua Franca**: The specification becomes the primary artifact. Code becomes its expression in a particular language and framework. Maintaining software means evolving specifications.\n\n**Executable Specifications**: Specifications must be precise, complete, and unambiguous enough to generate working systems. This eliminates the gap between intent and implementation.\n\n**Continuous Refinement**: Consistency validation happens continuously, not as a one-time gate. AI analyzes specifications for ambiguity, contradictions, and gaps as an ongoing process.\n\n**Research-Driven Context**: Research agents gather critical context throughout the specification process, investigating technical options, performance implications, and organizational constraints.\n\n**Bidirectional Feedback**: Production reality informs specification evolution. Metrics, incidents, and operational learnings become inputs for specification refinement.\n\n**Branching for Exploration**: Generate multiple implementation approaches from the same specification to explore different optimization targets—performance, maintainability, user experience, cost.\n\n## Implementation Approaches\n\nToday, practicing SDD requires assembling existing tools and maintaining discipline throughout the process. The methodology can be practiced with:\n\n- AI assistants for iterative specification development\n- Research agents for gathering technical context\n- Code generation tools for translating specifications to implementation\n- Version control systems adapted for specification-first workflows\n- Consistency checking through AI analysis of specification documents\n\nThe key is treating specifications as the source of truth, with code as the generated output that serves the specification rather than the other way around.\n\n## Streamlining SDD with Commands\n\nThe SDD methodology is significantly enhanced through three powerful commands that automate the specification → planning → tasking workflow:\n\n### The `/speckit.specify` Command\n\nThis command transforms a simple feature description (the user-prompt) into a complete, structured specification with automatic repository management:\n\n1. **Automatic Feature Numbering**: Scans existing specs to determine the next feature number (e.g., 001, 002, 003)\n2. **Branch Creation**: Generates a semantic branch name from your description and creates it automatically\n3. **Template-Based Generation**: Copies and customizes the feature specification template with your requirements\n4. **Directory Structure**: Creates the proper `specs/[branch-name]/` structure for all related documents\n\n### The `/speckit.plan` Command\n\nOnce a feature specification exists, this command creates a comprehensive implementation plan:\n\n1. **Specification Analysis**: Reads and understands the feature requirements, user stories, and acceptance criteria\n2. **Constitutional Compliance**: Ensures alignment with project constitution and architectural principles\n3. **Technical Translation**: Converts business requirements into technical architecture and implementation details\n4. **Detailed Documentation**: Generates supporting documents for data models, API contracts, and test scenarios\n5. **Quickstart Validation**: Produces a quickstart guide capturing key validation scenarios\n\n### The `/speckit.tasks` Command\n\nAfter a plan is created, this command analyzes the plan and related design documents to generate an executable task list:\n\n1. **Inputs**: Reads `plan.md` (required) and, if present, `data-model.md`, `contracts/`, and `research.md`\n2. **Task Derivation**: Converts contracts, entities, and scenarios into specific tasks\n3. **Parallelization**: Marks independent tasks `[P]` and outlines safe parallel groups\n4. **Output**: Writes `tasks.md` in the feature directory, ready for execution by a Task agent\n\n### Example: Building a Chat Feature\n\nHere's how these commands transform the traditional development workflow:\n\n**Traditional Approach:**\n\n```text\n1. Write a PRD in a document (2-3 hours)\n2. Create design documents (2-3 hours)\n3. Set up project structure manually (30 minutes)\n4. Write technical specifications (3-4 hours)\n5. Create test plans (2 hours)\nTotal: ~12 hours of documentation work\n```\n\n**SDD with Commands Approach:**\n\n```bash\n# Step 1: Create the feature specification (5 minutes)\n/speckit.specify Real-time chat system with message history and user presence\n\n# This automatically:\n# - Creates branch \"003-chat-system\"\n# - Generates specs/003-chat-system/spec.md\n# - Populates it with structured requirements\n\n# Step 2: Generate implementation plan (5 minutes)\n/speckit.plan WebSocket for real-time messaging, PostgreSQL for history, Redis for presence\n\n# Step 3: Generate executable tasks (5 minutes)\n/speckit.tasks\n\n# This automatically creates:\n# - specs/003-chat-system/plan.md\n# - specs/003-chat-system/research.md (WebSocket library comparisons)\n# - specs/003-chat-system/data-model.md (Message and User schemas)\n# - specs/003-chat-system/contracts/ (WebSocket events, REST endpoints)\n# - specs/003-chat-system/quickstart.md (Key validation scenarios)\n# - specs/003-chat-system/tasks.md (Task list derived from the plan)\n```\n\nIn 15 minutes, you have:\n\n- A complete feature specification with user stories and acceptance criteria\n- A detailed implementation plan with technology choices and rationale\n- API contracts and data models ready for code generation\n- Comprehensive test scenarios for both automated and manual testing\n- All documents properly versioned in a feature branch\n\n### The Power of Structured Automation\n\nThese commands don't just save time—they enforce consistency and completeness:\n\n1. **No Forgotten Details**: Templates ensure every aspect is considered, from non-functional requirements to error handling\n2. **Traceable Decisions**: Every technical choice links back to specific requirements\n3. **Living Documentation**: Specifications stay in sync with code because they generate it\n4. **Rapid Iteration**: Change requirements and regenerate plans in minutes, not days\n\nThe commands embody SDD principles by treating specifications as executable artifacts rather than static documents. They transform the specification process from a necessary evil into the driving force of development.\n\n### Template-Driven Quality: How Structure Constrains LLMs for Better Outcomes\n\nThe true power of these commands lies not just in automation, but in how the templates guide LLM behavior toward higher-quality specifications. The templates act as sophisticated prompts that constrain the LLM's output in productive ways:\n\n#### 1. **Preventing Premature Implementation Details**\n\nThe feature specification template explicitly instructs:\n\n```text\n- ✅ Focus on WHAT users need and WHY\n- ❌ Avoid HOW to implement (no tech stack, APIs, code structure)\n```\n\nThis constraint forces the LLM to maintain proper abstraction levels. When an LLM might naturally jump to \"implement using React with Redux,\" the template keeps it focused on \"users need real-time updates of their data.\" This separation ensures specifications remain stable even as implementation technologies change.\n\n#### 2. **Forcing Explicit Uncertainty Markers**\n\nBoth templates mandate the use of `[NEEDS CLARIFICATION]` markers:\n\n```text\nWhen creating this spec from a user prompt:\n1. **Mark all ambiguities**: Use [NEEDS CLARIFICATION: specific question]\n2. **Don't guess**: If the prompt doesn't specify something, mark it\n```\n\nThis prevents the common LLM behavior of making plausible but potentially incorrect assumptions. Instead of guessing that a \"login system\" uses email/password authentication, the LLM must mark it as `[NEEDS CLARIFICATION: auth method not specified - email/password, SSO, OAuth?]`.\n\n#### 3. **Structured Thinking Through Checklists**\n\nThe templates include comprehensive checklists that act as \"unit tests\" for the specification:\n\n```markdown\n### Requirement Completeness\n\n- [ ] No [NEEDS CLARIFICATION] markers remain\n- [ ] Requirements are testable and unambiguous\n- [ ] Success criteria are measurable\n```\n\nThese checklists force the LLM to self-review its output systematically, catching gaps that might otherwise slip through. It's like giving the LLM a quality assurance framework.\n\n#### 4. **Constitutional Compliance Through Gates**\n\nThe implementation plan template enforces architectural principles through phase gates:\n\n```markdown\n### Phase -1: Pre-Implementation Gates\n\n#### Simplicity Gate (Article VII)\n\n- [ ] Using ≤3 projects?\n- [ ] No future-proofing?\n\n#### Anti-Abstraction Gate (Article VIII)\n\n- [ ] Using framework directly?\n- [ ] Single model representation?\n```\n\nThese gates prevent over-engineering by making the LLM explicitly justify any complexity. If a gate fails, the LLM must document why in the \"Complexity Tracking\" section, creating accountability for architectural decisions.\n\n#### 5. **Hierarchical Detail Management**\n\nThe templates enforce proper information architecture:\n\n```text\n**IMPORTANT**: This implementation plan should remain high-level and readable.\nAny code samples, detailed algorithms, or extensive technical specifications\nmust be placed in the appropriate `implementation-details/` file\n```\n\nThis prevents the common problem of specifications becoming unreadable code dumps. The LLM learns to maintain appropriate detail levels, extracting complexity to separate files while keeping the main document navigable.\n\n#### 6. **Test-First Thinking**\n\nThe implementation template enforces test-first development:\n\n```text\n### File Creation Order\n1. Create `contracts/` with API specifications\n2. Create test files in order: contract → integration → e2e → unit\n3. Create source files to make tests pass\n```\n\nThis ordering constraint ensures the LLM thinks about testability and contracts before implementation, leading to more robust and verifiable specifications.\n\n#### 7. **Preventing Speculative Features**\n\nTemplates explicitly discourage speculation:\n\n```text\n- [ ] No speculative or \"might need\" features\n- [ ] All phases have clear prerequisites and deliverables\n```\n\nThis stops the LLM from adding \"nice to have\" features that complicate implementation. Every feature must trace back to a concrete user story with clear acceptance criteria.\n\n### The Compound Effect\n\nThese constraints work together to produce specifications that are:\n\n- **Complete**: Checklists ensure nothing is forgotten\n- **Unambiguous**: Forced clarification markers highlight uncertainties\n- **Testable**: Test-first thinking baked into the process\n- **Maintainable**: Proper abstraction levels and information hierarchy\n- **Implementable**: Clear phases with concrete deliverables\n\nThe templates transform the LLM from a creative writer into a disciplined specification engineer, channeling its capabilities toward producing consistently high-quality, executable specifications that truly drive development.\n\n## The Constitutional Foundation: Enforcing Architectural Discipline\n\nAt the heart of SDD lies a constitution—a set of immutable principles that govern how specifications become code. The constitution (`memory/constitution.md`) acts as the architectural DNA of the system, ensuring that every generated implementation maintains consistency, simplicity, and quality.\n\n### The Nine Articles of Development\n\nThe constitution defines nine articles that shape every aspect of the development process:\n\n#### Article I: Library-First Principle\n\nEvery feature must begin as a standalone library—no exceptions. This forces modular design from the start:\n\n```text\nEvery feature in Specify MUST begin its existence as a standalone library.\nNo feature shall be implemented directly within application code without\nfirst being abstracted into a reusable library component.\n```\n\nThis principle ensures that specifications generate modular, reusable code rather than monolithic applications. When the LLM generates an implementation plan, it must structure features as libraries with clear boundaries and minimal dependencies.\n\n#### Article II: CLI Interface Mandate\n\nEvery library must expose its functionality through a command-line interface:\n\n```text\nAll CLI interfaces MUST:\n- Accept text as input (via stdin, arguments, or files)\n- Produce text as output (via stdout)\n- Support JSON format for structured data exchange\n```\n\nThis enforces observability and testability. The LLM cannot hide functionality inside opaque classes—everything must be accessible and verifiable through text-based interfaces.\n\n#### Article III: Test-First Imperative\n\nThe most transformative article—no code before tests:\n\n```text\nThis is NON-NEGOTIABLE: All implementation MUST follow strict Test-Driven Development.\nNo implementation code shall be written before:\n1. Unit tests are written\n2. Tests are validated and approved by the user\n3. Tests are confirmed to FAIL (Red phase)\n```\n\nThis completely inverts traditional AI code generation. Instead of generating code and hoping it works, the LLM must first generate comprehensive tests that define behavior, get them approved, and only then generate implementation.\n\n#### Articles VII & VIII: Simplicity and Anti-Abstraction\n\nThese paired articles combat over-engineering:\n\n```text\nSection 7.3: Minimal Project Structure\n- Maximum 3 projects for initial implementation\n- Additional projects require documented justification\n\nSection 8.1: Framework Trust\n- Use framework features directly rather than wrapping them\n```\n\nWhen an LLM might naturally create elaborate abstractions, these articles force it to justify every layer of complexity. The implementation plan template's \"Phase -1 Gates\" directly enforce these principles.\n\n#### Article IX: Integration-First Testing\n\nPrioritizes real-world testing over isolated unit tests:\n\n```text\nTests MUST use realistic environments:\n- Prefer real databases over mocks\n- Use actual service instances over stubs\n- Contract tests mandatory before implementation\n```\n\nThis ensures generated code works in practice, not just in theory.\n\n### Constitutional Enforcement Through Templates\n\nThe implementation plan template operationalizes these articles through concrete checkpoints:\n\n```markdown\n### Phase -1: Pre-Implementation Gates\n\n#### Simplicity Gate (Article VII)\n\n- [ ] Using ≤3 projects?\n- [ ] No future-proofing?\n\n#### Anti-Abstraction Gate (Article VIII)\n\n- [ ] Using framework directly?\n- [ ] Single model representation?\n\n#### Integration-First Gate (Article IX)\n\n- [ ] Contracts defined?\n- [ ] Contract tests written?\n```\n\nThese gates act as compile-time checks for architectural principles. The LLM cannot proceed without either passing the gates or documenting justified exceptions in the \"Complexity Tracking\" section.\n\n### The Power of Immutable Principles\n\nThe constitution's power lies in its immutability. While implementation details can evolve, the core principles remain constant. This provides:\n\n1. **Consistency Across Time**: Code generated today follows the same principles as code generated next year\n2. **Consistency Across LLMs**: Different AI models produce architecturally compatible code\n3. **Architectural Integrity**: Every feature reinforces rather than undermines the system design\n4. **Quality Guarantees**: Test-first, library-first, and simplicity principles ensure maintainable code\n\n### Constitutional Evolution\n\nWhile principles are immutable, their application can evolve:\n\n```text\nSection 4.2: Amendment Process\nModifications to this constitution require:\n- Explicit documentation of the rationale for change\n- Review and approval by project maintainers\n- Backwards compatibility assessment\n```\n\nThis allows the methodology to learn and improve while maintaining stability. The constitution shows its own evolution with dated amendments, demonstrating how principles can be refined based on real-world experience.\n\n### Beyond Rules: A Development Philosophy\n\nThe constitution isn't just a rulebook—it's a philosophy that shapes how LLMs think about code generation:\n\n- **Observability Over Opacity**: Everything must be inspectable through CLI interfaces\n- **Simplicity Over Cleverness**: Start simple, add complexity only when proven necessary\n- **Integration Over Isolation**: Test in real environments, not artificial ones\n- **Modularity Over Monoliths**: Every feature is a library with clear boundaries\n\nBy embedding these principles into the specification and planning process, SDD ensures that generated code isn't just functional—it's maintainable, testable, and architecturally sound. The constitution transforms AI from a code generator into an architectural partner that respects and reinforces system design principles.\n\n## The Transformation\n\nThis isn't about replacing developers or automating creativity. It's about amplifying human capability by automating mechanical translation. It's about creating a tight feedback loop where specifications, research, and code evolve together, each iteration bringing deeper understanding and better alignment between intent and implementation.\n\nSoftware development needs better tools for maintaining alignment between intent and implementation. SDD provides the methodology for achieving this alignment through executable specifications that generate code rather than merely guiding it.\n"
  },
  {
    "path": "spec-kit.code-workspace",
    "content": "{\n\t\"folders\": [\n\t\t{\n\t\t\t\"path\": \".\"\n\t\t}\n\t],\n\t\"settings\": {}\n}"
  },
  {
    "path": "src/specify_cli/__init__.py",
    "content": "#!/usr/bin/env python3\n# /// script\n# requires-python = \">=3.11\"\n# dependencies = [\n#     \"typer\",\n#     \"rich\",\n#     \"platformdirs\",\n#     \"readchar\",\n#     \"httpx\",\n#     \"json5\",\n# ]\n# ///\n\"\"\"\nSpecify CLI - Setup tool for Specify projects\n\nUsage:\n    uvx specify-cli.py init <project-name>\n    uvx specify-cli.py init .\n    uvx specify-cli.py init --here\n\nOr install globally:\n    uv tool install --from specify-cli.py specify-cli\n    specify init <project-name>\n    specify init .\n    specify init --here\n\"\"\"\n\nimport os\nimport subprocess\nimport sys\nimport zipfile\nimport tempfile\nimport shutil\nimport json\nimport json5\nimport stat\nimport yaml\nfrom pathlib import Path\nfrom typing import Any, Optional, Tuple\n\nimport typer\nimport httpx\nfrom rich.console import Console\nfrom rich.panel import Panel\nfrom rich.progress import Progress, SpinnerColumn, TextColumn\nfrom rich.text import Text\nfrom rich.live import Live\nfrom rich.align import Align\nfrom rich.table import Table\nfrom rich.tree import Tree\nfrom typer.core import TyperGroup\n\n# For cross-platform keyboard input\nimport readchar\nimport ssl\nimport truststore\nfrom datetime import datetime, timezone\n\nssl_context = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)\nclient = httpx.Client(verify=ssl_context)\n\ndef _github_token(cli_token: str | None = None) -> str | None:\n    \"\"\"Return sanitized GitHub token (cli arg takes precedence) or None.\"\"\"\n    return ((cli_token or os.getenv(\"GH_TOKEN\") or os.getenv(\"GITHUB_TOKEN\") or \"\").strip()) or None\n\ndef _github_auth_headers(cli_token: str | None = None) -> dict:\n    \"\"\"Return Authorization header dict only when a non-empty token exists.\"\"\"\n    token = _github_token(cli_token)\n    return {\"Authorization\": f\"Bearer {token}\"} if token else {}\n\ndef _parse_rate_limit_headers(headers: httpx.Headers) -> dict:\n    \"\"\"Extract and parse GitHub rate-limit headers.\"\"\"\n    info = {}\n    \n    # Standard GitHub rate-limit headers\n    if \"X-RateLimit-Limit\" in headers:\n        info[\"limit\"] = headers.get(\"X-RateLimit-Limit\")\n    if \"X-RateLimit-Remaining\" in headers:\n        info[\"remaining\"] = headers.get(\"X-RateLimit-Remaining\")\n    if \"X-RateLimit-Reset\" in headers:\n        reset_epoch = int(headers.get(\"X-RateLimit-Reset\", \"0\"))\n        if reset_epoch:\n            reset_time = datetime.fromtimestamp(reset_epoch, tz=timezone.utc)\n            info[\"reset_epoch\"] = reset_epoch\n            info[\"reset_time\"] = reset_time\n            info[\"reset_local\"] = reset_time.astimezone()\n    \n    # Retry-After header (seconds or HTTP-date)\n    if \"Retry-After\" in headers:\n        retry_after = headers.get(\"Retry-After\")\n        try:\n            info[\"retry_after_seconds\"] = int(retry_after)\n        except ValueError:\n            # HTTP-date format - not implemented, just store as string\n            info[\"retry_after\"] = retry_after\n    \n    return info\n\ndef _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) -> str:\n    \"\"\"Format a user-friendly error message with rate-limit information.\"\"\"\n    rate_info = _parse_rate_limit_headers(headers)\n    \n    lines = [f\"GitHub API returned status {status_code} for {url}\"]\n    lines.append(\"\")\n    \n    if rate_info:\n        lines.append(\"[bold]Rate Limit Information:[/bold]\")\n        if \"limit\" in rate_info:\n            lines.append(f\"  • Rate Limit: {rate_info['limit']} requests/hour\")\n        if \"remaining\" in rate_info:\n            lines.append(f\"  • Remaining: {rate_info['remaining']}\")\n        if \"reset_local\" in rate_info:\n            reset_str = rate_info[\"reset_local\"].strftime(\"%Y-%m-%d %H:%M:%S %Z\")\n            lines.append(f\"  • Resets at: {reset_str}\")\n        if \"retry_after_seconds\" in rate_info:\n            lines.append(f\"  • Retry after: {rate_info['retry_after_seconds']} seconds\")\n        lines.append(\"\")\n    \n    # Add troubleshooting guidance\n    lines.append(\"[bold]Troubleshooting Tips:[/bold]\")\n    lines.append(\"  • If you're on a shared CI or corporate environment, you may be rate-limited.\")\n    lines.append(\"  • Consider using a GitHub token via --github-token or the GH_TOKEN/GITHUB_TOKEN\")\n    lines.append(\"    environment variable to increase rate limits.\")\n    lines.append(\"  • Authenticated requests have a limit of 5,000/hour vs 60/hour for unauthenticated.\")\n    \n    return \"\\n\".join(lines)\n\n# Agent configuration with name, folder, install URL, CLI tool requirement, and commands subdirectory\nAGENT_CONFIG = {\n    \"copilot\": {\n        \"name\": \"GitHub Copilot\",\n        \"folder\": \".github/\",\n        \"commands_subdir\": \"agents\",  # Special: uses agents/ not commands/\n        \"install_url\": None,  # IDE-based, no CLI check needed\n        \"requires_cli\": False,\n    },\n    \"claude\": {\n        \"name\": \"Claude Code\",\n        \"folder\": \".claude/\",\n        \"commands_subdir\": \"commands\",\n        \"install_url\": \"https://docs.anthropic.com/en/docs/claude-code/setup\",\n        \"requires_cli\": True,\n    },\n    \"gemini\": {\n        \"name\": \"Gemini CLI\",\n        \"folder\": \".gemini/\",\n        \"commands_subdir\": \"commands\",\n        \"install_url\": \"https://github.com/google-gemini/gemini-cli\",\n        \"requires_cli\": True,\n    },\n    \"cursor-agent\": {\n        \"name\": \"Cursor\",\n        \"folder\": \".cursor/\",\n        \"commands_subdir\": \"commands\",\n        \"install_url\": None,  # IDE-based\n        \"requires_cli\": False,\n    },\n    \"qwen\": {\n        \"name\": \"Qwen Code\",\n        \"folder\": \".qwen/\",\n        \"commands_subdir\": \"commands\",\n        \"install_url\": \"https://github.com/QwenLM/qwen-code\",\n        \"requires_cli\": True,\n    },\n    \"opencode\": {\n        \"name\": \"opencode\",\n        \"folder\": \".opencode/\",\n        \"commands_subdir\": \"command\",  # Special: singular 'command' not 'commands'\n        \"install_url\": \"https://opencode.ai\",\n        \"requires_cli\": True,\n    },\n    \"codex\": {\n        \"name\": \"Codex CLI\",\n        \"folder\": \".agents/\",\n        \"commands_subdir\": \"skills\",  # Codex now uses project skills directly\n        \"install_url\": \"https://github.com/openai/codex\",\n        \"requires_cli\": True,\n    },\n    \"windsurf\": {\n        \"name\": \"Windsurf\",\n        \"folder\": \".windsurf/\",\n        \"commands_subdir\": \"workflows\",  # Special: uses workflows/ not commands/\n        \"install_url\": None,  # IDE-based\n        \"requires_cli\": False,\n    },\n    \"junie\": {\n        \"name\": \"Junie\",\n        \"folder\": \".junie/\",\n        \"commands_subdir\": \"commands\",\n        \"install_url\": \"https://junie.jetbrains.com/\",\n        \"requires_cli\": True,\n    },\n    \"kilocode\": {\n        \"name\": \"Kilo Code\",\n        \"folder\": \".kilocode/\",\n        \"commands_subdir\": \"workflows\",  # Special: uses workflows/ not commands/\n        \"install_url\": None,  # IDE-based\n        \"requires_cli\": False,\n    },\n    \"auggie\": {\n        \"name\": \"Auggie CLI\",\n        \"folder\": \".augment/\",\n        \"commands_subdir\": \"commands\",\n        \"install_url\": \"https://docs.augmentcode.com/cli/setup-auggie/install-auggie-cli\",\n        \"requires_cli\": True,\n    },\n    \"codebuddy\": {\n        \"name\": \"CodeBuddy\",\n        \"folder\": \".codebuddy/\",\n        \"commands_subdir\": \"commands\",\n        \"install_url\": \"https://www.codebuddy.ai/cli\",\n        \"requires_cli\": True,\n    },\n    \"qodercli\": {\n        \"name\": \"Qoder CLI\",\n        \"folder\": \".qoder/\",\n        \"commands_subdir\": \"commands\",\n        \"install_url\": \"https://qoder.com/cli\",\n        \"requires_cli\": True,\n    },\n    \"roo\": {\n        \"name\": \"Roo Code\",\n        \"folder\": \".roo/\",\n        \"commands_subdir\": \"commands\",\n        \"install_url\": None,  # IDE-based\n        \"requires_cli\": False,\n    },\n    \"kiro-cli\": {\n        \"name\": \"Kiro CLI\",\n        \"folder\": \".kiro/\",\n        \"commands_subdir\": \"prompts\",  # Special: uses prompts/ not commands/\n        \"install_url\": \"https://kiro.dev/docs/cli/\",\n        \"requires_cli\": True,\n    },\n    \"amp\": {\n        \"name\": \"Amp\",\n        \"folder\": \".agents/\",\n        \"commands_subdir\": \"commands\",\n        \"install_url\": \"https://ampcode.com/manual#install\",\n        \"requires_cli\": True,\n    },\n    \"shai\": {\n        \"name\": \"SHAI\",\n        \"folder\": \".shai/\",\n        \"commands_subdir\": \"commands\",\n        \"install_url\": \"https://github.com/ovh/shai\",\n        \"requires_cli\": True,\n    },\n    \"tabnine\": {\n        \"name\": \"Tabnine CLI\",\n        \"folder\": \".tabnine/agent/\",\n        \"commands_subdir\": \"commands\",\n        \"install_url\": \"https://docs.tabnine.com/main/getting-started/tabnine-cli\",\n        \"requires_cli\": True,\n    },\n    \"agy\": {\n        \"name\": \"Antigravity\",\n        \"folder\": \".agent/\",\n        \"commands_subdir\": \"commands\",\n        \"install_url\": None,  # IDE-based\n        \"requires_cli\": False,\n    },\n    \"bob\": {\n        \"name\": \"IBM Bob\",\n        \"folder\": \".bob/\",\n        \"commands_subdir\": \"commands\",\n        \"install_url\": None,  # IDE-based\n        \"requires_cli\": False,\n    },\n    \"vibe\": {\n        \"name\": \"Mistral Vibe\",\n        \"folder\": \".vibe/\",\n        \"commands_subdir\": \"prompts\",\n        \"install_url\": \"https://github.com/mistralai/mistral-vibe\",\n        \"requires_cli\": True,\n    },\n    \"kimi\": {\n        \"name\": \"Kimi Code\",\n        \"folder\": \".kimi/\",\n        \"commands_subdir\": \"skills\",  # Kimi uses /skill:<name> with .kimi/skills/<name>/SKILL.md\n        \"install_url\": \"https://code.kimi.com/\",\n        \"requires_cli\": True,\n    },\n    \"trae\": {\n        \"name\": \"Trae\",\n        \"folder\": \".trae/\",\n        \"commands_subdir\": \"rules\",  # Trae uses .trae/rules/ for project rules\n        \"install_url\": None,  # IDE-based\n        \"requires_cli\": False,\n    },\n    \"pi\": {\n        \"name\": \"Pi Coding Agent\",\n        \"folder\": \".pi/\",\n        \"commands_subdir\": \"prompts\",\n        \"install_url\": \"https://www.npmjs.com/package/@mariozechner/pi-coding-agent\",\n        \"requires_cli\": True,\n    },\n    \"iflow\": {\n        \"name\": \"iFlow CLI\",\n        \"folder\": \".iflow/\",\n        \"commands_subdir\": \"commands\",\n        \"install_url\": \"https://docs.iflow.cn/en/cli/quickstart\",\n        \"requires_cli\": True,\n    },\n    \"generic\": {\n        \"name\": \"Generic (bring your own agent)\",\n        \"folder\": None,  # Set dynamically via --ai-commands-dir\n        \"commands_subdir\": \"commands\",\n        \"install_url\": None,\n        \"requires_cli\": False,\n    },\n}\n\nAI_ASSISTANT_ALIASES = {\n    \"kiro\": \"kiro-cli\",\n}\n\ndef _build_ai_assistant_help() -> str:\n    \"\"\"Build the --ai help text from AGENT_CONFIG so it stays in sync with runtime config.\"\"\"\n\n    non_generic_agents = sorted(agent for agent in AGENT_CONFIG if agent != \"generic\")\n    base_help = (\n        f\"AI assistant to use: {', '.join(non_generic_agents)}, \"\n        \"or generic (requires --ai-commands-dir).\"\n    )\n\n    if not AI_ASSISTANT_ALIASES:\n        return base_help\n\n    alias_phrases = []\n    for alias, target in sorted(AI_ASSISTANT_ALIASES.items()):\n        alias_phrases.append(f\"'{alias}' as an alias for '{target}'\")\n\n    if len(alias_phrases) == 1:\n        aliases_text = alias_phrases[0]\n    else:\n        aliases_text = ', '.join(alias_phrases[:-1]) + ' and ' + alias_phrases[-1]\n\n    return base_help + \" Use \" + aliases_text + \".\"\nAI_ASSISTANT_HELP = _build_ai_assistant_help()\n\nSCRIPT_TYPE_CHOICES = {\"sh\": \"POSIX Shell (bash/zsh)\", \"ps\": \"PowerShell\"}\n\nCLAUDE_LOCAL_PATH = Path.home() / \".claude\" / \"local\" / \"claude\"\n\nBANNER = \"\"\"\n███████╗██████╗ ███████╗ ██████╗██╗███████╗██╗   ██╗\n██╔════╝██╔══██╗██╔════╝██╔════╝██║██╔════╝╚██╗ ██╔╝\n███████╗██████╔╝█████╗  ██║     ██║█████╗   ╚████╔╝ \n╚════██║██╔═══╝ ██╔══╝  ██║     ██║██╔══╝    ╚██╔╝  \n███████║██║     ███████╗╚██████╗██║██║        ██║   \n╚══════╝╚═╝     ╚══════╝ ╚═════╝╚═╝╚═╝        ╚═╝   \n\"\"\"\n\nTAGLINE = \"GitHub Spec Kit - Spec-Driven Development Toolkit\"\nclass StepTracker:\n    \"\"\"Track and render hierarchical steps without emojis, similar to Claude Code tree output.\n    Supports live auto-refresh via an attached refresh callback.\n    \"\"\"\n    def __init__(self, title: str):\n        self.title = title\n        self.steps = []  # list of dicts: {key, label, status, detail}\n        self.status_order = {\"pending\": 0, \"running\": 1, \"done\": 2, \"error\": 3, \"skipped\": 4}\n        self._refresh_cb = None  # callable to trigger UI refresh\n\n    def attach_refresh(self, cb):\n        self._refresh_cb = cb\n\n    def add(self, key: str, label: str):\n        if key not in [s[\"key\"] for s in self.steps]:\n            self.steps.append({\"key\": key, \"label\": label, \"status\": \"pending\", \"detail\": \"\"})\n            self._maybe_refresh()\n\n    def start(self, key: str, detail: str = \"\"):\n        self._update(key, status=\"running\", detail=detail)\n\n    def complete(self, key: str, detail: str = \"\"):\n        self._update(key, status=\"done\", detail=detail)\n\n    def error(self, key: str, detail: str = \"\"):\n        self._update(key, status=\"error\", detail=detail)\n\n    def skip(self, key: str, detail: str = \"\"):\n        self._update(key, status=\"skipped\", detail=detail)\n\n    def _update(self, key: str, status: str, detail: str):\n        for s in self.steps:\n            if s[\"key\"] == key:\n                s[\"status\"] = status\n                if detail:\n                    s[\"detail\"] = detail\n                self._maybe_refresh()\n                return\n\n        self.steps.append({\"key\": key, \"label\": key, \"status\": status, \"detail\": detail})\n        self._maybe_refresh()\n\n    def _maybe_refresh(self):\n        if self._refresh_cb:\n            try:\n                self._refresh_cb()\n            except Exception:\n                pass\n\n    def render(self):\n        tree = Tree(f\"[cyan]{self.title}[/cyan]\", guide_style=\"grey50\")\n        for step in self.steps:\n            label = step[\"label\"]\n            detail_text = step[\"detail\"].strip() if step[\"detail\"] else \"\"\n\n            status = step[\"status\"]\n            if status == \"done\":\n                symbol = \"[green]●[/green]\"\n            elif status == \"pending\":\n                symbol = \"[green dim]○[/green dim]\"\n            elif status == \"running\":\n                symbol = \"[cyan]○[/cyan]\"\n            elif status == \"error\":\n                symbol = \"[red]●[/red]\"\n            elif status == \"skipped\":\n                symbol = \"[yellow]○[/yellow]\"\n            else:\n                symbol = \" \"\n\n            if status == \"pending\":\n                # Entire line light gray (pending)\n                if detail_text:\n                    line = f\"{symbol} [bright_black]{label} ({detail_text})[/bright_black]\"\n                else:\n                    line = f\"{symbol} [bright_black]{label}[/bright_black]\"\n            else:\n                # Label white, detail (if any) light gray in parentheses\n                if detail_text:\n                    line = f\"{symbol} [white]{label}[/white] [bright_black]({detail_text})[/bright_black]\"\n                else:\n                    line = f\"{symbol} [white]{label}[/white]\"\n\n            tree.add(line)\n        return tree\n\ndef get_key():\n    \"\"\"Get a single keypress in a cross-platform way using readchar.\"\"\"\n    key = readchar.readkey()\n\n    if key == readchar.key.UP or key == readchar.key.CTRL_P:\n        return 'up'\n    if key == readchar.key.DOWN or key == readchar.key.CTRL_N:\n        return 'down'\n\n    if key == readchar.key.ENTER:\n        return 'enter'\n\n    if key == readchar.key.ESC:\n        return 'escape'\n\n    if key == readchar.key.CTRL_C:\n        raise KeyboardInterrupt\n\n    return key\n\ndef select_with_arrows(options: dict, prompt_text: str = \"Select an option\", default_key: str = None) -> str:\n    \"\"\"\n    Interactive selection using arrow keys with Rich Live display.\n    \n    Args:\n        options: Dict with keys as option keys and values as descriptions\n        prompt_text: Text to show above the options\n        default_key: Default option key to start with\n        \n    Returns:\n        Selected option key\n    \"\"\"\n    option_keys = list(options.keys())\n    if default_key and default_key in option_keys:\n        selected_index = option_keys.index(default_key)\n    else:\n        selected_index = 0\n\n    selected_key = None\n\n    def create_selection_panel():\n        \"\"\"Create the selection panel with current selection highlighted.\"\"\"\n        table = Table.grid(padding=(0, 2))\n        table.add_column(style=\"cyan\", justify=\"left\", width=3)\n        table.add_column(style=\"white\", justify=\"left\")\n\n        for i, key in enumerate(option_keys):\n            if i == selected_index:\n                table.add_row(\"▶\", f\"[cyan]{key}[/cyan] [dim]({options[key]})[/dim]\")\n            else:\n                table.add_row(\" \", f\"[cyan]{key}[/cyan] [dim]({options[key]})[/dim]\")\n\n        table.add_row(\"\", \"\")\n        table.add_row(\"\", \"[dim]Use ↑/↓ to navigate, Enter to select, Esc to cancel[/dim]\")\n\n        return Panel(\n            table,\n            title=f\"[bold]{prompt_text}[/bold]\",\n            border_style=\"cyan\",\n            padding=(1, 2)\n        )\n\n    console.print()\n\n    def run_selection_loop():\n        nonlocal selected_key, selected_index\n        with Live(create_selection_panel(), console=console, transient=True, auto_refresh=False) as live:\n            while True:\n                try:\n                    key = get_key()\n                    if key == 'up':\n                        selected_index = (selected_index - 1) % len(option_keys)\n                    elif key == 'down':\n                        selected_index = (selected_index + 1) % len(option_keys)\n                    elif key == 'enter':\n                        selected_key = option_keys[selected_index]\n                        break\n                    elif key == 'escape':\n                        console.print(\"\\n[yellow]Selection cancelled[/yellow]\")\n                        raise typer.Exit(1)\n\n                    live.update(create_selection_panel(), refresh=True)\n\n                except KeyboardInterrupt:\n                    console.print(\"\\n[yellow]Selection cancelled[/yellow]\")\n                    raise typer.Exit(1)\n\n    run_selection_loop()\n\n    if selected_key is None:\n        console.print(\"\\n[red]Selection failed.[/red]\")\n        raise typer.Exit(1)\n\n    return selected_key\n\nconsole = Console()\n\nclass BannerGroup(TyperGroup):\n    \"\"\"Custom group that shows banner before help.\"\"\"\n\n    def format_help(self, ctx, formatter):\n        # Show banner before help\n        show_banner()\n        super().format_help(ctx, formatter)\n\n\napp = typer.Typer(\n    name=\"specify\",\n    help=\"Setup tool for Specify spec-driven development projects\",\n    add_completion=False,\n    invoke_without_command=True,\n    cls=BannerGroup,\n)\n\ndef show_banner():\n    \"\"\"Display the ASCII art banner.\"\"\"\n    banner_lines = BANNER.strip().split('\\n')\n    colors = [\"bright_blue\", \"blue\", \"cyan\", \"bright_cyan\", \"white\", \"bright_white\"]\n\n    styled_banner = Text()\n    for i, line in enumerate(banner_lines):\n        color = colors[i % len(colors)]\n        styled_banner.append(line + \"\\n\", style=color)\n\n    console.print(Align.center(styled_banner))\n    console.print(Align.center(Text(TAGLINE, style=\"italic bright_yellow\")))\n    console.print()\n\n@app.callback()\ndef callback(ctx: typer.Context):\n    \"\"\"Show banner when no subcommand is provided.\"\"\"\n    if ctx.invoked_subcommand is None and \"--help\" not in sys.argv and \"-h\" not in sys.argv:\n        show_banner()\n        console.print(Align.center(\"[dim]Run 'specify --help' for usage information[/dim]\"))\n        console.print()\n\ndef run_command(cmd: list[str], check_return: bool = True, capture: bool = False, shell: bool = False) -> Optional[str]:\n    \"\"\"Run a shell command and optionally capture output.\"\"\"\n    try:\n        if capture:\n            result = subprocess.run(cmd, check=check_return, capture_output=True, text=True, shell=shell)\n            return result.stdout.strip()\n        else:\n            subprocess.run(cmd, check=check_return, shell=shell)\n            return None\n    except subprocess.CalledProcessError as e:\n        if check_return:\n            console.print(f\"[red]Error running command:[/red] {' '.join(cmd)}\")\n            console.print(f\"[red]Exit code:[/red] {e.returncode}\")\n            if hasattr(e, 'stderr') and e.stderr:\n                console.print(f\"[red]Error output:[/red] {e.stderr}\")\n            raise\n        return None\n\ndef check_tool(tool: str, tracker: StepTracker = None) -> bool:\n    \"\"\"Check if a tool is installed. Optionally update tracker.\n    \n    Args:\n        tool: Name of the tool to check\n        tracker: Optional StepTracker to update with results\n        \n    Returns:\n        True if tool is found, False otherwise\n    \"\"\"\n    # Special handling for Claude CLI after `claude migrate-installer`\n    # See: https://github.com/github/spec-kit/issues/123\n    # The migrate-installer command REMOVES the original executable from PATH\n    # and creates an alias at ~/.claude/local/claude instead\n    # This path should be prioritized over other claude executables in PATH\n    if tool == \"claude\":\n        if CLAUDE_LOCAL_PATH.exists() and CLAUDE_LOCAL_PATH.is_file():\n            if tracker:\n                tracker.complete(tool, \"available\")\n            return True\n    \n    if tool == \"kiro-cli\":\n        # Kiro currently supports both executable names. Prefer kiro-cli and\n        # accept kiro as a compatibility fallback.\n        found = shutil.which(\"kiro-cli\") is not None or shutil.which(\"kiro\") is not None\n    else:\n        found = shutil.which(tool) is not None\n    \n    if tracker:\n        if found:\n            tracker.complete(tool, \"available\")\n        else:\n            tracker.error(tool, \"not found\")\n    \n    return found\n\ndef is_git_repo(path: Path = None) -> bool:\n    \"\"\"Check if the specified path is inside a git repository.\"\"\"\n    if path is None:\n        path = Path.cwd()\n    \n    if not path.is_dir():\n        return False\n\n    try:\n        # Use git command to check if inside a work tree\n        subprocess.run(\n            [\"git\", \"rev-parse\", \"--is-inside-work-tree\"],\n            check=True,\n            capture_output=True,\n            cwd=path,\n        )\n        return True\n    except (subprocess.CalledProcessError, FileNotFoundError):\n        return False\n\ndef init_git_repo(project_path: Path, quiet: bool = False) -> Tuple[bool, Optional[str]]:\n    \"\"\"Initialize a git repository in the specified path.\n    \n    Args:\n        project_path: Path to initialize git repository in\n        quiet: if True suppress console output (tracker handles status)\n    \n    Returns:\n        Tuple of (success: bool, error_message: Optional[str])\n    \"\"\"\n    try:\n        original_cwd = Path.cwd()\n        os.chdir(project_path)\n        if not quiet:\n            console.print(\"[cyan]Initializing git repository...[/cyan]\")\n        subprocess.run([\"git\", \"init\"], check=True, capture_output=True, text=True)\n        subprocess.run([\"git\", \"add\", \".\"], check=True, capture_output=True, text=True)\n        subprocess.run([\"git\", \"commit\", \"-m\", \"Initial commit from Specify template\"], check=True, capture_output=True, text=True)\n        if not quiet:\n            console.print(\"[green]✓[/green] Git repository initialized\")\n        return True, None\n\n    except subprocess.CalledProcessError as e:\n        error_msg = f\"Command: {' '.join(e.cmd)}\\nExit code: {e.returncode}\"\n        if e.stderr:\n            error_msg += f\"\\nError: {e.stderr.strip()}\"\n        elif e.stdout:\n            error_msg += f\"\\nOutput: {e.stdout.strip()}\"\n        \n        if not quiet:\n            console.print(f\"[red]Error initializing git repository:[/red] {e}\")\n        return False, error_msg\n    finally:\n        os.chdir(original_cwd)\n\ndef handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker=None) -> None:\n    \"\"\"Handle merging or copying of .vscode/settings.json files.\n\n    Note: when merge produces changes, rewritten output is normalized JSON and\n    existing JSONC comments/trailing commas are not preserved.\n    \"\"\"\n    def log(message, color=\"green\"):\n        if verbose and not tracker:\n            console.print(f\"[{color}]{message}[/] {rel_path}\")\n\n    def atomic_write_json(target_file: Path, payload: dict[str, Any]) -> None:\n        \"\"\"Atomically write JSON while preserving existing mode bits when possible.\"\"\"\n        temp_path: Optional[Path] = None\n        try:\n            with tempfile.NamedTemporaryFile(\n                mode='w',\n                encoding='utf-8',\n                dir=target_file.parent,\n                prefix=f\"{target_file.name}.\",\n                suffix=\".tmp\",\n                delete=False,\n            ) as f:\n                temp_path = Path(f.name)\n                json.dump(payload, f, indent=4)\n                f.write('\\n')\n\n            if target_file.exists():\n                try:\n                    existing_stat = target_file.stat()\n                    os.chmod(temp_path, stat.S_IMODE(existing_stat.st_mode))\n                    if hasattr(os, \"chown\"):\n                        try:\n                            os.chown(temp_path, existing_stat.st_uid, existing_stat.st_gid)\n                        except PermissionError:\n                            # Best-effort owner/group preservation without requiring elevated privileges.\n                            pass\n                except OSError:\n                    # Best-effort metadata preservation; data safety is prioritized.\n                    pass\n\n            os.replace(temp_path, target_file)\n        except Exception:\n            if temp_path and temp_path.exists():\n                temp_path.unlink()\n            raise\n\n    try:\n        with open(sub_item, 'r', encoding='utf-8') as f:\n            # json5 natively supports comments and trailing commas (JSONC)\n            new_settings = json5.load(f)\n\n        if dest_file.exists():\n            merged = merge_json_files(dest_file, new_settings, verbose=verbose and not tracker)\n            if merged is not None:\n                atomic_write_json(dest_file, merged)\n                log(\"Merged:\", \"green\")\n                log(\"Note: comments/trailing commas are normalized when rewritten\", \"yellow\")\n            else:\n                log(\"Skipped merge (preserved existing settings)\", \"yellow\")\n        else:\n            shutil.copy2(sub_item, dest_file)\n            log(\"Copied (no existing settings.json):\", \"blue\")\n\n    except Exception as e:\n        log(f\"Warning: Could not merge settings: {e}\", \"yellow\")\n        if not dest_file.exists():\n            shutil.copy2(sub_item, dest_file)\n\n\ndef merge_json_files(existing_path: Path, new_content: Any, verbose: bool = False) -> Optional[dict[str, Any]]:\n    \"\"\"Merge new JSON content into existing JSON file.\n\n    Performs a polite deep merge where:\n    - New keys are added\n    - Existing keys are preserved (not overwritten) unless both values are dictionaries\n    - Nested dictionaries are merged recursively only when both sides are dictionaries\n    - Lists and other values are preserved from base if they exist\n\n    Args:\n        existing_path: Path to existing JSON file\n        new_content: New JSON content to merge in\n        verbose: Whether to print merge details\n\n    Returns:\n        Merged JSON content as dict, or None if the existing file should be left untouched.\n    \"\"\"\n    # Load existing content first to have a safe fallback\n    existing_content = None\n    exists = existing_path.exists()\n\n    if exists:\n        try:\n            with open(existing_path, 'r', encoding='utf-8') as f:\n                # Handle comments (JSONC) natively with json5\n                # Note: json5 handles BOM automatically\n                existing_content = json5.load(f)\n        except FileNotFoundError:\n            # Handle race condition where file is deleted after exists() check\n            exists = False\n        except Exception as e:\n            if verbose:\n                console.print(f\"[yellow]Warning: Could not read or parse existing JSON in {existing_path.name} ({e}).[/yellow]\")\n            # Skip merge to preserve existing file if unparseable or inaccessible (e.g. PermissionError)\n            return None\n\n    # Validate template content\n    if not isinstance(new_content, dict):\n        if verbose:\n            console.print(f\"[yellow]Warning: Template content for {existing_path.name} is not a dictionary. Preserving existing settings.[/yellow]\")\n        return None\n\n    if not exists:\n        return new_content\n\n    # If existing content parsed but is not a dict, skip merge to avoid data loss\n    if not isinstance(existing_content, dict):\n        if verbose:\n            console.print(f\"[yellow]Warning: Existing JSON in {existing_path.name} is not an object. Skipping merge to avoid data loss.[/yellow]\")\n        return None\n\n    def deep_merge_polite(base: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]:\n        \"\"\"Recursively merge update dict into base dict, preserving base values.\"\"\"\n        result = base.copy()\n        for key, value in update.items():\n            if key not in result:\n                # Add new key\n                result[key] = value\n            elif isinstance(result[key], dict) and isinstance(value, dict):\n                # Recursively merge nested dictionaries\n                result[key] = deep_merge_polite(result[key], value)\n            else:\n                # Key already exists and values are not both dicts; preserve existing value.\n                # This ensures user settings aren't overwritten by template defaults.\n                pass\n        return result\n\n    merged = deep_merge_polite(existing_content, new_content)\n\n    # Detect if anything actually changed. If not, return None so the caller\n    # can skip rewriting the file (preserving user's comments/formatting).\n    if merged == existing_content:\n        return None\n\n    if verbose:\n        console.print(f\"[cyan]Merged JSON file:[/cyan] {existing_path.name}\")\n\n    return merged\n\ndef download_template_from_github(ai_assistant: str, download_dir: Path, *, script_type: str = \"sh\", verbose: bool = True, show_progress: bool = True, client: httpx.Client = None, debug: bool = False, github_token: str = None) -> Tuple[Path, dict]:\n    repo_owner = \"github\"\n    repo_name = \"spec-kit\"\n    if client is None:\n        client = httpx.Client(verify=ssl_context)\n\n    if verbose:\n        console.print(\"[cyan]Fetching latest release information...[/cyan]\")\n    api_url = f\"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/latest\"\n\n    try:\n        response = client.get(\n            api_url,\n            timeout=30,\n            follow_redirects=True,\n            headers=_github_auth_headers(github_token),\n        )\n        status = response.status_code\n        if status != 200:\n            # Format detailed error message with rate-limit info\n            error_msg = _format_rate_limit_error(status, response.headers, api_url)\n            if debug:\n                error_msg += f\"\\n\\n[dim]Response body (truncated 500):[/dim]\\n{response.text[:500]}\"\n            raise RuntimeError(error_msg)\n        try:\n            release_data = response.json()\n        except ValueError as je:\n            raise RuntimeError(f\"Failed to parse release JSON: {je}\\nRaw (truncated 400): {response.text[:400]}\")\n    except Exception as e:\n        console.print(\"[red]Error fetching release information[/red]\")\n        console.print(Panel(str(e), title=\"Fetch Error\", border_style=\"red\"))\n        raise typer.Exit(1)\n\n    assets = release_data.get(\"assets\", [])\n    pattern = f\"spec-kit-template-{ai_assistant}-{script_type}\"\n    matching_assets = [\n        asset for asset in assets\n        if pattern in asset[\"name\"] and asset[\"name\"].endswith(\".zip\")\n    ]\n\n    asset = matching_assets[0] if matching_assets else None\n\n    if asset is None:\n        console.print(f\"[red]No matching release asset found[/red] for [bold]{ai_assistant}[/bold] (expected pattern: [bold]{pattern}[/bold])\")\n        asset_names = [a.get('name', '?') for a in assets]\n        console.print(Panel(\"\\n\".join(asset_names) or \"(no assets)\", title=\"Available Assets\", border_style=\"yellow\"))\n        raise typer.Exit(1)\n\n    download_url = asset[\"browser_download_url\"]\n    filename = asset[\"name\"]\n    file_size = asset[\"size\"]\n\n    if verbose:\n        console.print(f\"[cyan]Found template:[/cyan] {filename}\")\n        console.print(f\"[cyan]Size:[/cyan] {file_size:,} bytes\")\n        console.print(f\"[cyan]Release:[/cyan] {release_data['tag_name']}\")\n\n    zip_path = download_dir / filename\n    if verbose:\n        console.print(\"[cyan]Downloading template...[/cyan]\")\n\n    try:\n        with client.stream(\n            \"GET\",\n            download_url,\n            timeout=60,\n            follow_redirects=True,\n            headers=_github_auth_headers(github_token),\n        ) as response:\n            if response.status_code != 200:\n                # Handle rate-limiting on download as well\n                error_msg = _format_rate_limit_error(response.status_code, response.headers, download_url)\n                if debug:\n                    error_msg += f\"\\n\\n[dim]Response body (truncated 400):[/dim]\\n{response.text[:400]}\"\n                raise RuntimeError(error_msg)\n            total_size = int(response.headers.get('content-length', 0))\n            with open(zip_path, 'wb') as f:\n                if total_size == 0:\n                    for chunk in response.iter_bytes(chunk_size=8192):\n                        f.write(chunk)\n                else:\n                    if show_progress:\n                        with Progress(\n                            SpinnerColumn(),\n                            TextColumn(\"[progress.description]{task.description}\"),\n                            TextColumn(\"[progress.percentage]{task.percentage:>3.0f}%\"),\n                            console=console,\n                        ) as progress:\n                            task = progress.add_task(\"Downloading...\", total=total_size)\n                            downloaded = 0\n                            for chunk in response.iter_bytes(chunk_size=8192):\n                                f.write(chunk)\n                                downloaded += len(chunk)\n                                progress.update(task, completed=downloaded)\n                    else:\n                        for chunk in response.iter_bytes(chunk_size=8192):\n                            f.write(chunk)\n    except Exception as e:\n        console.print(\"[red]Error downloading template[/red]\")\n        detail = str(e)\n        if zip_path.exists():\n            zip_path.unlink()\n        console.print(Panel(detail, title=\"Download Error\", border_style=\"red\"))\n        raise typer.Exit(1)\n    if verbose:\n        console.print(f\"Downloaded: {filename}\")\n    metadata = {\n        \"filename\": filename,\n        \"size\": file_size,\n        \"release\": release_data[\"tag_name\"],\n        \"asset_url\": download_url\n    }\n    return zip_path, metadata\n\ndef download_and_extract_template(project_path: Path, ai_assistant: str, script_type: str, is_current_dir: bool = False, *, verbose: bool = True, tracker: StepTracker | None = None, client: httpx.Client = None, debug: bool = False, github_token: str = None) -> Path:\n    \"\"\"Download the latest release and extract it to create a new project.\n    Returns project_path. Uses tracker if provided (with keys: fetch, download, extract, cleanup)\n    \"\"\"\n    current_dir = Path.cwd()\n\n    if tracker:\n        tracker.start(\"fetch\", \"contacting GitHub API\")\n    try:\n        zip_path, meta = download_template_from_github(\n            ai_assistant,\n            current_dir,\n            script_type=script_type,\n            verbose=verbose and tracker is None,\n            show_progress=(tracker is None),\n            client=client,\n            debug=debug,\n            github_token=github_token\n        )\n        if tracker:\n            tracker.complete(\"fetch\", f\"release {meta['release']} ({meta['size']:,} bytes)\")\n            tracker.add(\"download\", \"Download template\")\n            tracker.complete(\"download\", meta['filename'])\n    except Exception as e:\n        if tracker:\n            tracker.error(\"fetch\", str(e))\n        else:\n            if verbose:\n                console.print(f\"[red]Error downloading template:[/red] {e}\")\n        raise\n\n    if tracker:\n        tracker.add(\"extract\", \"Extract template\")\n        tracker.start(\"extract\")\n    elif verbose:\n        console.print(\"Extracting template...\")\n\n    try:\n        if not is_current_dir:\n            project_path.mkdir(parents=True)\n\n        with zipfile.ZipFile(zip_path, 'r') as zip_ref:\n            zip_contents = zip_ref.namelist()\n            if tracker:\n                tracker.start(\"zip-list\")\n                tracker.complete(\"zip-list\", f\"{len(zip_contents)} entries\")\n            elif verbose:\n                console.print(f\"[cyan]ZIP contains {len(zip_contents)} items[/cyan]\")\n\n            if is_current_dir:\n                with tempfile.TemporaryDirectory() as temp_dir:\n                    temp_path = Path(temp_dir)\n                    zip_ref.extractall(temp_path)\n\n                    extracted_items = list(temp_path.iterdir())\n                    if tracker:\n                        tracker.start(\"extracted-summary\")\n                        tracker.complete(\"extracted-summary\", f\"temp {len(extracted_items)} items\")\n                    elif verbose:\n                        console.print(f\"[cyan]Extracted {len(extracted_items)} items to temp location[/cyan]\")\n\n                    source_dir = temp_path\n                    if len(extracted_items) == 1 and extracted_items[0].is_dir():\n                        source_dir = extracted_items[0]\n                        if tracker:\n                            tracker.add(\"flatten\", \"Flatten nested directory\")\n                            tracker.complete(\"flatten\")\n                        elif verbose:\n                            console.print(\"[cyan]Found nested directory structure[/cyan]\")\n\n                    for item in source_dir.iterdir():\n                        dest_path = project_path / item.name\n                        if item.is_dir():\n                            if dest_path.exists():\n                                if verbose and not tracker:\n                                    console.print(f\"[yellow]Merging directory:[/yellow] {item.name}\")\n                                for sub_item in item.rglob('*'):\n                                    if sub_item.is_file():\n                                        rel_path = sub_item.relative_to(item)\n                                        dest_file = dest_path / rel_path\n                                        dest_file.parent.mkdir(parents=True, exist_ok=True)\n                                        # Special handling for .vscode/settings.json - merge instead of overwrite\n                                        if dest_file.name == \"settings.json\" and dest_file.parent.name == \".vscode\":\n                                            handle_vscode_settings(sub_item, dest_file, rel_path, verbose, tracker)\n                                        else:\n                                            shutil.copy2(sub_item, dest_file)\n                            else:\n                                shutil.copytree(item, dest_path)\n                        else:\n                            if dest_path.exists() and verbose and not tracker:\n                                console.print(f\"[yellow]Overwriting file:[/yellow] {item.name}\")\n                            shutil.copy2(item, dest_path)\n                    if verbose and not tracker:\n                        console.print(\"[cyan]Template files merged into current directory[/cyan]\")\n            else:\n                zip_ref.extractall(project_path)\n\n                extracted_items = list(project_path.iterdir())\n                if tracker:\n                    tracker.start(\"extracted-summary\")\n                    tracker.complete(\"extracted-summary\", f\"{len(extracted_items)} top-level items\")\n                elif verbose:\n                    console.print(f\"[cyan]Extracted {len(extracted_items)} items to {project_path}:[/cyan]\")\n                    for item in extracted_items:\n                        console.print(f\"  - {item.name} ({'dir' if item.is_dir() else 'file'})\")\n\n                if len(extracted_items) == 1 and extracted_items[0].is_dir():\n                    nested_dir = extracted_items[0]\n                    temp_move_dir = project_path.parent / f\"{project_path.name}_temp\"\n\n                    shutil.move(str(nested_dir), str(temp_move_dir))\n\n                    project_path.rmdir()\n\n                    shutil.move(str(temp_move_dir), str(project_path))\n                    if tracker:\n                        tracker.add(\"flatten\", \"Flatten nested directory\")\n                        tracker.complete(\"flatten\")\n                    elif verbose:\n                        console.print(\"[cyan]Flattened nested directory structure[/cyan]\")\n\n    except Exception as e:\n        if tracker:\n            tracker.error(\"extract\", str(e))\n        else:\n            if verbose:\n                console.print(f\"[red]Error extracting template:[/red] {e}\")\n                if debug:\n                    console.print(Panel(str(e), title=\"Extraction Error\", border_style=\"red\"))\n\n        if not is_current_dir and project_path.exists():\n            shutil.rmtree(project_path)\n        raise typer.Exit(1)\n    else:\n        if tracker:\n            tracker.complete(\"extract\")\n    finally:\n        if tracker:\n            tracker.add(\"cleanup\", \"Remove temporary archive\")\n\n        if zip_path.exists():\n            zip_path.unlink()\n            if tracker:\n                tracker.complete(\"cleanup\")\n            elif verbose:\n                console.print(f\"Cleaned up: {zip_path.name}\")\n\n    return project_path\n\n\ndef ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = None) -> None:\n    \"\"\"Ensure POSIX .sh scripts under .specify/scripts (recursively) have execute bits (no-op on Windows).\"\"\"\n    if os.name == \"nt\":\n        return  # Windows: skip silently\n    scripts_root = project_path / \".specify\" / \"scripts\"\n    if not scripts_root.is_dir():\n        return\n    failures: list[str] = []\n    updated = 0\n    for script in scripts_root.rglob(\"*.sh\"):\n        try:\n            if script.is_symlink() or not script.is_file():\n                continue\n            try:\n                with script.open(\"rb\") as f:\n                    if f.read(2) != b\"#!\":\n                        continue\n            except Exception:\n                continue\n            st = script.stat()\n            mode = st.st_mode\n            if mode & 0o111:\n                continue\n            new_mode = mode\n            if mode & 0o400:\n                new_mode |= 0o100\n            if mode & 0o040:\n                new_mode |= 0o010\n            if mode & 0o004:\n                new_mode |= 0o001\n            if not (new_mode & 0o100):\n                new_mode |= 0o100\n            os.chmod(script, new_mode)\n            updated += 1\n        except Exception as e:\n            failures.append(f\"{script.relative_to(scripts_root)}: {e}\")\n    if tracker:\n        detail = f\"{updated} updated\" + (f\", {len(failures)} failed\" if failures else \"\")\n        tracker.add(\"chmod\", \"Set script permissions recursively\")\n        (tracker.error if failures else tracker.complete)(\"chmod\", detail)\n    else:\n        if updated:\n            console.print(f\"[cyan]Updated execute permissions on {updated} script(s) recursively[/cyan]\")\n        if failures:\n            console.print(\"[yellow]Some scripts could not be updated:[/yellow]\")\n            for f in failures:\n                console.print(f\"  - {f}\")\n\ndef ensure_constitution_from_template(project_path: Path, tracker: StepTracker | None = None) -> None:\n    \"\"\"Copy constitution template to memory if it doesn't exist (preserves existing constitution on reinitialization).\"\"\"\n    memory_constitution = project_path / \".specify\" / \"memory\" / \"constitution.md\"\n    template_constitution = project_path / \".specify\" / \"templates\" / \"constitution-template.md\"\n\n    # If constitution already exists in memory, preserve it\n    if memory_constitution.exists():\n        if tracker:\n            tracker.add(\"constitution\", \"Constitution setup\")\n            tracker.skip(\"constitution\", \"existing file preserved\")\n        return\n\n    # If template doesn't exist, something went wrong with extraction\n    if not template_constitution.exists():\n        if tracker:\n            tracker.add(\"constitution\", \"Constitution setup\")\n            tracker.error(\"constitution\", \"template not found\")\n        return\n\n    # Copy template to memory directory\n    try:\n        memory_constitution.parent.mkdir(parents=True, exist_ok=True)\n        shutil.copy2(template_constitution, memory_constitution)\n        if tracker:\n            tracker.add(\"constitution\", \"Constitution setup\")\n            tracker.complete(\"constitution\", \"copied from template\")\n        else:\n            console.print(\"[cyan]Initialized constitution from template[/cyan]\")\n    except Exception as e:\n        if tracker:\n            tracker.add(\"constitution\", \"Constitution setup\")\n            tracker.error(\"constitution\", str(e))\n        else:\n            console.print(f\"[yellow]Warning: Could not initialize constitution: {e}[/yellow]\")\n\n\nINIT_OPTIONS_FILE = \".specify/init-options.json\"\n\n\ndef save_init_options(project_path: Path, options: dict[str, Any]) -> None:\n    \"\"\"Persist the CLI options used during ``specify init``.\n\n    Writes a small JSON file to ``.specify/init-options.json`` so that\n    later operations (e.g. preset install) can adapt their behaviour\n    without scanning the filesystem.\n    \"\"\"\n    dest = project_path / INIT_OPTIONS_FILE\n    dest.parent.mkdir(parents=True, exist_ok=True)\n    dest.write_text(json.dumps(options, indent=2, sort_keys=True))\n\n\ndef load_init_options(project_path: Path) -> dict[str, Any]:\n    \"\"\"Load the init options previously saved by ``specify init``.\n\n    Returns an empty dict if the file does not exist or cannot be parsed.\n    \"\"\"\n    path = project_path / INIT_OPTIONS_FILE\n    if not path.exists():\n        return {}\n    try:\n        return json.loads(path.read_text())\n    except (json.JSONDecodeError, OSError):\n        return {}\n\n\n# Agent-specific skill directory overrides for agents whose skills directory\n# doesn't follow the standard <agent_folder>/skills/ pattern\nAGENT_SKILLS_DIR_OVERRIDES = {\n    \"codex\": \".agents/skills\",  # Codex agent layout override\n}\n\n# Default skills directory for agents not in AGENT_CONFIG\nDEFAULT_SKILLS_DIR = \".agents/skills\"\n\n# Agents whose downloaded template already contains skills in the final layout.\nNATIVE_SKILLS_AGENTS = {\"codex\", \"kimi\"}\n\n# Enhanced descriptions for each spec-kit command skill\nSKILL_DESCRIPTIONS = {\n    \"specify\": \"Create or update feature specifications from natural language descriptions. Use when starting new features or refining requirements. Generates spec.md with user stories, functional requirements, and acceptance criteria following spec-driven development methodology.\",\n    \"plan\": \"Generate technical implementation plans from feature specifications. Use after creating a spec to define architecture, tech stack, and implementation phases. Creates plan.md with detailed technical design.\",\n    \"tasks\": \"Break down implementation plans into actionable task lists. Use after planning to create a structured task breakdown. Generates tasks.md with ordered, dependency-aware tasks.\",\n    \"implement\": \"Execute all tasks from the task breakdown to build the feature. Use after task generation to systematically implement the planned solution following TDD approach where applicable.\",\n    \"analyze\": \"Perform cross-artifact consistency analysis across spec.md, plan.md, and tasks.md. Use after task generation to identify gaps, duplications, and inconsistencies before implementation.\",\n    \"clarify\": \"Structured clarification workflow for underspecified requirements. Use before planning to resolve ambiguities through coverage-based questioning. Records answers in spec clarifications section.\",\n    \"constitution\": \"Create or update project governing principles and development guidelines. Use at project start to establish code quality, testing standards, and architectural constraints that guide all development.\",\n    \"checklist\": \"Generate custom quality checklists for validating requirements completeness and clarity. Use to create unit tests for English that ensure spec quality before implementation.\",\n    \"taskstoissues\": \"Convert tasks from tasks.md into GitHub issues. Use after task breakdown to track work items in GitHub project management.\",\n}\n\n\ndef _get_skills_dir(project_path: Path, selected_ai: str) -> Path:\n    \"\"\"Resolve the agent-specific skills directory for the given AI assistant.\n\n    Uses ``AGENT_SKILLS_DIR_OVERRIDES`` first, then falls back to\n    ``AGENT_CONFIG[agent][\"folder\"] + \"skills\"``, and finally to\n    ``DEFAULT_SKILLS_DIR``.\n    \"\"\"\n    if selected_ai in AGENT_SKILLS_DIR_OVERRIDES:\n        return project_path / AGENT_SKILLS_DIR_OVERRIDES[selected_ai]\n\n    agent_config = AGENT_CONFIG.get(selected_ai, {})\n    agent_folder = agent_config.get(\"folder\", \"\")\n    if agent_folder:\n        return project_path / agent_folder.rstrip(\"/\") / \"skills\"\n\n    return project_path / DEFAULT_SKILLS_DIR\n\n\ndef install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker | None = None) -> bool:\n    \"\"\"Install Prompt.MD files from templates/commands/ as agent skills.\n\n    Skills are written to the agent-specific skills directory following the\n    `agentskills.io <https://agentskills.io/specification>`_ specification.\n    Installation is additive — existing files are never removed and prompt\n    command files in the agent's commands directory are left untouched.\n\n    Args:\n        project_path: Target project directory.\n        selected_ai: AI assistant key from ``AGENT_CONFIG``.\n        tracker: Optional progress tracker.\n\n    Returns:\n        ``True`` if at least one skill was installed or all skills were\n        already present (idempotent re-run), ``False`` otherwise.\n    \"\"\"\n    # Locate command templates in the agent's extracted commands directory.\n    # download_and_extract_template() already placed the .md files here.\n    agent_config = AGENT_CONFIG.get(selected_ai, {})\n    agent_folder = agent_config.get(\"folder\", \"\")\n    commands_subdir = agent_config.get(\"commands_subdir\", \"commands\")\n    if agent_folder:\n        templates_dir = project_path / agent_folder.rstrip(\"/\") / commands_subdir\n    else:\n        templates_dir = project_path / commands_subdir\n\n    # Only consider speckit.*.md templates so that user-authored command\n    # files (e.g. custom slash commands, agent files) coexisting in the\n    # same commands directory are not incorrectly converted into skills.\n    template_glob = \"speckit.*.md\"\n\n    if not templates_dir.exists() or not any(templates_dir.glob(template_glob)):\n        # Fallback: try the repo-relative path (for running from source checkout)\n        # This also covers agents whose extracted commands are in a different\n        # format (e.g. gemini/tabnine use .toml, not .md).\n        script_dir = Path(__file__).parent.parent.parent  # up from src/specify_cli/\n        fallback_dir = script_dir / \"templates\" / \"commands\"\n        if fallback_dir.exists() and any(fallback_dir.glob(\"*.md\")):\n            templates_dir = fallback_dir\n            template_glob = \"*.md\"\n\n    if not templates_dir.exists() or not any(templates_dir.glob(template_glob)):\n        if tracker:\n            tracker.error(\"ai-skills\", \"command templates not found\")\n        else:\n            console.print(\"[yellow]Warning: command templates not found, skipping skills installation[/yellow]\")\n        return False\n\n    command_files = sorted(templates_dir.glob(template_glob))\n    if not command_files:\n        if tracker:\n            tracker.skip(\"ai-skills\", \"no command templates found\")\n        else:\n            console.print(\"[yellow]No command templates found to install[/yellow]\")\n        return False\n\n    # Resolve the correct skills directory for this agent\n    skills_dir = _get_skills_dir(project_path, selected_ai)\n    skills_dir.mkdir(parents=True, exist_ok=True)\n\n    if tracker:\n        tracker.start(\"ai-skills\")\n\n    installed_count = 0\n    skipped_count = 0\n    for command_file in command_files:\n        try:\n            content = command_file.read_text(encoding=\"utf-8\")\n\n            # Parse YAML frontmatter\n            if content.startswith(\"---\"):\n                parts = content.split(\"---\", 2)\n                if len(parts) >= 3:\n                    frontmatter = yaml.safe_load(parts[1])\n                    if not isinstance(frontmatter, dict):\n                        frontmatter = {}\n                    body = parts[2].strip()\n                else:\n                    # File starts with --- but has no closing ---\n                    console.print(f\"[yellow]Warning: {command_file.name} has malformed frontmatter (no closing ---), treating as plain content[/yellow]\")\n                    frontmatter = {}\n                    body = content\n            else:\n                frontmatter = {}\n                body = content\n\n            command_name = command_file.stem\n            # Normalize: extracted commands may be named \"speckit.<cmd>.md\"\n            # or \"speckit.<cmd>.agent.md\"; strip the \"speckit.\" prefix and\n            # any trailing \".agent\" suffix so skill names stay clean and\n            # SKILL_DESCRIPTIONS lookups work.\n            if command_name.startswith(\"speckit.\"):\n                command_name = command_name[len(\"speckit.\"):]\n            if command_name.endswith(\".agent\"):\n                command_name = command_name[:-len(\".agent\")]\n            if selected_ai == \"kimi\":\n                skill_name = f\"speckit.{command_name}\"\n            else:\n                skill_name = f\"speckit-{command_name}\"\n\n            # Create skill directory (additive — never removes existing content)\n            skill_dir = skills_dir / skill_name\n            skill_dir.mkdir(parents=True, exist_ok=True)\n\n            # Select the best description available\n            original_desc = frontmatter.get(\"description\", \"\")\n            enhanced_desc = SKILL_DESCRIPTIONS.get(command_name, original_desc or f\"Spec-kit workflow command: {command_name}\")\n\n            # Build SKILL.md following agentskills.io spec\n            # Use yaml.safe_dump to safely serialise the frontmatter and\n            # avoid YAML injection from descriptions containing colons,\n            # quotes, or newlines.\n            # Normalize source filename for metadata — strip speckit. prefix\n            # so it matches the canonical templates/commands/<cmd>.md path.\n            source_name = command_file.name\n            if source_name.startswith(\"speckit.\"):\n                source_name = source_name[len(\"speckit.\"):]\n            if source_name.endswith(\".agent.md\"):\n                source_name = source_name[:-len(\".agent.md\")] + \".md\"\n\n            frontmatter_data = {\n                \"name\": skill_name,\n                \"description\": enhanced_desc,\n                \"compatibility\": \"Requires spec-kit project structure with .specify/ directory\",\n                \"metadata\": {\n                    \"author\": \"github-spec-kit\",\n                    \"source\": f\"templates/commands/{source_name}\",\n                },\n            }\n            frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()\n            skill_content = (\n                f\"---\\n\"\n                f\"{frontmatter_text}\\n\"\n                f\"---\\n\\n\"\n                f\"# Speckit {command_name.title()} Skill\\n\\n\"\n                f\"{body}\\n\"\n            )\n\n            skill_file = skill_dir / \"SKILL.md\"\n            if skill_file.exists():\n                # Do not overwrite user-customized skills on re-runs\n                skipped_count += 1\n                continue\n            skill_file.write_text(skill_content, encoding=\"utf-8\")\n            installed_count += 1\n\n        except Exception as e:\n            console.print(f\"[yellow]Warning: Failed to install skill {command_file.stem}: {e}[/yellow]\")\n            continue\n\n    if tracker:\n        if installed_count > 0 and skipped_count > 0:\n            tracker.complete(\"ai-skills\", f\"{installed_count} new + {skipped_count} existing skills in {skills_dir.relative_to(project_path)}\")\n        elif installed_count > 0:\n            tracker.complete(\"ai-skills\", f\"{installed_count} skills → {skills_dir.relative_to(project_path)}\")\n        elif skipped_count > 0:\n            tracker.complete(\"ai-skills\", f\"{skipped_count} skills already present\")\n        else:\n            tracker.error(\"ai-skills\", \"no skills installed\")\n    else:\n        if installed_count > 0:\n            console.print(f\"[green]✓[/green] Installed {installed_count} agent skills to {skills_dir.relative_to(project_path)}/\")\n        elif skipped_count > 0:\n            console.print(f\"[green]✓[/green] {skipped_count} agent skills already present in {skills_dir.relative_to(project_path)}/\")\n        else:\n            console.print(\"[yellow]No skills were installed[/yellow]\")\n\n    return installed_count > 0 or skipped_count > 0\n\n\ndef _has_bundled_skills(project_path: Path, selected_ai: str) -> bool:\n    \"\"\"Return True when a native-skills agent has spec-kit bundled skills.\"\"\"\n    skills_dir = _get_skills_dir(project_path, selected_ai)\n    if not skills_dir.is_dir():\n        return False\n\n    pattern = \"speckit.*/SKILL.md\" if selected_ai == \"kimi\" else \"speckit-*/SKILL.md\"\n    return any(skills_dir.glob(pattern))\n\n\nAGENT_SKILLS_MIGRATIONS = {\n    \"agy\": {\n        \"error\": \"Explicit command support was deprecated in Antigravity version 1.20.5.\",\n        \"usage\": \"specify init <project> --ai agy --ai-skills\",\n        \"interactive_note\": (\n            \"'agy' was selected interactively; enabling [cyan]--ai-skills[/cyan] \"\n            \"automatically for compatibility (explicit .agent/commands usage is deprecated).\"\n        ),\n    },\n    \"codex\": {\n        \"error\": (\n            \"Custom prompt-based spec-kit initialization is deprecated for Codex CLI; \"\n            \"use agent skills instead.\"\n        ),\n        \"usage\": \"specify init <project> --ai codex --ai-skills\",\n        \"interactive_note\": (\n            \"'codex' was selected interactively; enabling [cyan]--ai-skills[/cyan] \"\n            \"automatically for compatibility (.agents/skills is the recommended Codex layout).\"\n        ),\n    },\n}\n\n\ndef _handle_agent_skills_migration(console: Console, agent_key: str) -> None:\n    \"\"\"Print a fail-fast migration error for agents that now require skills.\"\"\"\n    migration = AGENT_SKILLS_MIGRATIONS[agent_key]\n    console.print(f\"\\n[red]Error:[/red] {migration['error']}\")\n    console.print(\"Please use [cyan]--ai-skills[/cyan] when initializing to install templates as agent skills instead.\")\n    console.print(f\"[yellow]Usage:[/yellow] {migration['usage']}\")\n    raise typer.Exit(1)\n\n@app.command()\ndef init(\n    project_name: str = typer.Argument(None, help=\"Name for your new project directory (optional if using --here, or use '.' for current directory)\"),\n    ai_assistant: str = typer.Option(None, \"--ai\", help=AI_ASSISTANT_HELP),\n    ai_commands_dir: str = typer.Option(None, \"--ai-commands-dir\", help=\"Directory for agent command files (required with --ai generic, e.g. .myagent/commands/)\"),\n    script_type: str = typer.Option(None, \"--script\", help=\"Script type to use: sh or ps\"),\n    ignore_agent_tools: bool = typer.Option(False, \"--ignore-agent-tools\", help=\"Skip checks for AI agent tools like Claude Code\"),\n    no_git: bool = typer.Option(False, \"--no-git\", help=\"Skip git repository initialization\"),\n    here: bool = typer.Option(False, \"--here\", help=\"Initialize project in the current directory instead of creating a new one\"),\n    force: bool = typer.Option(False, \"--force\", help=\"Force merge/overwrite when using --here (skip confirmation)\"),\n    skip_tls: bool = typer.Option(False, \"--skip-tls\", help=\"Skip SSL/TLS verification (not recommended)\"),\n    debug: bool = typer.Option(False, \"--debug\", help=\"Show verbose diagnostic output for network and extraction failures\"),\n    github_token: str = typer.Option(None, \"--github-token\", help=\"GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)\"),\n    ai_skills: bool = typer.Option(False, \"--ai-skills\", help=\"Install Prompt.MD templates as agent skills (requires --ai)\"),\n    preset: str = typer.Option(None, \"--preset\", help=\"Install a preset during initialization (by preset ID)\"),\n):\n    \"\"\"\n    Initialize a new Specify project from the latest template.\n    \n    This command will:\n    1. Check that required tools are installed (git is optional)\n    2. Let you choose your AI assistant\n    3. Download the appropriate template from GitHub\n    4. Extract the template to a new project directory or current directory\n    5. Initialize a fresh git repository (if not --no-git and no existing repo)\n    6. Optionally set up AI assistant commands\n    \n    Examples:\n        specify init my-project\n        specify init my-project --ai claude\n        specify init my-project --ai copilot --no-git\n        specify init --ignore-agent-tools my-project\n        specify init . --ai claude         # Initialize in current directory\n        specify init .                     # Initialize in current directory (interactive AI selection)\n        specify init --here --ai claude    # Alternative syntax for current directory\n        specify init --here --ai codex --ai-skills\n        specify init --here --ai codebuddy\n        specify init --here --ai vibe      # Initialize with Mistral Vibe support\n        specify init --here\n        specify init --here --force  # Skip confirmation when current directory not empty\n        specify init my-project --ai claude --ai-skills   # Install agent skills\n        specify init --here --ai gemini --ai-skills\n        specify init my-project --ai generic --ai-commands-dir .myagent/commands/  # Unsupported agent\n        specify init my-project --ai claude --preset healthcare-compliance  # With preset\n    \"\"\"\n\n    show_banner()\n\n    # Detect when option values are likely misinterpreted flags (parameter ordering issue)\n    if ai_assistant and ai_assistant.startswith(\"--\"):\n        console.print(f\"[red]Error:[/red] Invalid value for --ai: '{ai_assistant}'\")\n        console.print(\"[yellow]Hint:[/yellow] Did you forget to provide a value for --ai?\")\n        console.print(\"[yellow]Example:[/yellow] specify init --ai claude --here\")\n        console.print(f\"[yellow]Available agents:[/yellow] {', '.join(AGENT_CONFIG.keys())}\")\n        raise typer.Exit(1)\n    \n    if ai_commands_dir and ai_commands_dir.startswith(\"--\"):\n        console.print(f\"[red]Error:[/red] Invalid value for --ai-commands-dir: '{ai_commands_dir}'\")\n        console.print(\"[yellow]Hint:[/yellow] Did you forget to provide a value for --ai-commands-dir?\")\n        console.print(\"[yellow]Example:[/yellow] specify init --ai generic --ai-commands-dir .myagent/commands/\")\n        raise typer.Exit(1)\n\n    if ai_assistant:\n        ai_assistant = AI_ASSISTANT_ALIASES.get(ai_assistant, ai_assistant)\n\n    if project_name == \".\":\n        here = True\n        project_name = None  # Clear project_name to use existing validation logic\n\n    if here and project_name:\n        console.print(\"[red]Error:[/red] Cannot specify both project name and --here flag\")\n        raise typer.Exit(1)\n\n    if not here and not project_name:\n        console.print(\"[red]Error:[/red] Must specify either a project name, use '.' for current directory, or use --here flag\")\n        raise typer.Exit(1)\n\n    if ai_skills and not ai_assistant:\n        console.print(\"[red]Error:[/red] --ai-skills requires --ai to be specified\")\n        console.print(\"[yellow]Usage:[/yellow] specify init <project> --ai <agent> --ai-skills\")\n        raise typer.Exit(1)\n\n    if here:\n        project_name = Path.cwd().name\n        project_path = Path.cwd()\n\n        existing_items = list(project_path.iterdir())\n        if existing_items:\n            console.print(f\"[yellow]Warning:[/yellow] Current directory is not empty ({len(existing_items)} items)\")\n            console.print(\"[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]\")\n            if force:\n                console.print(\"[cyan]--force supplied: skipping confirmation and proceeding with merge[/cyan]\")\n            else:\n                response = typer.confirm(\"Do you want to continue?\")\n                if not response:\n                    console.print(\"[yellow]Operation cancelled[/yellow]\")\n                    raise typer.Exit(0)\n    else:\n        project_path = Path(project_name).resolve()\n        if project_path.exists():\n            error_panel = Panel(\n                f\"Directory '[cyan]{project_name}[/cyan]' already exists\\n\"\n                \"Please choose a different project name or remove the existing directory.\",\n                title=\"[red]Directory Conflict[/red]\",\n                border_style=\"red\",\n                padding=(1, 2)\n            )\n            console.print()\n            console.print(error_panel)\n            raise typer.Exit(1)\n\n    if ai_assistant:\n        if ai_assistant not in AGENT_CONFIG:\n            console.print(f\"[red]Error:[/red] Invalid AI assistant '{ai_assistant}'. Choose from: {', '.join(AGENT_CONFIG.keys())}\")\n            raise typer.Exit(1)\n        selected_ai = ai_assistant\n    else:\n        # Create options dict for selection (agent_key: display_name)\n        ai_choices = {key: config[\"name\"] for key, config in AGENT_CONFIG.items()}\n        selected_ai = select_with_arrows(\n            ai_choices, \n            \"Choose your AI assistant:\", \n            \"copilot\"\n        )\n\n    # Agents that have moved from explicit commands/prompts to agent skills.\n    if selected_ai in AGENT_SKILLS_MIGRATIONS and not ai_skills:\n        # If selected interactively (no --ai provided), automatically enable\n        # ai_skills so the agent remains usable without requiring an extra flag.\n        # Preserve fail-fast behavior only for explicit '--ai <agent>' without skills.\n        if ai_assistant:\n            _handle_agent_skills_migration(console, selected_ai)\n        else:\n            ai_skills = True\n            console.print(f\"\\n[yellow]Note:[/yellow] {AGENT_SKILLS_MIGRATIONS[selected_ai]['interactive_note']}\")\n\n    # Validate --ai-commands-dir usage\n    if selected_ai == \"generic\":\n        if not ai_commands_dir:\n            console.print(\"[red]Error:[/red] --ai-commands-dir is required when using --ai generic\")\n            console.print(\"[dim]Example: specify init my-project --ai generic --ai-commands-dir .myagent/commands/[/dim]\")\n            raise typer.Exit(1)\n    elif ai_commands_dir:\n        console.print(f\"[red]Error:[/red] --ai-commands-dir can only be used with --ai generic (not '{selected_ai}')\")\n        raise typer.Exit(1)\n\n    current_dir = Path.cwd()\n\n    setup_lines = [\n        \"[cyan]Specify Project Setup[/cyan]\",\n        \"\",\n        f\"{'Project':<15} [green]{project_path.name}[/green]\",\n        f\"{'Working Path':<15} [dim]{current_dir}[/dim]\",\n    ]\n\n    if not here:\n        setup_lines.append(f\"{'Target Path':<15} [dim]{project_path}[/dim]\")\n\n    console.print(Panel(\"\\n\".join(setup_lines), border_style=\"cyan\", padding=(1, 2)))\n\n    should_init_git = False\n    if not no_git:\n        should_init_git = check_tool(\"git\")\n        if not should_init_git:\n            console.print(\"[yellow]Git not found - will skip repository initialization[/yellow]\")\n\n    if not ignore_agent_tools:\n        agent_config = AGENT_CONFIG.get(selected_ai)\n        if agent_config and agent_config[\"requires_cli\"]:\n            install_url = agent_config[\"install_url\"]\n            if not check_tool(selected_ai):\n                error_panel = Panel(\n                    f\"[cyan]{selected_ai}[/cyan] not found\\n\"\n                    f\"Install from: [cyan]{install_url}[/cyan]\\n\"\n                    f\"{agent_config['name']} is required to continue with this project type.\\n\\n\"\n                    \"Tip: Use [cyan]--ignore-agent-tools[/cyan] to skip this check\",\n                    title=\"[red]Agent Detection Error[/red]\",\n                    border_style=\"red\",\n                    padding=(1, 2)\n                )\n                console.print()\n                console.print(error_panel)\n                raise typer.Exit(1)\n\n    if script_type:\n        if script_type not in SCRIPT_TYPE_CHOICES:\n            console.print(f\"[red]Error:[/red] Invalid script type '{script_type}'. Choose from: {', '.join(SCRIPT_TYPE_CHOICES.keys())}\")\n            raise typer.Exit(1)\n        selected_script = script_type\n    else:\n        default_script = \"ps\" if os.name == \"nt\" else \"sh\"\n\n        if sys.stdin.isatty():\n            selected_script = select_with_arrows(SCRIPT_TYPE_CHOICES, \"Choose script type (or press Enter)\", default_script)\n        else:\n            selected_script = default_script\n\n    console.print(f\"[cyan]Selected AI assistant:[/cyan] {selected_ai}\")\n    console.print(f\"[cyan]Selected script type:[/cyan] {selected_script}\")\n\n    tracker = StepTracker(\"Initialize Specify Project\")\n\n    sys._specify_tracker_active = True\n\n    tracker.add(\"precheck\", \"Check required tools\")\n    tracker.complete(\"precheck\", \"ok\")\n    tracker.add(\"ai-select\", \"Select AI assistant\")\n    tracker.complete(\"ai-select\", f\"{selected_ai}\")\n    tracker.add(\"script-select\", \"Select script type\")\n    tracker.complete(\"script-select\", selected_script)\n    for key, label in [\n        (\"fetch\", \"Fetch latest release\"),\n        (\"download\", \"Download template\"),\n        (\"extract\", \"Extract template\"),\n        (\"zip-list\", \"Archive contents\"),\n        (\"extracted-summary\", \"Extraction summary\"),\n        (\"chmod\", \"Ensure scripts executable\"),\n        (\"constitution\", \"Constitution setup\"),\n    ]:\n        tracker.add(key, label)\n    if ai_skills:\n        tracker.add(\"ai-skills\", \"Install agent skills\")\n    for key, label in [\n        (\"cleanup\", \"Cleanup\"),\n        (\"git\", \"Initialize git repository\"),\n        (\"final\", \"Finalize\")\n    ]:\n        tracker.add(key, label)\n\n    # Track git error message outside Live context so it persists\n    git_error_message = None\n\n    with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live:\n        tracker.attach_refresh(lambda: live.update(tracker.render()))\n        try:\n            verify = not skip_tls\n            local_ssl_context = ssl_context if verify else False\n            local_client = httpx.Client(verify=local_ssl_context)\n\n            download_and_extract_template(project_path, selected_ai, selected_script, here, verbose=False, tracker=tracker, client=local_client, debug=debug, github_token=github_token)\n\n            # For generic agent, rename placeholder directory to user-specified path\n            if selected_ai == \"generic\" and ai_commands_dir:\n                placeholder_dir = project_path / \".speckit\" / \"commands\"\n                target_dir = project_path / ai_commands_dir\n                if placeholder_dir.is_dir():\n                    target_dir.parent.mkdir(parents=True, exist_ok=True)\n                    shutil.move(str(placeholder_dir), str(target_dir))\n                    # Clean up empty .speckit dir if it's now empty\n                    speckit_dir = project_path / \".speckit\"\n                    if speckit_dir.is_dir() and not any(speckit_dir.iterdir()):\n                        speckit_dir.rmdir()\n\n            ensure_executable_scripts(project_path, tracker=tracker)\n\n            ensure_constitution_from_template(project_path, tracker=tracker)\n\n            if ai_skills:\n                if selected_ai in NATIVE_SKILLS_AGENTS:\n                    skills_dir = _get_skills_dir(project_path, selected_ai)\n                    if not _has_bundled_skills(project_path, selected_ai):\n                        raise RuntimeError(\n                            f\"Expected bundled agent skills in {skills_dir.relative_to(project_path)}, \"\n                            \"but none were found. Re-run with an up-to-date template.\"\n                        )\n                    if tracker:\n                        tracker.start(\"ai-skills\")\n                        tracker.complete(\"ai-skills\", f\"bundled skills → {skills_dir.relative_to(project_path)}\")\n                    else:\n                        console.print(f\"[green]✓[/green] Using bundled agent skills in {skills_dir.relative_to(project_path)}/\")\n                else:\n                    skills_ok = install_ai_skills(project_path, selected_ai, tracker=tracker)\n\n                    # When --ai-skills is used on a NEW project and skills were\n                    # successfully installed, remove the command files that the\n                    # template archive just created.  Skills replace commands, so\n                    # keeping both would be confusing.  For --here on an existing\n                    # repo we leave pre-existing commands untouched to avoid a\n                    # breaking change.  We only delete AFTER skills succeed so the\n                    # project always has at least one of {commands, skills}.\n                    if skills_ok and not here:\n                        agent_cfg = AGENT_CONFIG.get(selected_ai, {})\n                        agent_folder = agent_cfg.get(\"folder\", \"\")\n                        commands_subdir = agent_cfg.get(\"commands_subdir\", \"commands\")\n                        if agent_folder:\n                            cmds_dir = project_path / agent_folder.rstrip(\"/\") / commands_subdir\n                            if cmds_dir.exists():\n                                try:\n                                    shutil.rmtree(cmds_dir)\n                                except OSError:\n                                    # Best-effort cleanup: skills are already installed,\n                                    # so leaving stale commands is non-fatal.\n                                    console.print(\"[yellow]Warning: could not remove extracted commands directory[/yellow]\")\n\n            if not no_git:\n                tracker.start(\"git\")\n                if is_git_repo(project_path):\n                    tracker.complete(\"git\", \"existing repo detected\")\n                elif should_init_git:\n                    success, error_msg = init_git_repo(project_path, quiet=True)\n                    if success:\n                        tracker.complete(\"git\", \"initialized\")\n                    else:\n                        tracker.error(\"git\", \"init failed\")\n                        git_error_message = error_msg\n                else:\n                    tracker.skip(\"git\", \"git not available\")\n            else:\n                tracker.skip(\"git\", \"--no-git flag\")\n\n            # Persist the CLI options so later operations (e.g. preset add)\n            # can adapt their behaviour without re-scanning the filesystem.\n            # Must be saved BEFORE preset install so _get_skills_dir() works.\n            save_init_options(project_path, {\n                \"ai\": selected_ai,\n                \"ai_skills\": ai_skills,\n                \"ai_commands_dir\": ai_commands_dir,\n                \"here\": here,\n                \"preset\": preset,\n                \"script\": selected_script,\n                \"speckit_version\": get_speckit_version(),\n            })\n\n            # Install preset if specified\n            if preset:\n                try:\n                    from .presets import PresetManager, PresetCatalog, PresetError\n                    preset_manager = PresetManager(project_path)\n                    speckit_ver = get_speckit_version()\n\n                    # Try local directory first, then catalog\n                    local_path = Path(preset).resolve()\n                    if local_path.is_dir() and (local_path / \"preset.yml\").exists():\n                        preset_manager.install_from_directory(local_path, speckit_ver)\n                    else:\n                        preset_catalog = PresetCatalog(project_path)\n                        pack_info = preset_catalog.get_pack_info(preset)\n                        if not pack_info:\n                            console.print(f\"[yellow]Warning:[/yellow] Preset '{preset}' not found in catalog. Skipping.\")\n                        else:\n                            try:\n                                zip_path = preset_catalog.download_pack(preset)\n                                preset_manager.install_from_zip(zip_path, speckit_ver)\n                                # Clean up downloaded ZIP to avoid cache accumulation\n                                try:\n                                    zip_path.unlink(missing_ok=True)\n                                except OSError:\n                                    # Best-effort cleanup; failure to delete is non-fatal\n                                    pass\n                            except PresetError as preset_err:\n                                console.print(f\"[yellow]Warning:[/yellow] Failed to install preset '{preset}': {preset_err}\")\n                except Exception as preset_err:\n                    console.print(f\"[yellow]Warning:[/yellow] Failed to install preset: {preset_err}\")\n\n            tracker.complete(\"final\", \"project ready\")\n        except Exception as e:\n            tracker.error(\"final\", str(e))\n            console.print(Panel(f\"Initialization failed: {e}\", title=\"Failure\", border_style=\"red\"))\n            if debug:\n                _env_pairs = [\n                    (\"Python\", sys.version.split()[0]),\n                    (\"Platform\", sys.platform),\n                    (\"CWD\", str(Path.cwd())),\n                ]\n                _label_width = max(len(k) for k, _ in _env_pairs)\n                env_lines = [f\"{k.ljust(_label_width)} → [bright_black]{v}[/bright_black]\" for k, v in _env_pairs]\n                console.print(Panel(\"\\n\".join(env_lines), title=\"Debug Environment\", border_style=\"magenta\"))\n            if not here and project_path.exists():\n                shutil.rmtree(project_path)\n            raise typer.Exit(1)\n        finally:\n            pass\n\n    console.print(tracker.render())\n    console.print(\"\\n[bold green]Project ready.[/bold green]\")\n    \n    # Show git error details if initialization failed\n    if git_error_message:\n        console.print()\n        git_error_panel = Panel(\n            f\"[yellow]Warning:[/yellow] Git repository initialization failed\\n\\n\"\n            f\"{git_error_message}\\n\\n\"\n            f\"[dim]You can initialize git manually later with:[/dim]\\n\"\n            f\"[cyan]cd {project_path if not here else '.'}[/cyan]\\n\"\n            f\"[cyan]git init[/cyan]\\n\"\n            f\"[cyan]git add .[/cyan]\\n\"\n            f\"[cyan]git commit -m \\\"Initial commit\\\"[/cyan]\",\n            title=\"[red]Git Initialization Failed[/red]\",\n            border_style=\"red\",\n            padding=(1, 2)\n        )\n        console.print(git_error_panel)\n\n    # Agent folder security notice\n    agent_config = AGENT_CONFIG.get(selected_ai)\n    if agent_config:\n        agent_folder = ai_commands_dir if selected_ai == \"generic\" else agent_config[\"folder\"]\n        if agent_folder:\n            security_notice = Panel(\n                f\"Some agents may store credentials, auth tokens, or other identifying and private artifacts in the agent folder within your project.\\n\"\n                f\"Consider adding [cyan]{agent_folder}[/cyan] (or parts of it) to [cyan].gitignore[/cyan] to prevent accidental credential leakage.\",\n                title=\"[yellow]Agent Folder Security[/yellow]\",\n                border_style=\"yellow\",\n                padding=(1, 2)\n            )\n            console.print()\n            console.print(security_notice)\n\n    steps_lines = []\n    if not here:\n        steps_lines.append(f\"1. Go to the project folder: [cyan]cd {project_name}[/cyan]\")\n        step_num = 2\n    else:\n        steps_lines.append(\"1. You're already in the project directory!\")\n        step_num = 2\n\n    if selected_ai == \"codex\" and ai_skills:\n        steps_lines.append(f\"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]\")\n        step_num += 1\n\n    codex_skill_mode = selected_ai == \"codex\" and ai_skills\n    kimi_skill_mode = selected_ai == \"kimi\"\n    native_skill_mode = codex_skill_mode or kimi_skill_mode\n    usage_label = \"skills\" if native_skill_mode else \"slash commands\"\n\n    def _display_cmd(name: str) -> str:\n        if codex_skill_mode:\n            return f\"$speckit-{name}\"\n        if kimi_skill_mode:\n            return f\"/skill:speckit.{name}\"\n        return f\"/speckit.{name}\"\n\n    steps_lines.append(f\"{step_num}. Start using {usage_label} with your AI agent:\")\n\n    steps_lines.append(f\"   {step_num}.1 [cyan]{_display_cmd('constitution')}[/] - Establish project principles\")\n    steps_lines.append(f\"   {step_num}.2 [cyan]{_display_cmd('specify')}[/] - Create baseline specification\")\n    steps_lines.append(f\"   {step_num}.3 [cyan]{_display_cmd('plan')}[/] - Create implementation plan\")\n    steps_lines.append(f\"   {step_num}.4 [cyan]{_display_cmd('tasks')}[/] - Generate actionable tasks\")\n    steps_lines.append(f\"   {step_num}.5 [cyan]{_display_cmd('implement')}[/] - Execute implementation\")\n\n    steps_panel = Panel(\"\\n\".join(steps_lines), title=\"Next Steps\", border_style=\"cyan\", padding=(1,2))\n    console.print()\n    console.print(steps_panel)\n\n    enhancement_intro = (\n        \"Optional skills that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]\"\n        if native_skill_mode\n        else \"Optional commands that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]\"\n    )\n    enhancement_lines = [\n        enhancement_intro,\n        \"\",\n        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)\",\n        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')}[/])\",\n        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')}[/])\"\n    ]\n    enhancements_title = \"Enhancement Skills\" if native_skill_mode else \"Enhancement Commands\"\n    enhancements_panel = Panel(\"\\n\".join(enhancement_lines), title=enhancements_title, border_style=\"cyan\", padding=(1,2))\n    console.print()\n    console.print(enhancements_panel)\n\n@app.command()\ndef check():\n    \"\"\"Check that all required tools are installed.\"\"\"\n    show_banner()\n    console.print(\"[bold]Checking for installed tools...[/bold]\\n\")\n\n    tracker = StepTracker(\"Check Available Tools\")\n\n    tracker.add(\"git\", \"Git version control\")\n    git_ok = check_tool(\"git\", tracker=tracker)\n\n    agent_results = {}\n    for agent_key, agent_config in AGENT_CONFIG.items():\n        if agent_key == \"generic\":\n            continue  # Generic is not a real agent to check\n        agent_name = agent_config[\"name\"]\n        requires_cli = agent_config[\"requires_cli\"]\n\n        tracker.add(agent_key, agent_name)\n\n        if requires_cli:\n            agent_results[agent_key] = check_tool(agent_key, tracker=tracker)\n        else:\n            # IDE-based agent - skip CLI check and mark as optional\n            tracker.skip(agent_key, \"IDE-based, no CLI check\")\n            agent_results[agent_key] = False  # Don't count IDE agents as \"found\"\n\n    # Check VS Code variants (not in agent config)\n    tracker.add(\"code\", \"Visual Studio Code\")\n    check_tool(\"code\", tracker=tracker)\n\n    tracker.add(\"code-insiders\", \"Visual Studio Code Insiders\")\n    check_tool(\"code-insiders\", tracker=tracker)\n\n    console.print(tracker.render())\n\n    console.print(\"\\n[bold green]Specify CLI is ready to use![/bold green]\")\n\n    if not git_ok:\n        console.print(\"[dim]Tip: Install git for repository management[/dim]\")\n\n    if not any(agent_results.values()):\n        console.print(\"[dim]Tip: Install an AI assistant for the best experience[/dim]\")\n\n@app.command()\ndef version():\n    \"\"\"Display version and system information.\"\"\"\n    import platform\n    import importlib.metadata\n    \n    show_banner()\n    \n    # Get CLI version from package metadata\n    cli_version = \"unknown\"\n    try:\n        cli_version = importlib.metadata.version(\"specify-cli\")\n    except Exception:\n        # Fallback: try reading from pyproject.toml if running from source\n        try:\n            import tomllib\n            pyproject_path = Path(__file__).parent.parent.parent / \"pyproject.toml\"\n            if pyproject_path.exists():\n                with open(pyproject_path, \"rb\") as f:\n                    data = tomllib.load(f)\n                    cli_version = data.get(\"project\", {}).get(\"version\", \"unknown\")\n        except Exception:\n            pass\n    \n    # Fetch latest template release version\n    repo_owner = \"github\"\n    repo_name = \"spec-kit\"\n    api_url = f\"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/latest\"\n    \n    template_version = \"unknown\"\n    release_date = \"unknown\"\n    \n    try:\n        response = client.get(\n            api_url,\n            timeout=10,\n            follow_redirects=True,\n            headers=_github_auth_headers(),\n        )\n        if response.status_code == 200:\n            release_data = response.json()\n            template_version = release_data.get(\"tag_name\", \"unknown\")\n            # Remove 'v' prefix if present\n            if template_version.startswith(\"v\"):\n                template_version = template_version[1:]\n            release_date = release_data.get(\"published_at\", \"unknown\")\n            if release_date != \"unknown\":\n                # Format the date nicely\n                try:\n                    dt = datetime.fromisoformat(release_date.replace('Z', '+00:00'))\n                    release_date = dt.strftime(\"%Y-%m-%d\")\n                except Exception:\n                    pass\n    except Exception:\n        pass\n\n    info_table = Table(show_header=False, box=None, padding=(0, 2))\n    info_table.add_column(\"Key\", style=\"cyan\", justify=\"right\")\n    info_table.add_column(\"Value\", style=\"white\")\n\n    info_table.add_row(\"CLI Version\", cli_version)\n    info_table.add_row(\"Template Version\", template_version)\n    info_table.add_row(\"Released\", release_date)\n    info_table.add_row(\"\", \"\")\n    info_table.add_row(\"Python\", platform.python_version())\n    info_table.add_row(\"Platform\", platform.system())\n    info_table.add_row(\"Architecture\", platform.machine())\n    info_table.add_row(\"OS Version\", platform.version())\n\n    panel = Panel(\n        info_table,\n        title=\"[bold cyan]Specify CLI Information[/bold cyan]\",\n        border_style=\"cyan\",\n        padding=(1, 2)\n    )\n\n    console.print(panel)\n    console.print()\n\n\n# ===== Extension Commands =====\n\nextension_app = typer.Typer(\n    name=\"extension\",\n    help=\"Manage spec-kit extensions\",\n    add_completion=False,\n)\napp.add_typer(extension_app, name=\"extension\")\n\ncatalog_app = typer.Typer(\n    name=\"catalog\",\n    help=\"Manage extension catalogs\",\n    add_completion=False,\n)\nextension_app.add_typer(catalog_app, name=\"catalog\")\n\npreset_app = typer.Typer(\n    name=\"preset\",\n    help=\"Manage spec-kit presets\",\n    add_completion=False,\n)\napp.add_typer(preset_app, name=\"preset\")\n\npreset_catalog_app = typer.Typer(\n    name=\"catalog\",\n    help=\"Manage preset catalogs\",\n    add_completion=False,\n)\npreset_app.add_typer(preset_catalog_app, name=\"catalog\")\n\n\ndef get_speckit_version() -> str:\n    \"\"\"Get current spec-kit version.\"\"\"\n    import importlib.metadata\n    try:\n        return importlib.metadata.version(\"specify-cli\")\n    except Exception:\n        # Fallback: try reading from pyproject.toml\n        try:\n            import tomllib\n            pyproject_path = Path(__file__).parent.parent.parent / \"pyproject.toml\"\n            if pyproject_path.exists():\n                with open(pyproject_path, \"rb\") as f:\n                    data = tomllib.load(f)\n                    return data.get(\"project\", {}).get(\"version\", \"unknown\")\n        except Exception:\n            # Intentionally ignore any errors while reading/parsing pyproject.toml.\n            # If this lookup fails for any reason, we fall back to returning \"unknown\" below.\n            pass\n    return \"unknown\"\n\n\n# ===== Preset Commands =====\n\n\n@preset_app.command(\"list\")\ndef preset_list():\n    \"\"\"List installed presets.\"\"\"\n    from .presets import PresetManager\n\n    project_root = Path.cwd()\n\n    specify_dir = project_root / \".specify\"\n    if not specify_dir.exists():\n        console.print(\"[red]Error:[/red] Not a spec-kit project (no .specify/ directory)\")\n        console.print(\"Run this command from a spec-kit project root\")\n        raise typer.Exit(1)\n\n    manager = PresetManager(project_root)\n    installed = manager.list_installed()\n\n    if not installed:\n        console.print(\"[yellow]No presets installed.[/yellow]\")\n        console.print(\"\\nInstall a preset with:\")\n        console.print(\"  [cyan]specify preset add <pack-name>[/cyan]\")\n        return\n\n    console.print(\"\\n[bold cyan]Installed Presets:[/bold cyan]\\n\")\n    for pack in installed:\n        status = \"[green]enabled[/green]\" if pack.get(\"enabled\", True) else \"[red]disabled[/red]\"\n        pri = pack.get('priority', 10)\n        console.print(f\"  [bold]{pack['name']}[/bold] ({pack['id']}) v{pack['version']} — {status} — priority {pri}\")\n        console.print(f\"    {pack['description']}\")\n        if pack.get(\"tags\"):\n            tags_str = \", \".join(pack[\"tags\"])\n            console.print(f\"    [dim]Tags: {tags_str}[/dim]\")\n        console.print(f\"    [dim]Templates: {pack['template_count']}[/dim]\")\n        console.print()\n\n\n@preset_app.command(\"add\")\ndef preset_add(\n    pack_id: str = typer.Argument(None, help=\"Preset ID to install from catalog\"),\n    from_url: str = typer.Option(None, \"--from\", help=\"Install from a URL (ZIP file)\"),\n    dev: str = typer.Option(None, \"--dev\", help=\"Install from local directory (development mode)\"),\n    priority: int = typer.Option(10, \"--priority\", help=\"Resolution priority (lower = higher precedence, default 10)\"),\n):\n    \"\"\"Install a preset.\"\"\"\n    from .presets import (\n        PresetManager,\n        PresetCatalog,\n        PresetError,\n        PresetValidationError,\n        PresetCompatibilityError,\n    )\n\n    project_root = Path.cwd()\n\n    specify_dir = project_root / \".specify\"\n    if not specify_dir.exists():\n        console.print(\"[red]Error:[/red] Not a spec-kit project (no .specify/ directory)\")\n        console.print(\"Run this command from a spec-kit project root\")\n        raise typer.Exit(1)\n\n    # Validate priority\n    if priority < 1:\n        console.print(\"[red]Error:[/red] Priority must be a positive integer (1 or higher)\")\n        raise typer.Exit(1)\n\n    manager = PresetManager(project_root)\n    speckit_version = get_speckit_version()\n\n    try:\n        if dev:\n            dev_path = Path(dev).resolve()\n            if not dev_path.exists():\n                console.print(f\"[red]Error:[/red] Directory not found: {dev}\")\n                raise typer.Exit(1)\n\n            console.print(f\"Installing preset from [cyan]{dev_path}[/cyan]...\")\n            manifest = manager.install_from_directory(dev_path, speckit_version, priority)\n            console.print(f\"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})\")\n\n        elif from_url:\n            # Validate URL scheme before downloading\n            from urllib.parse import urlparse as _urlparse\n            _parsed = _urlparse(from_url)\n            _is_localhost = _parsed.hostname in (\"localhost\", \"127.0.0.1\", \"::1\")\n            if _parsed.scheme != \"https\" and not (_parsed.scheme == \"http\" and _is_localhost):\n                console.print(f\"[red]Error:[/red] URL must use HTTPS (got {_parsed.scheme}://). HTTP is only allowed for localhost.\")\n                raise typer.Exit(1)\n\n            console.print(f\"Installing preset from [cyan]{from_url}[/cyan]...\")\n            import urllib.request\n            import urllib.error\n            import tempfile\n\n            with tempfile.TemporaryDirectory() as tmpdir:\n                zip_path = Path(tmpdir) / \"preset.zip\"\n                try:\n                    with urllib.request.urlopen(from_url, timeout=60) as response:\n                        zip_path.write_bytes(response.read())\n                except urllib.error.URLError as e:\n                    console.print(f\"[red]Error:[/red] Failed to download: {e}\")\n                    raise typer.Exit(1)\n\n                manifest = manager.install_from_zip(zip_path, speckit_version, priority)\n\n            console.print(f\"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})\")\n\n        elif pack_id:\n            catalog = PresetCatalog(project_root)\n            pack_info = catalog.get_pack_info(pack_id)\n\n            if not pack_info:\n                console.print(f\"[red]Error:[/red] Preset '{pack_id}' not found in catalog\")\n                raise typer.Exit(1)\n\n            if not pack_info.get(\"_install_allowed\", True):\n                catalog_name = pack_info.get(\"_catalog_name\", \"unknown\")\n                console.print(f\"[red]Error:[/red] Preset '{pack_id}' is from the '{catalog_name}' catalog which is discovery-only (install not allowed).\")\n                console.print(\"Add the catalog with --install-allowed or install from the preset's repository directly with --from.\")\n                raise typer.Exit(1)\n\n            console.print(f\"Installing preset [cyan]{pack_info.get('name', pack_id)}[/cyan]...\")\n\n            try:\n                zip_path = catalog.download_pack(pack_id)\n                manifest = manager.install_from_zip(zip_path, speckit_version, priority)\n                console.print(f\"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})\")\n            finally:\n                if 'zip_path' in locals() and zip_path.exists():\n                    zip_path.unlink(missing_ok=True)\n        else:\n            console.print(\"[red]Error:[/red] Specify a preset ID, --from URL, or --dev path\")\n            raise typer.Exit(1)\n\n    except PresetCompatibilityError as e:\n        console.print(f\"[red]Compatibility Error:[/red] {e}\")\n        raise typer.Exit(1)\n    except PresetValidationError as e:\n        console.print(f\"[red]Validation Error:[/red] {e}\")\n        raise typer.Exit(1)\n    except PresetError as e:\n        console.print(f\"[red]Error:[/red] {e}\")\n        raise typer.Exit(1)\n\n\n@preset_app.command(\"remove\")\ndef preset_remove(\n    pack_id: str = typer.Argument(..., help=\"Preset ID to remove\"),\n):\n    \"\"\"Remove an installed preset.\"\"\"\n    from .presets import PresetManager\n\n    project_root = Path.cwd()\n\n    specify_dir = project_root / \".specify\"\n    if not specify_dir.exists():\n        console.print(\"[red]Error:[/red] Not a spec-kit project (no .specify/ directory)\")\n        console.print(\"Run this command from a spec-kit project root\")\n        raise typer.Exit(1)\n\n    manager = PresetManager(project_root)\n\n    if not manager.registry.is_installed(pack_id):\n        console.print(f\"[red]Error:[/red] Preset '{pack_id}' is not installed\")\n        raise typer.Exit(1)\n\n    if manager.remove(pack_id):\n        console.print(f\"[green]✓[/green] Preset '{pack_id}' removed successfully\")\n    else:\n        console.print(f\"[red]Error:[/red] Failed to remove preset '{pack_id}'\")\n        raise typer.Exit(1)\n\n\n@preset_app.command(\"search\")\ndef preset_search(\n    query: str = typer.Argument(None, help=\"Search query\"),\n    tag: str = typer.Option(None, \"--tag\", help=\"Filter by tag\"),\n    author: str = typer.Option(None, \"--author\", help=\"Filter by author\"),\n):\n    \"\"\"Search for presets in the catalog.\"\"\"\n    from .presets import PresetCatalog, PresetError\n\n    project_root = Path.cwd()\n\n    specify_dir = project_root / \".specify\"\n    if not specify_dir.exists():\n        console.print(\"[red]Error:[/red] Not a spec-kit project (no .specify/ directory)\")\n        console.print(\"Run this command from a spec-kit project root\")\n        raise typer.Exit(1)\n\n    catalog = PresetCatalog(project_root)\n\n    try:\n        results = catalog.search(query=query, tag=tag, author=author)\n    except PresetError as e:\n        console.print(f\"[red]Error:[/red] {e}\")\n        raise typer.Exit(1)\n\n    if not results:\n        console.print(\"[yellow]No presets found matching your criteria.[/yellow]\")\n        return\n\n    console.print(f\"\\n[bold cyan]Presets ({len(results)} found):[/bold cyan]\\n\")\n    for pack in results:\n        console.print(f\"  [bold]{pack.get('name', pack['id'])}[/bold] ({pack['id']}) v{pack.get('version', '?')}\")\n        console.print(f\"    {pack.get('description', '')}\")\n        if pack.get(\"tags\"):\n            tags_str = \", \".join(pack[\"tags\"])\n            console.print(f\"    [dim]Tags: {tags_str}[/dim]\")\n        console.print()\n\n\n@preset_app.command(\"resolve\")\ndef preset_resolve(\n    template_name: str = typer.Argument(..., help=\"Template name to resolve (e.g., spec-template)\"),\n):\n    \"\"\"Show which template will be resolved for a given name.\"\"\"\n    from .presets import PresetResolver\n\n    project_root = Path.cwd()\n\n    specify_dir = project_root / \".specify\"\n    if not specify_dir.exists():\n        console.print(\"[red]Error:[/red] Not a spec-kit project (no .specify/ directory)\")\n        console.print(\"Run this command from a spec-kit project root\")\n        raise typer.Exit(1)\n\n    resolver = PresetResolver(project_root)\n    result = resolver.resolve_with_source(template_name)\n\n    if result:\n        console.print(f\"  [bold]{template_name}[/bold]: {result['path']}\")\n        console.print(f\"    [dim](from: {result['source']})[/dim]\")\n    else:\n        console.print(f\"  [yellow]{template_name}[/yellow]: not found\")\n        console.print(\"    [dim]No template with this name exists in the resolution stack[/dim]\")\n\n\n@preset_app.command(\"info\")\ndef preset_info(\n    pack_id: str = typer.Argument(..., help=\"Preset ID to get info about\"),\n):\n    \"\"\"Show detailed information about a preset.\"\"\"\n    from .extensions import normalize_priority\n    from .presets import PresetCatalog, PresetManager, PresetError\n\n    project_root = Path.cwd()\n\n    specify_dir = project_root / \".specify\"\n    if not specify_dir.exists():\n        console.print(\"[red]Error:[/red] Not a spec-kit project (no .specify/ directory)\")\n        console.print(\"Run this command from a spec-kit project root\")\n        raise typer.Exit(1)\n\n    # Check if installed locally first\n    manager = PresetManager(project_root)\n    local_pack = manager.get_pack(pack_id)\n\n    if local_pack:\n        console.print(f\"\\n[bold cyan]Preset: {local_pack.name}[/bold cyan]\\n\")\n        console.print(f\"  ID:          {local_pack.id}\")\n        console.print(f\"  Version:     {local_pack.version}\")\n        console.print(f\"  Description: {local_pack.description}\")\n        if local_pack.author:\n            console.print(f\"  Author:      {local_pack.author}\")\n        if local_pack.tags:\n            console.print(f\"  Tags:        {', '.join(local_pack.tags)}\")\n        console.print(f\"  Templates:   {len(local_pack.templates)}\")\n        for tmpl in local_pack.templates:\n            console.print(f\"    - {tmpl['name']} ({tmpl['type']}): {tmpl.get('description', '')}\")\n        repo = local_pack.data.get(\"preset\", {}).get(\"repository\")\n        if repo:\n            console.print(f\"  Repository:  {repo}\")\n        license_val = local_pack.data.get(\"preset\", {}).get(\"license\")\n        if license_val:\n            console.print(f\"  License:     {license_val}\")\n        console.print(\"\\n  [green]Status: installed[/green]\")\n        # Get priority from registry\n        pack_metadata = manager.registry.get(pack_id)\n        priority = normalize_priority(pack_metadata.get(\"priority\") if isinstance(pack_metadata, dict) else None)\n        console.print(f\"  [dim]Priority:[/dim] {priority}\")\n        console.print()\n        return\n\n    # Fall back to catalog\n    catalog = PresetCatalog(project_root)\n    try:\n        pack_info = catalog.get_pack_info(pack_id)\n    except PresetError:\n        pack_info = None\n\n    if not pack_info:\n        console.print(f\"[red]Error:[/red] Preset '{pack_id}' not found (not installed and not in catalog)\")\n        raise typer.Exit(1)\n\n    console.print(f\"\\n[bold cyan]Preset: {pack_info.get('name', pack_id)}[/bold cyan]\\n\")\n    console.print(f\"  ID:          {pack_info['id']}\")\n    console.print(f\"  Version:     {pack_info.get('version', '?')}\")\n    console.print(f\"  Description: {pack_info.get('description', '')}\")\n    if pack_info.get(\"author\"):\n        console.print(f\"  Author:      {pack_info['author']}\")\n    if pack_info.get(\"tags\"):\n        console.print(f\"  Tags:        {', '.join(pack_info['tags'])}\")\n    if pack_info.get(\"repository\"):\n        console.print(f\"  Repository:  {pack_info['repository']}\")\n    if pack_info.get(\"license\"):\n        console.print(f\"  License:     {pack_info['license']}\")\n    console.print(\"\\n  [yellow]Status: not installed[/yellow]\")\n    console.print(f\"  Install with: [cyan]specify preset add {pack_id}[/cyan]\")\n    console.print()\n\n\n@preset_app.command(\"set-priority\")\ndef preset_set_priority(\n    pack_id: str = typer.Argument(help=\"Preset ID\"),\n    priority: int = typer.Argument(help=\"New priority (lower = higher precedence)\"),\n):\n    \"\"\"Set the resolution priority of an installed preset.\"\"\"\n    from .presets import PresetManager\n\n    project_root = Path.cwd()\n\n    # Check if we're in a spec-kit project\n    specify_dir = project_root / \".specify\"\n    if not specify_dir.exists():\n        console.print(\"[red]Error:[/red] Not a spec-kit project (no .specify/ directory)\")\n        console.print(\"Run this command from a spec-kit project root\")\n        raise typer.Exit(1)\n\n    # Validate priority\n    if priority < 1:\n        console.print(\"[red]Error:[/red] Priority must be a positive integer (1 or higher)\")\n        raise typer.Exit(1)\n\n    manager = PresetManager(project_root)\n\n    # Check if preset is installed\n    if not manager.registry.is_installed(pack_id):\n        console.print(f\"[red]Error:[/red] Preset '{pack_id}' is not installed\")\n        raise typer.Exit(1)\n\n    # Get current metadata\n    metadata = manager.registry.get(pack_id)\n    if metadata is None or not isinstance(metadata, dict):\n        console.print(f\"[red]Error:[/red] Preset '{pack_id}' not found in registry (corrupted state)\")\n        raise typer.Exit(1)\n\n    from .extensions import normalize_priority\n    raw_priority = metadata.get(\"priority\")\n    # Only skip if the stored value is already a valid int equal to requested priority\n    # This ensures corrupted values (e.g., \"high\") get repaired even when setting to default (10)\n    if isinstance(raw_priority, int) and raw_priority == priority:\n        console.print(f\"[yellow]Preset '{pack_id}' already has priority {priority}[/yellow]\")\n        raise typer.Exit(0)\n\n    old_priority = normalize_priority(raw_priority)\n\n    # Update priority\n    manager.registry.update(pack_id, {\"priority\": priority})\n\n    console.print(f\"[green]✓[/green] Preset '{pack_id}' priority changed: {old_priority} → {priority}\")\n    console.print(\"\\n[dim]Lower priority = higher precedence in template resolution[/dim]\")\n\n\n@preset_app.command(\"enable\")\ndef preset_enable(\n    pack_id: str = typer.Argument(help=\"Preset ID to enable\"),\n):\n    \"\"\"Enable a disabled preset.\"\"\"\n    from .presets import PresetManager\n\n    project_root = Path.cwd()\n\n    # Check if we're in a spec-kit project\n    specify_dir = project_root / \".specify\"\n    if not specify_dir.exists():\n        console.print(\"[red]Error:[/red] Not a spec-kit project (no .specify/ directory)\")\n        console.print(\"Run this command from a spec-kit project root\")\n        raise typer.Exit(1)\n\n    manager = PresetManager(project_root)\n\n    # Check if preset is installed\n    if not manager.registry.is_installed(pack_id):\n        console.print(f\"[red]Error:[/red] Preset '{pack_id}' is not installed\")\n        raise typer.Exit(1)\n\n    # Get current metadata\n    metadata = manager.registry.get(pack_id)\n    if metadata is None or not isinstance(metadata, dict):\n        console.print(f\"[red]Error:[/red] Preset '{pack_id}' not found in registry (corrupted state)\")\n        raise typer.Exit(1)\n\n    if metadata.get(\"enabled\", True):\n        console.print(f\"[yellow]Preset '{pack_id}' is already enabled[/yellow]\")\n        raise typer.Exit(0)\n\n    # Enable the preset\n    manager.registry.update(pack_id, {\"enabled\": True})\n\n    console.print(f\"[green]✓[/green] Preset '{pack_id}' enabled\")\n    console.print(\"\\nTemplates from this preset will now be included in resolution.\")\n    console.print(\"[dim]Note: Previously registered commands/skills remain active.[/dim]\")\n\n\n@preset_app.command(\"disable\")\ndef preset_disable(\n    pack_id: str = typer.Argument(help=\"Preset ID to disable\"),\n):\n    \"\"\"Disable a preset without removing it.\"\"\"\n    from .presets import PresetManager\n\n    project_root = Path.cwd()\n\n    # Check if we're in a spec-kit project\n    specify_dir = project_root / \".specify\"\n    if not specify_dir.exists():\n        console.print(\"[red]Error:[/red] Not a spec-kit project (no .specify/ directory)\")\n        console.print(\"Run this command from a spec-kit project root\")\n        raise typer.Exit(1)\n\n    manager = PresetManager(project_root)\n\n    # Check if preset is installed\n    if not manager.registry.is_installed(pack_id):\n        console.print(f\"[red]Error:[/red] Preset '{pack_id}' is not installed\")\n        raise typer.Exit(1)\n\n    # Get current metadata\n    metadata = manager.registry.get(pack_id)\n    if metadata is None or not isinstance(metadata, dict):\n        console.print(f\"[red]Error:[/red] Preset '{pack_id}' not found in registry (corrupted state)\")\n        raise typer.Exit(1)\n\n    if not metadata.get(\"enabled\", True):\n        console.print(f\"[yellow]Preset '{pack_id}' is already disabled[/yellow]\")\n        raise typer.Exit(0)\n\n    # Disable the preset\n    manager.registry.update(pack_id, {\"enabled\": False})\n\n    console.print(f\"[green]✓[/green] Preset '{pack_id}' disabled\")\n    console.print(\"\\nTemplates from this preset will be skipped during resolution.\")\n    console.print(\"[dim]Note: Previously registered commands/skills remain active until preset removal.[/dim]\")\n    console.print(f\"To re-enable: specify preset enable {pack_id}\")\n\n\n# ===== Preset Catalog Commands =====\n\n\n@preset_catalog_app.command(\"list\")\ndef preset_catalog_list():\n    \"\"\"List all active preset catalogs.\"\"\"\n    from .presets import PresetCatalog, PresetValidationError\n\n    project_root = Path.cwd()\n\n    specify_dir = project_root / \".specify\"\n    if not specify_dir.exists():\n        console.print(\"[red]Error:[/red] Not a spec-kit project (no .specify/ directory)\")\n        console.print(\"Run this command from a spec-kit project root\")\n        raise typer.Exit(1)\n\n    catalog = PresetCatalog(project_root)\n\n    try:\n        active_catalogs = catalog.get_active_catalogs()\n    except PresetValidationError as e:\n        console.print(f\"[red]Error:[/red] {e}\")\n        raise typer.Exit(1)\n\n    console.print(\"\\n[bold cyan]Active Preset Catalogs:[/bold cyan]\\n\")\n    for entry in active_catalogs:\n        install_str = (\n            \"[green]install allowed[/green]\"\n            if entry.install_allowed\n            else \"[yellow]discovery only[/yellow]\"\n        )\n        console.print(f\"  [bold]{entry.name}[/bold] (priority {entry.priority})\")\n        if entry.description:\n            console.print(f\"     {entry.description}\")\n        console.print(f\"     URL: {entry.url}\")\n        console.print(f\"     Install: {install_str}\")\n        console.print()\n\n    config_path = project_root / \".specify\" / \"preset-catalogs.yml\"\n    user_config_path = Path.home() / \".specify\" / \"preset-catalogs.yml\"\n    if os.environ.get(\"SPECKIT_PRESET_CATALOG_URL\"):\n        console.print(\"[dim]Catalog configured via SPECKIT_PRESET_CATALOG_URL environment variable.[/dim]\")\n    else:\n        try:\n            proj_loaded = config_path.exists() and catalog._load_catalog_config(config_path) is not None\n        except PresetValidationError:\n            proj_loaded = False\n        if proj_loaded:\n            console.print(f\"[dim]Config: {config_path.relative_to(project_root)}[/dim]\")\n        else:\n            try:\n                user_loaded = user_config_path.exists() and catalog._load_catalog_config(user_config_path) is not None\n            except PresetValidationError:\n                user_loaded = False\n            if user_loaded:\n                console.print(\"[dim]Config: ~/.specify/preset-catalogs.yml[/dim]\")\n            else:\n                console.print(\"[dim]Using built-in default catalog stack.[/dim]\")\n                console.print(\n                    \"[dim]Add .specify/preset-catalogs.yml to customize.[/dim]\"\n                )\n\n\n@preset_catalog_app.command(\"add\")\ndef preset_catalog_add(\n    url: str = typer.Argument(help=\"Catalog URL (must use HTTPS)\"),\n    name: str = typer.Option(..., \"--name\", help=\"Catalog name\"),\n    priority: int = typer.Option(10, \"--priority\", help=\"Priority (lower = higher priority)\"),\n    install_allowed: bool = typer.Option(\n        False, \"--install-allowed/--no-install-allowed\",\n        help=\"Allow presets from this catalog to be installed\",\n    ),\n    description: str = typer.Option(\"\", \"--description\", help=\"Description of the catalog\"),\n):\n    \"\"\"Add a catalog to .specify/preset-catalogs.yml.\"\"\"\n    from .presets import PresetCatalog, PresetValidationError\n\n    project_root = Path.cwd()\n\n    specify_dir = project_root / \".specify\"\n    if not specify_dir.exists():\n        console.print(\"[red]Error:[/red] Not a spec-kit project (no .specify/ directory)\")\n        console.print(\"Run this command from a spec-kit project root\")\n        raise typer.Exit(1)\n\n    # Validate URL\n    tmp_catalog = PresetCatalog(project_root)\n    try:\n        tmp_catalog._validate_catalog_url(url)\n    except PresetValidationError as e:\n        console.print(f\"[red]Error:[/red] {e}\")\n        raise typer.Exit(1)\n\n    config_path = specify_dir / \"preset-catalogs.yml\"\n\n    # Load existing config\n    if config_path.exists():\n        try:\n            config = yaml.safe_load(config_path.read_text()) or {}\n        except Exception as e:\n            console.print(f\"[red]Error:[/red] Failed to read {config_path}: {e}\")\n            raise typer.Exit(1)\n    else:\n        config = {}\n\n    catalogs = config.get(\"catalogs\", [])\n    if not isinstance(catalogs, list):\n        console.print(\"[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.\")\n        raise typer.Exit(1)\n\n    # Check for duplicate name\n    for existing in catalogs:\n        if isinstance(existing, dict) and existing.get(\"name\") == name:\n            console.print(f\"[yellow]Warning:[/yellow] A catalog named '{name}' already exists.\")\n            console.print(\"Use 'specify preset catalog remove' first, or choose a different name.\")\n            raise typer.Exit(1)\n\n    catalogs.append({\n        \"name\": name,\n        \"url\": url,\n        \"priority\": priority,\n        \"install_allowed\": install_allowed,\n        \"description\": description,\n    })\n\n    config[\"catalogs\"] = catalogs\n    config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))\n\n    install_label = \"install allowed\" if install_allowed else \"discovery only\"\n    console.print(f\"\\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})\")\n    console.print(f\"  URL: {url}\")\n    console.print(f\"  Priority: {priority}\")\n    console.print(f\"\\nConfig saved to {config_path.relative_to(project_root)}\")\n\n\n@preset_catalog_app.command(\"remove\")\ndef preset_catalog_remove(\n    name: str = typer.Argument(help=\"Catalog name to remove\"),\n):\n    \"\"\"Remove a catalog from .specify/preset-catalogs.yml.\"\"\"\n    project_root = Path.cwd()\n\n    specify_dir = project_root / \".specify\"\n    if not specify_dir.exists():\n        console.print(\"[red]Error:[/red] Not a spec-kit project (no .specify/ directory)\")\n        console.print(\"Run this command from a spec-kit project root\")\n        raise typer.Exit(1)\n\n    config_path = specify_dir / \"preset-catalogs.yml\"\n    if not config_path.exists():\n        console.print(\"[red]Error:[/red] No preset catalog config found. Nothing to remove.\")\n        raise typer.Exit(1)\n\n    try:\n        config = yaml.safe_load(config_path.read_text()) or {}\n    except Exception:\n        console.print(\"[red]Error:[/red] Failed to read preset catalog config.\")\n        raise typer.Exit(1)\n\n    catalogs = config.get(\"catalogs\", [])\n    if not isinstance(catalogs, list):\n        console.print(\"[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.\")\n        raise typer.Exit(1)\n    original_count = len(catalogs)\n    catalogs = [c for c in catalogs if isinstance(c, dict) and c.get(\"name\") != name]\n\n    if len(catalogs) == original_count:\n        console.print(f\"[red]Error:[/red] Catalog '{name}' not found.\")\n        raise typer.Exit(1)\n\n    config[\"catalogs\"] = catalogs\n    config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))\n\n    console.print(f\"[green]✓[/green] Removed catalog '{name}'\")\n    if not catalogs:\n        console.print(\"\\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]\")\n\n\n# ===== Extension Commands =====\n\n\ndef _resolve_installed_extension(\n    argument: str,\n    installed_extensions: list,\n    command_name: str = \"command\",\n    allow_not_found: bool = False,\n) -> tuple[Optional[str], Optional[str]]:\n    \"\"\"Resolve an extension argument (ID or display name) to an installed extension.\n\n    Args:\n        argument: Extension ID or display name provided by user\n        installed_extensions: List of installed extension dicts from manager.list_installed()\n        command_name: Name of the command for error messages (e.g., \"enable\", \"disable\")\n        allow_not_found: If True, return (None, None) when not found instead of raising\n\n    Returns:\n        Tuple of (extension_id, display_name), or (None, None) if allow_not_found=True and not found\n\n    Raises:\n        typer.Exit: If extension not found (and allow_not_found=False) or name is ambiguous\n    \"\"\"\n    from rich.table import Table\n\n    # First, try exact ID match\n    for ext in installed_extensions:\n        if ext[\"id\"] == argument:\n            return (ext[\"id\"], ext[\"name\"])\n\n    # If not found by ID, try display name match\n    name_matches = [ext for ext in installed_extensions if ext[\"name\"].lower() == argument.lower()]\n\n    if len(name_matches) == 1:\n        # Unique display-name match\n        return (name_matches[0][\"id\"], name_matches[0][\"name\"])\n    elif len(name_matches) > 1:\n        # Ambiguous display-name match\n        console.print(\n            f\"[red]Error:[/red] Extension name '{argument}' is ambiguous. \"\n            \"Multiple installed extensions share this name:\"\n        )\n        table = Table(title=\"Matching extensions\")\n        table.add_column(\"ID\", style=\"cyan\", no_wrap=True)\n        table.add_column(\"Name\", style=\"white\")\n        table.add_column(\"Version\", style=\"green\")\n        for ext in name_matches:\n            table.add_row(ext.get(\"id\", \"\"), ext.get(\"name\", \"\"), str(ext.get(\"version\", \"\")))\n        console.print(table)\n        console.print(\"\\nPlease rerun using the extension ID:\")\n        console.print(f\"  [bold]specify extension {command_name} <extension-id>[/bold]\")\n        raise typer.Exit(1)\n    else:\n        # No match by ID or display name\n        if allow_not_found:\n            return (None, None)\n        console.print(f\"[red]Error:[/red] Extension '{argument}' is not installed\")\n        raise typer.Exit(1)\n\n\ndef _resolve_catalog_extension(\n    argument: str,\n    catalog,\n    command_name: str = \"info\",\n) -> tuple[Optional[dict], Optional[Exception]]:\n    \"\"\"Resolve an extension argument (ID or display name) from the catalog.\n\n    Args:\n        argument: Extension ID or display name provided by user\n        catalog: ExtensionCatalog instance\n        command_name: Name of the command for error messages\n\n    Returns:\n        Tuple of (extension_info, catalog_error)\n        - If found: (ext_info_dict, None)\n        - If catalog error: (None, error)\n        - If not found: (None, None)\n    \"\"\"\n    from rich.table import Table\n    from .extensions import ExtensionError\n\n    try:\n        # First try by ID\n        ext_info = catalog.get_extension_info(argument)\n        if ext_info:\n            return (ext_info, None)\n\n        # Try by display name - search using argument as query, then filter for exact match\n        search_results = catalog.search(query=argument)\n        name_matches = [ext for ext in search_results if ext[\"name\"].lower() == argument.lower()]\n\n        if len(name_matches) == 1:\n            return (name_matches[0], None)\n        elif len(name_matches) > 1:\n            # Ambiguous display-name match in catalog\n            console.print(\n                f\"[red]Error:[/red] Extension name '{argument}' is ambiguous. \"\n                \"Multiple catalog extensions share this name:\"\n            )\n            table = Table(title=\"Matching extensions\")\n            table.add_column(\"ID\", style=\"cyan\", no_wrap=True)\n            table.add_column(\"Name\", style=\"white\")\n            table.add_column(\"Version\", style=\"green\")\n            table.add_column(\"Catalog\", style=\"dim\")\n            for ext in name_matches:\n                table.add_row(\n                    ext.get(\"id\", \"\"),\n                    ext.get(\"name\", \"\"),\n                    str(ext.get(\"version\", \"\")),\n                    ext.get(\"_catalog_name\", \"\"),\n                )\n            console.print(table)\n            console.print(\"\\nPlease rerun using the extension ID:\")\n            console.print(f\"  [bold]specify extension {command_name} <extension-id>[/bold]\")\n            raise typer.Exit(1)\n\n        # Not found\n        return (None, None)\n\n    except ExtensionError as e:\n        return (None, e)\n\n\n@extension_app.command(\"list\")\ndef extension_list(\n    available: bool = typer.Option(False, \"--available\", help=\"Show available extensions from catalog\"),\n    all_extensions: bool = typer.Option(False, \"--all\", help=\"Show both installed and available\"),\n):\n    \"\"\"List installed extensions.\"\"\"\n    from .extensions import ExtensionManager\n\n    project_root = Path.cwd()\n\n    # Check if we're in a spec-kit project\n    specify_dir = project_root / \".specify\"\n    if not specify_dir.exists():\n        console.print(\"[red]Error:[/red] Not a spec-kit project (no .specify/ directory)\")\n        console.print(\"Run this command from a spec-kit project root\")\n        raise typer.Exit(1)\n\n    manager = ExtensionManager(project_root)\n    installed = manager.list_installed()\n\n    if not installed and not (available or all_extensions):\n        console.print(\"[yellow]No extensions installed.[/yellow]\")\n        console.print(\"\\nInstall an extension with:\")\n        console.print(\"  specify extension add <extension-name>\")\n        return\n\n    if installed:\n        console.print(\"\\n[bold cyan]Installed Extensions:[/bold cyan]\\n\")\n\n        for ext in installed:\n            status_icon = \"✓\" if ext[\"enabled\"] else \"✗\"\n            status_color = \"green\" if ext[\"enabled\"] else \"red\"\n\n            console.print(f\"  [{status_color}]{status_icon}[/{status_color}] [bold]{ext['name']}[/bold] (v{ext['version']})\")\n            console.print(f\"     [dim]{ext['id']}[/dim]\")\n            console.print(f\"     {ext['description']}\")\n            console.print(f\"     Commands: {ext['command_count']} | Hooks: {ext['hook_count']} | Priority: {ext['priority']} | Status: {'Enabled' if ext['enabled'] else 'Disabled'}\")\n            console.print()\n\n    if available or all_extensions:\n        console.print(\"\\nInstall an extension:\")\n        console.print(\"  [cyan]specify extension add <name>[/cyan]\")\n\n\n@catalog_app.command(\"list\")\ndef catalog_list():\n    \"\"\"List all active extension catalogs.\"\"\"\n    from .extensions import ExtensionCatalog, ValidationError\n\n    project_root = Path.cwd()\n\n    specify_dir = project_root / \".specify\"\n    if not specify_dir.exists():\n        console.print(\"[red]Error:[/red] Not a spec-kit project (no .specify/ directory)\")\n        console.print(\"Run this command from a spec-kit project root\")\n        raise typer.Exit(1)\n\n    catalog = ExtensionCatalog(project_root)\n\n    try:\n        active_catalogs = catalog.get_active_catalogs()\n    except ValidationError as e:\n        console.print(f\"[red]Error:[/red] {e}\")\n        raise typer.Exit(1)\n\n    console.print(\"\\n[bold cyan]Active Extension Catalogs:[/bold cyan]\\n\")\n    for entry in active_catalogs:\n        install_str = (\n            \"[green]install allowed[/green]\"\n            if entry.install_allowed\n            else \"[yellow]discovery only[/yellow]\"\n        )\n        console.print(f\"  [bold]{entry.name}[/bold] (priority {entry.priority})\")\n        if entry.description:\n            console.print(f\"     {entry.description}\")\n        console.print(f\"     URL: {entry.url}\")\n        console.print(f\"     Install: {install_str}\")\n        console.print()\n\n    config_path = project_root / \".specify\" / \"extension-catalogs.yml\"\n    user_config_path = Path.home() / \".specify\" / \"extension-catalogs.yml\"\n    if os.environ.get(\"SPECKIT_CATALOG_URL\"):\n        console.print(\"[dim]Catalog configured via SPECKIT_CATALOG_URL environment variable.[/dim]\")\n    else:\n        try:\n            proj_loaded = config_path.exists() and catalog._load_catalog_config(config_path) is not None\n        except ValidationError:\n            proj_loaded = False\n        if proj_loaded:\n            console.print(f\"[dim]Config: {config_path.relative_to(project_root)}[/dim]\")\n        else:\n            try:\n                user_loaded = user_config_path.exists() and catalog._load_catalog_config(user_config_path) is not None\n            except ValidationError:\n                user_loaded = False\n            if user_loaded:\n                console.print(\"[dim]Config: ~/.specify/extension-catalogs.yml[/dim]\")\n            else:\n                console.print(\"[dim]Using built-in default catalog stack.[/dim]\")\n                console.print(\n                    \"[dim]Add .specify/extension-catalogs.yml to customize.[/dim]\"\n                )\n\n\n@catalog_app.command(\"add\")\ndef catalog_add(\n    url: str = typer.Argument(help=\"Catalog URL (must use HTTPS)\"),\n    name: str = typer.Option(..., \"--name\", help=\"Catalog name\"),\n    priority: int = typer.Option(10, \"--priority\", help=\"Priority (lower = higher priority)\"),\n    install_allowed: bool = typer.Option(\n        False, \"--install-allowed/--no-install-allowed\",\n        help=\"Allow extensions from this catalog to be installed\",\n    ),\n    description: str = typer.Option(\"\", \"--description\", help=\"Description of the catalog\"),\n):\n    \"\"\"Add a catalog to .specify/extension-catalogs.yml.\"\"\"\n    from .extensions import ExtensionCatalog, ValidationError\n\n    project_root = Path.cwd()\n\n    specify_dir = project_root / \".specify\"\n    if not specify_dir.exists():\n        console.print(\"[red]Error:[/red] Not a spec-kit project (no .specify/ directory)\")\n        console.print(\"Run this command from a spec-kit project root\")\n        raise typer.Exit(1)\n\n    # Validate URL\n    tmp_catalog = ExtensionCatalog(project_root)\n    try:\n        tmp_catalog._validate_catalog_url(url)\n    except ValidationError as e:\n        console.print(f\"[red]Error:[/red] {e}\")\n        raise typer.Exit(1)\n\n    config_path = specify_dir / \"extension-catalogs.yml\"\n\n    # Load existing config\n    if config_path.exists():\n        try:\n            config = yaml.safe_load(config_path.read_text()) or {}\n        except Exception as e:\n            console.print(f\"[red]Error:[/red] Failed to read {config_path}: {e}\")\n            raise typer.Exit(1)\n    else:\n        config = {}\n\n    catalogs = config.get(\"catalogs\", [])\n    if not isinstance(catalogs, list):\n        console.print(\"[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.\")\n        raise typer.Exit(1)\n\n    # Check for duplicate name\n    for existing in catalogs:\n        if isinstance(existing, dict) and existing.get(\"name\") == name:\n            console.print(f\"[yellow]Warning:[/yellow] A catalog named '{name}' already exists.\")\n            console.print(\"Use 'specify extension catalog remove' first, or choose a different name.\")\n            raise typer.Exit(1)\n\n    catalogs.append({\n        \"name\": name,\n        \"url\": url,\n        \"priority\": priority,\n        \"install_allowed\": install_allowed,\n        \"description\": description,\n    })\n\n    config[\"catalogs\"] = catalogs\n    config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))\n\n    install_label = \"install allowed\" if install_allowed else \"discovery only\"\n    console.print(f\"\\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})\")\n    console.print(f\"  URL: {url}\")\n    console.print(f\"  Priority: {priority}\")\n    console.print(f\"\\nConfig saved to {config_path.relative_to(project_root)}\")\n\n\n@catalog_app.command(\"remove\")\ndef catalog_remove(\n    name: str = typer.Argument(help=\"Catalog name to remove\"),\n):\n    \"\"\"Remove a catalog from .specify/extension-catalogs.yml.\"\"\"\n    project_root = Path.cwd()\n\n    specify_dir = project_root / \".specify\"\n    if not specify_dir.exists():\n        console.print(\"[red]Error:[/red] Not a spec-kit project (no .specify/ directory)\")\n        console.print(\"Run this command from a spec-kit project root\")\n        raise typer.Exit(1)\n\n    config_path = specify_dir / \"extension-catalogs.yml\"\n    if not config_path.exists():\n        console.print(\"[red]Error:[/red] No catalog config found. Nothing to remove.\")\n        raise typer.Exit(1)\n\n    try:\n        config = yaml.safe_load(config_path.read_text()) or {}\n    except Exception:\n        console.print(\"[red]Error:[/red] Failed to read catalog config.\")\n        raise typer.Exit(1)\n\n    catalogs = config.get(\"catalogs\", [])\n    if not isinstance(catalogs, list):\n        console.print(\"[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.\")\n        raise typer.Exit(1)\n    original_count = len(catalogs)\n    catalogs = [c for c in catalogs if isinstance(c, dict) and c.get(\"name\") != name]\n\n    if len(catalogs) == original_count:\n        console.print(f\"[red]Error:[/red] Catalog '{name}' not found.\")\n        raise typer.Exit(1)\n\n    config[\"catalogs\"] = catalogs\n    config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))\n\n    console.print(f\"[green]✓[/green] Removed catalog '{name}'\")\n    if not catalogs:\n        console.print(\"\\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]\")\n\n\n@extension_app.command(\"add\")\ndef extension_add(\n    extension: str = typer.Argument(help=\"Extension name or path\"),\n    dev: bool = typer.Option(False, \"--dev\", help=\"Install from local directory\"),\n    from_url: Optional[str] = typer.Option(None, \"--from\", help=\"Install from custom URL\"),\n    priority: int = typer.Option(10, \"--priority\", help=\"Resolution priority (lower = higher precedence, default 10)\"),\n):\n    \"\"\"Install an extension.\"\"\"\n    from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError\n\n    project_root = Path.cwd()\n\n    # Check if we're in a spec-kit project\n    specify_dir = project_root / \".specify\"\n    if not specify_dir.exists():\n        console.print(\"[red]Error:[/red] Not a spec-kit project (no .specify/ directory)\")\n        console.print(\"Run this command from a spec-kit project root\")\n        raise typer.Exit(1)\n\n    # Validate priority\n    if priority < 1:\n        console.print(\"[red]Error:[/red] Priority must be a positive integer (1 or higher)\")\n        raise typer.Exit(1)\n\n    manager = ExtensionManager(project_root)\n    speckit_version = get_speckit_version()\n\n    try:\n        with console.status(f\"[cyan]Installing extension: {extension}[/cyan]\"):\n            if dev:\n                # Install from local directory\n                source_path = Path(extension).expanduser().resolve()\n                if not source_path.exists():\n                    console.print(f\"[red]Error:[/red] Directory not found: {source_path}\")\n                    raise typer.Exit(1)\n\n                if not (source_path / \"extension.yml\").exists():\n                    console.print(f\"[red]Error:[/red] No extension.yml found in {source_path}\")\n                    raise typer.Exit(1)\n\n                manifest = manager.install_from_directory(source_path, speckit_version, priority=priority)\n\n            elif from_url:\n                # Install from URL (ZIP file)\n                import urllib.request\n                import urllib.error\n                from urllib.parse import urlparse\n\n                # Validate URL\n                parsed = urlparse(from_url)\n                is_localhost = parsed.hostname in (\"localhost\", \"127.0.0.1\", \"::1\")\n\n                if parsed.scheme != \"https\" and not (parsed.scheme == \"http\" and is_localhost):\n                    console.print(\"[red]Error:[/red] URL must use HTTPS for security.\")\n                    console.print(\"HTTP is only allowed for localhost URLs.\")\n                    raise typer.Exit(1)\n\n                # Warn about untrusted sources\n                console.print(\"[yellow]Warning:[/yellow] Installing from external URL.\")\n                console.print(\"Only install extensions from sources you trust.\\n\")\n                console.print(f\"Downloading from {from_url}...\")\n\n                # Download ZIP to temp location\n                download_dir = project_root / \".specify\" / \"extensions\" / \".cache\" / \"downloads\"\n                download_dir.mkdir(parents=True, exist_ok=True)\n                zip_path = download_dir / f\"{extension}-url-download.zip\"\n\n                try:\n                    with urllib.request.urlopen(from_url, timeout=60) as response:\n                        zip_data = response.read()\n                    zip_path.write_bytes(zip_data)\n\n                    # Install from downloaded ZIP\n                    manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority)\n                except urllib.error.URLError as e:\n                    console.print(f\"[red]Error:[/red] Failed to download from {from_url}: {e}\")\n                    raise typer.Exit(1)\n                finally:\n                    # Clean up downloaded ZIP\n                    if zip_path.exists():\n                        zip_path.unlink()\n\n            else:\n                # Install from catalog\n                catalog = ExtensionCatalog(project_root)\n\n                # Check if extension exists in catalog (supports both ID and display name)\n                ext_info, catalog_error = _resolve_catalog_extension(extension, catalog, \"add\")\n                if catalog_error:\n                    console.print(f\"[red]Error:[/red] Could not query extension catalog: {catalog_error}\")\n                    raise typer.Exit(1)\n                if not ext_info:\n                    console.print(f\"[red]Error:[/red] Extension '{extension}' not found in catalog\")\n                    console.print(\"\\nSearch available extensions:\")\n                    console.print(\"  specify extension search\")\n                    raise typer.Exit(1)\n\n                # Enforce install_allowed policy\n                if not ext_info.get(\"_install_allowed\", True):\n                    catalog_name = ext_info.get(\"_catalog_name\", \"community\")\n                    console.print(\n                        f\"[red]Error:[/red] '{extension}' is available in the \"\n                        f\"'{catalog_name}' catalog but installation is not allowed from that catalog.\"\n                    )\n                    console.print(\n                        f\"\\nTo enable installation, add '{extension}' to an approved catalog \"\n                        f\"(install_allowed: true) in .specify/extension-catalogs.yml.\"\n                    )\n                    raise typer.Exit(1)\n\n                # Download extension ZIP (use resolved ID, not original argument which may be display name)\n                extension_id = ext_info['id']\n                console.print(f\"Downloading {ext_info['name']} v{ext_info.get('version', 'unknown')}...\")\n                zip_path = catalog.download_extension(extension_id)\n\n                try:\n                    # Install from downloaded ZIP\n                    manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority)\n                finally:\n                    # Clean up downloaded ZIP\n                    if zip_path.exists():\n                        zip_path.unlink()\n\n        console.print(\"\\n[green]✓[/green] Extension installed successfully!\")\n        console.print(f\"\\n[bold]{manifest.name}[/bold] (v{manifest.version})\")\n        console.print(f\"  {manifest.description}\")\n        console.print(\"\\n[bold cyan]Provided commands:[/bold cyan]\")\n        for cmd in manifest.commands:\n            console.print(f\"  • {cmd['name']} - {cmd.get('description', '')}\")\n\n        console.print(\"\\n[yellow]⚠[/yellow]  Configuration may be required\")\n        console.print(f\"   Check: .specify/extensions/{manifest.id}/\")\n\n    except ValidationError as e:\n        console.print(f\"\\n[red]Validation Error:[/red] {e}\")\n        raise typer.Exit(1)\n    except CompatibilityError as e:\n        console.print(f\"\\n[red]Compatibility Error:[/red] {e}\")\n        raise typer.Exit(1)\n    except ExtensionError as e:\n        console.print(f\"\\n[red]Error:[/red] {e}\")\n        raise typer.Exit(1)\n\n\n@extension_app.command(\"remove\")\ndef extension_remove(\n    extension: str = typer.Argument(help=\"Extension ID or name to remove\"),\n    keep_config: bool = typer.Option(False, \"--keep-config\", help=\"Don't remove config files\"),\n    force: bool = typer.Option(False, \"--force\", help=\"Skip confirmation\"),\n):\n    \"\"\"Uninstall an extension.\"\"\"\n    from .extensions import ExtensionManager\n\n    project_root = Path.cwd()\n\n    # Check if we're in a spec-kit project\n    specify_dir = project_root / \".specify\"\n    if not specify_dir.exists():\n        console.print(\"[red]Error:[/red] Not a spec-kit project (no .specify/ directory)\")\n        console.print(\"Run this command from a spec-kit project root\")\n        raise typer.Exit(1)\n\n    manager = ExtensionManager(project_root)\n\n    # Resolve extension ID from argument (handles ambiguous names)\n    installed = manager.list_installed()\n    extension_id, display_name = _resolve_installed_extension(extension, installed, \"remove\")\n\n    # Get extension info for command count\n    ext_manifest = manager.get_extension(extension_id)\n    cmd_count = len(ext_manifest.commands) if ext_manifest else 0\n\n    # Confirm removal\n    if not force:\n        console.print(\"\\n[yellow]⚠  This will remove:[/yellow]\")\n        console.print(f\"   • {cmd_count} commands from AI agent\")\n        console.print(f\"   • Extension directory: .specify/extensions/{extension_id}/\")\n        if not keep_config:\n            console.print(\"   • Config files (will be backed up)\")\n        console.print()\n\n        confirm = typer.confirm(\"Continue?\")\n        if not confirm:\n            console.print(\"Cancelled\")\n            raise typer.Exit(0)\n\n    # Remove extension\n    success = manager.remove(extension_id, keep_config=keep_config)\n\n    if success:\n        console.print(f\"\\n[green]✓[/green] Extension '{display_name}' removed successfully\")\n        if keep_config:\n            console.print(f\"\\nConfig files preserved in .specify/extensions/{extension_id}/\")\n        else:\n            console.print(f\"\\nConfig files backed up to .specify/extensions/.backup/{extension_id}/\")\n        console.print(f\"\\nTo reinstall: specify extension add {extension_id}\")\n    else:\n        console.print(\"[red]Error:[/red] Failed to remove extension\")\n        raise typer.Exit(1)\n\n\n@extension_app.command(\"search\")\ndef extension_search(\n    query: str = typer.Argument(None, help=\"Search query (optional)\"),\n    tag: Optional[str] = typer.Option(None, \"--tag\", help=\"Filter by tag\"),\n    author: Optional[str] = typer.Option(None, \"--author\", help=\"Filter by author\"),\n    verified: bool = typer.Option(False, \"--verified\", help=\"Show only verified extensions\"),\n):\n    \"\"\"Search for available extensions in catalog.\"\"\"\n    from .extensions import ExtensionCatalog, ExtensionError\n\n    project_root = Path.cwd()\n\n    # Check if we're in a spec-kit project\n    specify_dir = project_root / \".specify\"\n    if not specify_dir.exists():\n        console.print(\"[red]Error:[/red] Not a spec-kit project (no .specify/ directory)\")\n        console.print(\"Run this command from a spec-kit project root\")\n        raise typer.Exit(1)\n\n    catalog = ExtensionCatalog(project_root)\n\n    try:\n        console.print(\"🔍 Searching extension catalog...\")\n        results = catalog.search(query=query, tag=tag, author=author, verified_only=verified)\n\n        if not results:\n            console.print(\"\\n[yellow]No extensions found matching criteria[/yellow]\")\n            if query or tag or author or verified:\n                console.print(\"\\nTry:\")\n                console.print(\"  • Broader search terms\")\n                console.print(\"  • Remove filters\")\n                console.print(\"  • specify extension search (show all)\")\n            raise typer.Exit(0)\n\n        console.print(f\"\\n[green]Found {len(results)} extension(s):[/green]\\n\")\n\n        for ext in results:\n            # Extension header\n            verified_badge = \" [green]✓ Verified[/green]\" if ext.get(\"verified\") else \"\"\n            console.print(f\"[bold]{ext['name']}[/bold] (v{ext['version']}){verified_badge}\")\n            console.print(f\"  {ext['description']}\")\n\n            # Metadata\n            console.print(f\"\\n  [dim]Author:[/dim] {ext.get('author', 'Unknown')}\")\n            if ext.get('tags'):\n                tags_str = \", \".join(ext['tags'])\n                console.print(f\"  [dim]Tags:[/dim] {tags_str}\")\n\n            # Source catalog\n            catalog_name = ext.get(\"_catalog_name\", \"\")\n            install_allowed = ext.get(\"_install_allowed\", True)\n            if catalog_name:\n                if install_allowed:\n                    console.print(f\"  [dim]Catalog:[/dim] {catalog_name}\")\n                else:\n                    console.print(f\"  [dim]Catalog:[/dim] {catalog_name} [yellow](discovery only — not installable)[/yellow]\")\n\n            # Stats\n            stats = []\n            if ext.get('downloads') is not None:\n                stats.append(f\"Downloads: {ext['downloads']:,}\")\n            if ext.get('stars') is not None:\n                stats.append(f\"Stars: {ext['stars']}\")\n            if stats:\n                console.print(f\"  [dim]{' | '.join(stats)}[/dim]\")\n\n            # Links\n            if ext.get('repository'):\n                console.print(f\"  [dim]Repository:[/dim] {ext['repository']}\")\n\n            # Install command (show warning if not installable)\n            if install_allowed:\n                console.print(f\"\\n  [cyan]Install:[/cyan] specify extension add {ext['id']}\")\n            else:\n                console.print(f\"\\n  [yellow]⚠[/yellow]  Not directly installable from '{catalog_name}'.\")\n                console.print(\n                    f\"  Add to an approved catalog with install_allowed: true, \"\n                    f\"or install from a ZIP URL: specify extension add {ext['id']} --from <zip-url>\"\n                )\n            console.print()\n\n    except ExtensionError as e:\n        console.print(f\"\\n[red]Error:[/red] {e}\")\n        console.print(\"\\nTip: The catalog may be temporarily unavailable. Try again later.\")\n        raise typer.Exit(1)\n\n\n@extension_app.command(\"info\")\ndef extension_info(\n    extension: str = typer.Argument(help=\"Extension ID or name\"),\n):\n    \"\"\"Show detailed information about an extension.\"\"\"\n    from .extensions import ExtensionCatalog, ExtensionManager, normalize_priority\n\n    project_root = Path.cwd()\n\n    # Check if we're in a spec-kit project\n    specify_dir = project_root / \".specify\"\n    if not specify_dir.exists():\n        console.print(\"[red]Error:[/red] Not a spec-kit project (no .specify/ directory)\")\n        console.print(\"Run this command from a spec-kit project root\")\n        raise typer.Exit(1)\n\n    catalog = ExtensionCatalog(project_root)\n    manager = ExtensionManager(project_root)\n    installed = manager.list_installed()\n\n    # Try to resolve from installed extensions first (by ID or name)\n    # Use allow_not_found=True since the extension may be catalog-only\n    resolved_installed_id, resolved_installed_name = _resolve_installed_extension(\n        extension, installed, \"info\", allow_not_found=True\n    )\n\n    # Try catalog lookup (with error handling)\n    # If we resolved an installed extension by display name, use its ID for catalog lookup\n    # to ensure we get the correct catalog entry (not a different extension with same name)\n    lookup_key = resolved_installed_id if resolved_installed_id else extension\n    ext_info, catalog_error = _resolve_catalog_extension(lookup_key, catalog, \"info\")\n\n    # Case 1: Found in catalog - show full catalog info\n    if ext_info:\n        _print_extension_info(ext_info, manager)\n        return\n\n    # Case 2: Installed locally but catalog lookup failed or not in catalog\n    if resolved_installed_id:\n        # Get local manifest info\n        ext_manifest = manager.get_extension(resolved_installed_id)\n        metadata = manager.registry.get(resolved_installed_id)\n        metadata_is_dict = isinstance(metadata, dict)\n        if not metadata_is_dict:\n            console.print(\n                \"[yellow]Warning:[/yellow] Extension metadata appears to be corrupted; \"\n                \"some information may be unavailable.\"\n            )\n        version = metadata.get(\"version\", \"unknown\") if metadata_is_dict else \"unknown\"\n\n        console.print(f\"\\n[bold]{resolved_installed_name}[/bold] (v{version})\")\n        console.print(f\"ID: {resolved_installed_id}\")\n        console.print()\n\n        if ext_manifest:\n            console.print(f\"{ext_manifest.description}\")\n            console.print()\n            # Author is optional in extension.yml, safely retrieve it\n            author = ext_manifest.data.get(\"extension\", {}).get(\"author\")\n            if author:\n                console.print(f\"[dim]Author:[/dim] {author}\")\n                console.print()\n\n            if ext_manifest.commands:\n                console.print(\"[bold]Commands:[/bold]\")\n                for cmd in ext_manifest.commands:\n                    console.print(f\"  • {cmd['name']}: {cmd.get('description', '')}\")\n                console.print()\n\n        # Show catalog status\n        if catalog_error:\n            console.print(f\"[yellow]Catalog unavailable:[/yellow] {catalog_error}\")\n            console.print(\"[dim]Note: Using locally installed extension; catalog info could not be verified.[/dim]\")\n        else:\n            console.print(\"[yellow]Note:[/yellow] Not found in catalog (custom/local extension)\")\n\n        console.print()\n        console.print(\"[green]✓ Installed[/green]\")\n        priority = normalize_priority(metadata.get(\"priority\") if metadata_is_dict else None)\n        console.print(f\"[dim]Priority:[/dim] {priority}\")\n        console.print(f\"\\nTo remove: specify extension remove {resolved_installed_id}\")\n        return\n\n    # Case 3: Not found anywhere\n    if catalog_error:\n        console.print(f\"[red]Error:[/red] Could not query extension catalog: {catalog_error}\")\n        console.print(\"\\nTry again when online, or use the extension ID directly.\")\n    else:\n        console.print(f\"[red]Error:[/red] Extension '{extension}' not found\")\n        console.print(\"\\nTry: specify extension search\")\n    raise typer.Exit(1)\n\n\ndef _print_extension_info(ext_info: dict, manager):\n    \"\"\"Print formatted extension info from catalog data.\"\"\"\n    from .extensions import normalize_priority\n\n    # Header\n    verified_badge = \" [green]✓ Verified[/green]\" if ext_info.get(\"verified\") else \"\"\n    console.print(f\"\\n[bold]{ext_info['name']}[/bold] (v{ext_info['version']}){verified_badge}\")\n    console.print(f\"ID: {ext_info['id']}\")\n    console.print()\n\n    # Description\n    console.print(f\"{ext_info['description']}\")\n    console.print()\n\n    # Author and License\n    console.print(f\"[dim]Author:[/dim] {ext_info.get('author', 'Unknown')}\")\n    console.print(f\"[dim]License:[/dim] {ext_info.get('license', 'Unknown')}\")\n\n    # Source catalog\n    if ext_info.get(\"_catalog_name\"):\n        install_allowed = ext_info.get(\"_install_allowed\", True)\n        install_note = \"\" if install_allowed else \" [yellow](discovery only)[/yellow]\"\n        console.print(f\"[dim]Source catalog:[/dim] {ext_info['_catalog_name']}{install_note}\")\n    console.print()\n\n    # Requirements\n    if ext_info.get('requires'):\n        console.print(\"[bold]Requirements:[/bold]\")\n        reqs = ext_info['requires']\n        if reqs.get('speckit_version'):\n            console.print(f\"  • Spec Kit: {reqs['speckit_version']}\")\n        if reqs.get('tools'):\n            for tool in reqs['tools']:\n                tool_name = tool['name']\n                tool_version = tool.get('version', 'any')\n                required = \" (required)\" if tool.get('required') else \" (optional)\"\n                console.print(f\"  • {tool_name}: {tool_version}{required}\")\n        console.print()\n\n    # Provides\n    if ext_info.get('provides'):\n        console.print(\"[bold]Provides:[/bold]\")\n        provides = ext_info['provides']\n        if provides.get('commands'):\n            console.print(f\"  • Commands: {provides['commands']}\")\n        if provides.get('hooks'):\n            console.print(f\"  • Hooks: {provides['hooks']}\")\n        console.print()\n\n    # Tags\n    if ext_info.get('tags'):\n        tags_str = \", \".join(ext_info['tags'])\n        console.print(f\"[bold]Tags:[/bold] {tags_str}\")\n        console.print()\n\n    # Statistics\n    stats = []\n    if ext_info.get('downloads') is not None:\n        stats.append(f\"Downloads: {ext_info['downloads']:,}\")\n    if ext_info.get('stars') is not None:\n        stats.append(f\"Stars: {ext_info['stars']}\")\n    if stats:\n        console.print(f\"[bold]Statistics:[/bold] {' | '.join(stats)}\")\n        console.print()\n\n    # Links\n    console.print(\"[bold]Links:[/bold]\")\n    if ext_info.get('repository'):\n        console.print(f\"  • Repository: {ext_info['repository']}\")\n    if ext_info.get('homepage'):\n        console.print(f\"  • Homepage: {ext_info['homepage']}\")\n    if ext_info.get('documentation'):\n        console.print(f\"  • Documentation: {ext_info['documentation']}\")\n    if ext_info.get('changelog'):\n        console.print(f\"  • Changelog: {ext_info['changelog']}\")\n    console.print()\n\n    # Installation status and command\n    is_installed = manager.registry.is_installed(ext_info['id'])\n    install_allowed = ext_info.get(\"_install_allowed\", True)\n    if is_installed:\n        console.print(\"[green]✓ Installed[/green]\")\n        metadata = manager.registry.get(ext_info['id'])\n        priority = normalize_priority(metadata.get(\"priority\") if isinstance(metadata, dict) else None)\n        console.print(f\"[dim]Priority:[/dim] {priority}\")\n        console.print(f\"\\nTo remove: specify extension remove {ext_info['id']}\")\n    elif install_allowed:\n        console.print(\"[yellow]Not installed[/yellow]\")\n        console.print(f\"\\n[cyan]Install:[/cyan] specify extension add {ext_info['id']}\")\n    else:\n        catalog_name = ext_info.get(\"_catalog_name\", \"community\")\n        console.print(\"[yellow]Not installed[/yellow]\")\n        console.print(\n            f\"\\n[yellow]⚠[/yellow]  '{ext_info['id']}' is available in the '{catalog_name}' catalog \"\n            f\"but not in your approved catalog. Add it to .specify/extension-catalogs.yml \"\n            f\"with install_allowed: true to enable installation.\"\n        )\n\n\n@extension_app.command(\"update\")\ndef extension_update(\n    extension: str = typer.Argument(None, help=\"Extension ID or name to update (or all)\"),\n):\n    \"\"\"Update extension(s) to latest version.\"\"\"\n    from .extensions import (\n        ExtensionManager,\n        ExtensionCatalog,\n        ExtensionError,\n        ValidationError,\n        CommandRegistrar,\n        HookExecutor,\n        normalize_priority,\n    )\n    from packaging import version as pkg_version\n    import shutil\n\n    project_root = Path.cwd()\n\n    # Check if we're in a spec-kit project\n    specify_dir = project_root / \".specify\"\n    if not specify_dir.exists():\n        console.print(\"[red]Error:[/red] Not a spec-kit project (no .specify/ directory)\")\n        console.print(\"Run this command from a spec-kit project root\")\n        raise typer.Exit(1)\n\n    manager = ExtensionManager(project_root)\n    catalog = ExtensionCatalog(project_root)\n    speckit_version = get_speckit_version()\n\n    try:\n        # Get list of extensions to update\n        installed = manager.list_installed()\n        if extension:\n            # Update specific extension - resolve ID from argument (handles ambiguous names)\n            extension_id, _ = _resolve_installed_extension(extension, installed, \"update\")\n            extensions_to_update = [extension_id]\n        else:\n            # Update all extensions\n            extensions_to_update = [ext[\"id\"] for ext in installed]\n\n        if not extensions_to_update:\n            console.print(\"[yellow]No extensions installed[/yellow]\")\n            raise typer.Exit(0)\n\n        console.print(\"🔄 Checking for updates...\\n\")\n\n        updates_available = []\n\n        for ext_id in extensions_to_update:\n            # Get installed version\n            metadata = manager.registry.get(ext_id)\n            if metadata is None or not isinstance(metadata, dict) or \"version\" not in metadata:\n                console.print(f\"⚠  {ext_id}: Registry entry corrupted or missing (skipping)\")\n                continue\n            try:\n                installed_version = pkg_version.Version(metadata[\"version\"])\n            except pkg_version.InvalidVersion:\n                console.print(\n                    f\"⚠  {ext_id}: Invalid installed version '{metadata.get('version')}' in registry (skipping)\"\n                )\n                continue\n\n            # Get catalog info\n            ext_info = catalog.get_extension_info(ext_id)\n            if not ext_info:\n                console.print(f\"⚠  {ext_id}: Not found in catalog (skipping)\")\n                continue\n\n            # Check if installation is allowed from this catalog\n            if not ext_info.get(\"_install_allowed\", True):\n                console.print(f\"⚠  {ext_id}: Updates not allowed from '{ext_info.get('_catalog_name', 'catalog')}' (skipping)\")\n                continue\n\n            try:\n                catalog_version = pkg_version.Version(ext_info[\"version\"])\n            except pkg_version.InvalidVersion:\n                console.print(\n                    f\"⚠  {ext_id}: Invalid catalog version '{ext_info.get('version')}' (skipping)\"\n                )\n                continue\n\n            if catalog_version > installed_version:\n                updates_available.append(\n                    {\n                        \"id\": ext_id,\n                        \"name\": ext_info.get(\"name\", ext_id),  # Display name for status messages\n                        \"installed\": str(installed_version),\n                        \"available\": str(catalog_version),\n                        \"download_url\": ext_info.get(\"download_url\"),\n                    }\n                )\n            else:\n                console.print(f\"✓ {ext_id}: Up to date (v{installed_version})\")\n\n        if not updates_available:\n            console.print(\"\\n[green]All extensions are up to date![/green]\")\n            raise typer.Exit(0)\n\n        # Show available updates\n        console.print(\"\\n[bold]Updates available:[/bold]\\n\")\n        for update in updates_available:\n            console.print(\n                f\"  • {update['id']}: {update['installed']} → {update['available']}\"\n            )\n\n        console.print()\n        confirm = typer.confirm(\"Update these extensions?\")\n        if not confirm:\n            console.print(\"Cancelled\")\n            raise typer.Exit(0)\n\n        # Perform updates with atomic backup/restore\n        console.print()\n        updated_extensions = []\n        failed_updates = []\n        registrar = CommandRegistrar()\n        hook_executor = HookExecutor(project_root)\n\n        for update in updates_available:\n            extension_id = update[\"id\"]\n            ext_name = update[\"name\"]  # Use display name for user-facing messages\n            console.print(f\"📦 Updating {ext_name}...\")\n\n            # Backup paths\n            backup_base = manager.extensions_dir / \".backup\" / f\"{extension_id}-update\"\n            backup_ext_dir = backup_base / \"extension\"\n            backup_commands_dir = backup_base / \"commands\"\n            backup_config_dir = backup_base / \"config\"\n\n            # Store backup state\n            backup_registry_entry = None\n            backup_hooks = None  # None means no hooks key in config; {} means hooks key existed\n            backed_up_command_files = {}\n\n            try:\n                # 1. Backup registry entry (always, even if extension dir doesn't exist)\n                backup_registry_entry = manager.registry.get(extension_id)\n\n                # 2. Backup extension directory\n                extension_dir = manager.extensions_dir / extension_id\n                if extension_dir.exists():\n                    backup_base.mkdir(parents=True, exist_ok=True)\n                    if backup_ext_dir.exists():\n                        shutil.rmtree(backup_ext_dir)\n                    shutil.copytree(extension_dir, backup_ext_dir)\n\n                    # Backup config files separately so they can be restored\n                    # after a successful install (install_from_directory clears dest dir).\n                    config_files = list(extension_dir.glob(\"*-config.yml\")) + list(\n                        extension_dir.glob(\"*-config.local.yml\")\n                    )\n                    for cfg_file in config_files:\n                        backup_config_dir.mkdir(parents=True, exist_ok=True)\n                        shutil.copy2(cfg_file, backup_config_dir / cfg_file.name)\n\n                # 3. Backup command files for all agents\n                registered_commands = backup_registry_entry.get(\"registered_commands\", {})\n                for agent_name, cmd_names in registered_commands.items():\n                    if agent_name not in registrar.AGENT_CONFIGS:\n                        continue\n                    agent_config = registrar.AGENT_CONFIGS[agent_name]\n                    commands_dir = project_root / agent_config[\"dir\"]\n\n                    for cmd_name in cmd_names:\n                        cmd_file = commands_dir / f\"{cmd_name}{agent_config['extension']}\"\n                        if cmd_file.exists():\n                            backup_cmd_path = backup_commands_dir / agent_name / cmd_file.name\n                            backup_cmd_path.parent.mkdir(parents=True, exist_ok=True)\n                            shutil.copy2(cmd_file, backup_cmd_path)\n                            backed_up_command_files[str(cmd_file)] = str(backup_cmd_path)\n\n                        # Also backup copilot prompt files\n                        if agent_name == \"copilot\":\n                            prompt_file = project_root / \".github\" / \"prompts\" / f\"{cmd_name}.prompt.md\"\n                            if prompt_file.exists():\n                                backup_prompt_path = backup_commands_dir / \"copilot-prompts\" / prompt_file.name\n                                backup_prompt_path.parent.mkdir(parents=True, exist_ok=True)\n                                shutil.copy2(prompt_file, backup_prompt_path)\n                                backed_up_command_files[str(prompt_file)] = str(backup_prompt_path)\n\n                # 4. Backup hooks from extensions.yml\n                # Use backup_hooks=None to indicate config had no \"hooks\" key (don't create on restore)\n                # Use backup_hooks={} to indicate config had \"hooks\" key with no hooks for this extension\n                config = hook_executor.get_project_config()\n                if \"hooks\" in config:\n                    backup_hooks = {}  # Config has hooks key - preserve this fact\n                    for hook_name, hook_list in config[\"hooks\"].items():\n                        ext_hooks = [h for h in hook_list if h.get(\"extension\") == extension_id]\n                        if ext_hooks:\n                            backup_hooks[hook_name] = ext_hooks\n\n                # 5. Download new version\n                zip_path = catalog.download_extension(extension_id)\n                try:\n                    # 6. Validate extension ID from ZIP BEFORE modifying installation\n                    # Handle both root-level and nested extension.yml (GitHub auto-generated ZIPs)\n                    with zipfile.ZipFile(zip_path, \"r\") as zf:\n                        import yaml\n                        manifest_data = None\n                        namelist = zf.namelist()\n\n                        # First try root-level extension.yml\n                        if \"extension.yml\" in namelist:\n                            with zf.open(\"extension.yml\") as f:\n                                manifest_data = yaml.safe_load(f) or {}\n                        else:\n                            # Look for extension.yml in a single top-level subdirectory\n                            # (e.g., \"repo-name-branch/extension.yml\")\n                            manifest_paths = [n for n in namelist if n.endswith(\"/extension.yml\") and n.count(\"/\") == 1]\n                            if len(manifest_paths) == 1:\n                                with zf.open(manifest_paths[0]) as f:\n                                    manifest_data = yaml.safe_load(f) or {}\n\n                        if manifest_data is None:\n                            raise ValueError(\"Downloaded extension archive is missing 'extension.yml'\")\n\n                    zip_extension_id = manifest_data.get(\"extension\", {}).get(\"id\")\n                    if zip_extension_id != extension_id:\n                        raise ValueError(\n                            f\"Extension ID mismatch: expected '{extension_id}', got '{zip_extension_id}'\"\n                        )\n\n                    # 7. Remove old extension (handles command file cleanup and registry removal)\n                    manager.remove(extension_id, keep_config=True)\n\n                    # 8. Install new version\n                    _ = manager.install_from_zip(zip_path, speckit_version)\n\n                    # Restore user config files from backup after successful install.\n                    new_extension_dir = manager.extensions_dir / extension_id\n                    if backup_config_dir.exists() and new_extension_dir.exists():\n                        for cfg_file in backup_config_dir.iterdir():\n                            if cfg_file.is_file():\n                                shutil.copy2(cfg_file, new_extension_dir / cfg_file.name)\n\n                    # 9. Restore metadata from backup (installed_at, enabled state)\n                    if backup_registry_entry and isinstance(backup_registry_entry, dict):\n                        # Copy current registry entry to avoid mutating internal\n                        # registry state before explicit restore().\n                        current_metadata = manager.registry.get(extension_id)\n                        if current_metadata is None or not isinstance(current_metadata, dict):\n                            raise RuntimeError(\n                                f\"Registry entry for '{extension_id}' missing or corrupted after install — update incomplete\"\n                            )\n                        new_metadata = dict(current_metadata)\n\n                        # Preserve the original installation timestamp\n                        if \"installed_at\" in backup_registry_entry:\n                            new_metadata[\"installed_at\"] = backup_registry_entry[\"installed_at\"]\n\n                        # Preserve the original priority (normalized to handle corruption)\n                        if \"priority\" in backup_registry_entry:\n                            new_metadata[\"priority\"] = normalize_priority(backup_registry_entry[\"priority\"])\n\n                        # If extension was disabled before update, disable it again\n                        if not backup_registry_entry.get(\"enabled\", True):\n                            new_metadata[\"enabled\"] = False\n\n                        # Use restore() instead of update() because update() always\n                        # preserves the existing installed_at, ignoring our override\n                        manager.registry.restore(extension_id, new_metadata)\n\n                        # Also disable hooks in extensions.yml if extension was disabled\n                        if not backup_registry_entry.get(\"enabled\", True):\n                            config = hook_executor.get_project_config()\n                            if \"hooks\" in config:\n                                for hook_name in config[\"hooks\"]:\n                                    for hook in config[\"hooks\"][hook_name]:\n                                        if hook.get(\"extension\") == extension_id:\n                                            hook[\"enabled\"] = False\n                                hook_executor.save_project_config(config)\n                finally:\n                    # Clean up downloaded ZIP\n                    if zip_path.exists():\n                        zip_path.unlink()\n\n                # 10. Clean up backup on success\n                if backup_base.exists():\n                    shutil.rmtree(backup_base)\n\n                console.print(f\"   [green]✓[/green] Updated to v{update['available']}\")\n                updated_extensions.append(ext_name)\n\n            except KeyboardInterrupt:\n                raise\n            except Exception as e:\n                console.print(f\"   [red]✗[/red] Failed: {e}\")\n                failed_updates.append((ext_name, str(e)))\n\n                # Rollback on failure\n                console.print(f\"   [yellow]↩[/yellow] Rolling back {ext_name}...\")\n\n                try:\n                    # Restore extension directory\n                    # Only perform destructive rollback if backup exists (meaning we\n                    # actually modified the extension). This avoids deleting a valid\n                    # installation when failure happened before changes were made.\n                    extension_dir = manager.extensions_dir / extension_id\n                    if backup_ext_dir.exists():\n                        if extension_dir.exists():\n                            shutil.rmtree(extension_dir)\n                        shutil.copytree(backup_ext_dir, extension_dir)\n\n                    # Remove any NEW command files created by failed install\n                    # (files that weren't in the original backup)\n                    try:\n                        new_registry_entry = manager.registry.get(extension_id)\n                        if new_registry_entry is None or not isinstance(new_registry_entry, dict):\n                            new_registered_commands = {}\n                        else:\n                            new_registered_commands = new_registry_entry.get(\"registered_commands\", {})\n                        for agent_name, cmd_names in new_registered_commands.items():\n                            if agent_name not in registrar.AGENT_CONFIGS:\n                                continue\n                            agent_config = registrar.AGENT_CONFIGS[agent_name]\n                            commands_dir = project_root / agent_config[\"dir\"]\n\n                            for cmd_name in cmd_names:\n                                cmd_file = commands_dir / f\"{cmd_name}{agent_config['extension']}\"\n                                # Delete if it exists and wasn't in our backup\n                                if cmd_file.exists() and str(cmd_file) not in backed_up_command_files:\n                                    cmd_file.unlink()\n\n                                # Also handle copilot prompt files\n                                if agent_name == \"copilot\":\n                                    prompt_file = project_root / \".github\" / \"prompts\" / f\"{cmd_name}.prompt.md\"\n                                    if prompt_file.exists() and str(prompt_file) not in backed_up_command_files:\n                                        prompt_file.unlink()\n                    except KeyError:\n                        pass  # No new registry entry exists, nothing to clean up\n\n                    # Restore backed up command files\n                    for original_path, backup_path in backed_up_command_files.items():\n                        backup_file = Path(backup_path)\n                        if backup_file.exists():\n                            original_file = Path(original_path)\n                            original_file.parent.mkdir(parents=True, exist_ok=True)\n                            shutil.copy2(backup_file, original_file)\n\n                    # Restore hooks in extensions.yml\n                    # - backup_hooks=None means original config had no \"hooks\" key\n                    # - backup_hooks={} or {...} means config had hooks key\n                    config = hook_executor.get_project_config()\n                    if \"hooks\" in config:\n                        modified = False\n\n                        if backup_hooks is None:\n                            # Original config had no \"hooks\" key; remove it entirely\n                            del config[\"hooks\"]\n                            modified = True\n                        else:\n                            # Remove any hooks for this extension added by failed install\n                            for hook_name, hooks_list in config[\"hooks\"].items():\n                                original_len = len(hooks_list)\n                                config[\"hooks\"][hook_name] = [\n                                    h for h in hooks_list\n                                    if h.get(\"extension\") != extension_id\n                                ]\n                                if len(config[\"hooks\"][hook_name]) != original_len:\n                                    modified = True\n\n                            # Add back the backed up hooks if any\n                            if backup_hooks:\n                                for hook_name, hooks in backup_hooks.items():\n                                    if hook_name not in config[\"hooks\"]:\n                                        config[\"hooks\"][hook_name] = []\n                                    config[\"hooks\"][hook_name].extend(hooks)\n                                    modified = True\n\n                        if modified:\n                            hook_executor.save_project_config(config)\n\n                    # Restore registry entry (use restore() since entry was removed)\n                    if backup_registry_entry:\n                        manager.registry.restore(extension_id, backup_registry_entry)\n\n                    console.print(\"   [green]✓[/green] Rollback successful\")\n                    # Clean up backup directory only on successful rollback\n                    if backup_base.exists():\n                        shutil.rmtree(backup_base)\n                except Exception as rollback_error:\n                    console.print(f\"   [red]✗[/red] Rollback failed: {rollback_error}\")\n                    console.print(f\"   [dim]Backup preserved at: {backup_base}[/dim]\")\n\n        # Summary\n        console.print()\n        if updated_extensions:\n            console.print(f\"[green]✓[/green] Successfully updated {len(updated_extensions)} extension(s)\")\n        if failed_updates:\n            console.print(f\"[red]✗[/red] Failed to update {len(failed_updates)} extension(s):\")\n            for ext_name, error in failed_updates:\n                console.print(f\"   • {ext_name}: {error}\")\n            raise typer.Exit(1)\n\n    except ValidationError as e:\n        console.print(f\"\\n[red]Validation Error:[/red] {e}\")\n        raise typer.Exit(1)\n    except ExtensionError as e:\n        console.print(f\"\\n[red]Error:[/red] {e}\")\n        raise typer.Exit(1)\n\n\n@extension_app.command(\"enable\")\ndef extension_enable(\n    extension: str = typer.Argument(help=\"Extension ID or name to enable\"),\n):\n    \"\"\"Enable a disabled extension.\"\"\"\n    from .extensions import ExtensionManager, HookExecutor\n\n    project_root = Path.cwd()\n\n    # Check if we're in a spec-kit project\n    specify_dir = project_root / \".specify\"\n    if not specify_dir.exists():\n        console.print(\"[red]Error:[/red] Not a spec-kit project (no .specify/ directory)\")\n        console.print(\"Run this command from a spec-kit project root\")\n        raise typer.Exit(1)\n\n    manager = ExtensionManager(project_root)\n    hook_executor = HookExecutor(project_root)\n\n    # Resolve extension ID from argument (handles ambiguous names)\n    installed = manager.list_installed()\n    extension_id, display_name = _resolve_installed_extension(extension, installed, \"enable\")\n\n    # Update registry\n    metadata = manager.registry.get(extension_id)\n    if metadata is None or not isinstance(metadata, dict):\n        console.print(f\"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)\")\n        raise typer.Exit(1)\n\n    if metadata.get(\"enabled\", True):\n        console.print(f\"[yellow]Extension '{display_name}' is already enabled[/yellow]\")\n        raise typer.Exit(0)\n\n    manager.registry.update(extension_id, {\"enabled\": True})\n\n    # Enable hooks in extensions.yml\n    config = hook_executor.get_project_config()\n    if \"hooks\" in config:\n        for hook_name in config[\"hooks\"]:\n            for hook in config[\"hooks\"][hook_name]:\n                if hook.get(\"extension\") == extension_id:\n                    hook[\"enabled\"] = True\n        hook_executor.save_project_config(config)\n\n    console.print(f\"[green]✓[/green] Extension '{display_name}' enabled\")\n\n\n@extension_app.command(\"disable\")\ndef extension_disable(\n    extension: str = typer.Argument(help=\"Extension ID or name to disable\"),\n):\n    \"\"\"Disable an extension without removing it.\"\"\"\n    from .extensions import ExtensionManager, HookExecutor\n\n    project_root = Path.cwd()\n\n    # Check if we're in a spec-kit project\n    specify_dir = project_root / \".specify\"\n    if not specify_dir.exists():\n        console.print(\"[red]Error:[/red] Not a spec-kit project (no .specify/ directory)\")\n        console.print(\"Run this command from a spec-kit project root\")\n        raise typer.Exit(1)\n\n    manager = ExtensionManager(project_root)\n    hook_executor = HookExecutor(project_root)\n\n    # Resolve extension ID from argument (handles ambiguous names)\n    installed = manager.list_installed()\n    extension_id, display_name = _resolve_installed_extension(extension, installed, \"disable\")\n\n    # Update registry\n    metadata = manager.registry.get(extension_id)\n    if metadata is None or not isinstance(metadata, dict):\n        console.print(f\"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)\")\n        raise typer.Exit(1)\n\n    if not metadata.get(\"enabled\", True):\n        console.print(f\"[yellow]Extension '{display_name}' is already disabled[/yellow]\")\n        raise typer.Exit(0)\n\n    manager.registry.update(extension_id, {\"enabled\": False})\n\n    # Disable hooks in extensions.yml\n    config = hook_executor.get_project_config()\n    if \"hooks\" in config:\n        for hook_name in config[\"hooks\"]:\n            for hook in config[\"hooks\"][hook_name]:\n                if hook.get(\"extension\") == extension_id:\n                    hook[\"enabled\"] = False\n        hook_executor.save_project_config(config)\n\n    console.print(f\"[green]✓[/green] Extension '{display_name}' disabled\")\n    console.print(\"\\nCommands will no longer be available. Hooks will not execute.\")\n    console.print(f\"To re-enable: specify extension enable {extension_id}\")\n\n\n@extension_app.command(\"set-priority\")\ndef extension_set_priority(\n    extension: str = typer.Argument(help=\"Extension ID or name\"),\n    priority: int = typer.Argument(help=\"New priority (lower = higher precedence)\"),\n):\n    \"\"\"Set the resolution priority of an installed extension.\"\"\"\n    from .extensions import ExtensionManager\n\n    project_root = Path.cwd()\n\n    # Check if we're in a spec-kit project\n    specify_dir = project_root / \".specify\"\n    if not specify_dir.exists():\n        console.print(\"[red]Error:[/red] Not a spec-kit project (no .specify/ directory)\")\n        console.print(\"Run this command from a spec-kit project root\")\n        raise typer.Exit(1)\n\n    # Validate priority\n    if priority < 1:\n        console.print(\"[red]Error:[/red] Priority must be a positive integer (1 or higher)\")\n        raise typer.Exit(1)\n\n    manager = ExtensionManager(project_root)\n\n    # Resolve extension ID from argument (handles ambiguous names)\n    installed = manager.list_installed()\n    extension_id, display_name = _resolve_installed_extension(extension, installed, \"set-priority\")\n\n    # Get current metadata\n    metadata = manager.registry.get(extension_id)\n    if metadata is None or not isinstance(metadata, dict):\n        console.print(f\"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)\")\n        raise typer.Exit(1)\n\n    from .extensions import normalize_priority\n    raw_priority = metadata.get(\"priority\")\n    # Only skip if the stored value is already a valid int equal to requested priority\n    # This ensures corrupted values (e.g., \"high\") get repaired even when setting to default (10)\n    if isinstance(raw_priority, int) and raw_priority == priority:\n        console.print(f\"[yellow]Extension '{display_name}' already has priority {priority}[/yellow]\")\n        raise typer.Exit(0)\n\n    old_priority = normalize_priority(raw_priority)\n\n    # Update priority\n    manager.registry.update(extension_id, {\"priority\": priority})\n\n    console.print(f\"[green]✓[/green] Extension '{display_name}' priority changed: {old_priority} → {priority}\")\n    console.print(\"\\n[dim]Lower priority = higher precedence in template resolution[/dim]\")\n\n\ndef main():\n    app()\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "src/specify_cli/agents.py",
    "content": "\"\"\"\nAgent Command Registrar for Spec Kit\n\nShared infrastructure for registering commands with AI agents.\nUsed by both the extension system and the preset system to write\ncommand files into agent-specific directories in the correct format.\n\"\"\"\n\nfrom pathlib import Path\nfrom typing import Dict, List, Any\n\nimport platform\nimport yaml\n\n\nclass CommandRegistrar:\n    \"\"\"Handles registration of commands with AI agents.\n\n    Supports writing command files in Markdown or TOML format to the\n    appropriate agent directory, with correct argument placeholders\n    and companion files (e.g. Copilot .prompt.md).\n    \"\"\"\n\n    # Agent configurations with directory, format, and argument placeholder\n    AGENT_CONFIGS = {\n        \"claude\": {\n            \"dir\": \".claude/commands\",\n            \"format\": \"markdown\",\n            \"args\": \"$ARGUMENTS\",\n            \"extension\": \".md\"\n        },\n        \"gemini\": {\n            \"dir\": \".gemini/commands\",\n            \"format\": \"toml\",\n            \"args\": \"{{args}}\",\n            \"extension\": \".toml\"\n        },\n        \"copilot\": {\n            \"dir\": \".github/agents\",\n            \"format\": \"markdown\",\n            \"args\": \"$ARGUMENTS\",\n            \"extension\": \".agent.md\"\n        },\n        \"cursor\": {\n            \"dir\": \".cursor/commands\",\n            \"format\": \"markdown\",\n            \"args\": \"$ARGUMENTS\",\n            \"extension\": \".md\"\n        },\n        \"qwen\": {\n            \"dir\": \".qwen/commands\",\n            \"format\": \"markdown\",\n            \"args\": \"$ARGUMENTS\",\n            \"extension\": \".md\"\n        },\n        \"opencode\": {\n            \"dir\": \".opencode/command\",\n            \"format\": \"markdown\",\n            \"args\": \"$ARGUMENTS\",\n            \"extension\": \".md\"\n        },\n        \"codex\": {\n            \"dir\": \".agents/skills\",\n            \"format\": \"markdown\",\n            \"args\": \"$ARGUMENTS\",\n            \"extension\": \"/SKILL.md\",\n        },\n        \"windsurf\": {\n            \"dir\": \".windsurf/workflows\",\n            \"format\": \"markdown\",\n            \"args\": \"$ARGUMENTS\",\n            \"extension\": \".md\"\n        },\n        \"junie\": {\n            \"dir\": \".junie/commands\",\n            \"format\": \"markdown\",\n            \"args\": \"$ARGUMENTS\",\n            \"extension\": \".md\"\n        },\n        \"kilocode\": {\n            \"dir\": \".kilocode/workflows\",\n            \"format\": \"markdown\",\n            \"args\": \"$ARGUMENTS\",\n            \"extension\": \".md\"\n        },\n        \"auggie\": {\n            \"dir\": \".augment/commands\",\n            \"format\": \"markdown\",\n            \"args\": \"$ARGUMENTS\",\n            \"extension\": \".md\"\n        },\n        \"roo\": {\n            \"dir\": \".roo/commands\",\n            \"format\": \"markdown\",\n            \"args\": \"$ARGUMENTS\",\n            \"extension\": \".md\"\n        },\n        \"codebuddy\": {\n            \"dir\": \".codebuddy/commands\",\n            \"format\": \"markdown\",\n            \"args\": \"$ARGUMENTS\",\n            \"extension\": \".md\"\n        },\n        \"qodercli\": {\n            \"dir\": \".qoder/commands\",\n            \"format\": \"markdown\",\n            \"args\": \"$ARGUMENTS\",\n            \"extension\": \".md\"\n        },\n        \"kiro-cli\": {\n            \"dir\": \".kiro/prompts\",\n            \"format\": \"markdown\",\n            \"args\": \"$ARGUMENTS\",\n            \"extension\": \".md\"\n        },\n        \"pi\": {\n            \"dir\": \".pi/prompts\",\n            \"format\": \"markdown\",\n            \"args\": \"$ARGUMENTS\",\n            \"extension\": \".md\"\n        },\n        \"amp\": {\n            \"dir\": \".agents/commands\",\n            \"format\": \"markdown\",\n            \"args\": \"$ARGUMENTS\",\n            \"extension\": \".md\"\n        },\n        \"shai\": {\n            \"dir\": \".shai/commands\",\n            \"format\": \"markdown\",\n            \"args\": \"$ARGUMENTS\",\n            \"extension\": \".md\"\n        },\n        \"tabnine\": {\n            \"dir\": \".tabnine/agent/commands\",\n            \"format\": \"toml\",\n            \"args\": \"{{args}}\",\n            \"extension\": \".toml\"\n        },\n        \"bob\": {\n            \"dir\": \".bob/commands\",\n            \"format\": \"markdown\",\n            \"args\": \"$ARGUMENTS\",\n            \"extension\": \".md\"\n        },\n        \"kimi\": {\n            \"dir\": \".kimi/skills\",\n            \"format\": \"markdown\",\n            \"args\": \"$ARGUMENTS\",\n            \"extension\": \"/SKILL.md\",\n        },\n        \"trae\": {\n            \"dir\": \".trae/rules\",\n            \"format\": \"markdown\",\n            \"args\": \"$ARGUMENTS\",\n            \"extension\": \".md\"\n        },\n        \"iflow\": {\n            \"dir\": \".iflow/commands\",\n            \"format\": \"markdown\",\n            \"args\": \"$ARGUMENTS\",\n            \"extension\": \".md\"\n        }\n    }\n\n    @staticmethod\n    def parse_frontmatter(content: str) -> tuple[dict, str]:\n        \"\"\"Parse YAML frontmatter from Markdown content.\n\n        Args:\n            content: Markdown content with YAML frontmatter\n\n        Returns:\n            Tuple of (frontmatter_dict, body_content)\n        \"\"\"\n        if not content.startswith(\"---\"):\n            return {}, content\n\n        # Find second ---\n        end_marker = content.find(\"---\", 3)\n        if end_marker == -1:\n            return {}, content\n\n        frontmatter_str = content[3:end_marker].strip()\n        body = content[end_marker + 3:].strip()\n\n        try:\n            frontmatter = yaml.safe_load(frontmatter_str) or {}\n        except yaml.YAMLError:\n            frontmatter = {}\n\n        if not isinstance(frontmatter, dict):\n            frontmatter = {}\n\n        return frontmatter, body\n\n    @staticmethod\n    def render_frontmatter(fm: dict) -> str:\n        \"\"\"Render frontmatter dictionary as YAML.\n\n        Args:\n            fm: Frontmatter dictionary\n\n        Returns:\n            YAML-formatted frontmatter with delimiters\n        \"\"\"\n        if not fm:\n            return \"\"\n\n        yaml_str = yaml.dump(fm, default_flow_style=False, sort_keys=False)\n        return f\"---\\n{yaml_str}---\\n\"\n\n    def _adjust_script_paths(self, frontmatter: dict) -> dict:\n        \"\"\"Adjust script paths from extension-relative to repo-relative.\n\n        Args:\n            frontmatter: Frontmatter dictionary\n\n        Returns:\n            Modified frontmatter with adjusted paths\n        \"\"\"\n        for script_key in (\"scripts\", \"agent_scripts\"):\n            scripts = frontmatter.get(script_key)\n            if not isinstance(scripts, dict):\n                continue\n\n            for key, script_path in scripts.items():\n                if isinstance(script_path, str) and script_path.startswith(\"../../scripts/\"):\n                    scripts[key] = f\".specify/scripts/{script_path[14:]}\"\n        return frontmatter\n\n    def render_markdown_command(\n        self,\n        frontmatter: dict,\n        body: str,\n        source_id: str,\n        context_note: str = None\n    ) -> str:\n        \"\"\"Render command in Markdown format.\n\n        Args:\n            frontmatter: Command frontmatter\n            body: Command body content\n            source_id: Source identifier (extension or preset ID)\n            context_note: Custom context comment (default: <!-- Source: {source_id} -->)\n\n        Returns:\n            Formatted Markdown command file content\n        \"\"\"\n        if context_note is None:\n            context_note = f\"\\n<!-- Source: {source_id} -->\\n\"\n        return self.render_frontmatter(frontmatter) + \"\\n\" + context_note + body\n\n    def render_toml_command(\n        self,\n        frontmatter: dict,\n        body: str,\n        source_id: str\n    ) -> str:\n        \"\"\"Render command in TOML format.\n\n        Args:\n            frontmatter: Command frontmatter\n            body: Command body content\n            source_id: Source identifier (extension or preset ID)\n\n        Returns:\n            Formatted TOML command file content\n        \"\"\"\n        toml_lines = []\n\n        if \"description\" in frontmatter:\n            desc = frontmatter[\"description\"].replace('\"', '\\\\\"')\n            toml_lines.append(f'description = \"{desc}\"')\n            toml_lines.append(\"\")\n\n        toml_lines.append(f\"# Source: {source_id}\")\n        toml_lines.append(\"\")\n\n        toml_lines.append('prompt = \"\"\"')\n        toml_lines.append(body)\n        toml_lines.append('\"\"\"')\n\n        return \"\\n\".join(toml_lines)\n\n    def render_skill_command(\n        self,\n        agent_name: str,\n        skill_name: str,\n        frontmatter: dict,\n        body: str,\n        source_id: str,\n        source_file: str,\n        project_root: Path,\n    ) -> str:\n        \"\"\"Render a command override as a SKILL.md file.\n\n        SKILL-target agents should receive the same skills-oriented\n        frontmatter shape used elsewhere in the project instead of the\n        original command frontmatter.\n        \"\"\"\n        if not isinstance(frontmatter, dict):\n            frontmatter = {}\n\n        if agent_name == \"codex\":\n            body = self._resolve_codex_skill_placeholders(frontmatter, body, project_root)\n\n        description = frontmatter.get(\"description\", f\"Spec-kit workflow command: {skill_name}\")\n        skill_frontmatter = {\n            \"name\": skill_name,\n            \"description\": description,\n            \"compatibility\": \"Requires spec-kit project structure with .specify/ directory\",\n            \"metadata\": {\n                \"author\": \"github-spec-kit\",\n                \"source\": f\"{source_id}:{source_file}\",\n            },\n        }\n        return self.render_frontmatter(skill_frontmatter) + \"\\n\" + body\n\n    @staticmethod\n    def _resolve_codex_skill_placeholders(frontmatter: dict, body: str, project_root: Path) -> str:\n        \"\"\"Resolve script placeholders for Codex skill overrides.\n\n        This intentionally scopes the fix to Codex, which is the newly\n        migrated runtime path in this PR. Existing Kimi behavior is left\n        unchanged for now.\n        \"\"\"\n        try:\n            from . import load_init_options\n        except ImportError:\n            return body\n\n        if not isinstance(frontmatter, dict):\n            frontmatter = {}\n\n        scripts = frontmatter.get(\"scripts\", {}) or {}\n        agent_scripts = frontmatter.get(\"agent_scripts\", {}) or {}\n        if not isinstance(scripts, dict):\n            scripts = {}\n        if not isinstance(agent_scripts, dict):\n            agent_scripts = {}\n\n        script_variant = load_init_options(project_root).get(\"script\")\n        if script_variant not in {\"sh\", \"ps\"}:\n            fallback_order = []\n            default_variant = \"ps\" if platform.system().lower().startswith(\"win\") else \"sh\"\n            secondary_variant = \"sh\" if default_variant == \"ps\" else \"ps\"\n\n            if default_variant in scripts or default_variant in agent_scripts:\n                fallback_order.append(default_variant)\n            if secondary_variant in scripts or secondary_variant in agent_scripts:\n                fallback_order.append(secondary_variant)\n\n            for key in scripts:\n                if key not in fallback_order:\n                    fallback_order.append(key)\n            for key in agent_scripts:\n                if key not in fallback_order:\n                    fallback_order.append(key)\n\n            script_variant = fallback_order[0] if fallback_order else None\n\n        script_command = scripts.get(script_variant) if script_variant else None\n        if script_command:\n            script_command = script_command.replace(\"{ARGS}\", \"$ARGUMENTS\")\n            body = body.replace(\"{SCRIPT}\", script_command)\n\n        agent_script_command = agent_scripts.get(script_variant) if script_variant else None\n        if agent_script_command:\n            agent_script_command = agent_script_command.replace(\"{ARGS}\", \"$ARGUMENTS\")\n            body = body.replace(\"{AGENT_SCRIPT}\", agent_script_command)\n\n        return body.replace(\"{ARGS}\", \"$ARGUMENTS\").replace(\"__AGENT__\", \"codex\")\n\n    def _convert_argument_placeholder(self, content: str, from_placeholder: str, to_placeholder: str) -> str:\n        \"\"\"Convert argument placeholder format.\n\n        Args:\n            content: Command content\n            from_placeholder: Source placeholder (e.g., \"$ARGUMENTS\")\n            to_placeholder: Target placeholder (e.g., \"{{args}}\")\n\n        Returns:\n            Content with converted placeholders\n        \"\"\"\n        return content.replace(from_placeholder, to_placeholder)\n\n    @staticmethod\n    def _compute_output_name(agent_name: str, cmd_name: str, agent_config: Dict[str, Any]) -> str:\n        \"\"\"Compute the on-disk command or skill name for an agent.\"\"\"\n        if agent_config[\"extension\"] != \"/SKILL.md\":\n            return cmd_name\n\n        short_name = cmd_name\n        if short_name.startswith(\"speckit.\"):\n            short_name = short_name[len(\"speckit.\"):]\n\n        return f\"speckit.{short_name}\" if agent_name == \"kimi\" else f\"speckit-{short_name}\"\n\n    def register_commands(\n        self,\n        agent_name: str,\n        commands: List[Dict[str, Any]],\n        source_id: str,\n        source_dir: Path,\n        project_root: Path,\n        context_note: str = None\n    ) -> List[str]:\n        \"\"\"Register commands for a specific agent.\n\n        Args:\n            agent_name: Agent name (claude, gemini, copilot, etc.)\n            commands: List of command info dicts with 'name', 'file', and optional 'aliases'\n            source_id: Identifier of the source (extension or preset ID)\n            source_dir: Directory containing command source files\n            project_root: Path to project root\n            context_note: Custom context comment for markdown output\n\n        Returns:\n            List of registered command names\n\n        Raises:\n            ValueError: If agent is not supported\n        \"\"\"\n        if agent_name not in self.AGENT_CONFIGS:\n            raise ValueError(f\"Unsupported agent: {agent_name}\")\n\n        agent_config = self.AGENT_CONFIGS[agent_name]\n        commands_dir = project_root / agent_config[\"dir\"]\n        commands_dir.mkdir(parents=True, exist_ok=True)\n\n        registered = []\n\n        for cmd_info in commands:\n            cmd_name = cmd_info[\"name\"]\n            cmd_file = cmd_info[\"file\"]\n\n            source_file = source_dir / cmd_file\n            if not source_file.exists():\n                continue\n\n            content = source_file.read_text(encoding=\"utf-8\")\n            frontmatter, body = self.parse_frontmatter(content)\n\n            frontmatter = self._adjust_script_paths(frontmatter)\n\n            body = self._convert_argument_placeholder(\n                body, \"$ARGUMENTS\", agent_config[\"args\"]\n            )\n\n            output_name = self._compute_output_name(agent_name, cmd_name, agent_config)\n\n            if agent_config[\"extension\"] == \"/SKILL.md\":\n                output = self.render_skill_command(\n                    agent_name, output_name, frontmatter, body, source_id, cmd_file, project_root\n                )\n            elif agent_config[\"format\"] == \"markdown\":\n                output = self.render_markdown_command(frontmatter, body, source_id, context_note)\n            elif agent_config[\"format\"] == \"toml\":\n                output = self.render_toml_command(frontmatter, body, source_id)\n            else:\n                raise ValueError(f\"Unsupported format: {agent_config['format']}\")\n\n            dest_file = commands_dir / f\"{output_name}{agent_config['extension']}\"\n            dest_file.parent.mkdir(parents=True, exist_ok=True)\n            dest_file.write_text(output, encoding=\"utf-8\")\n\n            if agent_name == \"copilot\":\n                self.write_copilot_prompt(project_root, cmd_name)\n\n            registered.append(cmd_name)\n\n            for alias in cmd_info.get(\"aliases\", []):\n                alias_output_name = self._compute_output_name(agent_name, alias, agent_config)\n                alias_output = output\n                if agent_config[\"extension\"] == \"/SKILL.md\":\n                    alias_output = self.render_skill_command(\n                        agent_name, alias_output_name, frontmatter, body, source_id, cmd_file, project_root\n                    )\n                alias_file = commands_dir / f\"{alias_output_name}{agent_config['extension']}\"\n                alias_file.parent.mkdir(parents=True, exist_ok=True)\n                alias_file.write_text(alias_output, encoding=\"utf-8\")\n                if agent_name == \"copilot\":\n                    self.write_copilot_prompt(project_root, alias)\n                registered.append(alias)\n\n        return registered\n\n    @staticmethod\n    def write_copilot_prompt(project_root: Path, cmd_name: str) -> None:\n        \"\"\"Generate a companion .prompt.md file for a Copilot agent command.\n\n        Args:\n            project_root: Path to project root\n            cmd_name: Command name (e.g. 'speckit.my-ext.example')\n        \"\"\"\n        prompts_dir = project_root / \".github\" / \"prompts\"\n        prompts_dir.mkdir(parents=True, exist_ok=True)\n        prompt_file = prompts_dir / f\"{cmd_name}.prompt.md\"\n        prompt_file.write_text(f\"---\\nagent: {cmd_name}\\n---\\n\", encoding=\"utf-8\")\n\n    def register_commands_for_all_agents(\n        self,\n        commands: List[Dict[str, Any]],\n        source_id: str,\n        source_dir: Path,\n        project_root: Path,\n        context_note: str = None\n    ) -> Dict[str, List[str]]:\n        \"\"\"Register commands for all detected agents in the project.\n\n        Args:\n            commands: List of command info dicts\n            source_id: Identifier of the source (extension or preset ID)\n            source_dir: Directory containing command source files\n            project_root: Path to project root\n            context_note: Custom context comment for markdown output\n\n        Returns:\n            Dictionary mapping agent names to list of registered commands\n        \"\"\"\n        results = {}\n\n        for agent_name, agent_config in self.AGENT_CONFIGS.items():\n            agent_dir = project_root / agent_config[\"dir\"]\n\n            if agent_dir.exists():\n                try:\n                    registered = self.register_commands(\n                        agent_name, commands, source_id, source_dir, project_root,\n                        context_note=context_note\n                    )\n                    if registered:\n                        results[agent_name] = registered\n                except ValueError:\n                    continue\n\n        return results\n\n    def unregister_commands(\n        self,\n        registered_commands: Dict[str, List[str]],\n        project_root: Path\n    ) -> None:\n        \"\"\"Remove previously registered command files from agent directories.\n\n        Args:\n            registered_commands: Dict mapping agent names to command name lists\n            project_root: Path to project root\n        \"\"\"\n        for agent_name, cmd_names in registered_commands.items():\n            if agent_name not in self.AGENT_CONFIGS:\n                continue\n\n            agent_config = self.AGENT_CONFIGS[agent_name]\n            commands_dir = project_root / agent_config[\"dir\"]\n\n            for cmd_name in cmd_names:\n                output_name = self._compute_output_name(agent_name, cmd_name, agent_config)\n                cmd_file = commands_dir / f\"{output_name}{agent_config['extension']}\"\n                if cmd_file.exists():\n                    cmd_file.unlink()\n\n                if agent_name == \"copilot\":\n                    prompt_file = project_root / \".github\" / \"prompts\" / f\"{cmd_name}.prompt.md\"\n                    if prompt_file.exists():\n                        prompt_file.unlink()\n"
  },
  {
    "path": "src/specify_cli/extensions.py",
    "content": "\"\"\"\nExtension Manager for Spec Kit\n\nHandles installation, removal, and management of Spec Kit extensions.\nExtensions are modular packages that add commands and functionality to spec-kit\nwithout bloating the core framework.\n\"\"\"\n\nimport json\nimport hashlib\nimport os\nimport tempfile\nimport zipfile\nimport shutil\nimport copy\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Optional, Dict, List, Any, Callable, Set\nfrom datetime import datetime, timezone\nimport re\n\nimport pathspec\n\nimport yaml\nfrom packaging import version as pkg_version\nfrom packaging.specifiers import SpecifierSet, InvalidSpecifier\n\n\nclass ExtensionError(Exception):\n    \"\"\"Base exception for extension-related errors.\"\"\"\n    pass\n\n\nclass ValidationError(ExtensionError):\n    \"\"\"Raised when extension manifest validation fails.\"\"\"\n    pass\n\n\nclass CompatibilityError(ExtensionError):\n    \"\"\"Raised when extension is incompatible with current environment.\"\"\"\n    pass\n\n\ndef normalize_priority(value: Any, default: int = 10) -> int:\n    \"\"\"Normalize a stored priority value for sorting and display.\n\n    Corrupted registry data may contain missing, non-numeric, or non-positive\n    values. In those cases, fall back to the default priority.\n\n    Args:\n        value: Priority value to normalize (may be int, str, None, etc.)\n        default: Default priority to use for invalid values (default: 10)\n\n    Returns:\n        Normalized priority as positive integer (>= 1)\n    \"\"\"\n    try:\n        priority = int(value)\n    except (TypeError, ValueError):\n        return default\n    return priority if priority >= 1 else default\n\n\n@dataclass\nclass CatalogEntry:\n    \"\"\"Represents a single catalog entry in the catalog stack.\"\"\"\n    url: str\n    name: str\n    priority: int\n    install_allowed: bool\n    description: str = \"\"\n\n\nclass ExtensionManifest:\n    \"\"\"Represents and validates an extension manifest (extension.yml).\"\"\"\n\n    SCHEMA_VERSION = \"1.0\"\n    REQUIRED_FIELDS = [\"schema_version\", \"extension\", \"requires\", \"provides\"]\n\n    def __init__(self, manifest_path: Path):\n        \"\"\"Load and validate extension manifest.\n\n        Args:\n            manifest_path: Path to extension.yml file\n\n        Raises:\n            ValidationError: If manifest is invalid\n        \"\"\"\n        self.path = manifest_path\n        self.data = self._load_yaml(manifest_path)\n        self._validate()\n\n    def _load_yaml(self, path: Path) -> dict:\n        \"\"\"Load YAML file safely.\"\"\"\n        try:\n            with open(path, 'r') as f:\n                return yaml.safe_load(f) or {}\n        except yaml.YAMLError as e:\n            raise ValidationError(f\"Invalid YAML in {path}: {e}\")\n        except FileNotFoundError:\n            raise ValidationError(f\"Manifest not found: {path}\")\n\n    def _validate(self):\n        \"\"\"Validate manifest structure and required fields.\"\"\"\n        # Check required top-level fields\n        for field in self.REQUIRED_FIELDS:\n            if field not in self.data:\n                raise ValidationError(f\"Missing required field: {field}\")\n\n        # Validate schema version\n        if self.data[\"schema_version\"] != self.SCHEMA_VERSION:\n            raise ValidationError(\n                f\"Unsupported schema version: {self.data['schema_version']} \"\n                f\"(expected {self.SCHEMA_VERSION})\"\n            )\n\n        # Validate extension metadata\n        ext = self.data[\"extension\"]\n        for field in [\"id\", \"name\", \"version\", \"description\"]:\n            if field not in ext:\n                raise ValidationError(f\"Missing extension.{field}\")\n\n        # Validate extension ID format\n        if not re.match(r'^[a-z0-9-]+$', ext[\"id\"]):\n            raise ValidationError(\n                f\"Invalid extension ID '{ext['id']}': \"\n                \"must be lowercase alphanumeric with hyphens only\"\n            )\n\n        # Validate semantic version\n        try:\n            pkg_version.Version(ext[\"version\"])\n        except pkg_version.InvalidVersion:\n            raise ValidationError(f\"Invalid version: {ext['version']}\")\n\n        # Validate requires section\n        requires = self.data[\"requires\"]\n        if \"speckit_version\" not in requires:\n            raise ValidationError(\"Missing requires.speckit_version\")\n\n        # Validate provides section\n        provides = self.data[\"provides\"]\n        if \"commands\" not in provides or not provides[\"commands\"]:\n            raise ValidationError(\"Extension must provide at least one command\")\n\n        # Validate commands\n        for cmd in provides[\"commands\"]:\n            if \"name\" not in cmd or \"file\" not in cmd:\n                raise ValidationError(\"Command missing 'name' or 'file'\")\n\n            # Validate command name format\n            if not re.match(r'^speckit\\.[a-z0-9-]+\\.[a-z0-9-]+$', cmd[\"name\"]):\n                raise ValidationError(\n                    f\"Invalid command name '{cmd['name']}': \"\n                    \"must follow pattern 'speckit.{extension}.{command}'\"\n                )\n\n    @property\n    def id(self) -> str:\n        \"\"\"Get extension ID.\"\"\"\n        return self.data[\"extension\"][\"id\"]\n\n    @property\n    def name(self) -> str:\n        \"\"\"Get extension name.\"\"\"\n        return self.data[\"extension\"][\"name\"]\n\n    @property\n    def version(self) -> str:\n        \"\"\"Get extension version.\"\"\"\n        return self.data[\"extension\"][\"version\"]\n\n    @property\n    def description(self) -> str:\n        \"\"\"Get extension description.\"\"\"\n        return self.data[\"extension\"][\"description\"]\n\n    @property\n    def requires_speckit_version(self) -> str:\n        \"\"\"Get required spec-kit version range.\"\"\"\n        return self.data[\"requires\"][\"speckit_version\"]\n\n    @property\n    def commands(self) -> List[Dict[str, Any]]:\n        \"\"\"Get list of provided commands.\"\"\"\n        return self.data[\"provides\"][\"commands\"]\n\n    @property\n    def hooks(self) -> Dict[str, Any]:\n        \"\"\"Get hook definitions.\"\"\"\n        return self.data.get(\"hooks\", {})\n\n    def get_hash(self) -> str:\n        \"\"\"Calculate SHA256 hash of manifest file.\"\"\"\n        with open(self.path, 'rb') as f:\n            return f\"sha256:{hashlib.sha256(f.read()).hexdigest()}\"\n\n\nclass ExtensionRegistry:\n    \"\"\"Manages the registry of installed extensions.\"\"\"\n\n    REGISTRY_FILE = \".registry\"\n    SCHEMA_VERSION = \"1.0\"\n\n    def __init__(self, extensions_dir: Path):\n        \"\"\"Initialize registry.\n\n        Args:\n            extensions_dir: Path to .specify/extensions/ directory\n        \"\"\"\n        self.extensions_dir = extensions_dir\n        self.registry_path = extensions_dir / self.REGISTRY_FILE\n        self.data = self._load()\n\n    def _load(self) -> dict:\n        \"\"\"Load registry from disk.\"\"\"\n        if not self.registry_path.exists():\n            return {\n                \"schema_version\": self.SCHEMA_VERSION,\n                \"extensions\": {}\n            }\n\n        try:\n            with open(self.registry_path, 'r') as f:\n                data = json.load(f)\n            # Validate loaded data is a dict (handles corrupted registry files)\n            if not isinstance(data, dict):\n                return {\n                    \"schema_version\": self.SCHEMA_VERSION,\n                    \"extensions\": {}\n                }\n            # Normalize extensions field (handles corrupted extensions value)\n            if not isinstance(data.get(\"extensions\"), dict):\n                data[\"extensions\"] = {}\n            return data\n        except (json.JSONDecodeError, FileNotFoundError):\n            # Corrupted or missing registry, start fresh\n            return {\n                \"schema_version\": self.SCHEMA_VERSION,\n                \"extensions\": {}\n            }\n\n    def _save(self):\n        \"\"\"Save registry to disk.\"\"\"\n        self.extensions_dir.mkdir(parents=True, exist_ok=True)\n        with open(self.registry_path, 'w') as f:\n            json.dump(self.data, f, indent=2)\n\n    def add(self, extension_id: str, metadata: dict):\n        \"\"\"Add extension to registry.\n\n        Args:\n            extension_id: Extension ID\n            metadata: Extension metadata (version, source, etc.)\n        \"\"\"\n        self.data[\"extensions\"][extension_id] = {\n            **copy.deepcopy(metadata),\n            \"installed_at\": datetime.now(timezone.utc).isoformat()\n        }\n        self._save()\n\n    def update(self, extension_id: str, metadata: dict):\n        \"\"\"Update extension metadata in registry, merging with existing entry.\n\n        Merges the provided metadata with the existing entry, preserving any\n        fields not specified in the new metadata. The installed_at timestamp\n        is always preserved from the original entry.\n\n        Use this method instead of add() when updating existing extension\n        metadata (e.g., enabling/disabling) to preserve the original\n        installation timestamp and other existing fields.\n\n        Args:\n            extension_id: Extension ID\n            metadata: Extension metadata fields to update (merged with existing)\n\n        Raises:\n            KeyError: If extension is not installed\n        \"\"\"\n        extensions = self.data.get(\"extensions\")\n        if not isinstance(extensions, dict) or extension_id not in extensions:\n            raise KeyError(f\"Extension '{extension_id}' is not installed\")\n        # Merge new metadata with existing, preserving original installed_at\n        existing = extensions[extension_id]\n        # Handle corrupted registry entries (e.g., string/list instead of dict)\n        if not isinstance(existing, dict):\n            existing = {}\n        # Merge: existing fields preserved, new fields override (deep copy to prevent caller mutation)\n        merged = {**existing, **copy.deepcopy(metadata)}\n        # Always preserve original installed_at based on key existence, not truthiness,\n        # to handle cases where the field exists but may be falsy (legacy/corruption)\n        if \"installed_at\" in existing:\n            merged[\"installed_at\"] = existing[\"installed_at\"]\n        else:\n            # If not present in existing, explicitly remove from merged if caller provided it\n            merged.pop(\"installed_at\", None)\n        extensions[extension_id] = merged\n        self._save()\n\n    def restore(self, extension_id: str, metadata: dict):\n        \"\"\"Restore extension metadata to registry without modifying timestamps.\n\n        Use this method for rollback scenarios where you have a complete backup\n        of the registry entry (including installed_at) and want to restore it\n        exactly as it was.\n\n        Args:\n            extension_id: Extension ID\n            metadata: Complete extension metadata including installed_at\n\n        Raises:\n            ValueError: If metadata is None or not a dict\n        \"\"\"\n        if metadata is None or not isinstance(metadata, dict):\n            raise ValueError(f\"Cannot restore '{extension_id}': metadata must be a dict\")\n        # Ensure extensions dict exists (handle corrupted registry)\n        if not isinstance(self.data.get(\"extensions\"), dict):\n            self.data[\"extensions\"] = {}\n        self.data[\"extensions\"][extension_id] = copy.deepcopy(metadata)\n        self._save()\n\n    def remove(self, extension_id: str):\n        \"\"\"Remove extension from registry.\n\n        Args:\n            extension_id: Extension ID\n        \"\"\"\n        extensions = self.data.get(\"extensions\")\n        if not isinstance(extensions, dict):\n            return\n        if extension_id in extensions:\n            del extensions[extension_id]\n            self._save()\n\n    def get(self, extension_id: str) -> Optional[dict]:\n        \"\"\"Get extension metadata from registry.\n\n        Returns a deep copy to prevent callers from accidentally mutating\n        nested internal registry state without going through the write path.\n\n        Args:\n            extension_id: Extension ID\n\n        Returns:\n            Deep copy of extension metadata, or None if not found or corrupted\n        \"\"\"\n        extensions = self.data.get(\"extensions\")\n        if not isinstance(extensions, dict):\n            return None\n        entry = extensions.get(extension_id)\n        # Return None for missing or corrupted (non-dict) entries\n        if entry is None or not isinstance(entry, dict):\n            return None\n        return copy.deepcopy(entry)\n\n    def list(self) -> Dict[str, dict]:\n        \"\"\"Get all installed extensions with valid metadata.\n\n        Returns a deep copy of extensions with dict metadata only.\n        Corrupted entries (non-dict values) are filtered out.\n\n        Returns:\n            Dictionary of extension_id -> metadata (deep copies), empty dict if corrupted\n        \"\"\"\n        extensions = self.data.get(\"extensions\", {}) or {}\n        if not isinstance(extensions, dict):\n            return {}\n        # Filter to only valid dict entries to match type contract\n        return {\n            ext_id: copy.deepcopy(meta)\n            for ext_id, meta in extensions.items()\n            if isinstance(meta, dict)\n        }\n\n    def keys(self) -> set:\n        \"\"\"Get all extension IDs including corrupted entries.\n\n        Lightweight method that returns IDs without deep-copying metadata.\n        Use this when you only need to check which extensions are tracked.\n\n        Returns:\n            Set of extension IDs (includes corrupted entries)\n        \"\"\"\n        extensions = self.data.get(\"extensions\", {}) or {}\n        if not isinstance(extensions, dict):\n            return set()\n        return set(extensions.keys())\n\n    def is_installed(self, extension_id: str) -> bool:\n        \"\"\"Check if extension is installed.\n\n        Args:\n            extension_id: Extension ID\n\n        Returns:\n            True if extension is installed, False if not or registry corrupted\n        \"\"\"\n        extensions = self.data.get(\"extensions\")\n        if not isinstance(extensions, dict):\n            return False\n        return extension_id in extensions\n\n    def list_by_priority(self, include_disabled: bool = False) -> List[tuple]:\n        \"\"\"Get all installed extensions sorted by priority.\n\n        Lower priority number = higher precedence (checked first).\n        Extensions with equal priority are sorted alphabetically by ID\n        for deterministic ordering.\n\n        Args:\n            include_disabled: If True, include disabled extensions. Default False.\n\n        Returns:\n            List of (extension_id, metadata_copy) tuples sorted by priority.\n            Metadata is deep-copied to prevent accidental mutation.\n        \"\"\"\n        extensions = self.data.get(\"extensions\", {}) or {}\n        if not isinstance(extensions, dict):\n            extensions = {}\n        sortable_extensions = []\n        for ext_id, meta in extensions.items():\n            if not isinstance(meta, dict):\n                continue\n            # Skip disabled extensions unless explicitly requested\n            if not include_disabled and not meta.get(\"enabled\", True):\n                continue\n            metadata_copy = copy.deepcopy(meta)\n            metadata_copy[\"priority\"] = normalize_priority(metadata_copy.get(\"priority\", 10))\n            sortable_extensions.append((ext_id, metadata_copy))\n        return sorted(\n            sortable_extensions,\n            key=lambda item: (item[1][\"priority\"], item[0]),\n        )\n\n\nclass ExtensionManager:\n    \"\"\"Manages extension lifecycle: installation, removal, updates.\"\"\"\n\n    def __init__(self, project_root: Path):\n        \"\"\"Initialize extension manager.\n\n        Args:\n            project_root: Path to project root directory\n        \"\"\"\n        self.project_root = project_root\n        self.extensions_dir = project_root / \".specify\" / \"extensions\"\n        self.registry = ExtensionRegistry(self.extensions_dir)\n\n    @staticmethod\n    def _load_extensionignore(source_dir: Path) -> Optional[Callable[[str, List[str]], Set[str]]]:\n        \"\"\"Load .extensionignore and return an ignore function for shutil.copytree.\n\n        The .extensionignore file uses .gitignore-compatible patterns (one per line).\n        Lines starting with '#' are comments. Blank lines are ignored.\n        The .extensionignore file itself is always excluded.\n\n        Pattern semantics mirror .gitignore:\n        - '*' matches anything except '/'\n        - '**' matches zero or more directories\n        - '?' matches any single character except '/'\n        - Trailing '/' restricts a pattern to directories only\n        - Patterns with '/' (other than trailing) are anchored to the root\n        - '!' negates a previously excluded pattern\n\n        Args:\n            source_dir: Path to the extension source directory\n\n        Returns:\n            An ignore function compatible with shutil.copytree, or None\n            if no .extensionignore file exists.\n        \"\"\"\n        ignore_file = source_dir / \".extensionignore\"\n        if not ignore_file.exists():\n            return None\n\n        lines: List[str] = ignore_file.read_text().splitlines()\n\n        # Normalise backslashes in patterns so Windows-authored files work\n        normalised: List[str] = []\n        for line in lines:\n            stripped = line.strip()\n            if stripped and not stripped.startswith(\"#\"):\n                normalised.append(stripped.replace(\"\\\\\", \"/\"))\n            else:\n                # Preserve blanks/comments so pathspec line numbers stay stable\n                normalised.append(line)\n\n        # Always ignore the .extensionignore file itself\n        normalised.append(\".extensionignore\")\n\n        spec = pathspec.GitIgnoreSpec.from_lines(normalised)\n\n        def _ignore(directory: str, entries: List[str]) -> Set[str]:\n            ignored: Set[str] = set()\n            rel_dir = Path(directory).relative_to(source_dir)\n            for entry in entries:\n                rel_path = str(rel_dir / entry) if str(rel_dir) != \".\" else entry\n                # Normalise to forward slashes for consistent matching\n                rel_path_fwd = rel_path.replace(\"\\\\\", \"/\")\n\n                entry_full = Path(directory) / entry\n                if entry_full.is_dir():\n                    # Append '/' so directory-only patterns (e.g. tests/) match\n                    if spec.match_file(rel_path_fwd + \"/\"):\n                        ignored.add(entry)\n                else:\n                    if spec.match_file(rel_path_fwd):\n                        ignored.add(entry)\n            return ignored\n\n        return _ignore\n\n    def check_compatibility(\n        self,\n        manifest: ExtensionManifest,\n        speckit_version: str\n    ) -> bool:\n        \"\"\"Check if extension is compatible with current spec-kit version.\n\n        Args:\n            manifest: Extension manifest\n            speckit_version: Current spec-kit version\n\n        Returns:\n            True if compatible\n\n        Raises:\n            CompatibilityError: If extension is incompatible\n        \"\"\"\n        required = manifest.requires_speckit_version\n        current = pkg_version.Version(speckit_version)\n\n        # Parse version specifier (e.g., \">=0.1.0,<2.0.0\")\n        try:\n            specifier = SpecifierSet(required)\n            if current not in specifier:\n                raise CompatibilityError(\n                    f\"Extension requires spec-kit {required}, \"\n                    f\"but {speckit_version} is installed.\\n\"\n                    f\"Upgrade spec-kit with: uv tool install specify-cli --force\"\n                )\n        except InvalidSpecifier:\n            raise CompatibilityError(f\"Invalid version specifier: {required}\")\n\n        return True\n\n    def install_from_directory(\n        self,\n        source_dir: Path,\n        speckit_version: str,\n        register_commands: bool = True,\n        priority: int = 10,\n    ) -> ExtensionManifest:\n        \"\"\"Install extension from a local directory.\n\n        Args:\n            source_dir: Path to extension directory\n            speckit_version: Current spec-kit version\n            register_commands: If True, register commands with AI agents\n            priority: Resolution priority (lower = higher precedence, default 10)\n\n        Returns:\n            Installed extension manifest\n\n        Raises:\n            ValidationError: If manifest is invalid or priority is invalid\n            CompatibilityError: If extension is incompatible\n        \"\"\"\n        # Validate priority\n        if priority < 1:\n            raise ValidationError(\"Priority must be a positive integer (1 or higher)\")\n\n        # Load and validate manifest\n        manifest_path = source_dir / \"extension.yml\"\n        manifest = ExtensionManifest(manifest_path)\n\n        # Check compatibility\n        self.check_compatibility(manifest, speckit_version)\n\n        # Check if already installed\n        if self.registry.is_installed(manifest.id):\n            raise ExtensionError(\n                f\"Extension '{manifest.id}' is already installed. \"\n                f\"Use 'specify extension remove {manifest.id}' first.\"\n            )\n\n        # Install extension\n        dest_dir = self.extensions_dir / manifest.id\n        if dest_dir.exists():\n            shutil.rmtree(dest_dir)\n\n        ignore_fn = self._load_extensionignore(source_dir)\n        shutil.copytree(source_dir, dest_dir, ignore=ignore_fn)\n\n        # Register commands with AI agents\n        registered_commands = {}\n        if register_commands:\n            registrar = CommandRegistrar()\n            # Register for all detected agents\n            registered_commands = registrar.register_commands_for_all_agents(\n                manifest, dest_dir, self.project_root\n            )\n\n        # Register hooks\n        hook_executor = HookExecutor(self.project_root)\n        hook_executor.register_hooks(manifest)\n\n        # Update registry\n        self.registry.add(manifest.id, {\n            \"version\": manifest.version,\n            \"source\": \"local\",\n            \"manifest_hash\": manifest.get_hash(),\n            \"enabled\": True,\n            \"priority\": priority,\n            \"registered_commands\": registered_commands\n        })\n\n        return manifest\n\n    def install_from_zip(\n        self,\n        zip_path: Path,\n        speckit_version: str,\n        priority: int = 10,\n    ) -> ExtensionManifest:\n        \"\"\"Install extension from ZIP file.\n\n        Args:\n            zip_path: Path to extension ZIP file\n            speckit_version: Current spec-kit version\n            priority: Resolution priority (lower = higher precedence, default 10)\n\n        Returns:\n            Installed extension manifest\n\n        Raises:\n            ValidationError: If manifest is invalid or priority is invalid\n            CompatibilityError: If extension is incompatible\n        \"\"\"\n        # Validate priority early\n        if priority < 1:\n            raise ValidationError(\"Priority must be a positive integer (1 or higher)\")\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            temp_path = Path(tmpdir)\n\n            # Extract ZIP safely (prevent Zip Slip attack)\n            with zipfile.ZipFile(zip_path, 'r') as zf:\n                # Validate all paths first before extracting anything\n                temp_path_resolved = temp_path.resolve()\n                for member in zf.namelist():\n                    member_path = (temp_path / member).resolve()\n                    # Use is_relative_to for safe path containment check\n                    try:\n                        member_path.relative_to(temp_path_resolved)\n                    except ValueError:\n                        raise ValidationError(\n                            f\"Unsafe path in ZIP archive: {member} (potential path traversal)\"\n                        )\n                # Only extract after all paths are validated\n                zf.extractall(temp_path)\n\n            # Find extension directory (may be nested)\n            extension_dir = temp_path\n            manifest_path = extension_dir / \"extension.yml\"\n\n            # Check if manifest is in a subdirectory\n            if not manifest_path.exists():\n                subdirs = [d for d in temp_path.iterdir() if d.is_dir()]\n                if len(subdirs) == 1:\n                    extension_dir = subdirs[0]\n                    manifest_path = extension_dir / \"extension.yml\"\n\n            if not manifest_path.exists():\n                raise ValidationError(\"No extension.yml found in ZIP file\")\n\n            # Install from extracted directory\n            return self.install_from_directory(extension_dir, speckit_version, priority=priority)\n\n    def remove(self, extension_id: str, keep_config: bool = False) -> bool:\n        \"\"\"Remove an installed extension.\n\n        Args:\n            extension_id: Extension ID\n            keep_config: If True, preserve config files (don't delete extension dir)\n\n        Returns:\n            True if extension was removed\n        \"\"\"\n        if not self.registry.is_installed(extension_id):\n            return False\n\n        # Get registered commands before removal\n        metadata = self.registry.get(extension_id)\n        registered_commands = metadata.get(\"registered_commands\", {}) if metadata else {}\n\n        extension_dir = self.extensions_dir / extension_id\n\n        # Unregister commands from all AI agents\n        if registered_commands:\n            registrar = CommandRegistrar()\n            registrar.unregister_commands(registered_commands, self.project_root)\n\n        if keep_config:\n            # Preserve config files, only remove non-config files\n            if extension_dir.exists():\n                for child in extension_dir.iterdir():\n                    # Keep top-level *-config.yml and *-config.local.yml files\n                    if child.is_file() and (\n                        child.name.endswith(\"-config.yml\") or\n                        child.name.endswith(\"-config.local.yml\")\n                    ):\n                        continue\n                    if child.is_dir():\n                        shutil.rmtree(child)\n                    else:\n                        child.unlink()\n        else:\n            # Backup config files before deleting\n            if extension_dir.exists():\n                # Use subdirectory per extension to avoid name accumulation\n                # (e.g., jira-jira-config.yml on repeated remove/install cycles)\n                backup_dir = self.extensions_dir / \".backup\" / extension_id\n                backup_dir.mkdir(parents=True, exist_ok=True)\n\n                # Backup both primary and local override config files\n                config_files = list(extension_dir.glob(\"*-config.yml\")) + list(\n                    extension_dir.glob(\"*-config.local.yml\")\n                )\n                for config_file in config_files:\n                    backup_path = backup_dir / config_file.name\n                    shutil.copy2(config_file, backup_path)\n\n            # Remove extension directory\n            if extension_dir.exists():\n                shutil.rmtree(extension_dir)\n\n        # Unregister hooks\n        hook_executor = HookExecutor(self.project_root)\n        hook_executor.unregister_hooks(extension_id)\n\n        # Update registry\n        self.registry.remove(extension_id)\n\n        return True\n\n    def list_installed(self) -> List[Dict[str, Any]]:\n        \"\"\"List all installed extensions with metadata.\n\n        Returns:\n            List of extension metadata dictionaries\n        \"\"\"\n        result = []\n\n        for ext_id, metadata in self.registry.list().items():\n            # Ensure metadata is a dictionary to avoid AttributeError when using .get()\n            if not isinstance(metadata, dict):\n                metadata = {}\n            ext_dir = self.extensions_dir / ext_id\n            manifest_path = ext_dir / \"extension.yml\"\n\n            try:\n                manifest = ExtensionManifest(manifest_path)\n                result.append({\n                    \"id\": ext_id,\n                    \"name\": manifest.name,\n                    \"version\": metadata.get(\"version\", \"unknown\"),\n                    \"description\": manifest.description,\n                    \"enabled\": metadata.get(\"enabled\", True),\n                    \"priority\": normalize_priority(metadata.get(\"priority\")),\n                    \"installed_at\": metadata.get(\"installed_at\"),\n                    \"command_count\": len(manifest.commands),\n                    \"hook_count\": len(manifest.hooks)\n                })\n            except ValidationError:\n                # Corrupted extension\n                result.append({\n                    \"id\": ext_id,\n                    \"name\": ext_id,\n                    \"version\": metadata.get(\"version\", \"unknown\"),\n                    \"description\": \"⚠️ Corrupted extension\",\n                    \"enabled\": False,\n                    \"priority\": normalize_priority(metadata.get(\"priority\")),\n                    \"installed_at\": metadata.get(\"installed_at\"),\n                    \"command_count\": 0,\n                    \"hook_count\": 0\n                })\n\n        return result\n\n    def get_extension(self, extension_id: str) -> Optional[ExtensionManifest]:\n        \"\"\"Get manifest for an installed extension.\n\n        Args:\n            extension_id: Extension ID\n\n        Returns:\n            Extension manifest or None if not installed\n        \"\"\"\n        if not self.registry.is_installed(extension_id):\n            return None\n\n        ext_dir = self.extensions_dir / extension_id\n        manifest_path = ext_dir / \"extension.yml\"\n\n        try:\n            return ExtensionManifest(manifest_path)\n        except ValidationError:\n            return None\n\n\ndef version_satisfies(current: str, required: str) -> bool:\n    \"\"\"Check if current version satisfies required version specifier.\n\n    Args:\n        current: Current version (e.g., \"0.1.5\")\n        required: Required version specifier (e.g., \">=0.1.0,<2.0.0\")\n\n    Returns:\n        True if version satisfies requirement\n    \"\"\"\n    try:\n        current_ver = pkg_version.Version(current)\n        specifier = SpecifierSet(required)\n        return current_ver in specifier\n    except (pkg_version.InvalidVersion, InvalidSpecifier):\n        return False\n\n\nclass CommandRegistrar:\n    \"\"\"Handles registration of extension commands with AI agents.\n\n    This is a backward-compatible wrapper around the shared CommandRegistrar\n    in agents.py. Extension-specific methods accept ExtensionManifest objects\n    and delegate to the generic API.\n    \"\"\"\n\n    # Re-export AGENT_CONFIGS at class level for direct attribute access\n    from .agents import CommandRegistrar as _AgentRegistrar\n    AGENT_CONFIGS = _AgentRegistrar.AGENT_CONFIGS\n\n    def __init__(self):\n        from .agents import CommandRegistrar as _Registrar\n        self._registrar = _Registrar()\n\n    # Delegate static/utility methods\n    @staticmethod\n    def parse_frontmatter(content: str) -> tuple[dict, str]:\n        from .agents import CommandRegistrar as _Registrar\n        return _Registrar.parse_frontmatter(content)\n\n    @staticmethod\n    def render_frontmatter(fm: dict) -> str:\n        from .agents import CommandRegistrar as _Registrar\n        return _Registrar.render_frontmatter(fm)\n\n    @staticmethod\n    def _write_copilot_prompt(project_root, cmd_name: str) -> None:\n        from .agents import CommandRegistrar as _Registrar\n        _Registrar.write_copilot_prompt(project_root, cmd_name)\n\n    def _render_markdown_command(self, frontmatter, body, ext_id):\n        # Preserve extension-specific comment format for backward compatibility\n        context_note = f\"\\n<!-- Extension: {ext_id} -->\\n<!-- Config: .specify/extensions/{ext_id}/ -->\\n\"\n        return self._registrar.render_frontmatter(frontmatter) + \"\\n\" + context_note + body\n\n    def _render_toml_command(self, frontmatter, body, ext_id):\n        # Preserve extension-specific context comments for backward compatibility\n        base = self._registrar.render_toml_command(frontmatter, body, ext_id)\n        context_lines = f\"# Extension: {ext_id}\\n# Config: .specify/extensions/{ext_id}/\\n\"\n        return base.rstrip(\"\\n\") + \"\\n\" + context_lines\n\n    def register_commands_for_agent(\n        self,\n        agent_name: str,\n        manifest: ExtensionManifest,\n        extension_dir: Path,\n        project_root: Path\n    ) -> List[str]:\n        \"\"\"Register extension commands for a specific agent.\"\"\"\n        if agent_name not in self.AGENT_CONFIGS:\n            raise ExtensionError(f\"Unsupported agent: {agent_name}\")\n        context_note = f\"\\n<!-- Extension: {manifest.id} -->\\n<!-- Config: .specify/extensions/{manifest.id}/ -->\\n\"\n        return self._registrar.register_commands(\n            agent_name, manifest.commands, manifest.id, extension_dir, project_root,\n            context_note=context_note\n        )\n\n    def register_commands_for_all_agents(\n        self,\n        manifest: ExtensionManifest,\n        extension_dir: Path,\n        project_root: Path\n    ) -> Dict[str, List[str]]:\n        \"\"\"Register extension commands for all detected agents.\"\"\"\n        context_note = f\"\\n<!-- Extension: {manifest.id} -->\\n<!-- Config: .specify/extensions/{manifest.id}/ -->\\n\"\n        return self._registrar.register_commands_for_all_agents(\n            manifest.commands, manifest.id, extension_dir, project_root,\n            context_note=context_note\n        )\n\n    def unregister_commands(\n        self,\n        registered_commands: Dict[str, List[str]],\n        project_root: Path\n    ) -> None:\n        \"\"\"Remove previously registered command files from agent directories.\"\"\"\n        self._registrar.unregister_commands(registered_commands, project_root)\n\n    def register_commands_for_claude(\n        self,\n        manifest: ExtensionManifest,\n        extension_dir: Path,\n        project_root: Path\n    ) -> List[str]:\n        \"\"\"Register extension commands for Claude Code agent.\"\"\"\n        return self.register_commands_for_agent(\"claude\", manifest, extension_dir, project_root)\n\n\nclass ExtensionCatalog:\n    \"\"\"Manages extension catalog fetching, caching, and searching.\"\"\"\n\n    DEFAULT_CATALOG_URL = \"https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json\"\n    COMMUNITY_CATALOG_URL = \"https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json\"\n    CACHE_DURATION = 3600  # 1 hour in seconds\n\n    def __init__(self, project_root: Path):\n        \"\"\"Initialize extension catalog manager.\n\n        Args:\n            project_root: Root directory of the spec-kit project\n        \"\"\"\n        self.project_root = project_root\n        self.extensions_dir = project_root / \".specify\" / \"extensions\"\n        self.cache_dir = self.extensions_dir / \".cache\"\n        self.cache_file = self.cache_dir / \"catalog.json\"\n        self.cache_metadata_file = self.cache_dir / \"catalog-metadata.json\"\n\n    def _validate_catalog_url(self, url: str) -> None:\n        \"\"\"Validate that a catalog URL uses HTTPS (localhost HTTP allowed).\n\n        Args:\n            url: URL to validate\n\n        Raises:\n            ValidationError: If URL is invalid or uses non-HTTPS scheme\n        \"\"\"\n        from urllib.parse import urlparse\n\n        parsed = urlparse(url)\n        is_localhost = parsed.hostname in (\"localhost\", \"127.0.0.1\", \"::1\")\n        if parsed.scheme != \"https\" and not (parsed.scheme == \"http\" and is_localhost):\n            raise ValidationError(\n                f\"Catalog URL must use HTTPS (got {parsed.scheme}://). \"\n                \"HTTP is only allowed for localhost.\"\n            )\n        if not parsed.netloc:\n            raise ValidationError(\"Catalog URL must be a valid URL with a host.\")\n\n    def _load_catalog_config(self, config_path: Path) -> Optional[List[CatalogEntry]]:\n        \"\"\"Load catalog stack configuration from a YAML file.\n\n        Args:\n            config_path: Path to extension-catalogs.yml\n\n        Returns:\n            Ordered list of CatalogEntry objects, or None if file doesn't exist.\n\n        Raises:\n            ValidationError: If any catalog entry has an invalid URL,\n                the file cannot be parsed, a priority value is invalid,\n                or the file exists but contains no valid catalog entries\n                (fail-closed for security).\n        \"\"\"\n        if not config_path.exists():\n            return None\n        try:\n            data = yaml.safe_load(config_path.read_text()) or {}\n        except (yaml.YAMLError, OSError) as e:\n            raise ValidationError(\n                f\"Failed to read catalog config {config_path}: {e}\"\n            )\n        catalogs_data = data.get(\"catalogs\", [])\n        if not catalogs_data:\n            # File exists but has no catalogs key or empty list - fail closed\n            raise ValidationError(\n                f\"Catalog config {config_path} exists but contains no 'catalogs' entries. \"\n                f\"Remove the file to use built-in defaults, or add valid catalog entries.\"\n            )\n        if not isinstance(catalogs_data, list):\n            raise ValidationError(\n                f\"Invalid catalog config: 'catalogs' must be a list, got {type(catalogs_data).__name__}\"\n            )\n        entries: List[CatalogEntry] = []\n        skipped_entries: List[int] = []\n        for idx, item in enumerate(catalogs_data):\n            if not isinstance(item, dict):\n                raise ValidationError(\n                    f\"Invalid catalog entry at index {idx}: expected a mapping, got {type(item).__name__}\"\n                )\n            url = str(item.get(\"url\", \"\")).strip()\n            if not url:\n                skipped_entries.append(idx)\n                continue\n            self._validate_catalog_url(url)\n            try:\n                priority = int(item.get(\"priority\", idx + 1))\n            except (TypeError, ValueError):\n                raise ValidationError(\n                    f\"Invalid priority for catalog '{item.get('name', idx + 1)}': \"\n                    f\"expected integer, got {item.get('priority')!r}\"\n                )\n            raw_install = item.get(\"install_allowed\", False)\n            if isinstance(raw_install, str):\n                install_allowed = raw_install.strip().lower() in (\"true\", \"yes\", \"1\")\n            else:\n                install_allowed = bool(raw_install)\n            entries.append(CatalogEntry(\n                url=url,\n                name=str(item.get(\"name\", f\"catalog-{idx + 1}\")),\n                priority=priority,\n                install_allowed=install_allowed,\n                description=str(item.get(\"description\", \"\")),\n            ))\n        entries.sort(key=lambda e: e.priority)\n        if not entries:\n            # All entries were invalid (missing URLs) - fail closed for security\n            raise ValidationError(\n                f\"Catalog config {config_path} contains {len(catalogs_data)} entries but none have valid URLs \"\n                f\"(entries at indices {skipped_entries} were skipped). \"\n                f\"Each catalog entry must have a 'url' field.\"\n            )\n        return entries\n\n    def get_active_catalogs(self) -> List[CatalogEntry]:\n        \"\"\"Get the ordered list of active catalogs.\n\n        Resolution order:\n        1. SPECKIT_CATALOG_URL env var — single catalog replacing all defaults\n        2. Project-level .specify/extension-catalogs.yml\n        3. User-level ~/.specify/extension-catalogs.yml\n        4. Built-in default stack (default + community)\n\n        Returns:\n            List of CatalogEntry objects sorted by priority (ascending)\n\n        Raises:\n            ValidationError: If a catalog URL is invalid\n        \"\"\"\n        import sys\n\n        # 1. SPECKIT_CATALOG_URL env var replaces all defaults for backward compat\n        if env_value := os.environ.get(\"SPECKIT_CATALOG_URL\"):\n            catalog_url = env_value.strip()\n            self._validate_catalog_url(catalog_url)\n            if catalog_url != self.DEFAULT_CATALOG_URL:\n                if not getattr(self, \"_non_default_catalog_warning_shown\", False):\n                    print(\n                        \"Warning: Using non-default extension catalog. \"\n                        \"Only use catalogs from sources you trust.\",\n                        file=sys.stderr,\n                    )\n                    self._non_default_catalog_warning_shown = True\n            return [CatalogEntry(url=catalog_url, name=\"custom\", priority=1, install_allowed=True, description=\"Custom catalog via SPECKIT_CATALOG_URL\")]\n\n        # 2. Project-level config overrides all defaults\n        project_config_path = self.project_root / \".specify\" / \"extension-catalogs.yml\"\n        catalogs = self._load_catalog_config(project_config_path)\n        if catalogs is not None:\n            return catalogs\n\n        # 3. User-level config\n        user_config_path = Path.home() / \".specify\" / \"extension-catalogs.yml\"\n        catalogs = self._load_catalog_config(user_config_path)\n        if catalogs is not None:\n            return catalogs\n\n        # 4. Built-in default stack\n        return [\n            CatalogEntry(url=self.DEFAULT_CATALOG_URL, name=\"default\", priority=1, install_allowed=True, description=\"Built-in catalog of installable extensions\"),\n            CatalogEntry(url=self.COMMUNITY_CATALOG_URL, name=\"community\", priority=2, install_allowed=False, description=\"Community-contributed extensions (discovery only)\"),\n        ]\n\n    def get_catalog_url(self) -> str:\n        \"\"\"Get the primary catalog URL.\n\n        Returns the URL of the highest-priority catalog. Kept for backward\n        compatibility. Use get_active_catalogs() for full multi-catalog support.\n\n        Returns:\n            URL of the primary catalog\n\n        Raises:\n            ValidationError: If a catalog URL is invalid\n        \"\"\"\n        active = self.get_active_catalogs()\n        return active[0].url if active else self.DEFAULT_CATALOG_URL\n\n    def _fetch_single_catalog(self, entry: CatalogEntry, force_refresh: bool = False) -> Dict[str, Any]:\n        \"\"\"Fetch a single catalog with per-URL caching.\n\n        For the DEFAULT_CATALOG_URL, uses legacy cache files (self.cache_file /\n        self.cache_metadata_file) for backward compatibility. For all other URLs,\n        uses URL-hash-based cache files in self.cache_dir.\n\n        Args:\n            entry: CatalogEntry describing the catalog to fetch\n            force_refresh: If True, bypass cache\n\n        Returns:\n            Catalog data dictionary\n\n        Raises:\n            ExtensionError: If catalog cannot be fetched or has invalid format\n        \"\"\"\n        import urllib.request\n        import urllib.error\n\n        # Determine cache file paths (backward compat for default catalog)\n        if entry.url == self.DEFAULT_CATALOG_URL:\n            cache_file = self.cache_file\n            cache_meta_file = self.cache_metadata_file\n            is_valid = not force_refresh and self.is_cache_valid()\n        else:\n            url_hash = hashlib.sha256(entry.url.encode()).hexdigest()[:16]\n            cache_file = self.cache_dir / f\"catalog-{url_hash}.json\"\n            cache_meta_file = self.cache_dir / f\"catalog-{url_hash}-metadata.json\"\n            is_valid = False\n            if not force_refresh and cache_file.exists() and cache_meta_file.exists():\n                try:\n                    metadata = json.loads(cache_meta_file.read_text())\n                    cached_at = datetime.fromisoformat(metadata.get(\"cached_at\", \"\"))\n                    if cached_at.tzinfo is None:\n                        cached_at = cached_at.replace(tzinfo=timezone.utc)\n                    age = (datetime.now(timezone.utc) - cached_at).total_seconds()\n                    is_valid = age < self.CACHE_DURATION\n                except (json.JSONDecodeError, ValueError, KeyError, TypeError):\n                    # If metadata is invalid or missing expected fields, treat cache as invalid\n                    pass\n\n        # Use cache if valid\n        if is_valid:\n            try:\n                return json.loads(cache_file.read_text())\n            except json.JSONDecodeError:\n                pass\n\n        # Fetch from network\n        try:\n            with urllib.request.urlopen(entry.url, timeout=10) as response:\n                catalog_data = json.loads(response.read())\n\n            if \"schema_version\" not in catalog_data or \"extensions\" not in catalog_data:\n                raise ExtensionError(f\"Invalid catalog format from {entry.url}\")\n\n            # Save to cache\n            self.cache_dir.mkdir(parents=True, exist_ok=True)\n            cache_file.write_text(json.dumps(catalog_data, indent=2))\n            cache_meta_file.write_text(json.dumps({\n                \"cached_at\": datetime.now(timezone.utc).isoformat(),\n                \"catalog_url\": entry.url,\n            }, indent=2))\n\n            return catalog_data\n\n        except urllib.error.URLError as e:\n            raise ExtensionError(f\"Failed to fetch catalog from {entry.url}: {e}\")\n        except json.JSONDecodeError as e:\n            raise ExtensionError(f\"Invalid JSON in catalog from {entry.url}: {e}\")\n\n    def _get_merged_extensions(self, force_refresh: bool = False) -> List[Dict[str, Any]]:\n        \"\"\"Fetch and merge extensions from all active catalogs.\n\n        Higher-priority (lower priority number) catalogs win on conflicts\n        (same extension id in two catalogs). Each extension dict is annotated with:\n          - _catalog_name: name of the source catalog\n          - _install_allowed: whether installation is allowed from this catalog\n\n        Catalogs that fail to fetch are skipped. Raises ExtensionError only if\n        ALL catalogs fail.\n\n        Args:\n            force_refresh: If True, bypass all caches\n\n        Returns:\n            List of merged extension dicts\n\n        Raises:\n            ExtensionError: If all catalogs fail to fetch\n        \"\"\"\n        import sys\n\n        active_catalogs = self.get_active_catalogs()\n        merged: Dict[str, Dict[str, Any]] = {}\n        any_success = False\n\n        for catalog_entry in active_catalogs:\n            try:\n                catalog_data = self._fetch_single_catalog(catalog_entry, force_refresh)\n                any_success = True\n            except ExtensionError as e:\n                print(\n                    f\"Warning: Could not fetch catalog '{catalog_entry.name}': {e}\",\n                    file=sys.stderr,\n                )\n                continue\n\n            for ext_id, ext_data in catalog_data.get(\"extensions\", {}).items():\n                if ext_id not in merged:  # Higher-priority catalog wins\n                    merged[ext_id] = {\n                        **ext_data,\n                        \"id\": ext_id,\n                        \"_catalog_name\": catalog_entry.name,\n                        \"_install_allowed\": catalog_entry.install_allowed,\n                    }\n\n        if not any_success and active_catalogs:\n            raise ExtensionError(\"Failed to fetch any extension catalog\")\n\n        return list(merged.values())\n\n    def is_cache_valid(self) -> bool:\n        \"\"\"Check if cached catalog is still valid.\n\n        Returns:\n            True if cache exists and is within cache duration\n        \"\"\"\n        if not self.cache_file.exists() or not self.cache_metadata_file.exists():\n            return False\n\n        try:\n            metadata = json.loads(self.cache_metadata_file.read_text())\n            cached_at = datetime.fromisoformat(metadata.get(\"cached_at\", \"\"))\n            if cached_at.tzinfo is None:\n                cached_at = cached_at.replace(tzinfo=timezone.utc)\n            age_seconds = (datetime.now(timezone.utc) - cached_at).total_seconds()\n            return age_seconds < self.CACHE_DURATION\n        except (json.JSONDecodeError, ValueError, KeyError, TypeError):\n            return False\n\n    def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]:\n        \"\"\"Fetch extension catalog from URL or cache.\n\n        Args:\n            force_refresh: If True, bypass cache and fetch from network\n\n        Returns:\n            Catalog data dictionary\n\n        Raises:\n            ExtensionError: If catalog cannot be fetched\n        \"\"\"\n        # Check cache first unless force refresh\n        if not force_refresh and self.is_cache_valid():\n            try:\n                return json.loads(self.cache_file.read_text())\n            except json.JSONDecodeError:\n                pass  # Fall through to network fetch\n\n        # Fetch from network\n        catalog_url = self.get_catalog_url()\n\n        try:\n            import urllib.request\n            import urllib.error\n\n            with urllib.request.urlopen(catalog_url, timeout=10) as response:\n                catalog_data = json.loads(response.read())\n\n            # Validate catalog structure\n            if \"schema_version\" not in catalog_data or \"extensions\" not in catalog_data:\n                raise ExtensionError(\"Invalid catalog format\")\n\n            # Save to cache\n            self.cache_dir.mkdir(parents=True, exist_ok=True)\n            self.cache_file.write_text(json.dumps(catalog_data, indent=2))\n\n            # Save cache metadata\n            metadata = {\n                \"cached_at\": datetime.now(timezone.utc).isoformat(),\n                \"catalog_url\": catalog_url,\n            }\n            self.cache_metadata_file.write_text(json.dumps(metadata, indent=2))\n\n            return catalog_data\n\n        except urllib.error.URLError as e:\n            raise ExtensionError(f\"Failed to fetch catalog from {catalog_url}: {e}\")\n        except json.JSONDecodeError as e:\n            raise ExtensionError(f\"Invalid JSON in catalog: {e}\")\n\n    def search(\n        self,\n        query: Optional[str] = None,\n        tag: Optional[str] = None,\n        author: Optional[str] = None,\n        verified_only: bool = False,\n    ) -> List[Dict[str, Any]]:\n        \"\"\"Search catalog for extensions across all active catalogs.\n\n        Args:\n            query: Search query (searches name, description, tags)\n            tag: Filter by specific tag\n            author: Filter by author name\n            verified_only: If True, show only verified extensions\n\n        Returns:\n            List of matching extension metadata, each annotated with\n            ``_catalog_name`` and ``_install_allowed`` from its source catalog.\n        \"\"\"\n        all_extensions = self._get_merged_extensions()\n\n        results = []\n\n        for ext_data in all_extensions:\n            ext_id = ext_data[\"id\"]\n\n            # Apply filters\n            if verified_only and not ext_data.get(\"verified\", False):\n                continue\n\n            if author and ext_data.get(\"author\", \"\").lower() != author.lower():\n                continue\n\n            if tag and tag.lower() not in [t.lower() for t in ext_data.get(\"tags\", [])]:\n                continue\n\n            if query:\n                # Search in name, description, and tags\n                query_lower = query.lower()\n                searchable_text = \" \".join(\n                    [\n                        ext_data.get(\"name\", \"\"),\n                        ext_data.get(\"description\", \"\"),\n                        ext_id,\n                    ]\n                    + ext_data.get(\"tags\", [])\n                ).lower()\n\n                if query_lower not in searchable_text:\n                    continue\n\n            results.append(ext_data)\n\n        return results\n\n    def get_extension_info(self, extension_id: str) -> Optional[Dict[str, Any]]:\n        \"\"\"Get detailed information about a specific extension.\n\n        Searches all active catalogs in priority order.\n\n        Args:\n            extension_id: ID of the extension\n\n        Returns:\n            Extension metadata (annotated with ``_catalog_name`` and\n            ``_install_allowed``) or None if not found.\n        \"\"\"\n        all_extensions = self._get_merged_extensions()\n        for ext_data in all_extensions:\n            if ext_data[\"id\"] == extension_id:\n                return ext_data\n        return None\n\n    def download_extension(self, extension_id: str, target_dir: Optional[Path] = None) -> Path:\n        \"\"\"Download extension ZIP from catalog.\n\n        Args:\n            extension_id: ID of the extension to download\n            target_dir: Directory to save ZIP file (defaults to temp directory)\n\n        Returns:\n            Path to downloaded ZIP file\n\n        Raises:\n            ExtensionError: If extension not found or download fails\n        \"\"\"\n        import urllib.request\n        import urllib.error\n\n        # Get extension info from catalog\n        ext_info = self.get_extension_info(extension_id)\n        if not ext_info:\n            raise ExtensionError(f\"Extension '{extension_id}' not found in catalog\")\n\n        download_url = ext_info.get(\"download_url\")\n        if not download_url:\n            raise ExtensionError(f\"Extension '{extension_id}' has no download URL\")\n\n        # Validate download URL requires HTTPS (prevent man-in-the-middle attacks)\n        from urllib.parse import urlparse\n        parsed = urlparse(download_url)\n        is_localhost = parsed.hostname in (\"localhost\", \"127.0.0.1\", \"::1\")\n        if parsed.scheme != \"https\" and not (parsed.scheme == \"http\" and is_localhost):\n            raise ExtensionError(\n                f\"Extension download URL must use HTTPS: {download_url}\"\n            )\n\n        # Determine target path\n        if target_dir is None:\n            target_dir = self.cache_dir / \"downloads\"\n        target_dir.mkdir(parents=True, exist_ok=True)\n\n        version = ext_info.get(\"version\", \"unknown\")\n        zip_filename = f\"{extension_id}-{version}.zip\"\n        zip_path = target_dir / zip_filename\n\n        # Download the ZIP file\n        try:\n            with urllib.request.urlopen(download_url, timeout=60) as response:\n                zip_data = response.read()\n\n            zip_path.write_bytes(zip_data)\n            return zip_path\n\n        except urllib.error.URLError as e:\n            raise ExtensionError(f\"Failed to download extension from {download_url}: {e}\")\n        except IOError as e:\n            raise ExtensionError(f\"Failed to save extension ZIP: {e}\")\n\n    def clear_cache(self):\n        \"\"\"Clear the catalog cache (both legacy and URL-hash-based files).\"\"\"\n        if self.cache_file.exists():\n            self.cache_file.unlink()\n        if self.cache_metadata_file.exists():\n            self.cache_metadata_file.unlink()\n        # Also clear any per-URL hash-based cache files\n        if self.cache_dir.exists():\n            for extra_cache in self.cache_dir.glob(\"catalog-*.json\"):\n                if extra_cache != self.cache_file:\n                    extra_cache.unlink(missing_ok=True)\n            for extra_meta in self.cache_dir.glob(\"catalog-*-metadata.json\"):\n                extra_meta.unlink(missing_ok=True)\n\n\nclass ConfigManager:\n    \"\"\"Manages layered configuration for extensions.\n\n    Configuration layers (in order of precedence from lowest to highest):\n    1. Defaults (from extension.yml)\n    2. Project config (.specify/extensions/{ext-id}/{ext-id}-config.yml)\n    3. Local config (.specify/extensions/{ext-id}/local-config.yml) - gitignored\n    4. Environment variables (SPECKIT_{EXT_ID}_{KEY})\n    \"\"\"\n\n    def __init__(self, project_root: Path, extension_id: str):\n        \"\"\"Initialize config manager for an extension.\n\n        Args:\n            project_root: Root directory of the spec-kit project\n            extension_id: ID of the extension\n        \"\"\"\n        self.project_root = project_root\n        self.extension_id = extension_id\n        self.extension_dir = project_root / \".specify\" / \"extensions\" / extension_id\n\n    def _load_yaml_config(self, file_path: Path) -> Dict[str, Any]:\n        \"\"\"Load configuration from YAML file.\n\n        Args:\n            file_path: Path to YAML file\n\n        Returns:\n            Configuration dictionary\n        \"\"\"\n        if not file_path.exists():\n            return {}\n\n        try:\n            return yaml.safe_load(file_path.read_text()) or {}\n        except (yaml.YAMLError, OSError):\n            return {}\n\n    def _get_extension_defaults(self) -> Dict[str, Any]:\n        \"\"\"Get default configuration from extension manifest.\n\n        Returns:\n            Default configuration dictionary\n        \"\"\"\n        manifest_path = self.extension_dir / \"extension.yml\"\n        if not manifest_path.exists():\n            return {}\n\n        manifest_data = self._load_yaml_config(manifest_path)\n        return manifest_data.get(\"config\", {}).get(\"defaults\", {})\n\n    def _get_project_config(self) -> Dict[str, Any]:\n        \"\"\"Get project-level configuration.\n\n        Returns:\n            Project configuration dictionary\n        \"\"\"\n        config_file = self.extension_dir / f\"{self.extension_id}-config.yml\"\n        return self._load_yaml_config(config_file)\n\n    def _get_local_config(self) -> Dict[str, Any]:\n        \"\"\"Get local configuration (gitignored, machine-specific).\n\n        Returns:\n            Local configuration dictionary\n        \"\"\"\n        config_file = self.extension_dir / \"local-config.yml\"\n        return self._load_yaml_config(config_file)\n\n    def _get_env_config(self) -> Dict[str, Any]:\n        \"\"\"Get configuration from environment variables.\n\n        Environment variables follow the pattern:\n        SPECKIT_{EXT_ID}_{SECTION}_{KEY}\n\n        For example:\n        - SPECKIT_JIRA_CONNECTION_URL\n        - SPECKIT_JIRA_PROJECT_KEY\n\n        Returns:\n            Configuration dictionary from environment variables\n        \"\"\"\n        import os\n\n        env_config = {}\n        ext_id_upper = self.extension_id.replace(\"-\", \"_\").upper()\n        prefix = f\"SPECKIT_{ext_id_upper}_\"\n\n        for key, value in os.environ.items():\n            if not key.startswith(prefix):\n                continue\n\n            # Remove prefix and split into parts\n            config_path = key[len(prefix):].lower().split(\"_\")\n\n            # Build nested dict\n            current = env_config\n            for part in config_path[:-1]:\n                if part not in current:\n                    current[part] = {}\n                current = current[part]\n\n            # Set the final value\n            current[config_path[-1]] = value\n\n        return env_config\n\n    def _merge_configs(self, base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:\n        \"\"\"Recursively merge two configuration dictionaries.\n\n        Args:\n            base: Base configuration\n            override: Configuration to merge on top\n\n        Returns:\n            Merged configuration\n        \"\"\"\n        result = base.copy()\n\n        for key, value in override.items():\n            if key in result and isinstance(result[key], dict) and isinstance(value, dict):\n                # Recursive merge for nested dicts\n                result[key] = self._merge_configs(result[key], value)\n            else:\n                # Override value\n                result[key] = value\n\n        return result\n\n    def get_config(self) -> Dict[str, Any]:\n        \"\"\"Get final merged configuration for the extension.\n\n        Merges configuration layers in order:\n        defaults -> project -> local -> env\n\n        Returns:\n            Final merged configuration dictionary\n        \"\"\"\n        # Start with defaults\n        config = self._get_extension_defaults()\n\n        # Merge project config\n        config = self._merge_configs(config, self._get_project_config())\n\n        # Merge local config\n        config = self._merge_configs(config, self._get_local_config())\n\n        # Merge environment config\n        config = self._merge_configs(config, self._get_env_config())\n\n        return config\n\n    def get_value(self, key_path: str, default: Any = None) -> Any:\n        \"\"\"Get a specific configuration value by dot-notation path.\n\n        Args:\n            key_path: Dot-separated path to config value (e.g., \"connection.url\")\n            default: Default value if key not found\n\n        Returns:\n            Configuration value or default\n\n        Example:\n            >>> config = ConfigManager(project_root, \"jira\")\n            >>> url = config.get_value(\"connection.url\")\n            >>> timeout = config.get_value(\"connection.timeout\", 30)\n        \"\"\"\n        config = self.get_config()\n        keys = key_path.split(\".\")\n\n        current = config\n        for key in keys:\n            if not isinstance(current, dict) or key not in current:\n                return default\n            current = current[key]\n\n        return current\n\n    def has_value(self, key_path: str) -> bool:\n        \"\"\"Check if a configuration value exists.\n\n        Args:\n            key_path: Dot-separated path to config value\n\n        Returns:\n            True if value exists (even if None), False otherwise\n        \"\"\"\n        config = self.get_config()\n        keys = key_path.split(\".\")\n\n        current = config\n        for key in keys:\n            if not isinstance(current, dict) or key not in current:\n                return False\n            current = current[key]\n\n        return True\n\n\nclass HookExecutor:\n    \"\"\"Manages extension hook execution.\"\"\"\n\n    def __init__(self, project_root: Path):\n        \"\"\"Initialize hook executor.\n\n        Args:\n            project_root: Root directory of the spec-kit project\n        \"\"\"\n        self.project_root = project_root\n        self.extensions_dir = project_root / \".specify\" / \"extensions\"\n        self.config_file = project_root / \".specify\" / \"extensions.yml\"\n\n    def get_project_config(self) -> Dict[str, Any]:\n        \"\"\"Load project-level extension configuration.\n\n        Returns:\n            Extension configuration dictionary\n        \"\"\"\n        if not self.config_file.exists():\n            return {\n                \"installed\": [],\n                \"settings\": {\"auto_execute_hooks\": True},\n                \"hooks\": {},\n            }\n\n        try:\n            return yaml.safe_load(self.config_file.read_text()) or {}\n        except (yaml.YAMLError, OSError):\n            return {\n                \"installed\": [],\n                \"settings\": {\"auto_execute_hooks\": True},\n                \"hooks\": {},\n            }\n\n    def save_project_config(self, config: Dict[str, Any]):\n        \"\"\"Save project-level extension configuration.\n\n        Args:\n            config: Configuration dictionary to save\n        \"\"\"\n        self.config_file.parent.mkdir(parents=True, exist_ok=True)\n        self.config_file.write_text(\n            yaml.dump(config, default_flow_style=False, sort_keys=False)\n        )\n\n    def register_hooks(self, manifest: ExtensionManifest):\n        \"\"\"Register extension hooks in project config.\n\n        Args:\n            manifest: Extension manifest with hooks to register\n        \"\"\"\n        if not hasattr(manifest, \"hooks\") or not manifest.hooks:\n            return\n\n        config = self.get_project_config()\n\n        # Ensure hooks dict exists\n        if \"hooks\" not in config:\n            config[\"hooks\"] = {}\n\n        # Register each hook\n        for hook_name, hook_config in manifest.hooks.items():\n            if hook_name not in config[\"hooks\"]:\n                config[\"hooks\"][hook_name] = []\n\n            # Add hook entry\n            hook_entry = {\n                \"extension\": manifest.id,\n                \"command\": hook_config.get(\"command\"),\n                \"enabled\": True,\n                \"optional\": hook_config.get(\"optional\", True),\n                \"prompt\": hook_config.get(\n                    \"prompt\", f\"Execute {hook_config.get('command')}?\"\n                ),\n                \"description\": hook_config.get(\"description\", \"\"),\n                \"condition\": hook_config.get(\"condition\"),\n            }\n\n            # Check if already registered\n            existing = [\n                h\n                for h in config[\"hooks\"][hook_name]\n                if h.get(\"extension\") == manifest.id\n            ]\n\n            if not existing:\n                config[\"hooks\"][hook_name].append(hook_entry)\n            else:\n                # Update existing\n                for i, h in enumerate(config[\"hooks\"][hook_name]):\n                    if h.get(\"extension\") == manifest.id:\n                        config[\"hooks\"][hook_name][i] = hook_entry\n\n        self.save_project_config(config)\n\n    def unregister_hooks(self, extension_id: str):\n        \"\"\"Remove extension hooks from project config.\n\n        Args:\n            extension_id: ID of extension to unregister\n        \"\"\"\n        config = self.get_project_config()\n\n        if \"hooks\" not in config:\n            return\n\n        # Remove hooks for this extension\n        for hook_name in config[\"hooks\"]:\n            config[\"hooks\"][hook_name] = [\n                h\n                for h in config[\"hooks\"][hook_name]\n                if h.get(\"extension\") != extension_id\n            ]\n\n        # Clean up empty hook arrays\n        config[\"hooks\"] = {\n            name: hooks for name, hooks in config[\"hooks\"].items() if hooks\n        }\n\n        self.save_project_config(config)\n\n    def get_hooks_for_event(self, event_name: str) -> List[Dict[str, Any]]:\n        \"\"\"Get all registered hooks for a specific event.\n\n        Args:\n            event_name: Name of the event (e.g., 'after_tasks')\n\n        Returns:\n            List of hook configurations\n        \"\"\"\n        config = self.get_project_config()\n        hooks = config.get(\"hooks\", {}).get(event_name, [])\n\n        # Filter to enabled hooks only\n        return [h for h in hooks if h.get(\"enabled\", True)]\n\n    def should_execute_hook(self, hook: Dict[str, Any]) -> bool:\n        \"\"\"Determine if a hook should be executed based on its condition.\n\n        Args:\n            hook: Hook configuration\n\n        Returns:\n            True if hook should execute, False otherwise\n        \"\"\"\n        condition = hook.get(\"condition\")\n\n        if not condition:\n            return True\n\n        # Parse and evaluate condition\n        try:\n            return self._evaluate_condition(condition, hook.get(\"extension\"))\n        except Exception:\n            # If condition evaluation fails, default to not executing\n            return False\n\n    def _evaluate_condition(self, condition: str, extension_id: Optional[str]) -> bool:\n        \"\"\"Evaluate a hook condition expression.\n\n        Supported condition patterns:\n        - \"config.key.path is set\" - checks if config value exists\n        - \"config.key.path == 'value'\" - checks if config equals value\n        - \"config.key.path != 'value'\" - checks if config not equals value\n        - \"env.VAR_NAME is set\" - checks if environment variable exists\n        - \"env.VAR_NAME == 'value'\" - checks if env var equals value\n\n        Args:\n            condition: Condition expression string\n            extension_id: Extension ID for config lookup\n\n        Returns:\n            True if condition is met, False otherwise\n        \"\"\"\n        import os\n\n        condition = condition.strip()\n\n        # Pattern: \"config.key.path is set\"\n        if match := re.match(r'config\\.([a-z0-9_.]+)\\s+is\\s+set', condition, re.IGNORECASE):\n            key_path = match.group(1)\n            if not extension_id:\n                return False\n\n            config_manager = ConfigManager(self.project_root, extension_id)\n            return config_manager.has_value(key_path)\n\n        # Pattern: \"config.key.path == 'value'\" or \"config.key.path != 'value'\"\n        if match := re.match(r'config\\.([a-z0-9_.]+)\\s*(==|!=)\\s*[\"\\']([^\"\\']+)[\"\\']', condition, re.IGNORECASE):\n            key_path = match.group(1)\n            operator = match.group(2)\n            expected_value = match.group(3)\n\n            if not extension_id:\n                return False\n\n            config_manager = ConfigManager(self.project_root, extension_id)\n            actual_value = config_manager.get_value(key_path)\n\n            # Normalize boolean values to lowercase for comparison\n            # (YAML True/False vs condition strings 'true'/'false')\n            if isinstance(actual_value, bool):\n                normalized_value = \"true\" if actual_value else \"false\"\n            else:\n                normalized_value = str(actual_value)\n\n            if operator == \"==\":\n                return normalized_value == expected_value\n            else:  # !=\n                return normalized_value != expected_value\n\n        # Pattern: \"env.VAR_NAME is set\"\n        if match := re.match(r'env\\.([A-Z0-9_]+)\\s+is\\s+set', condition, re.IGNORECASE):\n            var_name = match.group(1).upper()\n            return var_name in os.environ\n\n        # Pattern: \"env.VAR_NAME == 'value'\" or \"env.VAR_NAME != 'value'\"\n        if match := re.match(r'env\\.([A-Z0-9_]+)\\s*(==|!=)\\s*[\"\\']([^\"\\']+)[\"\\']', condition, re.IGNORECASE):\n            var_name = match.group(1).upper()\n            operator = match.group(2)\n            expected_value = match.group(3)\n\n            actual_value = os.environ.get(var_name, \"\")\n\n            if operator == \"==\":\n                return actual_value == expected_value\n            else:  # !=\n                return actual_value != expected_value\n\n        # Unknown condition format, default to False for safety\n        return False\n\n    def format_hook_message(\n        self, event_name: str, hooks: List[Dict[str, Any]]\n    ) -> str:\n        \"\"\"Format hook execution message for display in command output.\n\n        Args:\n            event_name: Name of the event\n            hooks: List of hooks to execute\n\n        Returns:\n            Formatted message string\n        \"\"\"\n        if not hooks:\n            return \"\"\n\n        lines = [\"\\n## Extension Hooks\\n\"]\n        lines.append(f\"Hooks available for event '{event_name}':\\n\")\n\n        for hook in hooks:\n            extension = hook.get(\"extension\")\n            command = hook.get(\"command\")\n            optional = hook.get(\"optional\", True)\n            prompt = hook.get(\"prompt\", \"\")\n            description = hook.get(\"description\", \"\")\n\n            if optional:\n                lines.append(f\"\\n**Optional Hook**: {extension}\")\n                lines.append(f\"Command: `/{command}`\")\n                if description:\n                    lines.append(f\"Description: {description}\")\n                lines.append(f\"\\nPrompt: {prompt}\")\n                lines.append(f\"To execute: `/{command}`\")\n            else:\n                lines.append(f\"\\n**Automatic Hook**: {extension}\")\n                lines.append(f\"Executing: `/{command}`\")\n                lines.append(f\"EXECUTE_COMMAND: {command}\")\n\n        return \"\\n\".join(lines)\n\n    def check_hooks_for_event(self, event_name: str) -> Dict[str, Any]:\n        \"\"\"Check for hooks registered for a specific event.\n\n        This method is designed to be called by AI agents after core commands complete.\n\n        Args:\n            event_name: Name of the event (e.g., 'after_spec', 'after_tasks')\n\n        Returns:\n            Dictionary with hook information:\n            - has_hooks: bool - Whether hooks exist for this event\n            - hooks: List[Dict] - List of hooks (with condition evaluation applied)\n            - message: str - Formatted message for display\n        \"\"\"\n        hooks = self.get_hooks_for_event(event_name)\n\n        if not hooks:\n            return {\n                \"has_hooks\": False,\n                \"hooks\": [],\n                \"message\": \"\"\n            }\n\n        # Filter hooks by condition\n        executable_hooks = []\n        for hook in hooks:\n            if self.should_execute_hook(hook):\n                executable_hooks.append(hook)\n\n        if not executable_hooks:\n            return {\n                \"has_hooks\": False,\n                \"hooks\": [],\n                \"message\": f\"# No executable hooks for event '{event_name}' (conditions not met)\"\n            }\n\n        return {\n            \"has_hooks\": True,\n            \"hooks\": executable_hooks,\n            \"message\": self.format_hook_message(event_name, executable_hooks)\n        }\n\n    def execute_hook(self, hook: Dict[str, Any]) -> Dict[str, Any]:\n        \"\"\"Execute a single hook command.\n\n        Note: This returns information about how to execute the hook.\n        The actual execution is delegated to the AI agent.\n\n        Args:\n            hook: Hook configuration\n\n        Returns:\n            Dictionary with execution information:\n            - command: str - Command to execute\n            - extension: str - Extension ID\n            - optional: bool - Whether hook is optional\n            - description: str - Hook description\n        \"\"\"\n        return {\n            \"command\": hook.get(\"command\"),\n            \"extension\": hook.get(\"extension\"),\n            \"optional\": hook.get(\"optional\", True),\n            \"description\": hook.get(\"description\", \"\"),\n            \"prompt\": hook.get(\"prompt\", \"\")\n        }\n\n    def enable_hooks(self, extension_id: str):\n        \"\"\"Enable all hooks for an extension.\n\n        Args:\n            extension_id: Extension ID\n        \"\"\"\n        config = self.get_project_config()\n\n        if \"hooks\" not in config:\n            return\n\n        # Enable all hooks for this extension\n        for hook_name in config[\"hooks\"]:\n            for hook in config[\"hooks\"][hook_name]:\n                if hook.get(\"extension\") == extension_id:\n                    hook[\"enabled\"] = True\n\n        self.save_project_config(config)\n\n    def disable_hooks(self, extension_id: str):\n        \"\"\"Disable all hooks for an extension.\n\n        Args:\n            extension_id: Extension ID\n        \"\"\"\n        config = self.get_project_config()\n\n        if \"hooks\" not in config:\n            return\n\n        # Disable all hooks for this extension\n        for hook_name in config[\"hooks\"]:\n            for hook in config[\"hooks\"][hook_name]:\n                if hook.get(\"extension\") == extension_id:\n                    hook[\"enabled\"] = False\n\n        self.save_project_config(config)\n\n"
  },
  {
    "path": "src/specify_cli/presets.py",
    "content": "\"\"\"\nPreset Manager for Spec Kit\n\nHandles installation, removal, and management of Spec Kit presets.\nPresets are self-contained, versioned collections of templates\n(artifact, command, and script templates) that can be installed to\ncustomize the Spec-Driven Development workflow.\n\"\"\"\n\nimport copy\nimport json\nimport hashlib\nimport os\nimport tempfile\nimport zipfile\nimport shutil\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Optional, Dict, List, Any\nfrom datetime import datetime, timezone\nimport re\n\nimport yaml\nfrom packaging import version as pkg_version\nfrom packaging.specifiers import SpecifierSet, InvalidSpecifier\n\nfrom .extensions import ExtensionRegistry, normalize_priority\n\n\n@dataclass\nclass PresetCatalogEntry:\n    \"\"\"Represents a single entry in the preset catalog stack.\"\"\"\n    url: str\n    name: str\n    priority: int\n    install_allowed: bool\n    description: str = \"\"\n\n\nclass PresetError(Exception):\n    \"\"\"Base exception for preset-related errors.\"\"\"\n    pass\n\n\nclass PresetValidationError(PresetError):\n    \"\"\"Raised when preset manifest validation fails.\"\"\"\n    pass\n\n\nclass PresetCompatibilityError(PresetError):\n    \"\"\"Raised when preset is incompatible with current environment.\"\"\"\n    pass\n\n\nVALID_PRESET_TEMPLATE_TYPES = {\"template\", \"command\", \"script\"}\n\n\nclass PresetManifest:\n    \"\"\"Represents and validates a preset manifest (preset.yml).\"\"\"\n\n    SCHEMA_VERSION = \"1.0\"\n    REQUIRED_FIELDS = [\"schema_version\", \"preset\", \"requires\", \"provides\"]\n\n    def __init__(self, manifest_path: Path):\n        \"\"\"Load and validate preset manifest.\n\n        Args:\n            manifest_path: Path to preset.yml file\n\n        Raises:\n            PresetValidationError: If manifest is invalid\n        \"\"\"\n        self.path = manifest_path\n        self.data = self._load_yaml(manifest_path)\n        self._validate()\n\n    def _load_yaml(self, path: Path) -> dict:\n        \"\"\"Load YAML file safely.\"\"\"\n        try:\n            with open(path, 'r') as f:\n                return yaml.safe_load(f) or {}\n        except yaml.YAMLError as e:\n            raise PresetValidationError(f\"Invalid YAML in {path}: {e}\")\n        except FileNotFoundError:\n            raise PresetValidationError(f\"Manifest not found: {path}\")\n\n    def _validate(self):\n        \"\"\"Validate manifest structure and required fields.\"\"\"\n        # Check required top-level fields\n        for field in self.REQUIRED_FIELDS:\n            if field not in self.data:\n                raise PresetValidationError(f\"Missing required field: {field}\")\n\n        # Validate schema version\n        if self.data[\"schema_version\"] != self.SCHEMA_VERSION:\n            raise PresetValidationError(\n                f\"Unsupported schema version: {self.data['schema_version']} \"\n                f\"(expected {self.SCHEMA_VERSION})\"\n            )\n\n        # Validate preset metadata\n        pack = self.data[\"preset\"]\n        for field in [\"id\", \"name\", \"version\", \"description\"]:\n            if field not in pack:\n                raise PresetValidationError(f\"Missing preset.{field}\")\n\n        # Validate pack ID format\n        if not re.match(r'^[a-z0-9-]+$', pack[\"id\"]):\n            raise PresetValidationError(\n                f\"Invalid preset ID '{pack['id']}': \"\n                \"must be lowercase alphanumeric with hyphens only\"\n            )\n\n        # Validate semantic version\n        try:\n            pkg_version.Version(pack[\"version\"])\n        except pkg_version.InvalidVersion:\n            raise PresetValidationError(f\"Invalid version: {pack['version']}\")\n\n        # Validate requires section\n        requires = self.data[\"requires\"]\n        if \"speckit_version\" not in requires:\n            raise PresetValidationError(\"Missing requires.speckit_version\")\n\n        # Validate provides section\n        provides = self.data[\"provides\"]\n        if \"templates\" not in provides or not provides[\"templates\"]:\n            raise PresetValidationError(\n                \"Preset must provide at least one template\"\n            )\n\n        # Validate templates\n        for tmpl in provides[\"templates\"]:\n            if \"type\" not in tmpl or \"name\" not in tmpl or \"file\" not in tmpl:\n                raise PresetValidationError(\n                    \"Template missing 'type', 'name', or 'file'\"\n                )\n\n            if tmpl[\"type\"] not in VALID_PRESET_TEMPLATE_TYPES:\n                raise PresetValidationError(\n                    f\"Invalid template type '{tmpl['type']}': \"\n                    f\"must be one of {sorted(VALID_PRESET_TEMPLATE_TYPES)}\"\n                )\n\n            # Validate file path safety: must be relative, no parent traversal\n            file_path = tmpl[\"file\"]\n            normalized = os.path.normpath(file_path)\n            if os.path.isabs(normalized) or normalized.startswith(\"..\"):\n                raise PresetValidationError(\n                    f\"Invalid template file path '{file_path}': \"\n                    \"must be a relative path within the preset directory\"\n                )\n\n            # Validate template name format\n            if tmpl[\"type\"] == \"command\":\n                # Commands use dot notation (e.g. speckit.specify)\n                if not re.match(r'^[a-z0-9.-]+$', tmpl[\"name\"]):\n                    raise PresetValidationError(\n                        f\"Invalid command name '{tmpl['name']}': \"\n                        \"must be lowercase alphanumeric with hyphens and dots only\"\n                    )\n            else:\n                if not re.match(r'^[a-z0-9-]+$', tmpl[\"name\"]):\n                    raise PresetValidationError(\n                        f\"Invalid template name '{tmpl['name']}': \"\n                        \"must be lowercase alphanumeric with hyphens only\"\n                    )\n\n    @property\n    def id(self) -> str:\n        \"\"\"Get preset ID.\"\"\"\n        return self.data[\"preset\"][\"id\"]\n\n    @property\n    def name(self) -> str:\n        \"\"\"Get preset name.\"\"\"\n        return self.data[\"preset\"][\"name\"]\n\n    @property\n    def version(self) -> str:\n        \"\"\"Get preset version.\"\"\"\n        return self.data[\"preset\"][\"version\"]\n\n    @property\n    def description(self) -> str:\n        \"\"\"Get preset description.\"\"\"\n        return self.data[\"preset\"][\"description\"]\n\n    @property\n    def author(self) -> str:\n        \"\"\"Get preset author.\"\"\"\n        return self.data[\"preset\"].get(\"author\", \"\")\n\n    @property\n    def requires_speckit_version(self) -> str:\n        \"\"\"Get required spec-kit version range.\"\"\"\n        return self.data[\"requires\"][\"speckit_version\"]\n\n    @property\n    def templates(self) -> List[Dict[str, Any]]:\n        \"\"\"Get list of provided templates.\"\"\"\n        return self.data[\"provides\"][\"templates\"]\n\n    @property\n    def tags(self) -> List[str]:\n        \"\"\"Get preset tags.\"\"\"\n        return self.data.get(\"tags\", [])\n\n    def get_hash(self) -> str:\n        \"\"\"Calculate SHA256 hash of manifest file.\"\"\"\n        with open(self.path, 'rb') as f:\n            return f\"sha256:{hashlib.sha256(f.read()).hexdigest()}\"\n\n\nclass PresetRegistry:\n    \"\"\"Manages the registry of installed presets.\"\"\"\n\n    REGISTRY_FILE = \".registry\"\n    SCHEMA_VERSION = \"1.0\"\n\n    def __init__(self, packs_dir: Path):\n        \"\"\"Initialize registry.\n\n        Args:\n            packs_dir: Path to .specify/presets/ directory\n        \"\"\"\n        self.packs_dir = packs_dir\n        self.registry_path = packs_dir / self.REGISTRY_FILE\n        self.data = self._load()\n\n    def _load(self) -> dict:\n        \"\"\"Load registry from disk.\"\"\"\n        if not self.registry_path.exists():\n            return {\n                \"schema_version\": self.SCHEMA_VERSION,\n                \"presets\": {}\n            }\n\n        try:\n            with open(self.registry_path, 'r') as f:\n                data = json.load(f)\n            # Validate loaded data is a dict (handles corrupted registry files)\n            if not isinstance(data, dict):\n                return {\n                    \"schema_version\": self.SCHEMA_VERSION,\n                    \"presets\": {}\n                }\n            # Normalize presets field (handles corrupted presets value)\n            if not isinstance(data.get(\"presets\"), dict):\n                data[\"presets\"] = {}\n            return data\n        except (json.JSONDecodeError, FileNotFoundError):\n            return {\n                \"schema_version\": self.SCHEMA_VERSION,\n                \"presets\": {}\n            }\n\n    def _save(self):\n        \"\"\"Save registry to disk.\"\"\"\n        self.packs_dir.mkdir(parents=True, exist_ok=True)\n        with open(self.registry_path, 'w') as f:\n            json.dump(self.data, f, indent=2)\n\n    def add(self, pack_id: str, metadata: dict):\n        \"\"\"Add preset to registry.\n\n        Args:\n            pack_id: Preset ID\n            metadata: Pack metadata (version, source, etc.)\n        \"\"\"\n        self.data[\"presets\"][pack_id] = {\n            **copy.deepcopy(metadata),\n            \"installed_at\": datetime.now(timezone.utc).isoformat()\n        }\n        self._save()\n\n    def remove(self, pack_id: str):\n        \"\"\"Remove preset from registry.\n\n        Args:\n            pack_id: Preset ID\n        \"\"\"\n        packs = self.data.get(\"presets\")\n        if not isinstance(packs, dict):\n            return\n        if pack_id in packs:\n            del packs[pack_id]\n            self._save()\n\n    def update(self, pack_id: str, updates: dict):\n        \"\"\"Update preset metadata in registry.\n\n        Merges the provided updates with the existing entry, preserving any\n        fields not specified. The installed_at timestamp is always preserved\n        from the original entry.\n\n        Args:\n            pack_id: Preset ID\n            updates: Partial metadata to merge into existing metadata\n\n        Raises:\n            KeyError: If preset is not installed\n        \"\"\"\n        packs = self.data.get(\"presets\")\n        if not isinstance(packs, dict) or pack_id not in packs:\n            raise KeyError(f\"Preset '{pack_id}' not found in registry\")\n        existing = packs[pack_id]\n        # Handle corrupted registry entries (e.g., string/list instead of dict)\n        if not isinstance(existing, dict):\n            existing = {}\n        # Merge: existing fields preserved, new fields override (deep copy to prevent caller mutation)\n        merged = {**existing, **copy.deepcopy(updates)}\n        # Always preserve original installed_at based on key existence, not truthiness,\n        # to handle cases where the field exists but may be falsy (legacy/corruption)\n        if \"installed_at\" in existing:\n            merged[\"installed_at\"] = existing[\"installed_at\"]\n        else:\n            # If not present in existing, explicitly remove from merged if caller provided it\n            merged.pop(\"installed_at\", None)\n        packs[pack_id] = merged\n        self._save()\n\n    def restore(self, pack_id: str, metadata: dict):\n        \"\"\"Restore preset metadata to registry without modifying timestamps.\n\n        Use this method for rollback scenarios where you have a complete backup\n        of the registry entry (including installed_at) and want to restore it\n        exactly as it was.\n\n        Args:\n            pack_id: Preset ID\n            metadata: Complete preset metadata including installed_at\n\n        Raises:\n            ValueError: If metadata is None or not a dict\n        \"\"\"\n        if metadata is None or not isinstance(metadata, dict):\n            raise ValueError(f\"Cannot restore '{pack_id}': metadata must be a dict\")\n        # Ensure presets dict exists (handle corrupted registry)\n        if not isinstance(self.data.get(\"presets\"), dict):\n            self.data[\"presets\"] = {}\n        self.data[\"presets\"][pack_id] = copy.deepcopy(metadata)\n        self._save()\n\n    def get(self, pack_id: str) -> Optional[dict]:\n        \"\"\"Get preset metadata from registry.\n\n        Returns a deep copy to prevent callers from accidentally mutating\n        nested internal registry state without going through the write path.\n\n        Args:\n            pack_id: Preset ID\n\n        Returns:\n            Deep copy of preset metadata, or None if not found or corrupted\n        \"\"\"\n        packs = self.data.get(\"presets\")\n        if not isinstance(packs, dict):\n            return None\n        entry = packs.get(pack_id)\n        # Return None for missing or corrupted (non-dict) entries\n        if entry is None or not isinstance(entry, dict):\n            return None\n        return copy.deepcopy(entry)\n\n    def list(self) -> Dict[str, dict]:\n        \"\"\"Get all installed presets with valid metadata.\n\n        Returns a deep copy of presets with dict metadata only.\n        Corrupted entries (non-dict values) are filtered out.\n\n        Returns:\n            Dictionary of pack_id -> metadata (deep copies), empty dict if corrupted\n        \"\"\"\n        packs = self.data.get(\"presets\", {}) or {}\n        if not isinstance(packs, dict):\n            return {}\n        # Filter to only valid dict entries to match type contract\n        return {\n            pack_id: copy.deepcopy(meta)\n            for pack_id, meta in packs.items()\n            if isinstance(meta, dict)\n        }\n\n    def keys(self) -> set:\n        \"\"\"Get all preset IDs including corrupted entries.\n\n        Lightweight method that returns IDs without deep-copying metadata.\n        Use this when you only need to check which presets are tracked.\n\n        Returns:\n            Set of preset IDs (includes corrupted entries)\n        \"\"\"\n        packs = self.data.get(\"presets\", {}) or {}\n        if not isinstance(packs, dict):\n            return set()\n        return set(packs.keys())\n\n    def list_by_priority(self, include_disabled: bool = False) -> List[tuple]:\n        \"\"\"Get all installed presets sorted by priority.\n\n        Lower priority number = higher precedence (checked first).\n        Presets with equal priority are sorted alphabetically by ID\n        for deterministic ordering.\n\n        Args:\n            include_disabled: If True, include disabled presets. Default False.\n\n        Returns:\n            List of (pack_id, metadata_copy) tuples sorted by priority.\n            Metadata is deep-copied to prevent accidental mutation.\n        \"\"\"\n        packs = self.data.get(\"presets\", {}) or {}\n        if not isinstance(packs, dict):\n            packs = {}\n        sortable_packs = []\n        for pack_id, meta in packs.items():\n            if not isinstance(meta, dict):\n                continue\n            # Skip disabled presets unless explicitly requested\n            if not include_disabled and not meta.get(\"enabled\", True):\n                continue\n            metadata_copy = copy.deepcopy(meta)\n            metadata_copy[\"priority\"] = normalize_priority(metadata_copy.get(\"priority\", 10))\n            sortable_packs.append((pack_id, metadata_copy))\n        return sorted(\n            sortable_packs,\n            key=lambda item: (item[1][\"priority\"], item[0]),\n        )\n\n    def is_installed(self, pack_id: str) -> bool:\n        \"\"\"Check if preset is installed.\n\n        Args:\n            pack_id: Preset ID\n\n        Returns:\n            True if pack is installed, False if not or registry corrupted\n        \"\"\"\n        packs = self.data.get(\"presets\")\n        if not isinstance(packs, dict):\n            return False\n        return pack_id in packs\n\n\nclass PresetManager:\n    \"\"\"Manages preset lifecycle: installation, removal, updates.\"\"\"\n\n    def __init__(self, project_root: Path):\n        \"\"\"Initialize preset manager.\n\n        Args:\n            project_root: Path to project root directory\n        \"\"\"\n        self.project_root = project_root\n        self.presets_dir = project_root / \".specify\" / \"presets\"\n        self.registry = PresetRegistry(self.presets_dir)\n\n    def check_compatibility(\n        self,\n        manifest: PresetManifest,\n        speckit_version: str\n    ) -> bool:\n        \"\"\"Check if preset is compatible with current spec-kit version.\n\n        Args:\n            manifest: Preset manifest\n            speckit_version: Current spec-kit version\n\n        Returns:\n            True if compatible\n\n        Raises:\n            PresetCompatibilityError: If pack is incompatible\n        \"\"\"\n        required = manifest.requires_speckit_version\n        current = pkg_version.Version(speckit_version)\n\n        try:\n            specifier = SpecifierSet(required)\n            if current not in specifier:\n                raise PresetCompatibilityError(\n                    f\"Preset requires spec-kit {required}, \"\n                    f\"but {speckit_version} is installed.\\n\"\n                    f\"Upgrade spec-kit with: uv tool install specify-cli --force\"\n                )\n        except InvalidSpecifier:\n            raise PresetCompatibilityError(\n                f\"Invalid version specifier: {required}\"\n            )\n\n        return True\n\n    def _register_commands(\n        self,\n        manifest: PresetManifest,\n        preset_dir: Path\n    ) -> Dict[str, List[str]]:\n        \"\"\"Register preset command overrides with all detected AI agents.\n\n        Scans the preset's templates for type \"command\", reads each command\n        file, and writes it to every detected agent directory using the\n        CommandRegistrar from the agents module.\n\n        Args:\n            manifest: Preset manifest\n            preset_dir: Installed preset directory\n\n        Returns:\n            Dictionary mapping agent names to lists of registered command names\n        \"\"\"\n        command_templates = [\n            t for t in manifest.templates if t.get(\"type\") == \"command\"\n        ]\n        if not command_templates:\n            return {}\n\n        # Filter out extension command overrides if the extension isn't installed.\n        # Command names follow the pattern: speckit.<ext-id>.<cmd-name>\n        # Core commands (e.g. speckit.specify) have only one dot — always register.\n        extensions_dir = self.project_root / \".specify\" / \"extensions\"\n        filtered = []\n        for cmd in command_templates:\n            parts = cmd[\"name\"].split(\".\")\n            if len(parts) >= 3 and parts[0] == \"speckit\":\n                ext_id = parts[1]\n                if not (extensions_dir / ext_id).is_dir():\n                    continue\n            filtered.append(cmd)\n\n        if not filtered:\n            return {}\n\n        try:\n            from .agents import CommandRegistrar\n        except ImportError:\n            return {}\n\n        registrar = CommandRegistrar()\n        return registrar.register_commands_for_all_agents(\n            filtered, manifest.id, preset_dir, self.project_root\n        )\n\n    def _unregister_commands(self, registered_commands: Dict[str, List[str]]) -> None:\n        \"\"\"Remove previously registered command files from agent directories.\n\n        Args:\n            registered_commands: Dict mapping agent names to command name lists\n        \"\"\"\n        try:\n            from .agents import CommandRegistrar\n        except ImportError:\n            return\n\n        registrar = CommandRegistrar()\n        registrar.unregister_commands(registered_commands, self.project_root)\n\n    def _get_skills_dir(self) -> Optional[Path]:\n        \"\"\"Return the skills directory if ``--ai-skills`` was used during init.\n\n        Reads ``.specify/init-options.json`` to determine whether skills\n        are enabled and which agent was selected, then delegates to\n        the module-level ``_get_skills_dir()`` helper for the concrete path.\n\n        Returns:\n            The skills directory ``Path``, or ``None`` if skills were not\n            enabled or the init-options file is missing.\n        \"\"\"\n        from . import load_init_options, _get_skills_dir\n\n        opts = load_init_options(self.project_root)\n        if not opts.get(\"ai_skills\"):\n            return None\n\n        agent = opts.get(\"ai\")\n        if not agent:\n            return None\n\n        skills_dir = _get_skills_dir(self.project_root, agent)\n        if not skills_dir.is_dir():\n            return None\n\n        return skills_dir\n\n    def _register_skills(\n        self,\n        manifest: \"PresetManifest\",\n        preset_dir: Path,\n    ) -> List[str]:\n        \"\"\"Generate SKILL.md files for preset command overrides.\n\n        For every command template in the preset, checks whether a\n        corresponding skill already exists in any detected skills\n        directory.  If so, the skill is overwritten with content derived\n        from the preset's command file.  This ensures that presets that\n        override commands also propagate to the agentskills.io skill\n        layer when ``--ai-skills`` was used during project initialisation.\n\n        Args:\n            manifest: Preset manifest.\n            preset_dir: Installed preset directory.\n\n        Returns:\n            List of skill names that were written (for registry storage).\n        \"\"\"\n        command_templates = [\n            t for t in manifest.templates if t.get(\"type\") == \"command\"\n        ]\n        if not command_templates:\n            return []\n\n        # Filter out extension command overrides if the extension isn't installed,\n        # matching the same logic used by _register_commands().\n        extensions_dir = self.project_root / \".specify\" / \"extensions\"\n        filtered = []\n        for cmd in command_templates:\n            parts = cmd[\"name\"].split(\".\")\n            if len(parts) >= 3 and parts[0] == \"speckit\":\n                ext_id = parts[1]\n                if not (extensions_dir / ext_id).is_dir():\n                    continue\n            filtered.append(cmd)\n\n        if not filtered:\n            return []\n\n        skills_dir = self._get_skills_dir()\n        if not skills_dir:\n            return []\n\n        from . import SKILL_DESCRIPTIONS, load_init_options\n\n        opts = load_init_options(self.project_root)\n        selected_ai = opts.get(\"ai\", \"\")\n\n        written: List[str] = []\n\n        for cmd_tmpl in filtered:\n            cmd_name = cmd_tmpl[\"name\"]\n            cmd_file_rel = cmd_tmpl[\"file\"]\n            source_file = preset_dir / cmd_file_rel\n            if not source_file.exists():\n                continue\n\n            # Derive the short command name (e.g. \"specify\" from \"speckit.specify\")\n            short_name = cmd_name\n            if short_name.startswith(\"speckit.\"):\n                short_name = short_name[len(\"speckit.\"):]\n            if selected_ai == \"kimi\":\n                skill_name = f\"speckit.{short_name}\"\n            else:\n                skill_name = f\"speckit-{short_name}\"\n\n            # Only overwrite if the skill already exists (i.e. --ai-skills was used)\n            skill_subdir = skills_dir / skill_name\n            if not skill_subdir.exists():\n                continue\n\n            # Parse the command file\n            content = source_file.read_text(encoding=\"utf-8\")\n            if content.startswith(\"---\"):\n                parts = content.split(\"---\", 2)\n                if len(parts) >= 3:\n                    frontmatter = yaml.safe_load(parts[1])\n                    if not isinstance(frontmatter, dict):\n                        frontmatter = {}\n                    body = parts[2].strip()\n                else:\n                    frontmatter = {}\n                    body = content\n            else:\n                frontmatter = {}\n                body = content\n\n            original_desc = frontmatter.get(\"description\", \"\")\n            enhanced_desc = SKILL_DESCRIPTIONS.get(\n                short_name,\n                original_desc or f\"Spec-kit workflow command: {short_name}\",\n            )\n\n            frontmatter_data = {\n                \"name\": skill_name,\n                \"description\": enhanced_desc,\n                \"compatibility\": \"Requires spec-kit project structure with .specify/ directory\",\n                \"metadata\": {\n                    \"author\": \"github-spec-kit\",\n                    \"source\": f\"preset:{manifest.id}\",\n                },\n            }\n            frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()\n            skill_content = (\n                f\"---\\n\"\n                f\"{frontmatter_text}\\n\"\n                f\"---\\n\\n\"\n                f\"# Speckit {short_name.title()} Skill\\n\\n\"\n                f\"{body}\\n\"\n            )\n\n            skill_file = skill_subdir / \"SKILL.md\"\n            skill_file.write_text(skill_content, encoding=\"utf-8\")\n            written.append(skill_name)\n\n        return written\n\n    def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None:\n        \"\"\"Restore original SKILL.md files after a preset is removed.\n\n        For each skill that was overridden by the preset, attempts to\n        regenerate the skill from the core command template.  If no core\n        template exists, the skill directory is removed.\n\n        Args:\n            skill_names: List of skill names written by the preset.\n            preset_dir: The preset's installed directory (may already be deleted).\n        \"\"\"\n        if not skill_names:\n            return\n\n        skills_dir = self._get_skills_dir()\n        if not skills_dir:\n            return\n\n        from . import SKILL_DESCRIPTIONS\n\n        # Locate core command templates from the project's installed templates\n        core_templates_dir = self.project_root / \".specify\" / \"templates\" / \"commands\"\n\n        for skill_name in skill_names:\n            # Derive command name from skill name (speckit-specify -> specify)\n            short_name = skill_name\n            if short_name.startswith(\"speckit-\"):\n                short_name = short_name[len(\"speckit-\"):]\n            elif short_name.startswith(\"speckit.\"):\n                short_name = short_name[len(\"speckit.\"):]\n\n            skill_subdir = skills_dir / skill_name\n            skill_file = skill_subdir / \"SKILL.md\"\n            if not skill_file.exists():\n                continue\n\n            # Try to find the core command template\n            core_file = core_templates_dir / f\"{short_name}.md\" if core_templates_dir.exists() else None\n            if core_file and not core_file.exists():\n                core_file = None\n\n            if core_file:\n                # Restore from core template\n                content = core_file.read_text(encoding=\"utf-8\")\n                if content.startswith(\"---\"):\n                    parts = content.split(\"---\", 2)\n                    if len(parts) >= 3:\n                        frontmatter = yaml.safe_load(parts[1])\n                        if not isinstance(frontmatter, dict):\n                            frontmatter = {}\n                        body = parts[2].strip()\n                    else:\n                        frontmatter = {}\n                        body = content\n                else:\n                    frontmatter = {}\n                    body = content\n\n                original_desc = frontmatter.get(\"description\", \"\")\n                enhanced_desc = SKILL_DESCRIPTIONS.get(\n                    short_name,\n                    original_desc or f\"Spec-kit workflow command: {short_name}\",\n                )\n\n                frontmatter_data = {\n                    \"name\": skill_name,\n                    \"description\": enhanced_desc,\n                    \"compatibility\": \"Requires spec-kit project structure with .specify/ directory\",\n                    \"metadata\": {\n                        \"author\": \"github-spec-kit\",\n                        \"source\": f\"templates/commands/{short_name}.md\",\n                    },\n                }\n                frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()\n                skill_content = (\n                    f\"---\\n\"\n                    f\"{frontmatter_text}\\n\"\n                    f\"---\\n\\n\"\n                    f\"# Speckit {short_name.title()} Skill\\n\\n\"\n                    f\"{body}\\n\"\n                )\n                skill_file.write_text(skill_content, encoding=\"utf-8\")\n            else:\n                # No core template — remove the skill entirely\n                shutil.rmtree(skill_subdir)\n\n    def install_from_directory(\n        self,\n        source_dir: Path,\n        speckit_version: str,\n        priority: int = 10,\n    ) -> PresetManifest:\n        \"\"\"Install preset from a local directory.\n\n        Args:\n            source_dir: Path to preset directory\n            speckit_version: Current spec-kit version\n            priority: Resolution priority (lower = higher precedence, default 10)\n\n        Returns:\n            Installed preset manifest\n\n        Raises:\n            PresetValidationError: If manifest is invalid or priority is invalid\n            PresetCompatibilityError: If pack is incompatible\n        \"\"\"\n        # Validate priority\n        if priority < 1:\n            raise PresetValidationError(\"Priority must be a positive integer (1 or higher)\")\n\n        manifest_path = source_dir / \"preset.yml\"\n        manifest = PresetManifest(manifest_path)\n\n        self.check_compatibility(manifest, speckit_version)\n\n        if self.registry.is_installed(manifest.id):\n            raise PresetError(\n                f\"Preset '{manifest.id}' is already installed. \"\n                f\"Use 'specify preset remove {manifest.id}' first.\"\n            )\n\n        dest_dir = self.presets_dir / manifest.id\n        if dest_dir.exists():\n            shutil.rmtree(dest_dir)\n\n        shutil.copytree(source_dir, dest_dir)\n\n        # Register command overrides with AI agents\n        registered_commands = self._register_commands(manifest, dest_dir)\n\n        # Update corresponding skills when --ai-skills was previously used\n        registered_skills = self._register_skills(manifest, dest_dir)\n\n        self.registry.add(manifest.id, {\n            \"version\": manifest.version,\n            \"source\": \"local\",\n            \"manifest_hash\": manifest.get_hash(),\n            \"enabled\": True,\n            \"priority\": priority,\n            \"registered_commands\": registered_commands,\n            \"registered_skills\": registered_skills,\n        })\n\n        return manifest\n\n    def install_from_zip(\n        self,\n        zip_path: Path,\n        speckit_version: str,\n        priority: int = 10,\n    ) -> PresetManifest:\n        \"\"\"Install preset from ZIP file.\n\n        Args:\n            zip_path: Path to preset ZIP file\n            speckit_version: Current spec-kit version\n            priority: Resolution priority (lower = higher precedence, default 10)\n\n        Returns:\n            Installed preset manifest\n\n        Raises:\n            PresetValidationError: If manifest is invalid or priority is invalid\n            PresetCompatibilityError: If pack is incompatible\n        \"\"\"\n        # Validate priority early\n        if priority < 1:\n            raise PresetValidationError(\"Priority must be a positive integer (1 or higher)\")\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            temp_path = Path(tmpdir)\n\n            with zipfile.ZipFile(zip_path, 'r') as zf:\n                temp_path_resolved = temp_path.resolve()\n                for member in zf.namelist():\n                    member_path = (temp_path / member).resolve()\n                    try:\n                        member_path.relative_to(temp_path_resolved)\n                    except ValueError:\n                        raise PresetValidationError(\n                            f\"Unsafe path in ZIP archive: {member} \"\n                            \"(potential path traversal)\"\n                        )\n                zf.extractall(temp_path)\n\n            pack_dir = temp_path\n            manifest_path = pack_dir / \"preset.yml\"\n\n            if not manifest_path.exists():\n                subdirs = [d for d in temp_path.iterdir() if d.is_dir()]\n                if len(subdirs) == 1:\n                    pack_dir = subdirs[0]\n                    manifest_path = pack_dir / \"preset.yml\"\n\n            if not manifest_path.exists():\n                raise PresetValidationError(\n                    \"No preset.yml found in ZIP file\"\n                )\n\n            return self.install_from_directory(pack_dir, speckit_version, priority)\n\n    def remove(self, pack_id: str) -> bool:\n        \"\"\"Remove an installed preset.\n\n        Args:\n            pack_id: Preset ID\n\n        Returns:\n            True if pack was removed\n        \"\"\"\n        if not self.registry.is_installed(pack_id):\n            return False\n\n        # Unregister commands from AI agents\n        metadata = self.registry.get(pack_id)\n        registered_commands = metadata.get(\"registered_commands\", {}) if metadata else {}\n        if registered_commands:\n            self._unregister_commands(registered_commands)\n\n        # Restore original skills when preset is removed\n        registered_skills = metadata.get(\"registered_skills\", []) if metadata else []\n        pack_dir = self.presets_dir / pack_id\n        if registered_skills:\n            self._unregister_skills(registered_skills, pack_dir)\n\n        if pack_dir.exists():\n            shutil.rmtree(pack_dir)\n\n        self.registry.remove(pack_id)\n        return True\n\n    def list_installed(self) -> List[Dict[str, Any]]:\n        \"\"\"List all installed presets with metadata.\n\n        Returns:\n            List of preset metadata dictionaries\n        \"\"\"\n        result = []\n\n        for pack_id, metadata in self.registry.list().items():\n            # Ensure metadata is a dictionary to avoid AttributeError when using .get()\n            if not isinstance(metadata, dict):\n                metadata = {}\n            pack_dir = self.presets_dir / pack_id\n            manifest_path = pack_dir / \"preset.yml\"\n\n            try:\n                manifest = PresetManifest(manifest_path)\n                result.append({\n                    \"id\": pack_id,\n                    \"name\": manifest.name,\n                    \"version\": metadata.get(\"version\", manifest.version),\n                    \"description\": manifest.description,\n                    \"enabled\": metadata.get(\"enabled\", True),\n                    \"installed_at\": metadata.get(\"installed_at\"),\n                    \"template_count\": len(manifest.templates),\n                    \"tags\": manifest.tags,\n                    \"priority\": normalize_priority(metadata.get(\"priority\")),\n                })\n            except PresetValidationError:\n                result.append({\n                    \"id\": pack_id,\n                    \"name\": pack_id,\n                    \"version\": metadata.get(\"version\", \"unknown\"),\n                    \"description\": \"⚠️ Corrupted preset\",\n                    \"enabled\": False,\n                    \"installed_at\": metadata.get(\"installed_at\"),\n                    \"template_count\": 0,\n                    \"tags\": [],\n                    \"priority\": normalize_priority(metadata.get(\"priority\")),\n                })\n\n        return result\n\n    def get_pack(self, pack_id: str) -> Optional[PresetManifest]:\n        \"\"\"Get manifest for an installed preset.\n\n        Args:\n            pack_id: Preset ID\n\n        Returns:\n            Preset manifest or None if not installed\n        \"\"\"\n        if not self.registry.is_installed(pack_id):\n            return None\n\n        pack_dir = self.presets_dir / pack_id\n        manifest_path = pack_dir / \"preset.yml\"\n\n        try:\n            return PresetManifest(manifest_path)\n        except PresetValidationError:\n            return None\n\n\nclass PresetCatalog:\n    \"\"\"Manages preset catalog fetching, caching, and searching.\n\n    Supports multi-catalog stacks with priority-based resolution,\n    mirroring the extension catalog system.\n    \"\"\"\n\n    DEFAULT_CATALOG_URL = \"https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.json\"\n    COMMUNITY_CATALOG_URL = \"https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json\"\n    CACHE_DURATION = 3600  # 1 hour in seconds\n\n    def __init__(self, project_root: Path):\n        \"\"\"Initialize preset catalog manager.\n\n        Args:\n            project_root: Root directory of the spec-kit project\n        \"\"\"\n        self.project_root = project_root\n        self.presets_dir = project_root / \".specify\" / \"presets\"\n        self.cache_dir = self.presets_dir / \".cache\"\n        self.cache_file = self.cache_dir / \"catalog.json\"\n        self.cache_metadata_file = self.cache_dir / \"catalog-metadata.json\"\n\n    def _validate_catalog_url(self, url: str) -> None:\n        \"\"\"Validate that a catalog URL uses HTTPS (localhost HTTP allowed).\n\n        Args:\n            url: URL to validate\n\n        Raises:\n            PresetValidationError: If URL is invalid or uses non-HTTPS scheme\n        \"\"\"\n        from urllib.parse import urlparse\n\n        parsed = urlparse(url)\n        is_localhost = parsed.hostname in (\"localhost\", \"127.0.0.1\", \"::1\")\n        if parsed.scheme != \"https\" and not (\n            parsed.scheme == \"http\" and is_localhost\n        ):\n            raise PresetValidationError(\n                f\"Catalog URL must use HTTPS (got {parsed.scheme}://). \"\n                \"HTTP is only allowed for localhost.\"\n            )\n        if not parsed.netloc:\n            raise PresetValidationError(\n                \"Catalog URL must be a valid URL with a host.\"\n            )\n\n    def _load_catalog_config(self, config_path: Path) -> Optional[List[PresetCatalogEntry]]:\n        \"\"\"Load catalog stack configuration from a YAML file.\n\n        Args:\n            config_path: Path to preset-catalogs.yml\n\n        Returns:\n            Ordered list of PresetCatalogEntry objects, or None if file\n            doesn't exist or contains no valid catalog entries.\n\n        Raises:\n            PresetValidationError: If any catalog entry has an invalid URL,\n                the file cannot be parsed, or a priority value is invalid.\n        \"\"\"\n        if not config_path.exists():\n            return None\n        try:\n            data = yaml.safe_load(config_path.read_text()) or {}\n        except (yaml.YAMLError, OSError) as e:\n            raise PresetValidationError(\n                f\"Failed to read catalog config {config_path}: {e}\"\n            )\n        if not isinstance(data, dict):\n            raise PresetValidationError(\n                f\"Invalid catalog config {config_path}: expected a mapping at root, got {type(data).__name__}\"\n            )\n        catalogs_data = data.get(\"catalogs\", [])\n        if not catalogs_data:\n            return None\n        if not isinstance(catalogs_data, list):\n            raise PresetValidationError(\n                f\"Invalid catalog config: 'catalogs' must be a list, got {type(catalogs_data).__name__}\"\n            )\n        entries: List[PresetCatalogEntry] = []\n        for idx, item in enumerate(catalogs_data):\n            if not isinstance(item, dict):\n                raise PresetValidationError(\n                    f\"Invalid catalog entry at index {idx}: expected a mapping, got {type(item).__name__}\"\n                )\n            url = str(item.get(\"url\", \"\")).strip()\n            if not url:\n                continue\n            self._validate_catalog_url(url)\n            try:\n                priority = int(item.get(\"priority\", idx + 1))\n            except (TypeError, ValueError):\n                raise PresetValidationError(\n                    f\"Invalid priority for catalog '{item.get('name', idx + 1)}': \"\n                    f\"expected integer, got {item.get('priority')!r}\"\n                )\n            raw_install = item.get(\"install_allowed\", False)\n            if isinstance(raw_install, str):\n                install_allowed = raw_install.strip().lower() in (\"true\", \"yes\", \"1\")\n            else:\n                install_allowed = bool(raw_install)\n            entries.append(PresetCatalogEntry(\n                url=url,\n                name=str(item.get(\"name\", f\"catalog-{idx + 1}\")),\n                priority=priority,\n                install_allowed=install_allowed,\n                description=str(item.get(\"description\", \"\")),\n            ))\n        entries.sort(key=lambda e: e.priority)\n        return entries if entries else None\n\n    def get_active_catalogs(self) -> List[PresetCatalogEntry]:\n        \"\"\"Get the ordered list of active preset catalogs.\n\n        Resolution order:\n        1. SPECKIT_PRESET_CATALOG_URL env var — single catalog replacing all defaults\n        2. Project-level .specify/preset-catalogs.yml\n        3. User-level ~/.specify/preset-catalogs.yml\n        4. Built-in default stack (default + community)\n\n        Returns:\n            List of PresetCatalogEntry objects sorted by priority (ascending)\n\n        Raises:\n            PresetValidationError: If a catalog URL is invalid\n        \"\"\"\n        import sys\n\n        # 1. SPECKIT_PRESET_CATALOG_URL env var replaces all defaults\n        if env_value := os.environ.get(\"SPECKIT_PRESET_CATALOG_URL\"):\n            catalog_url = env_value.strip()\n            self._validate_catalog_url(catalog_url)\n            if catalog_url != self.DEFAULT_CATALOG_URL:\n                if not getattr(self, \"_non_default_catalog_warning_shown\", False):\n                    print(\n                        \"Warning: Using non-default preset catalog. \"\n                        \"Only use catalogs from sources you trust.\",\n                        file=sys.stderr,\n                    )\n                    self._non_default_catalog_warning_shown = True\n            return [PresetCatalogEntry(url=catalog_url, name=\"custom\", priority=1, install_allowed=True, description=\"Custom catalog via SPECKIT_PRESET_CATALOG_URL\")]\n\n        # 2. Project-level config overrides all defaults\n        project_config_path = self.project_root / \".specify\" / \"preset-catalogs.yml\"\n        catalogs = self._load_catalog_config(project_config_path)\n        if catalogs is not None:\n            return catalogs\n\n        # 3. User-level config\n        user_config_path = Path.home() / \".specify\" / \"preset-catalogs.yml\"\n        catalogs = self._load_catalog_config(user_config_path)\n        if catalogs is not None:\n            return catalogs\n\n        # 4. Built-in default stack\n        return [\n            PresetCatalogEntry(url=self.DEFAULT_CATALOG_URL, name=\"default\", priority=1, install_allowed=True, description=\"Built-in catalog of installable presets\"),\n            PresetCatalogEntry(url=self.COMMUNITY_CATALOG_URL, name=\"community\", priority=2, install_allowed=False, description=\"Community-contributed presets (discovery only)\"),\n        ]\n\n    def get_catalog_url(self) -> str:\n        \"\"\"Get the primary catalog URL.\n\n        Returns the URL of the highest-priority catalog. Kept for backward\n        compatibility. Use get_active_catalogs() for full multi-catalog support.\n\n        Returns:\n            URL of the primary catalog\n        \"\"\"\n        active = self.get_active_catalogs()\n        return active[0].url if active else self.DEFAULT_CATALOG_URL\n\n    def _get_cache_paths(self, url: str):\n        \"\"\"Get cache file paths for a given catalog URL.\n\n        For the DEFAULT_CATALOG_URL, uses legacy cache files for backward\n        compatibility. For all other URLs, uses URL-hash-based cache files.\n\n        Returns:\n            Tuple of (cache_file_path, cache_metadata_path)\n        \"\"\"\n        if url == self.DEFAULT_CATALOG_URL:\n            return self.cache_file, self.cache_metadata_file\n        url_hash = hashlib.sha256(url.encode()).hexdigest()[:16]\n        return (\n            self.cache_dir / f\"catalog-{url_hash}.json\",\n            self.cache_dir / f\"catalog-{url_hash}-metadata.json\",\n        )\n\n    def _is_url_cache_valid(self, url: str) -> bool:\n        \"\"\"Check if cached catalog for a specific URL is still valid.\"\"\"\n        cache_file, metadata_file = self._get_cache_paths(url)\n        if not cache_file.exists() or not metadata_file.exists():\n            return False\n        try:\n            metadata = json.loads(metadata_file.read_text())\n            cached_at = datetime.fromisoformat(metadata.get(\"cached_at\", \"\"))\n            if cached_at.tzinfo is None:\n                cached_at = cached_at.replace(tzinfo=timezone.utc)\n            age_seconds = (\n                datetime.now(timezone.utc) - cached_at\n            ).total_seconds()\n            return age_seconds < self.CACHE_DURATION\n        except (json.JSONDecodeError, ValueError, KeyError, TypeError):\n            return False\n\n    def _fetch_single_catalog(self, entry: PresetCatalogEntry, force_refresh: bool = False) -> Dict[str, Any]:\n        \"\"\"Fetch a single catalog with per-URL caching.\n\n        Args:\n            entry: PresetCatalogEntry describing the catalog to fetch\n            force_refresh: If True, bypass cache\n\n        Returns:\n            Catalog data dictionary\n\n        Raises:\n            PresetError: If catalog cannot be fetched\n        \"\"\"\n        cache_file, metadata_file = self._get_cache_paths(entry.url)\n\n        if not force_refresh and self._is_url_cache_valid(entry.url):\n            try:\n                return json.loads(cache_file.read_text())\n            except json.JSONDecodeError:\n                pass\n\n        try:\n            import urllib.request\n            import urllib.error\n\n            with urllib.request.urlopen(entry.url, timeout=10) as response:\n                catalog_data = json.loads(response.read())\n\n            if (\n                \"schema_version\" not in catalog_data\n                or \"presets\" not in catalog_data\n            ):\n                raise PresetError(\"Invalid preset catalog format\")\n\n            self.cache_dir.mkdir(parents=True, exist_ok=True)\n            cache_file.write_text(json.dumps(catalog_data, indent=2))\n            metadata = {\n                \"cached_at\": datetime.now(timezone.utc).isoformat(),\n                \"catalog_url\": entry.url,\n            }\n            metadata_file.write_text(json.dumps(metadata, indent=2))\n\n            return catalog_data\n\n        except (ImportError, Exception) as e:\n            if isinstance(e, PresetError):\n                raise\n            raise PresetError(\n                f\"Failed to fetch preset catalog from {entry.url}: {e}\"\n            )\n\n    def _get_merged_packs(self, force_refresh: bool = False) -> Dict[str, Dict[str, Any]]:\n        \"\"\"Fetch and merge presets from all active catalogs.\n\n        Higher-priority catalogs (lower priority number) win on ID conflicts.\n\n        Returns:\n            Merged dictionary of pack_id -> pack_data\n        \"\"\"\n        active_catalogs = self.get_active_catalogs()\n        merged: Dict[str, Dict[str, Any]] = {}\n\n        for entry in reversed(active_catalogs):\n            try:\n                data = self._fetch_single_catalog(entry, force_refresh)\n                for pack_id, pack_data in data.get(\"presets\", {}).items():\n                    pack_data_with_catalog = {**pack_data, \"_catalog_name\": entry.name, \"_install_allowed\": entry.install_allowed}\n                    merged[pack_id] = pack_data_with_catalog\n            except PresetError:\n                continue\n\n        return merged\n\n    def is_cache_valid(self) -> bool:\n        \"\"\"Check if cached catalog is still valid.\n\n        Returns:\n            True if cache exists and is within cache duration\n        \"\"\"\n        if not self.cache_file.exists() or not self.cache_metadata_file.exists():\n            return False\n\n        try:\n            metadata = json.loads(self.cache_metadata_file.read_text())\n            cached_at = datetime.fromisoformat(metadata.get(\"cached_at\", \"\"))\n            if cached_at.tzinfo is None:\n                cached_at = cached_at.replace(tzinfo=timezone.utc)\n            age_seconds = (\n                datetime.now(timezone.utc) - cached_at\n            ).total_seconds()\n            return age_seconds < self.CACHE_DURATION\n        except (json.JSONDecodeError, ValueError, KeyError, TypeError):\n            return False\n\n    def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]:\n        \"\"\"Fetch preset catalog from URL or cache.\n\n        Args:\n            force_refresh: If True, bypass cache and fetch from network\n\n        Returns:\n            Catalog data dictionary\n\n        Raises:\n            PresetError: If catalog cannot be fetched\n        \"\"\"\n        catalog_url = self.get_catalog_url()\n\n        if not force_refresh and self.is_cache_valid():\n            try:\n                metadata = json.loads(self.cache_metadata_file.read_text())\n                if metadata.get(\"catalog_url\") == catalog_url:\n                    return json.loads(self.cache_file.read_text())\n            except (json.JSONDecodeError, OSError):\n                # Cache is corrupt or unreadable; fall through to network fetch\n                pass\n\n        try:\n            import urllib.request\n            import urllib.error\n\n            with urllib.request.urlopen(catalog_url, timeout=10) as response:\n                catalog_data = json.loads(response.read())\n\n            if (\n                \"schema_version\" not in catalog_data\n                or \"presets\" not in catalog_data\n            ):\n                raise PresetError(\"Invalid preset catalog format\")\n\n            self.cache_dir.mkdir(parents=True, exist_ok=True)\n            self.cache_file.write_text(json.dumps(catalog_data, indent=2))\n\n            metadata = {\n                \"cached_at\": datetime.now(timezone.utc).isoformat(),\n                \"catalog_url\": catalog_url,\n            }\n            self.cache_metadata_file.write_text(\n                json.dumps(metadata, indent=2)\n            )\n\n            return catalog_data\n\n        except (ImportError, Exception) as e:\n            if isinstance(e, PresetError):\n                raise\n            raise PresetError(\n                f\"Failed to fetch preset catalog from {catalog_url}: {e}\"\n            )\n\n    def search(\n        self,\n        query: Optional[str] = None,\n        tag: Optional[str] = None,\n        author: Optional[str] = None,\n    ) -> List[Dict[str, Any]]:\n        \"\"\"Search catalog for presets.\n\n        Searches across all active catalogs (merged by priority) so that\n        community and custom catalogs are included in results.\n\n        Args:\n            query: Search query (searches name, description, tags)\n            tag: Filter by specific tag\n            author: Filter by author name\n\n        Returns:\n            List of matching preset metadata\n        \"\"\"\n        try:\n            packs = self._get_merged_packs()\n        except PresetError:\n            return []\n\n        results = []\n\n        for pack_id, pack_data in packs.items():\n            if author and pack_data.get(\"author\", \"\").lower() != author.lower():\n                continue\n\n            if tag and tag.lower() not in [\n                t.lower() for t in pack_data.get(\"tags\", [])\n            ]:\n                continue\n\n            if query:\n                query_lower = query.lower()\n                searchable_text = \" \".join(\n                    [\n                        pack_data.get(\"name\", \"\"),\n                        pack_data.get(\"description\", \"\"),\n                        pack_id,\n                    ]\n                    + pack_data.get(\"tags\", [])\n                ).lower()\n\n                if query_lower not in searchable_text:\n                    continue\n\n            results.append({**pack_data, \"id\": pack_id})\n\n        return results\n\n    def get_pack_info(\n        self, pack_id: str\n    ) -> Optional[Dict[str, Any]]:\n        \"\"\"Get detailed information about a specific preset.\n\n        Searches across all active catalogs (merged by priority).\n\n        Args:\n            pack_id: ID of the preset\n\n        Returns:\n            Pack metadata or None if not found\n        \"\"\"\n        try:\n            packs = self._get_merged_packs()\n        except PresetError:\n            return None\n\n        if pack_id in packs:\n            return {**packs[pack_id], \"id\": pack_id}\n        return None\n\n    def download_pack(\n        self, pack_id: str, target_dir: Optional[Path] = None\n    ) -> Path:\n        \"\"\"Download preset ZIP from catalog.\n\n        Args:\n            pack_id: ID of the preset to download\n            target_dir: Directory to save ZIP file (defaults to cache directory)\n\n        Returns:\n            Path to downloaded ZIP file\n\n        Raises:\n            PresetError: If pack not found or download fails\n        \"\"\"\n        import urllib.request\n        import urllib.error\n\n        pack_info = self.get_pack_info(pack_id)\n        if not pack_info:\n            raise PresetError(\n                f\"Preset '{pack_id}' not found in catalog\"\n            )\n\n        if not pack_info.get(\"_install_allowed\", True):\n            catalog_name = pack_info.get(\"_catalog_name\", \"unknown\")\n            raise PresetError(\n                f\"Preset '{pack_id}' is from the '{catalog_name}' catalog which does not allow installation. \"\n                f\"Use --from with the preset's repository URL instead.\"\n            )\n\n        download_url = pack_info.get(\"download_url\")\n        if not download_url:\n            raise PresetError(\n                f\"Preset '{pack_id}' has no download URL\"\n            )\n\n        from urllib.parse import urlparse\n\n        parsed = urlparse(download_url)\n        is_localhost = parsed.hostname in (\"localhost\", \"127.0.0.1\", \"::1\")\n        if parsed.scheme != \"https\" and not (\n            parsed.scheme == \"http\" and is_localhost\n        ):\n            raise PresetError(\n                f\"Preset download URL must use HTTPS: {download_url}\"\n            )\n\n        if target_dir is None:\n            target_dir = self.cache_dir / \"downloads\"\n        target_dir.mkdir(parents=True, exist_ok=True)\n\n        version = pack_info.get(\"version\", \"unknown\")\n        zip_filename = f\"{pack_id}-{version}.zip\"\n        zip_path = target_dir / zip_filename\n\n        try:\n            with urllib.request.urlopen(download_url, timeout=60) as response:\n                zip_data = response.read()\n\n            zip_path.write_bytes(zip_data)\n            return zip_path\n\n        except urllib.error.URLError as e:\n            raise PresetError(\n                f\"Failed to download preset from {download_url}: {e}\"\n            )\n        except IOError as e:\n            raise PresetError(f\"Failed to save preset ZIP: {e}\")\n\n    def clear_cache(self):\n        \"\"\"Clear all catalog cache files, including per-URL hashed caches.\"\"\"\n        if self.cache_dir.exists():\n            for f in self.cache_dir.iterdir():\n                if f.is_file() and f.name.startswith(\"catalog\"):\n                    f.unlink(missing_ok=True)\n\n\nclass PresetResolver:\n    \"\"\"Resolves template names to file paths using a priority stack.\n\n    Resolution order:\n    1. .specify/templates/overrides/          - Project-local overrides\n    2. .specify/presets/<preset-id>/          - Installed presets\n    3. .specify/extensions/<ext-id>/templates/ - Extension-provided templates\n    4. .specify/templates/                    - Core templates (shipped with Spec Kit)\n    \"\"\"\n\n    def __init__(self, project_root: Path):\n        \"\"\"Initialize preset resolver.\n\n        Args:\n            project_root: Path to project root directory\n        \"\"\"\n        self.project_root = project_root\n        self.templates_dir = project_root / \".specify\" / \"templates\"\n        self.presets_dir = project_root / \".specify\" / \"presets\"\n        self.overrides_dir = self.templates_dir / \"overrides\"\n        self.extensions_dir = project_root / \".specify\" / \"extensions\"\n\n    def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]:\n        \"\"\"Build unified list of registered and unregistered extensions sorted by priority.\n\n        Registered extensions use their stored priority; unregistered directories\n        get implicit priority=10. Results are sorted by (priority, ext_id) for\n        deterministic ordering.\n\n        Returns:\n            List of (priority, ext_id, metadata_or_none) tuples sorted by priority.\n        \"\"\"\n        if not self.extensions_dir.exists():\n            return []\n\n        registry = ExtensionRegistry(self.extensions_dir)\n        # Use keys() to track ALL extensions (including corrupted entries) without deep copy\n        # This prevents corrupted entries from being picked up as \"unregistered\" dirs\n        registered_extension_ids = registry.keys()\n\n        # Get all registered extensions including disabled; we filter disabled manually below\n        all_registered = registry.list_by_priority(include_disabled=True)\n\n        all_extensions: list[tuple[int, str, dict | None]] = []\n\n        # Only include enabled extensions in the result\n        for ext_id, metadata in all_registered:\n            # Skip disabled extensions\n            if not metadata.get(\"enabled\", True):\n                continue\n            priority = normalize_priority(metadata.get(\"priority\") if metadata else None)\n            all_extensions.append((priority, ext_id, metadata))\n\n        # Add unregistered directories with implicit priority=10\n        for ext_dir in self.extensions_dir.iterdir():\n            if not ext_dir.is_dir() or ext_dir.name.startswith(\".\"):\n                continue\n            if ext_dir.name not in registered_extension_ids:\n                all_extensions.append((10, ext_dir.name, None))\n\n        # Sort by (priority, ext_id) for deterministic ordering\n        all_extensions.sort(key=lambda x: (x[0], x[1]))\n        return all_extensions\n\n    def resolve(\n        self,\n        template_name: str,\n        template_type: str = \"template\",\n    ) -> Optional[Path]:\n        \"\"\"Resolve a template name to its file path.\n\n        Walks the priority stack and returns the first match.\n\n        Args:\n            template_name: Template name (e.g., \"spec-template\")\n            template_type: Template type (\"template\", \"command\", or \"script\")\n\n        Returns:\n            Path to the resolved template file, or None if not found\n        \"\"\"\n        # Determine subdirectory based on template type\n        if template_type == \"template\":\n            subdirs = [\"templates\", \"\"]\n        elif template_type == \"command\":\n            subdirs = [\"commands\"]\n        elif template_type == \"script\":\n            subdirs = [\"scripts\"]\n        else:\n            subdirs = [\"\"]\n\n        # Determine file extension based on template type\n        ext = \".md\"\n        if template_type == \"script\":\n            ext = \".sh\"  # scripts use .sh; callers can also check .ps1\n\n        # Priority 1: Project-local overrides\n        if template_type == \"script\":\n            override = self.overrides_dir / \"scripts\" / f\"{template_name}{ext}\"\n        else:\n            override = self.overrides_dir / f\"{template_name}{ext}\"\n        if override.exists():\n            return override\n\n        # Priority 2: Installed presets (sorted by priority — lower number wins)\n        if self.presets_dir.exists():\n            registry = PresetRegistry(self.presets_dir)\n            for pack_id, _metadata in registry.list_by_priority():\n                pack_dir = self.presets_dir / pack_id\n                for subdir in subdirs:\n                    if subdir:\n                        candidate = pack_dir / subdir / f\"{template_name}{ext}\"\n                    else:\n                        candidate = pack_dir / f\"{template_name}{ext}\"\n                    if candidate.exists():\n                        return candidate\n\n        # Priority 3: Extension-provided templates (sorted by priority — lower number wins)\n        for _priority, ext_id, _metadata in self._get_all_extensions_by_priority():\n            ext_dir = self.extensions_dir / ext_id\n            if not ext_dir.is_dir():\n                continue\n            for subdir in subdirs:\n                if subdir:\n                    candidate = ext_dir / subdir / f\"{template_name}{ext}\"\n                else:\n                    candidate = ext_dir / f\"{template_name}{ext}\"\n                if candidate.exists():\n                    return candidate\n\n        # Priority 4: Core templates\n        if template_type == \"template\":\n            core = self.templates_dir / f\"{template_name}.md\"\n            if core.exists():\n                return core\n        elif template_type == \"command\":\n            core = self.templates_dir / \"commands\" / f\"{template_name}.md\"\n            if core.exists():\n                return core\n        elif template_type == \"script\":\n            core = self.templates_dir / \"scripts\" / f\"{template_name}{ext}\"\n            if core.exists():\n                return core\n\n        return None\n\n    def resolve_with_source(\n        self,\n        template_name: str,\n        template_type: str = \"template\",\n    ) -> Optional[Dict[str, str]]:\n        \"\"\"Resolve a template name and return source attribution.\n\n        Args:\n            template_name: Template name (e.g., \"spec-template\")\n            template_type: Template type (\"template\", \"command\", or \"script\")\n\n        Returns:\n            Dictionary with 'path' and 'source' keys, or None if not found\n        \"\"\"\n        # Delegate to resolve() for the actual lookup, then determine source\n        resolved = self.resolve(template_name, template_type)\n        if resolved is None:\n            return None\n\n        resolved_str = str(resolved)\n\n        # Determine source attribution\n        if str(self.overrides_dir) in resolved_str:\n            return {\"path\": resolved_str, \"source\": \"project override\"}\n\n        if str(self.presets_dir) in resolved_str and self.presets_dir.exists():\n            registry = PresetRegistry(self.presets_dir)\n            for pack_id, _metadata in registry.list_by_priority():\n                pack_dir = self.presets_dir / pack_id\n                try:\n                    resolved.relative_to(pack_dir)\n                    meta = registry.get(pack_id)\n                    version = meta.get(\"version\", \"?\") if meta else \"?\"\n                    return {\n                        \"path\": resolved_str,\n                        \"source\": f\"{pack_id} v{version}\",\n                    }\n                except ValueError:\n                    continue\n\n        for _priority, ext_id, ext_meta in self._get_all_extensions_by_priority():\n            ext_dir = self.extensions_dir / ext_id\n            if not ext_dir.is_dir():\n                continue\n            try:\n                resolved.relative_to(ext_dir)\n                if ext_meta:\n                    version = ext_meta.get(\"version\", \"?\")\n                    return {\n                        \"path\": resolved_str,\n                        \"source\": f\"extension:{ext_id} v{version}\",\n                    }\n                else:\n                    return {\n                        \"path\": resolved_str,\n                        \"source\": f\"extension:{ext_id} (unregistered)\",\n                    }\n            except ValueError:\n                continue\n\n        return {\"path\": resolved_str, \"source\": \"core\"}\n"
  },
  {
    "path": "templates/agent-file-template.md",
    "content": "# [PROJECT NAME] Development Guidelines\n\nAuto-generated from all feature plans. Last updated: [DATE]\n\n## Active Technologies\n\n[EXTRACTED FROM ALL PLAN.MD FILES]\n\n## Project Structure\n\n```text\n[ACTUAL STRUCTURE FROM PLANS]\n```\n\n## Commands\n\n[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES]\n\n## Code Style\n\n[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE]\n\n## Recent Changes\n\n[LAST 3 FEATURES AND WHAT THEY ADDED]\n\n<!-- MANUAL ADDITIONS START -->\n<!-- MANUAL ADDITIONS END -->\n"
  },
  {
    "path": "templates/checklist-template.md",
    "content": "# [CHECKLIST TYPE] Checklist: [FEATURE NAME]\n\n**Purpose**: [Brief description of what this checklist covers]\n**Created**: [DATE]\n**Feature**: [Link to spec.md or relevant documentation]\n\n**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements.\n\n<!-- \n  ============================================================================\n  IMPORTANT: The checklist items below are SAMPLE ITEMS for illustration only.\n  \n  The /speckit.checklist command MUST replace these with actual items based on:\n  - User's specific checklist request\n  - Feature requirements from spec.md\n  - Technical context from plan.md\n  - Implementation details from tasks.md\n  \n  DO NOT keep these sample items in the generated checklist file.\n  ============================================================================\n-->\n\n## [Category 1]\n\n- [ ] CHK001 First checklist item with clear action\n- [ ] CHK002 Second checklist item\n- [ ] CHK003 Third checklist item\n\n## [Category 2]\n\n- [ ] CHK004 Another category item\n- [ ] CHK005 Item with specific criteria\n- [ ] CHK006 Final item in this category\n\n## Notes\n\n- Check items off as completed: `[x]`\n- Add comments or findings inline\n- Link to relevant resources or documentation\n- Items are numbered sequentially for easy reference\n"
  },
  {
    "path": "templates/commands/analyze.md",
    "content": "---\ndescription: Perform a non-destructive cross-artifact consistency and quality analysis across spec.md, plan.md, and tasks.md after task generation.\nscripts:\n  sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks\n  ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks\n---\n\n## User Input\n\n```text\n$ARGUMENTS\n```\n\nYou **MUST** consider the user input before proceeding (if not empty).\n\n## Goal\n\nIdentify inconsistencies, duplications, ambiguities, and underspecified items across the three core artifacts (`spec.md`, `plan.md`, `tasks.md`) before implementation. This command MUST run only after `/speckit.tasks` has successfully produced a complete `tasks.md`.\n\n## Operating Constraints\n\n**STRICTLY READ-ONLY**: Do **not** modify any files. Output a structured analysis report. Offer an optional remediation plan (user must explicitly approve before any follow-up editing commands would be invoked manually).\n\n**Constitution Authority**: The project constitution (`/memory/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasks—not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `/speckit.analyze`.\n\n## Execution Steps\n\n### 1. Initialize Analysis Context\n\nRun `{SCRIPT}` once from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS. Derive absolute paths:\n\n- SPEC = FEATURE_DIR/spec.md\n- PLAN = FEATURE_DIR/plan.md\n- TASKS = FEATURE_DIR/tasks.md\n\nAbort with an error message if any required file is missing (instruct the user to run missing prerequisite command).\nFor single quotes in args like \"I'm Groot\", use escape syntax: e.g 'I'\\''m Groot' (or double-quote if possible: \"I'm Groot\").\n\n### 2. Load Artifacts (Progressive Disclosure)\n\nLoad only the minimal necessary context from each artifact:\n\n**From spec.md:**\n\n- Overview/Context\n- Functional Requirements\n- Non-Functional Requirements\n- User Stories\n- Edge Cases (if present)\n\n**From plan.md:**\n\n- Architecture/stack choices\n- Data Model references\n- Phases\n- Technical constraints\n\n**From tasks.md:**\n\n- Task IDs\n- Descriptions\n- Phase grouping\n- Parallel markers [P]\n- Referenced file paths\n\n**From constitution:**\n\n- Load `/memory/constitution.md` for principle validation\n\n### 3. Build Semantic Models\n\nCreate internal representations (do not include raw artifacts in output):\n\n- **Requirements inventory**: Each functional + non-functional requirement with a stable key (derive slug based on imperative phrase; e.g., \"User can upload file\" → `user-can-upload-file`)\n- **User story/action inventory**: Discrete user actions with acceptance criteria\n- **Task coverage mapping**: Map each task to one or more requirements or stories (inference by keyword / explicit reference patterns like IDs or key phrases)\n- **Constitution rule set**: Extract principle names and MUST/SHOULD normative statements\n\n### 4. Detection Passes (Token-Efficient Analysis)\n\nFocus on high-signal findings. Limit to 50 findings total; aggregate remainder in overflow summary.\n\n#### A. Duplication Detection\n\n- Identify near-duplicate requirements\n- Mark lower-quality phrasing for consolidation\n\n#### B. Ambiguity Detection\n\n- Flag vague adjectives (fast, scalable, secure, intuitive, robust) lacking measurable criteria\n- Flag unresolved placeholders (TODO, TKTK, ???, `<placeholder>`, etc.)\n\n#### C. Underspecification\n\n- Requirements with verbs but missing object or measurable outcome\n- User stories missing acceptance criteria alignment\n- Tasks referencing files or components not defined in spec/plan\n\n#### D. Constitution Alignment\n\n- Any requirement or plan element conflicting with a MUST principle\n- Missing mandated sections or quality gates from constitution\n\n#### E. Coverage Gaps\n\n- Requirements with zero associated tasks\n- Tasks with no mapped requirement/story\n- Non-functional requirements not reflected in tasks (e.g., performance, security)\n\n#### F. Inconsistency\n\n- Terminology drift (same concept named differently across files)\n- Data entities referenced in plan but absent in spec (or vice versa)\n- Task ordering contradictions (e.g., integration tasks before foundational setup tasks without dependency note)\n- Conflicting requirements (e.g., one requires Next.js while other specifies Vue)\n\n### 5. Severity Assignment\n\nUse this heuristic to prioritize findings:\n\n- **CRITICAL**: Violates constitution MUST, missing core spec artifact, or requirement with zero coverage that blocks baseline functionality\n- **HIGH**: Duplicate or conflicting requirement, ambiguous security/performance attribute, untestable acceptance criterion\n- **MEDIUM**: Terminology drift, missing non-functional task coverage, underspecified edge case\n- **LOW**: Style/wording improvements, minor redundancy not affecting execution order\n\n### 6. Produce Compact Analysis Report\n\nOutput a Markdown report (no file writes) with the following structure:\n\n## Specification Analysis Report\n\n| ID | Category | Severity | Location(s) | Summary | Recommendation |\n|----|----------|----------|-------------|---------|----------------|\n| A1 | Duplication | HIGH | spec.md:L120-134 | Two similar requirements ... | Merge phrasing; keep clearer version |\n\n(Add one row per finding; generate stable IDs prefixed by category initial.)\n\n**Coverage Summary Table:**\n\n| Requirement Key | Has Task? | Task IDs | Notes |\n|-----------------|-----------|----------|-------|\n\n**Constitution Alignment Issues:** (if any)\n\n**Unmapped Tasks:** (if any)\n\n**Metrics:**\n\n- Total Requirements\n- Total Tasks\n- Coverage % (requirements with >=1 task)\n- Ambiguity Count\n- Duplication Count\n- Critical Issues Count\n\n### 7. Provide Next Actions\n\nAt end of report, output a concise Next Actions block:\n\n- If CRITICAL issues exist: Recommend resolving before `/speckit.implement`\n- If only LOW/MEDIUM: User may proceed, but provide improvement suggestions\n- Provide explicit command suggestions: e.g., \"Run /speckit.specify with refinement\", \"Run /speckit.plan to adjust architecture\", \"Manually edit tasks.md to add coverage for 'performance-metrics'\"\n\n### 8. Offer Remediation\n\nAsk the user: \"Would you like me to suggest concrete remediation edits for the top N issues?\" (Do NOT apply them automatically.)\n\n## Operating Principles\n\n### Context Efficiency\n\n- **Minimal high-signal tokens**: Focus on actionable findings, not exhaustive documentation\n- **Progressive disclosure**: Load artifacts incrementally; don't dump all content into analysis\n- **Token-efficient output**: Limit findings table to 50 rows; summarize overflow\n- **Deterministic results**: Rerunning without changes should produce consistent IDs and counts\n\n### Analysis Guidelines\n\n- **NEVER modify files** (this is read-only analysis)\n- **NEVER hallucinate missing sections** (if absent, report them accurately)\n- **Prioritize constitution violations** (these are always CRITICAL)\n- **Use examples over exhaustive rules** (cite specific instances, not generic patterns)\n- **Report zero issues gracefully** (emit success report with coverage statistics)\n\n## Context\n\n{ARGS}\n"
  },
  {
    "path": "templates/commands/checklist.md",
    "content": "---\ndescription: Generate a custom checklist for the current feature based on user requirements.\nscripts:\n  sh: scripts/bash/check-prerequisites.sh --json\n  ps: scripts/powershell/check-prerequisites.ps1 -Json\n---\n\n## Checklist Purpose: \"Unit Tests for English\"\n\n**CRITICAL CONCEPT**: Checklists are **UNIT TESTS FOR REQUIREMENTS WRITING** - they validate the quality, clarity, and completeness of requirements in a given domain.\n\n**NOT for verification/testing**:\n\n- ❌ NOT \"Verify the button clicks correctly\"\n- ❌ NOT \"Test error handling works\"\n- ❌ NOT \"Confirm the API returns 200\"\n- ❌ NOT checking if code/implementation matches the spec\n\n**FOR requirements quality validation**:\n\n- ✅ \"Are visual hierarchy requirements defined for all card types?\" (completeness)\n- ✅ \"Is 'prominent display' quantified with specific sizing/positioning?\" (clarity)\n- ✅ \"Are hover state requirements consistent across all interactive elements?\" (consistency)\n- ✅ \"Are accessibility requirements defined for keyboard navigation?\" (coverage)\n- ✅ \"Does the spec define what happens when logo image fails to load?\" (edge cases)\n\n**Metaphor**: If your spec is code written in English, the checklist is its unit test suite. You're testing whether the requirements are well-written, complete, unambiguous, and ready for implementation - NOT whether the implementation works.\n\n## User Input\n\n```text\n$ARGUMENTS\n```\n\nYou **MUST** consider the user input before proceeding (if not empty).\n\n## Execution Steps\n\n1. **Setup**: Run `{SCRIPT}` from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS list.\n   - All file paths must be absolute.\n   - For single quotes in args like \"I'm Groot\", use escape syntax: e.g 'I'\\''m Groot' (or double-quote if possible: \"I'm Groot\").\n\n2. **Clarify intent (dynamic)**: Derive up to THREE initial contextual clarifying questions (no pre-baked catalog). They MUST:\n   - Be generated from the user's phrasing + extracted signals from spec/plan/tasks\n   - Only ask about information that materially changes checklist content\n   - Be skipped individually if already unambiguous in `$ARGUMENTS`\n   - Prefer precision over breadth\n\n   Generation algorithm:\n   1. Extract signals: feature domain keywords (e.g., auth, latency, UX, API), risk indicators (\"critical\", \"must\", \"compliance\"), stakeholder hints (\"QA\", \"review\", \"security team\"), and explicit deliverables (\"a11y\", \"rollback\", \"contracts\").\n   2. Cluster signals into candidate focus areas (max 4) ranked by relevance.\n   3. Identify probable audience & timing (author, reviewer, QA, release) if not explicit.\n   4. Detect missing dimensions: scope breadth, depth/rigor, risk emphasis, exclusion boundaries, measurable acceptance criteria.\n   5. Formulate questions chosen from these archetypes:\n      - Scope refinement (e.g., \"Should this include integration touchpoints with X and Y or stay limited to local module correctness?\")\n      - Risk prioritization (e.g., \"Which of these potential risk areas should receive mandatory gating checks?\")\n      - Depth calibration (e.g., \"Is this a lightweight pre-commit sanity list or a formal release gate?\")\n      - Audience framing (e.g., \"Will this be used by the author only or peers during PR review?\")\n      - Boundary exclusion (e.g., \"Should we explicitly exclude performance tuning items this round?\")\n      - Scenario class gap (e.g., \"No recovery flows detected—are rollback / partial failure paths in scope?\")\n\n   Question formatting rules:\n   - If presenting options, generate a compact table with columns: Option | Candidate | Why It Matters\n   - Limit to A–E options maximum; omit table if a free-form answer is clearer\n   - Never ask the user to restate what they already said\n   - Avoid speculative categories (no hallucination). If uncertain, ask explicitly: \"Confirm whether X belongs in scope.\"\n\n   Defaults when interaction impossible:\n   - Depth: Standard\n   - Audience: Reviewer (PR) if code-related; Author otherwise\n   - Focus: Top 2 relevance clusters\n\n   Output the questions (label Q1/Q2/Q3). After answers: if ≥2 scenario classes (Alternate / Exception / Recovery / Non-Functional domain) remain unclear, you MAY ask up to TWO more targeted follow‑ups (Q4/Q5) with a one-line justification each (e.g., \"Unresolved recovery path risk\"). Do not exceed five total questions. Skip escalation if user explicitly declines more.\n\n3. **Understand user request**: Combine `$ARGUMENTS` + clarifying answers:\n   - Derive checklist theme (e.g., security, review, deploy, ux)\n   - Consolidate explicit must-have items mentioned by user\n   - Map focus selections to category scaffolding\n   - Infer any missing context from spec/plan/tasks (do NOT hallucinate)\n\n4. **Load feature context**: Read from FEATURE_DIR:\n   - spec.md: Feature requirements and scope\n   - plan.md (if exists): Technical details, dependencies\n   - tasks.md (if exists): Implementation tasks\n\n   **Context Loading Strategy**:\n   - Load only necessary portions relevant to active focus areas (avoid full-file dumping)\n   - Prefer summarizing long sections into concise scenario/requirement bullets\n   - Use progressive disclosure: add follow-on retrieval only if gaps detected\n   - If source docs are large, generate interim summary items instead of embedding raw text\n\n5. **Generate checklist** - Create \"Unit Tests for Requirements\":\n   - Create `FEATURE_DIR/checklists/` directory if it doesn't exist\n   - Generate unique checklist filename:\n     - Use short, descriptive name based on domain (e.g., `ux.md`, `api.md`, `security.md`)\n     - Format: `[domain].md`\n   - File handling behavior:\n     - If file does NOT exist: Create new file and number items starting from CHK001\n     - If file exists: Append new items to existing file, continuing from the last CHK ID (e.g., if last item is CHK015, start new items at CHK016)\n   - Never delete or replace existing checklist content - always preserve and append\n\n   **CORE PRINCIPLE - Test the Requirements, Not the Implementation**:\n   Every checklist item MUST evaluate the REQUIREMENTS THEMSELVES for:\n   - **Completeness**: Are all necessary requirements present?\n   - **Clarity**: Are requirements unambiguous and specific?\n   - **Consistency**: Do requirements align with each other?\n   - **Measurability**: Can requirements be objectively verified?\n   - **Coverage**: Are all scenarios/edge cases addressed?\n\n   **Category Structure** - Group items by requirement quality dimensions:\n   - **Requirement Completeness** (Are all necessary requirements documented?)\n   - **Requirement Clarity** (Are requirements specific and unambiguous?)\n   - **Requirement Consistency** (Do requirements align without conflicts?)\n   - **Acceptance Criteria Quality** (Are success criteria measurable?)\n   - **Scenario Coverage** (Are all flows/cases addressed?)\n   - **Edge Case Coverage** (Are boundary conditions defined?)\n   - **Non-Functional Requirements** (Performance, Security, Accessibility, etc. - are they specified?)\n   - **Dependencies & Assumptions** (Are they documented and validated?)\n   - **Ambiguities & Conflicts** (What needs clarification?)\n\n   **HOW TO WRITE CHECKLIST ITEMS - \"Unit Tests for English\"**:\n\n   ❌ **WRONG** (Testing implementation):\n   - \"Verify landing page displays 3 episode cards\"\n   - \"Test hover states work on desktop\"\n   - \"Confirm logo click navigates home\"\n\n   ✅ **CORRECT** (Testing requirements quality):\n   - \"Are the exact number and layout of featured episodes specified?\" [Completeness]\n   - \"Is 'prominent display' quantified with specific sizing/positioning?\" [Clarity]\n   - \"Are hover state requirements consistent across all interactive elements?\" [Consistency]\n   - \"Are keyboard navigation requirements defined for all interactive UI?\" [Coverage]\n   - \"Is the fallback behavior specified when logo image fails to load?\" [Edge Cases]\n   - \"Are loading states defined for asynchronous episode data?\" [Completeness]\n   - \"Does the spec define visual hierarchy for competing UI elements?\" [Clarity]\n\n   **ITEM STRUCTURE**:\n   Each item should follow this pattern:\n   - Question format asking about requirement quality\n   - Focus on what's WRITTEN (or not written) in the spec/plan\n   - Include quality dimension in brackets [Completeness/Clarity/Consistency/etc.]\n   - Reference spec section `[Spec §X.Y]` when checking existing requirements\n   - Use `[Gap]` marker when checking for missing requirements\n\n   **EXAMPLES BY QUALITY DIMENSION**:\n\n   Completeness:\n   - \"Are error handling requirements defined for all API failure modes? [Gap]\"\n   - \"Are accessibility requirements specified for all interactive elements? [Completeness]\"\n   - \"Are mobile breakpoint requirements defined for responsive layouts? [Gap]\"\n\n   Clarity:\n   - \"Is 'fast loading' quantified with specific timing thresholds? [Clarity, Spec §NFR-2]\"\n   - \"Are 'related episodes' selection criteria explicitly defined? [Clarity, Spec §FR-5]\"\n   - \"Is 'prominent' defined with measurable visual properties? [Ambiguity, Spec §FR-4]\"\n\n   Consistency:\n   - \"Do navigation requirements align across all pages? [Consistency, Spec §FR-10]\"\n   - \"Are card component requirements consistent between landing and detail pages? [Consistency]\"\n\n   Coverage:\n   - \"Are requirements defined for zero-state scenarios (no episodes)? [Coverage, Edge Case]\"\n   - \"Are concurrent user interaction scenarios addressed? [Coverage, Gap]\"\n   - \"Are requirements specified for partial data loading failures? [Coverage, Exception Flow]\"\n\n   Measurability:\n   - \"Are visual hierarchy requirements measurable/testable? [Acceptance Criteria, Spec §FR-1]\"\n   - \"Can 'balanced visual weight' be objectively verified? [Measurability, Spec §FR-2]\"\n\n   **Scenario Classification & Coverage** (Requirements Quality Focus):\n   - Check if requirements exist for: Primary, Alternate, Exception/Error, Recovery, Non-Functional scenarios\n   - For each scenario class, ask: \"Are [scenario type] requirements complete, clear, and consistent?\"\n   - If scenario class missing: \"Are [scenario type] requirements intentionally excluded or missing? [Gap]\"\n   - Include resilience/rollback when state mutation occurs: \"Are rollback requirements defined for migration failures? [Gap]\"\n\n   **Traceability Requirements**:\n   - MINIMUM: ≥80% of items MUST include at least one traceability reference\n   - Each item should reference: spec section `[Spec §X.Y]`, or use markers: `[Gap]`, `[Ambiguity]`, `[Conflict]`, `[Assumption]`\n   - If no ID system exists: \"Is a requirement & acceptance criteria ID scheme established? [Traceability]\"\n\n   **Surface & Resolve Issues** (Requirements Quality Problems):\n   Ask questions about the requirements themselves:\n   - Ambiguities: \"Is the term 'fast' quantified with specific metrics? [Ambiguity, Spec §NFR-1]\"\n   - Conflicts: \"Do navigation requirements conflict between §FR-10 and §FR-10a? [Conflict]\"\n   - Assumptions: \"Is the assumption of 'always available podcast API' validated? [Assumption]\"\n   - Dependencies: \"Are external podcast API requirements documented? [Dependency, Gap]\"\n   - Missing definitions: \"Is 'visual hierarchy' defined with measurable criteria? [Gap]\"\n\n   **Content Consolidation**:\n   - Soft cap: If raw candidate items > 40, prioritize by risk/impact\n   - Merge near-duplicates checking the same requirement aspect\n   - If >5 low-impact edge cases, create one item: \"Are edge cases X, Y, Z addressed in requirements? [Coverage]\"\n\n   **🚫 ABSOLUTELY PROHIBITED** - These make it an implementation test, not a requirements test:\n   - ❌ Any item starting with \"Verify\", \"Test\", \"Confirm\", \"Check\" + implementation behavior\n   - ❌ References to code execution, user actions, system behavior\n   - ❌ \"Displays correctly\", \"works properly\", \"functions as expected\"\n   - ❌ \"Click\", \"navigate\", \"render\", \"load\", \"execute\"\n   - ❌ Test cases, test plans, QA procedures\n   - ❌ Implementation details (frameworks, APIs, algorithms)\n\n   **✅ REQUIRED PATTERNS** - These test requirements quality:\n   - ✅ \"Are [requirement type] defined/specified/documented for [scenario]?\"\n   - ✅ \"Is [vague term] quantified/clarified with specific criteria?\"\n   - ✅ \"Are requirements consistent between [section A] and [section B]?\"\n   - ✅ \"Can [requirement] be objectively measured/verified?\"\n   - ✅ \"Are [edge cases/scenarios] addressed in requirements?\"\n   - ✅ \"Does the spec define [missing aspect]?\"\n\n6. **Structure Reference**: Generate the checklist following the canonical template in `templates/checklist-template.md` for title, meta section, category headings, and ID formatting. If template is unavailable, use: H1 title, purpose/created meta lines, `##` category sections containing `- [ ] CHK### <requirement item>` lines with globally incrementing IDs starting at CHK001.\n\n7. **Report**: Output full path to checklist file, item count, and summarize whether the run created a new file or appended to an existing one. Summarize:\n   - Focus areas selected\n   - Depth level\n   - Actor/timing\n   - Any explicit user-specified must-have items incorporated\n\n**Important**: Each `/speckit.checklist` command invocation uses a short, descriptive checklist filename and either creates a new file or appends to an existing one. This allows:\n\n- Multiple checklists of different types (e.g., `ux.md`, `test.md`, `security.md`)\n- Simple, memorable filenames that indicate checklist purpose\n- Easy identification and navigation in the `checklists/` folder\n\nTo avoid clutter, use descriptive types and clean up obsolete checklists when done.\n\n## Example Checklist Types & Sample Items\n\n**UX Requirements Quality:** `ux.md`\n\nSample items (testing the requirements, NOT the implementation):\n\n- \"Are visual hierarchy requirements defined with measurable criteria? [Clarity, Spec §FR-1]\"\n- \"Is the number and positioning of UI elements explicitly specified? [Completeness, Spec §FR-1]\"\n- \"Are interaction state requirements (hover, focus, active) consistently defined? [Consistency]\"\n- \"Are accessibility requirements specified for all interactive elements? [Coverage, Gap]\"\n- \"Is fallback behavior defined when images fail to load? [Edge Case, Gap]\"\n- \"Can 'prominent display' be objectively measured? [Measurability, Spec §FR-4]\"\n\n**API Requirements Quality:** `api.md`\n\nSample items:\n\n- \"Are error response formats specified for all failure scenarios? [Completeness]\"\n- \"Are rate limiting requirements quantified with specific thresholds? [Clarity]\"\n- \"Are authentication requirements consistent across all endpoints? [Consistency]\"\n- \"Are retry/timeout requirements defined for external dependencies? [Coverage, Gap]\"\n- \"Is versioning strategy documented in requirements? [Gap]\"\n\n**Performance Requirements Quality:** `performance.md`\n\nSample items:\n\n- \"Are performance requirements quantified with specific metrics? [Clarity]\"\n- \"Are performance targets defined for all critical user journeys? [Coverage]\"\n- \"Are performance requirements under different load conditions specified? [Completeness]\"\n- \"Can performance requirements be objectively measured? [Measurability]\"\n- \"Are degradation requirements defined for high-load scenarios? [Edge Case, Gap]\"\n\n**Security Requirements Quality:** `security.md`\n\nSample items:\n\n- \"Are authentication requirements specified for all protected resources? [Coverage]\"\n- \"Are data protection requirements defined for sensitive information? [Completeness]\"\n- \"Is the threat model documented and requirements aligned to it? [Traceability]\"\n- \"Are security requirements consistent with compliance obligations? [Consistency]\"\n- \"Are security failure/breach response requirements defined? [Gap, Exception Flow]\"\n\n## Anti-Examples: What NOT To Do\n\n**❌ WRONG - These test implementation, not requirements:**\n\n```markdown\n- [ ] CHK001 - Verify landing page displays 3 episode cards [Spec §FR-001]\n- [ ] CHK002 - Test hover states work correctly on desktop [Spec §FR-003]\n- [ ] CHK003 - Confirm logo click navigates to home page [Spec §FR-010]\n- [ ] CHK004 - Check that related episodes section shows 3-5 items [Spec §FR-005]\n```\n\n**✅ CORRECT - These test requirements quality:**\n\n```markdown\n- [ ] CHK001 - Are the number and layout of featured episodes explicitly specified? [Completeness, Spec §FR-001]\n- [ ] CHK002 - Are hover state requirements consistently defined for all interactive elements? [Consistency, Spec §FR-003]\n- [ ] CHK003 - Are navigation requirements clear for all clickable brand elements? [Clarity, Spec §FR-010]\n- [ ] CHK004 - Is the selection criteria for related episodes documented? [Gap, Spec §FR-005]\n- [ ] CHK005 - Are loading state requirements defined for asynchronous episode data? [Gap]\n- [ ] CHK006 - Can \"visual hierarchy\" requirements be objectively measured? [Measurability, Spec §FR-001]\n```\n\n**Key Differences:**\n\n- Wrong: Tests if the system works correctly\n- Correct: Tests if the requirements are written correctly\n- Wrong: Verification of behavior\n- Correct: Validation of requirement quality\n- Wrong: \"Does it do X?\"\n- Correct: \"Is X clearly specified?\"\n"
  },
  {
    "path": "templates/commands/clarify.md",
    "content": "---\ndescription: Identify underspecified areas in the current feature spec by asking up to 5 highly targeted clarification questions and encoding answers back into the spec.\nhandoffs: \n  - label: Build Technical Plan\n    agent: speckit.plan\n    prompt: Create a plan for the spec. I am building with...\nscripts:\n   sh: scripts/bash/check-prerequisites.sh --json --paths-only\n   ps: scripts/powershell/check-prerequisites.ps1 -Json -PathsOnly\n---\n\n## User Input\n\n```text\n$ARGUMENTS\n```\n\nYou **MUST** consider the user input before proceeding (if not empty).\n\n## Outline\n\nGoal: Detect and reduce ambiguity or missing decision points in the active feature specification and record the clarifications directly in the spec file.\n\nNote: This clarification workflow is expected to run (and be completed) BEFORE invoking `/speckit.plan`. If the user explicitly states they are skipping clarification (e.g., exploratory spike), you may proceed, but must warn that downstream rework risk increases.\n\nExecution steps:\n\n1. Run `{SCRIPT}` from repo root **once** (combined `--json --paths-only` mode / `-Json -PathsOnly`). Parse minimal JSON payload fields:\n   - `FEATURE_DIR`\n   - `FEATURE_SPEC`\n   - (Optionally capture `IMPL_PLAN`, `TASKS` for future chained flows.)\n   - If JSON parsing fails, abort and instruct user to re-run `/speckit.specify` or verify feature branch environment.\n   - For single quotes in args like \"I'm Groot\", use escape syntax: e.g 'I'\\''m Groot' (or double-quote if possible: \"I'm Groot\").\n\n2. Load the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked).\n\n   Functional Scope & Behavior:\n   - Core user goals & success criteria\n   - Explicit out-of-scope declarations\n   - User roles / personas differentiation\n\n   Domain & Data Model:\n   - Entities, attributes, relationships\n   - Identity & uniqueness rules\n   - Lifecycle/state transitions\n   - Data volume / scale assumptions\n\n   Interaction & UX Flow:\n   - Critical user journeys / sequences\n   - Error/empty/loading states\n   - Accessibility or localization notes\n\n   Non-Functional Quality Attributes:\n   - Performance (latency, throughput targets)\n   - Scalability (horizontal/vertical, limits)\n   - Reliability & availability (uptime, recovery expectations)\n   - Observability (logging, metrics, tracing signals)\n   - Security & privacy (authN/Z, data protection, threat assumptions)\n   - Compliance / regulatory constraints (if any)\n\n   Integration & External Dependencies:\n   - External services/APIs and failure modes\n   - Data import/export formats\n   - Protocol/versioning assumptions\n\n   Edge Cases & Failure Handling:\n   - Negative scenarios\n   - Rate limiting / throttling\n   - Conflict resolution (e.g., concurrent edits)\n\n   Constraints & Tradeoffs:\n   - Technical constraints (language, storage, hosting)\n   - Explicit tradeoffs or rejected alternatives\n\n   Terminology & Consistency:\n   - Canonical glossary terms\n   - Avoided synonyms / deprecated terms\n\n   Completion Signals:\n   - Acceptance criteria testability\n   - Measurable Definition of Done style indicators\n\n   Misc / Placeholders:\n   - TODO markers / unresolved decisions\n   - Ambiguous adjectives (\"robust\", \"intuitive\") lacking quantification\n\n   For each category with Partial or Missing status, add a candidate question opportunity unless:\n   - Clarification would not materially change implementation or validation strategy\n   - Information is better deferred to planning phase (note internally)\n\n3. Generate (internally) a prioritized queue of candidate clarification questions (maximum 5). Do NOT output them all at once. Apply these constraints:\n    - Maximum of 5 total questions across the whole session.\n    - Each question must be answerable with EITHER:\n       - A short multiple‑choice selection (2–5 distinct, mutually exclusive options), OR\n       - A one-word / short‑phrase answer (explicitly constrain: \"Answer in <=5 words\").\n    - Only include questions whose answers materially impact architecture, data modeling, task decomposition, test design, UX behavior, operational readiness, or compliance validation.\n    - Ensure category coverage balance: attempt to cover the highest impact unresolved categories first; avoid asking two low-impact questions when a single high-impact area (e.g., security posture) is unresolved.\n    - Exclude questions already answered, trivial stylistic preferences, or plan-level execution details (unless blocking correctness).\n    - Favor clarifications that reduce downstream rework risk or prevent misaligned acceptance tests.\n    - If more than 5 categories remain unresolved, select the top 5 by (Impact * Uncertainty) heuristic.\n\n4. Sequential questioning loop (interactive):\n    - Present EXACTLY ONE question at a time.\n    - For multiple‑choice questions:\n       - **Analyze all options** and determine the **most suitable option** based on:\n          - Best practices for the project type\n          - Common patterns in similar implementations\n          - Risk reduction (security, performance, maintainability)\n          - Alignment with any explicit project goals or constraints visible in the spec\n       - Present your **recommended option prominently** at the top with clear reasoning (1-2 sentences explaining why this is the best choice).\n       - Format as: `**Recommended:** Option [X] - <reasoning>`\n       - Then render all options as a Markdown table:\n\n       | Option | Description |\n       |--------|-------------|\n       | A | <Option A description> |\n       | B | <Option B description> |\n       | C | <Option C description> (add D/E as needed up to 5) |\n       | Short | Provide a different short answer (<=5 words) (Include only if free-form alternative is appropriate) |\n\n       - After the table, add: `You can reply with the option letter (e.g., \"A\"), accept the recommendation by saying \"yes\" or \"recommended\", or provide your own short answer.`\n    - For short‑answer style (no meaningful discrete options):\n       - Provide your **suggested answer** based on best practices and context.\n       - Format as: `**Suggested:** <your proposed answer> - <brief reasoning>`\n       - Then output: `Format: Short answer (<=5 words). You can accept the suggestion by saying \"yes\" or \"suggested\", or provide your own answer.`\n    - After the user answers:\n       - If the user replies with \"yes\", \"recommended\", or \"suggested\", use your previously stated recommendation/suggestion as the answer.\n       - Otherwise, validate the answer maps to one option or fits the <=5 word constraint.\n       - If ambiguous, ask for a quick disambiguation (count still belongs to same question; do not advance).\n       - Once satisfactory, record it in working memory (do not yet write to disk) and move to the next queued question.\n    - Stop asking further questions when:\n       - All critical ambiguities resolved early (remaining queued items become unnecessary), OR\n       - User signals completion (\"done\", \"good\", \"no more\"), OR\n       - You reach 5 asked questions.\n    - Never reveal future queued questions in advance.\n    - If no valid questions exist at start, immediately report no critical ambiguities.\n\n5. Integration after EACH accepted answer (incremental update approach):\n    - Maintain in-memory representation of the spec (loaded once at start) plus the raw file contents.\n    - For the first integrated answer in this session:\n       - Ensure a `## Clarifications` section exists (create it just after the highest-level contextual/overview section per the spec template if missing).\n       - Under it, create (if not present) a `### Session YYYY-MM-DD` subheading for today.\n    - Append a bullet line immediately after acceptance: `- Q: <question> → A: <final answer>`.\n    - Then immediately apply the clarification to the most appropriate section(s):\n       - Functional ambiguity → Update or add a bullet in Functional Requirements.\n       - User interaction / actor distinction → Update User Stories or Actors subsection (if present) with clarified role, constraint, or scenario.\n       - Data shape / entities → Update Data Model (add fields, types, relationships) preserving ordering; note added constraints succinctly.\n       - Non-functional constraint → Add/modify measurable criteria in Non-Functional / Quality Attributes section (convert vague adjective to metric or explicit target).\n       - Edge case / negative flow → Add a new bullet under Edge Cases / Error Handling (or create such subsection if template provides placeholder for it).\n       - Terminology conflict → Normalize term across spec; retain original only if necessary by adding `(formerly referred to as \"X\")` once.\n    - If the clarification invalidates an earlier ambiguous statement, replace that statement instead of duplicating; leave no obsolete contradictory text.\n    - Save the spec file AFTER each integration to minimize risk of context loss (atomic overwrite).\n    - Preserve formatting: do not reorder unrelated sections; keep heading hierarchy intact.\n    - Keep each inserted clarification minimal and testable (avoid narrative drift).\n\n6. Validation (performed after EACH write plus final pass):\n   - Clarifications session contains exactly one bullet per accepted answer (no duplicates).\n   - Total asked (accepted) questions ≤ 5.\n   - Updated sections contain no lingering vague placeholders the new answer was meant to resolve.\n   - No contradictory earlier statement remains (scan for now-invalid alternative choices removed).\n   - Markdown structure valid; only allowed new headings: `## Clarifications`, `### Session YYYY-MM-DD`.\n   - Terminology consistency: same canonical term used across all updated sections.\n\n7. Write the updated spec back to `FEATURE_SPEC`.\n\n8. Report completion (after questioning loop ends or early termination):\n   - Number of questions asked & answered.\n   - Path to updated spec.\n   - Sections touched (list names).\n   - Coverage summary table listing each taxonomy category with Status: Resolved (was Partial/Missing and addressed), Deferred (exceeds question quota or better suited for planning), Clear (already sufficient), Outstanding (still Partial/Missing but low impact).\n   - If any Outstanding or Deferred remain, recommend whether to proceed to `/speckit.plan` or run `/speckit.clarify` again later post-plan.\n   - Suggested next command.\n\nBehavior rules:\n\n- If no meaningful ambiguities found (or all potential questions would be low-impact), respond: \"No critical ambiguities detected worth formal clarification.\" and suggest proceeding.\n- If spec file missing, instruct user to run `/speckit.specify` first (do not create a new spec here).\n- Never exceed 5 total asked questions (clarification retries for a single question do not count as new questions).\n- Avoid speculative tech stack questions unless the absence blocks functional clarity.\n- Respect user early termination signals (\"stop\", \"done\", \"proceed\").\n- If no questions asked due to full coverage, output a compact coverage summary (all categories Clear) then suggest advancing.\n- If quota reached with unresolved high-impact categories remaining, explicitly flag them under Deferred with rationale.\n\nContext for prioritization: {ARGS}\n"
  },
  {
    "path": "templates/commands/constitution.md",
    "content": "---\ndescription: Create or update the project constitution from interactive or provided principle inputs, ensuring all dependent templates stay in sync.\nhandoffs: \n  - label: Build Specification\n    agent: speckit.specify\n    prompt: Implement the feature specification based on the updated constitution. I want to build...\n---\n\n## User Input\n\n```text\n$ARGUMENTS\n```\n\nYou **MUST** consider the user input before proceeding (if not empty).\n\n## Outline\n\nYou are updating the project constitution at `.specify/memory/constitution.md`. This file is a TEMPLATE containing placeholder tokens in square brackets (e.g. `[PROJECT_NAME]`, `[PRINCIPLE_1_NAME]`). Your job is to (a) collect/derive concrete values, (b) fill the template precisely, and (c) propagate any amendments across dependent artifacts.\n\n**Note**: If `.specify/memory/constitution.md` does not exist yet, it should have been initialized from `.specify/templates/constitution-template.md` during project setup. If it's missing, copy the template first.\n\nFollow this execution flow:\n\n1. Load the existing constitution at `.specify/memory/constitution.md`.\n   - Identify every placeholder token of the form `[ALL_CAPS_IDENTIFIER]`.\n   **IMPORTANT**: The user might require less or more principles than the ones used in the template. If a number is specified, respect that - follow the general template. You will update the doc accordingly.\n\n2. Collect/derive values for placeholders:\n   - If user input (conversation) supplies a value, use it.\n   - Otherwise infer from existing repo context (README, docs, prior constitution versions if embedded).\n   - For governance dates: `RATIFICATION_DATE` is the original adoption date (if unknown ask or mark TODO), `LAST_AMENDED_DATE` is today if changes are made, otherwise keep previous.\n   - `CONSTITUTION_VERSION` must increment according to semantic versioning rules:\n     - MAJOR: Backward incompatible governance/principle removals or redefinitions.\n     - MINOR: New principle/section added or materially expanded guidance.\n     - PATCH: Clarifications, wording, typo fixes, non-semantic refinements.\n   - If version bump type ambiguous, propose reasoning before finalizing.\n\n3. Draft the updated constitution content:\n   - Replace every placeholder with concrete text (no bracketed tokens left except intentionally retained template slots that the project has chosen not to define yet—explicitly justify any left).\n   - Preserve heading hierarchy and comments can be removed once replaced unless they still add clarifying guidance.\n   - Ensure each Principle section: succinct name line, paragraph (or bullet list) capturing non‑negotiable rules, explicit rationale if not obvious.\n   - Ensure Governance section lists amendment procedure, versioning policy, and compliance review expectations.\n\n4. Consistency propagation checklist (convert prior checklist into active validations):\n   - Read `.specify/templates/plan-template.md` and ensure any \"Constitution Check\" or rules align with updated principles.\n   - Read `.specify/templates/spec-template.md` for scope/requirements alignment—update if constitution adds/removes mandatory sections or constraints.\n   - Read `.specify/templates/tasks-template.md` and ensure task categorization reflects new or removed principle-driven task types (e.g., observability, versioning, testing discipline).\n   - Read each command file in `.specify/templates/commands/*.md` (including this one) to verify no outdated references (agent-specific names like CLAUDE only) remain when generic guidance is required.\n   - Read any runtime guidance docs (e.g., `README.md`, `docs/quickstart.md`, or agent-specific guidance files if present). Update references to principles changed.\n\n5. Produce a Sync Impact Report (prepend as an HTML comment at top of the constitution file after update):\n   - Version change: old → new\n   - List of modified principles (old title → new title if renamed)\n   - Added sections\n   - Removed sections\n   - Templates requiring updates (✅ updated / ⚠ pending) with file paths\n   - Follow-up TODOs if any placeholders intentionally deferred.\n\n6. Validation before final output:\n   - No remaining unexplained bracket tokens.\n   - Version line matches report.\n   - Dates ISO format YYYY-MM-DD.\n   - Principles are declarative, testable, and free of vague language (\"should\" → replace with MUST/SHOULD rationale where appropriate).\n\n7. Write the completed constitution back to `.specify/memory/constitution.md` (overwrite).\n\n8. Output a final summary to the user with:\n   - New version and bump rationale.\n   - Any files flagged for manual follow-up.\n   - Suggested commit message (e.g., `docs: amend constitution to vX.Y.Z (principle additions + governance update)`).\n\nFormatting & Style Requirements:\n\n- Use Markdown headings exactly as in the template (do not demote/promote levels).\n- Wrap long rationale lines to keep readability (<100 chars ideally) but do not hard enforce with awkward breaks.\n- Keep a single blank line between sections.\n- Avoid trailing whitespace.\n\nIf the user supplies partial updates (e.g., only one principle revision), still perform validation and version decision steps.\n\nIf critical info missing (e.g., ratification date truly unknown), insert `TODO(<FIELD_NAME>): explanation` and include in the Sync Impact Report under deferred items.\n\nDo not create a new template; always operate on the existing `.specify/memory/constitution.md` file.\n"
  },
  {
    "path": "templates/commands/implement.md",
    "content": "---\ndescription: Execute the implementation plan by processing and executing all tasks defined in tasks.md\nscripts:\n  sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks\n  ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks\n---\n\n## User Input\n\n```text\n$ARGUMENTS\n```\n\nYou **MUST** consider the user input before proceeding (if not empty).\n\n## Pre-Execution Checks\n\n**Check for extension hooks (before implementation)**:\n- Check if `.specify/extensions.yml` exists in the project root.\n- If it exists, read it and look for entries under the `hooks.before_implement` key\n- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally\n- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.\n- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:\n  - If the hook has no `condition` field, or it is null/empty, treat the hook as executable\n  - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation\n- For each executable hook, output the following based on its `optional` flag:\n  - **Optional hook** (`optional: true`):\n    ```\n    ## Extension Hooks\n\n    **Optional Pre-Hook**: {extension}\n    Command: `/{command}`\n    Description: {description}\n\n    Prompt: {prompt}\n    To execute: `/{command}`\n    ```\n  - **Mandatory hook** (`optional: false`):\n    ```\n    ## Extension Hooks\n\n    **Automatic Pre-Hook**: {extension}\n    Executing: `/{command}`\n    EXECUTE_COMMAND: {command}\n    \n    Wait for the result of the hook command before proceeding to the Outline.\n    ```\n- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently\n\n## Outline\n\n1. Run `{SCRIPT}` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like \"I'm Groot\", use escape syntax: e.g 'I'\\''m Groot' (or double-quote if possible: \"I'm Groot\").\n\n2. **Check checklists status** (if FEATURE_DIR/checklists/ exists):\n   - Scan all checklist files in the checklists/ directory\n   - For each checklist, count:\n     - Total items: All lines matching `- [ ]` or `- [X]` or `- [x]`\n     - Completed items: Lines matching `- [X]` or `- [x]`\n     - Incomplete items: Lines matching `- [ ]`\n   - Create a status table:\n\n     ```text\n     | Checklist | Total | Completed | Incomplete | Status |\n     |-----------|-------|-----------|------------|--------|\n     | ux.md     | 12    | 12        | 0          | ✓ PASS |\n     | test.md   | 8     | 5         | 3          | ✗ FAIL |\n     | security.md | 6   | 6         | 0          | ✓ PASS |\n     ```\n\n   - Calculate overall status:\n     - **PASS**: All checklists have 0 incomplete items\n     - **FAIL**: One or more checklists have incomplete items\n\n   - **If any checklist is incomplete**:\n     - Display the table with incomplete item counts\n     - **STOP** and ask: \"Some checklists are incomplete. Do you want to proceed with implementation anyway? (yes/no)\"\n     - Wait for user response before continuing\n     - If user says \"no\" or \"wait\" or \"stop\", halt execution\n     - If user says \"yes\" or \"proceed\" or \"continue\", proceed to step 3\n\n   - **If all checklists are complete**:\n     - Display the table showing all checklists passed\n     - Automatically proceed to step 3\n\n3. Load and analyze the implementation context:\n   - **REQUIRED**: Read tasks.md for the complete task list and execution plan\n   - **REQUIRED**: Read plan.md for tech stack, architecture, and file structure\n   - **IF EXISTS**: Read data-model.md for entities and relationships\n   - **IF EXISTS**: Read contracts/ for API specifications and test requirements\n   - **IF EXISTS**: Read research.md for technical decisions and constraints\n   - **IF EXISTS**: Read quickstart.md for integration scenarios\n\n4. **Project Setup Verification**:\n   - **REQUIRED**: Create/verify ignore files based on actual project setup:\n\n   **Detection & Creation Logic**:\n   - Check if the following command succeeds to determine if the repository is a git repo (create/verify .gitignore if so):\n\n     ```sh\n     git rev-parse --git-dir 2>/dev/null\n     ```\n\n   - Check if Dockerfile* exists or Docker in plan.md → create/verify .dockerignore\n   - Check if .eslintrc* exists → create/verify .eslintignore\n   - Check if eslint.config.* exists → ensure the config's `ignores` entries cover required patterns\n   - Check if .prettierrc* exists → create/verify .prettierignore\n   - Check if .npmrc or package.json exists → create/verify .npmignore (if publishing)\n   - Check if terraform files (*.tf) exist → create/verify .terraformignore\n   - Check if .helmignore needed (helm charts present) → create/verify .helmignore\n\n   **If ignore file already exists**: Verify it contains essential patterns, append missing critical patterns only\n   **If ignore file missing**: Create with full pattern set for detected technology\n\n   **Common Patterns by Technology** (from plan.md tech stack):\n   - **Node.js/JavaScript/TypeScript**: `node_modules/`, `dist/`, `build/`, `*.log`, `.env*`\n   - **Python**: `__pycache__/`, `*.pyc`, `.venv/`, `venv/`, `dist/`, `*.egg-info/`\n   - **Java**: `target/`, `*.class`, `*.jar`, `.gradle/`, `build/`\n   - **C#/.NET**: `bin/`, `obj/`, `*.user`, `*.suo`, `packages/`\n   - **Go**: `*.exe`, `*.test`, `vendor/`, `*.out`\n   - **Ruby**: `.bundle/`, `log/`, `tmp/`, `*.gem`, `vendor/bundle/`\n   - **PHP**: `vendor/`, `*.log`, `*.cache`, `*.env`\n   - **Rust**: `target/`, `debug/`, `release/`, `*.rs.bk`, `*.rlib`, `*.prof*`, `.idea/`, `*.log`, `.env*`\n   - **Kotlin**: `build/`, `out/`, `.gradle/`, `.idea/`, `*.class`, `*.jar`, `*.iml`, `*.log`, `.env*`\n   - **C++**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.so`, `*.a`, `*.exe`, `*.dll`, `.idea/`, `*.log`, `.env*`\n   - **C**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.a`, `*.so`, `*.exe`, `*.dll`, `autom4te.cache/`, `config.status`, `config.log`, `.idea/`, `*.log`, `.env*`\n   - **Swift**: `.build/`, `DerivedData/`, `*.swiftpm/`, `Packages/`\n   - **R**: `.Rproj.user/`, `.Rhistory`, `.RData`, `.Ruserdata`, `*.Rproj`, `packrat/`, `renv/`\n   - **Universal**: `.DS_Store`, `Thumbs.db`, `*.tmp`, `*.swp`, `.vscode/`, `.idea/`\n\n   **Tool-Specific Patterns**:\n   - **Docker**: `node_modules/`, `.git/`, `Dockerfile*`, `.dockerignore`, `*.log*`, `.env*`, `coverage/`\n   - **ESLint**: `node_modules/`, `dist/`, `build/`, `coverage/`, `*.min.js`\n   - **Prettier**: `node_modules/`, `dist/`, `build/`, `coverage/`, `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`\n   - **Terraform**: `.terraform/`, `*.tfstate*`, `*.tfvars`, `.terraform.lock.hcl`\n   - **Kubernetes/k8s**: `*.secret.yaml`, `secrets/`, `.kube/`, `kubeconfig*`, `*.key`, `*.crt`\n\n5. Parse tasks.md structure and extract:\n   - **Task phases**: Setup, Tests, Core, Integration, Polish\n   - **Task dependencies**: Sequential vs parallel execution rules\n   - **Task details**: ID, description, file paths, parallel markers [P]\n   - **Execution flow**: Order and dependency requirements\n\n6. Execute implementation following the task plan:\n   - **Phase-by-phase execution**: Complete each phase before moving to the next\n   - **Respect dependencies**: Run sequential tasks in order, parallel tasks [P] can run together  \n   - **Follow TDD approach**: Execute test tasks before their corresponding implementation tasks\n   - **File-based coordination**: Tasks affecting the same files must run sequentially\n   - **Validation checkpoints**: Verify each phase completion before proceeding\n\n7. Implementation execution rules:\n   - **Setup first**: Initialize project structure, dependencies, configuration\n   - **Tests before code**: If you need to write tests for contracts, entities, and integration scenarios\n   - **Core development**: Implement models, services, CLI commands, endpoints\n   - **Integration work**: Database connections, middleware, logging, external services\n   - **Polish and validation**: Unit tests, performance optimization, documentation\n\n8. Progress tracking and error handling:\n   - Report progress after each completed task\n   - Halt execution if any non-parallel task fails\n   - For parallel tasks [P], continue with successful tasks, report failed ones\n   - Provide clear error messages with context for debugging\n   - Suggest next steps if implementation cannot proceed\n   - **IMPORTANT** For completed tasks, make sure to mark the task off as [X] in the tasks file.\n\n9. Completion validation:\n   - Verify all required tasks are completed\n   - Check that implemented features match the original specification\n   - Validate that tests pass and coverage meets requirements\n   - Confirm the implementation follows the technical plan\n   - Report final status with summary of completed work\n\nNote: This command assumes a complete task breakdown exists in tasks.md. If tasks are incomplete or missing, suggest running `/speckit.tasks` first to regenerate the task list.\n\n10. **Check for extension hooks**: After completion validation, check if `.specify/extensions.yml` exists in the project root.\n    - If it exists, read it and look for entries under the `hooks.after_implement` key\n    - If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally\n    - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.\n    - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:\n      - If the hook has no `condition` field, or it is null/empty, treat the hook as executable\n      - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation\n    - For each executable hook, output the following based on its `optional` flag:\n      - **Optional hook** (`optional: true`):\n        ```\n        ## Extension Hooks\n\n        **Optional Hook**: {extension}\n        Command: `/{command}`\n        Description: {description}\n\n        Prompt: {prompt}\n        To execute: `/{command}`\n        ```\n      - **Mandatory hook** (`optional: false`):\n        ```\n        ## Extension Hooks\n\n        **Automatic Hook**: {extension}\n        Executing: `/{command}`\n        EXECUTE_COMMAND: {command}\n        ```\n    - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently\n"
  },
  {
    "path": "templates/commands/plan.md",
    "content": "---\ndescription: Execute the implementation planning workflow using the plan template to generate design artifacts.\nhandoffs: \n  - label: Create Tasks\n    agent: speckit.tasks\n    prompt: Break the plan into tasks\n    send: true\n  - label: Create Checklist\n    agent: speckit.checklist\n    prompt: Create a checklist for the following domain...\nscripts:\n  sh: scripts/bash/setup-plan.sh --json\n  ps: scripts/powershell/setup-plan.ps1 -Json\nagent_scripts:\n  sh: scripts/bash/update-agent-context.sh __AGENT__\n  ps: scripts/powershell/update-agent-context.ps1 -AgentType __AGENT__\n---\n\n## User Input\n\n```text\n$ARGUMENTS\n```\n\nYou **MUST** consider the user input before proceeding (if not empty).\n\n## Pre-Execution Checks\n\n**Check for extension hooks (before planning)**:\n- Check if `.specify/extensions.yml` exists in the project root.\n- If it exists, read it and look for entries under the `hooks.before_plan` key\n- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally\n- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.\n- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:\n  - If the hook has no `condition` field, or it is null/empty, treat the hook as executable\n  - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation\n- For each executable hook, output the following based on its `optional` flag:\n  - **Optional hook** (`optional: true`):\n    ```\n    ## Extension Hooks\n\n    **Optional Pre-Hook**: {extension}\n    Command: `/{command}`\n    Description: {description}\n\n    Prompt: {prompt}\n    To execute: `/{command}`\n    ```\n  - **Mandatory hook** (`optional: false`):\n    ```\n    ## Extension Hooks\n\n    **Automatic Pre-Hook**: {extension}\n    Executing: `/{command}`\n    EXECUTE_COMMAND: {command}\n\n    Wait for the result of the hook command before proceeding to the Outline.\n    ```\n- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently\n\n## Outline\n\n1. **Setup**: Run `{SCRIPT}` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. For single quotes in args like \"I'm Groot\", use escape syntax: e.g 'I'\\''m Groot' (or double-quote if possible: \"I'm Groot\").\n\n2. **Load context**: Read FEATURE_SPEC and `/memory/constitution.md`. Load IMPL_PLAN template (already copied).\n\n3. **Execute plan workflow**: Follow the structure in IMPL_PLAN template to:\n   - Fill Technical Context (mark unknowns as \"NEEDS CLARIFICATION\")\n   - Fill Constitution Check section from constitution\n   - Evaluate gates (ERROR if violations unjustified)\n   - Phase 0: Generate research.md (resolve all NEEDS CLARIFICATION)\n   - Phase 1: Generate data-model.md, contracts/, quickstart.md\n   - Phase 1: Update agent context by running the agent script\n   - Re-evaluate Constitution Check post-design\n\n4. **Stop and report**: Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generated artifacts.\n\n5. **Check for extension hooks**: After reporting, check if `.specify/extensions.yml` exists in the project root.\n   - If it exists, read it and look for entries under the `hooks.after_plan` key\n   - If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally\n   - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.\n   - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:\n     - If the hook has no `condition` field, or it is null/empty, treat the hook as executable\n     - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation\n   - For each executable hook, output the following based on its `optional` flag:\n     - **Optional hook** (`optional: true`):\n       ```\n       ## Extension Hooks\n\n       **Optional Hook**: {extension}\n       Command: `/{command}`\n       Description: {description}\n\n       Prompt: {prompt}\n       To execute: `/{command}`\n       ```\n     - **Mandatory hook** (`optional: false`):\n       ```\n       ## Extension Hooks\n\n       **Automatic Hook**: {extension}\n       Executing: `/{command}`\n       EXECUTE_COMMAND: {command}\n       ```\n   - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently\n\n## Phases\n\n### Phase 0: Outline & Research\n\n1. **Extract unknowns from Technical Context** above:\n   - For each NEEDS CLARIFICATION → research task\n   - For each dependency → best practices task\n   - For each integration → patterns task\n\n2. **Generate and dispatch research agents**:\n\n   ```text\n   For each unknown in Technical Context:\n     Task: \"Research {unknown} for {feature context}\"\n   For each technology choice:\n     Task: \"Find best practices for {tech} in {domain}\"\n   ```\n\n3. **Consolidate findings** in `research.md` using format:\n   - Decision: [what was chosen]\n   - Rationale: [why chosen]\n   - Alternatives considered: [what else evaluated]\n\n**Output**: research.md with all NEEDS CLARIFICATION resolved\n\n### Phase 1: Design & Contracts\n\n**Prerequisites:** `research.md` complete\n\n1. **Extract entities from feature spec** → `data-model.md`:\n   - Entity name, fields, relationships\n   - Validation rules from requirements\n   - State transitions if applicable\n\n2. **Define interface contracts** (if project has external interfaces) → `/contracts/`:\n   - Identify what interfaces the project exposes to users or other systems\n   - Document the contract format appropriate for the project type\n   - Examples: public APIs for libraries, command schemas for CLI tools, endpoints for web services, grammars for parsers, UI contracts for applications\n   - Skip if project is purely internal (build scripts, one-off tools, etc.)\n\n3. **Agent context update**:\n   - Run `{AGENT_SCRIPT}`\n   - These scripts detect which AI agent is in use\n   - Update the appropriate agent-specific context file\n   - Add only new technology from current plan\n   - Preserve manual additions between markers\n\n**Output**: data-model.md, /contracts/*, quickstart.md, agent-specific file\n\n## Key rules\n\n- Use absolute paths\n- ERROR on gate failures or unresolved clarifications\n"
  },
  {
    "path": "templates/commands/specify.md",
    "content": "---\ndescription: Create or update the feature specification from a natural language feature description.\nhandoffs: \n  - label: Build Technical Plan\n    agent: speckit.plan\n    prompt: Create a plan for the spec. I am building with...\n  - label: Clarify Spec Requirements\n    agent: speckit.clarify\n    prompt: Clarify specification requirements\n    send: true\nscripts:\n  sh: scripts/bash/create-new-feature.sh \"{ARGS}\"\n  ps: scripts/powershell/create-new-feature.ps1 \"{ARGS}\"\n---\n\n## User Input\n\n```text\n$ARGUMENTS\n```\n\nYou **MUST** consider the user input before proceeding (if not empty).\n\n## Pre-Execution Checks\n\n**Check for extension hooks (before specification)**:\n- Check if `.specify/extensions.yml` exists in the project root.\n- If it exists, read it and look for entries under the `hooks.before_specify` key\n- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally\n- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.\n- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:\n  - If the hook has no `condition` field, or it is null/empty, treat the hook as executable\n  - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation\n- For each executable hook, output the following based on its `optional` flag:\n  - **Optional hook** (`optional: true`):\n    ```\n    ## Extension Hooks\n\n    **Optional Pre-Hook**: {extension}\n    Command: `/{command}`\n    Description: {description}\n\n    Prompt: {prompt}\n    To execute: `/{command}`\n    ```\n  - **Mandatory hook** (`optional: false`):\n    ```\n    ## Extension Hooks\n\n    **Automatic Pre-Hook**: {extension}\n    Executing: `/{command}`\n    EXECUTE_COMMAND: {command}\n\n    Wait for the result of the hook command before proceeding to the Outline.\n    ```\n- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently\n\n## Outline\n\nThe text the user typed after `/speckit.specify` in the triggering message **is** the feature description. Assume you always have it available in this conversation even if `{ARGS}` appears literally below. Do not ask the user to repeat it unless they provided an empty command.\n\nGiven that feature description, do this:\n\n1. **Generate a concise short name** (2-4 words) for the branch:\n   - Analyze the feature description and extract the most meaningful keywords\n   - Create a 2-4 word short name that captures the essence of the feature\n   - Use action-noun format when possible (e.g., \"add-user-auth\", \"fix-payment-bug\")\n   - Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)\n   - Keep it concise but descriptive enough to understand the feature at a glance\n   - Examples:\n     - \"I want to add user authentication\" → \"user-auth\"\n     - \"Implement OAuth2 integration for the API\" → \"oauth2-api-integration\"\n     - \"Create a dashboard for analytics\" → \"analytics-dashboard\"\n     - \"Fix payment processing timeout bug\" → \"fix-payment-timeout\"\n\n2. **Create the feature branch** by running the script with `--short-name` (and `--json`), and do NOT pass `--number` (the script auto-detects the next globally available number across all branches and spec directories):\n\n   - Bash example: `{SCRIPT} --json --short-name \"user-auth\" \"Add user authentication\"`\n   - PowerShell example: `{SCRIPT} -Json -ShortName \"user-auth\" \"Add user authentication\"`\n\n   **IMPORTANT**:\n   - Do NOT pass `--number` — the script determines the correct next number automatically\n   - Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably\n   - You must only ever run this script once per feature\n   - The JSON is provided in the terminal as output - always refer to it to get the actual content you're looking for\n   - The JSON output will contain BRANCH_NAME and SPEC_FILE paths\n   - For single quotes in args like \"I'm Groot\", use escape syntax: e.g 'I'\\''m Groot' (or double-quote if possible: \"I'm Groot\")\n\n3. Load `templates/spec-template.md` to understand required sections.\n\n4. Follow this execution flow:\n\n    1. Parse user description from Input\n       If empty: ERROR \"No feature description provided\"\n    2. Extract key concepts from description\n       Identify: actors, actions, data, constraints\n    3. For unclear aspects:\n       - Make informed guesses based on context and industry standards\n       - Only mark with [NEEDS CLARIFICATION: specific question] if:\n         - The choice significantly impacts feature scope or user experience\n         - Multiple reasonable interpretations exist with different implications\n         - No reasonable default exists\n       - **LIMIT: Maximum 3 [NEEDS CLARIFICATION] markers total**\n       - Prioritize clarifications by impact: scope > security/privacy > user experience > technical details\n    4. Fill User Scenarios & Testing section\n       If no clear user flow: ERROR \"Cannot determine user scenarios\"\n    5. Generate Functional Requirements\n       Each requirement must be testable\n       Use reasonable defaults for unspecified details (document assumptions in Assumptions section)\n    6. Define Success Criteria\n       Create measurable, technology-agnostic outcomes\n       Include both quantitative metrics (time, performance, volume) and qualitative measures (user satisfaction, task completion)\n       Each criterion must be verifiable without implementation details\n    7. Identify Key Entities (if data involved)\n    8. Return: SUCCESS (spec ready for planning)\n\n5. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings.\n\n6. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria:\n\n   a. **Create Spec Quality Checklist**: Generate a checklist file at `FEATURE_DIR/checklists/requirements.md` using the checklist template structure with these validation items:\n\n      ```markdown\n      # Specification Quality Checklist: [FEATURE NAME]\n      \n      **Purpose**: Validate specification completeness and quality before proceeding to planning\n      **Created**: [DATE]\n      **Feature**: [Link to spec.md]\n      \n      ## Content Quality\n      \n      - [ ] No implementation details (languages, frameworks, APIs)\n      - [ ] Focused on user value and business needs\n      - [ ] Written for non-technical stakeholders\n      - [ ] All mandatory sections completed\n      \n      ## Requirement Completeness\n      \n      - [ ] No [NEEDS CLARIFICATION] markers remain\n      - [ ] Requirements are testable and unambiguous\n      - [ ] Success criteria are measurable\n      - [ ] Success criteria are technology-agnostic (no implementation details)\n      - [ ] All acceptance scenarios are defined\n      - [ ] Edge cases are identified\n      - [ ] Scope is clearly bounded\n      - [ ] Dependencies and assumptions identified\n      \n      ## Feature Readiness\n      \n      - [ ] All functional requirements have clear acceptance criteria\n      - [ ] User scenarios cover primary flows\n      - [ ] Feature meets measurable outcomes defined in Success Criteria\n      - [ ] No implementation details leak into specification\n      \n      ## Notes\n      \n      - Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`\n      ```\n\n   b. **Run Validation Check**: Review the spec against each checklist item:\n      - For each item, determine if it passes or fails\n      - Document specific issues found (quote relevant spec sections)\n\n   c. **Handle Validation Results**:\n\n      - **If all items pass**: Mark checklist complete and proceed to step 7\n\n      - **If items fail (excluding [NEEDS CLARIFICATION])**:\n        1. List the failing items and specific issues\n        2. Update the spec to address each issue\n        3. Re-run validation until all items pass (max 3 iterations)\n        4. If still failing after 3 iterations, document remaining issues in checklist notes and warn user\n\n      - **If [NEEDS CLARIFICATION] markers remain**:\n        1. Extract all [NEEDS CLARIFICATION: ...] markers from the spec\n        2. **LIMIT CHECK**: If more than 3 markers exist, keep only the 3 most critical (by scope/security/UX impact) and make informed guesses for the rest\n        3. For each clarification needed (max 3), present options to user in this format:\n\n           ```markdown\n           ## Question [N]: [Topic]\n           \n           **Context**: [Quote relevant spec section]\n           \n           **What we need to know**: [Specific question from NEEDS CLARIFICATION marker]\n           \n           **Suggested Answers**:\n           \n           | Option | Answer | Implications |\n           |--------|--------|--------------|\n           | A      | [First suggested answer] | [What this means for the feature] |\n           | B      | [Second suggested answer] | [What this means for the feature] |\n           | C      | [Third suggested answer] | [What this means for the feature] |\n           | Custom | Provide your own answer | [Explain how to provide custom input] |\n           \n           **Your choice**: _[Wait for user response]_\n           ```\n\n        4. **CRITICAL - Table Formatting**: Ensure markdown tables are properly formatted:\n           - Use consistent spacing with pipes aligned\n           - Each cell should have spaces around content: `| Content |` not `|Content|`\n           - Header separator must have at least 3 dashes: `|--------|`\n           - Test that the table renders correctly in markdown preview\n        5. Number questions sequentially (Q1, Q2, Q3 - max 3 total)\n        6. Present all questions together before waiting for responses\n        7. Wait for user to respond with their choices for all questions (e.g., \"Q1: A, Q2: Custom - [details], Q3: B\")\n        8. Update the spec by replacing each [NEEDS CLARIFICATION] marker with the user's selected or provided answer\n        9. Re-run validation after all clarifications are resolved\n\n   d. **Update Checklist**: After each validation iteration, update the checklist file with current pass/fail status\n\n7. Report completion with branch name, spec file path, checklist results, and readiness for the next phase (`/speckit.clarify` or `/speckit.plan`).\n\n8. **Check for extension hooks**: After reporting completion, check if `.specify/extensions.yml` exists in the project root.\n   - If it exists, read it and look for entries under the `hooks.after_specify` key\n   - If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally\n   - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.\n   - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:\n     - If the hook has no `condition` field, or it is null/empty, treat the hook as executable\n     - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation\n   - For each executable hook, output the following based on its `optional` flag:\n     - **Optional hook** (`optional: true`):\n       ```\n       ## Extension Hooks\n\n       **Optional Hook**: {extension}\n       Command: `/{command}`\n       Description: {description}\n\n       Prompt: {prompt}\n       To execute: `/{command}`\n       ```\n     - **Mandatory hook** (`optional: false`):\n       ```\n       ## Extension Hooks\n\n       **Automatic Hook**: {extension}\n       Executing: `/{command}`\n       EXECUTE_COMMAND: {command}\n       ```\n   - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently\n\n**NOTE:** The script creates and checks out the new branch and initializes the spec file before writing.\n\n## Quick Guidelines\n\n- Focus on **WHAT** users need and **WHY**.\n- Avoid HOW to implement (no tech stack, APIs, code structure).\n- Written for business stakeholders, not developers.\n- DO NOT create any checklists that are embedded in the spec. That will be a separate command.\n\n### Section Requirements\n\n- **Mandatory sections**: Must be completed for every feature\n- **Optional sections**: Include only when relevant to the feature\n- When a section doesn't apply, remove it entirely (don't leave as \"N/A\")\n\n### For AI Generation\n\nWhen creating this spec from a user prompt:\n\n1. **Make informed guesses**: Use context, industry standards, and common patterns to fill gaps\n2. **Document assumptions**: Record reasonable defaults in the Assumptions section\n3. **Limit clarifications**: Maximum 3 [NEEDS CLARIFICATION] markers - use only for critical decisions that:\n   - Significantly impact feature scope or user experience\n   - Have multiple reasonable interpretations with different implications\n   - Lack any reasonable default\n4. **Prioritize clarifications**: scope > security/privacy > user experience > technical details\n5. **Think like a tester**: Every vague requirement should fail the \"testable and unambiguous\" checklist item\n6. **Common areas needing clarification** (only if no reasonable default exists):\n   - Feature scope and boundaries (include/exclude specific use cases)\n   - User types and permissions (if multiple conflicting interpretations possible)\n   - Security/compliance requirements (when legally/financially significant)\n\n**Examples of reasonable defaults** (don't ask about these):\n\n- Data retention: Industry-standard practices for the domain\n- Performance targets: Standard web/mobile app expectations unless specified\n- Error handling: User-friendly messages with appropriate fallbacks\n- Authentication method: Standard session-based or OAuth2 for web apps\n- Integration patterns: Use project-appropriate patterns (REST/GraphQL for web services, function calls for libraries, CLI args for tools, etc.)\n\n### Success Criteria Guidelines\n\nSuccess criteria must be:\n\n1. **Measurable**: Include specific metrics (time, percentage, count, rate)\n2. **Technology-agnostic**: No mention of frameworks, languages, databases, or tools\n3. **User-focused**: Describe outcomes from user/business perspective, not system internals\n4. **Verifiable**: Can be tested/validated without knowing implementation details\n\n**Good examples**:\n\n- \"Users can complete checkout in under 3 minutes\"\n- \"System supports 10,000 concurrent users\"\n- \"95% of searches return results in under 1 second\"\n- \"Task completion rate improves by 40%\"\n\n**Bad examples** (implementation-focused):\n\n- \"API response time is under 200ms\" (too technical, use \"Users see results instantly\")\n- \"Database can handle 1000 TPS\" (implementation detail, use user-facing metric)\n- \"React components render efficiently\" (framework-specific)\n- \"Redis cache hit rate above 80%\" (technology-specific)\n"
  },
  {
    "path": "templates/commands/tasks.md",
    "content": "---\ndescription: Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts.\nhandoffs: \n  - label: Analyze For Consistency\n    agent: speckit.analyze\n    prompt: Run a project analysis for consistency\n    send: true\n  - label: Implement Project\n    agent: speckit.implement\n    prompt: Start the implementation in phases\n    send: true\nscripts:\n  sh: scripts/bash/check-prerequisites.sh --json\n  ps: scripts/powershell/check-prerequisites.ps1 -Json\n---\n\n## User Input\n\n```text\n$ARGUMENTS\n```\n\nYou **MUST** consider the user input before proceeding (if not empty).\n\n## Pre-Execution Checks\n\n**Check for extension hooks (before tasks generation)**:\n- Check if `.specify/extensions.yml` exists in the project root.\n- If it exists, read it and look for entries under the `hooks.before_tasks` key\n- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally\n- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.\n- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:\n  - If the hook has no `condition` field, or it is null/empty, treat the hook as executable\n  - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation\n- For each executable hook, output the following based on its `optional` flag:\n  - **Optional hook** (`optional: true`):\n    ```\n    ## Extension Hooks\n\n    **Optional Pre-Hook**: {extension}\n    Command: `/{command}`\n    Description: {description}\n\n    Prompt: {prompt}\n    To execute: `/{command}`\n    ```\n  - **Mandatory hook** (`optional: false`):\n    ```\n    ## Extension Hooks\n\n    **Automatic Pre-Hook**: {extension}\n    Executing: `/{command}`\n    EXECUTE_COMMAND: {command}\n    \n    Wait for the result of the hook command before proceeding to the Outline.\n    ```\n- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently\n\n## Outline\n\n1. **Setup**: Run `{SCRIPT}` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like \"I'm Groot\", use escape syntax: e.g 'I'\\''m Groot' (or double-quote if possible: \"I'm Groot\").\n\n2. **Load design documents**: Read from FEATURE_DIR:\n   - **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities)\n   - **Optional**: data-model.md (entities), contracts/ (interface contracts), research.md (decisions), quickstart.md (test scenarios)\n   - Note: Not all projects have all documents. Generate tasks based on what's available.\n\n3. **Execute task generation workflow**:\n   - Load plan.md and extract tech stack, libraries, project structure\n   - Load spec.md and extract user stories with their priorities (P1, P2, P3, etc.)\n   - If data-model.md exists: Extract entities and map to user stories\n   - If contracts/ exists: Map interface contracts to user stories\n   - If research.md exists: Extract decisions for setup tasks\n   - Generate tasks organized by user story (see Task Generation Rules below)\n   - Generate dependency graph showing user story completion order\n   - Create parallel execution examples per user story\n   - Validate task completeness (each user story has all needed tasks, independently testable)\n\n4. **Generate tasks.md**: Use `templates/tasks-template.md` as structure, fill with:\n   - Correct feature name from plan.md\n   - Phase 1: Setup tasks (project initialization)\n   - Phase 2: Foundational tasks (blocking prerequisites for all user stories)\n   - Phase 3+: One phase per user story (in priority order from spec.md)\n   - Each phase includes: story goal, independent test criteria, tests (if requested), implementation tasks\n   - Final Phase: Polish & cross-cutting concerns\n   - All tasks must follow the strict checklist format (see Task Generation Rules below)\n   - Clear file paths for each task\n   - Dependencies section showing story completion order\n   - Parallel execution examples per story\n   - Implementation strategy section (MVP first, incremental delivery)\n\n5. **Report**: Output path to generated tasks.md and summary:\n   - Total task count\n   - Task count per user story\n   - Parallel opportunities identified\n   - Independent test criteria for each story\n   - Suggested MVP scope (typically just User Story 1)\n   - Format validation: Confirm ALL tasks follow the checklist format (checkbox, ID, labels, file paths)\n\n6. **Check for extension hooks**: After tasks.md is generated, check if `.specify/extensions.yml` exists in the project root.\n   - If it exists, read it and look for entries under the `hooks.after_tasks` key\n   - If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally\n   - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.\n   - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:\n     - If the hook has no `condition` field, or it is null/empty, treat the hook as executable\n     - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation\n   - For each executable hook, output the following based on its `optional` flag:\n     - **Optional hook** (`optional: true`):\n       ```\n       ## Extension Hooks\n\n       **Optional Hook**: {extension}\n       Command: `/{command}`\n       Description: {description}\n\n       Prompt: {prompt}\n       To execute: `/{command}`\n       ```\n     - **Mandatory hook** (`optional: false`):\n       ```\n       ## Extension Hooks\n\n       **Automatic Hook**: {extension}\n       Executing: `/{command}`\n       EXECUTE_COMMAND: {command}\n       ```\n   - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently\n\nContext for task generation: {ARGS}\n\nThe tasks.md should be immediately executable - each task must be specific enough that an LLM can complete it without additional context.\n\n## Task Generation Rules\n\n**CRITICAL**: Tasks MUST be organized by user story to enable independent implementation and testing.\n\n**Tests are OPTIONAL**: Only generate test tasks if explicitly requested in the feature specification or if user requests TDD approach.\n\n### Checklist Format (REQUIRED)\n\nEvery task MUST strictly follow this format:\n\n```text\n- [ ] [TaskID] [P?] [Story?] Description with file path\n```\n\n**Format Components**:\n\n1. **Checkbox**: ALWAYS start with `- [ ]` (markdown checkbox)\n2. **Task ID**: Sequential number (T001, T002, T003...) in execution order\n3. **[P] marker**: Include ONLY if task is parallelizable (different files, no dependencies on incomplete tasks)\n4. **[Story] label**: REQUIRED for user story phase tasks only\n   - Format: [US1], [US2], [US3], etc. (maps to user stories from spec.md)\n   - Setup phase: NO story label\n   - Foundational phase: NO story label  \n   - User Story phases: MUST have story label\n   - Polish phase: NO story label\n5. **Description**: Clear action with exact file path\n\n**Examples**:\n\n- ✅ CORRECT: `- [ ] T001 Create project structure per implementation plan`\n- ✅ CORRECT: `- [ ] T005 [P] Implement authentication middleware in src/middleware/auth.py`\n- ✅ CORRECT: `- [ ] T012 [P] [US1] Create User model in src/models/user.py`\n- ✅ CORRECT: `- [ ] T014 [US1] Implement UserService in src/services/user_service.py`\n- ❌ WRONG: `- [ ] Create User model` (missing ID and Story label)\n- ❌ WRONG: `T001 [US1] Create model` (missing checkbox)\n- ❌ WRONG: `- [ ] [US1] Create User model` (missing Task ID)\n- ❌ WRONG: `- [ ] T001 [US1] Create model` (missing file path)\n\n### Task Organization\n\n1. **From User Stories (spec.md)** - PRIMARY ORGANIZATION:\n   - Each user story (P1, P2, P3...) gets its own phase\n   - Map all related components to their story:\n     - Models needed for that story\n     - Services needed for that story\n     - Interfaces/UI needed for that story\n     - If tests requested: Tests specific to that story\n   - Mark story dependencies (most stories should be independent)\n\n2. **From Contracts**:\n   - Map each interface contract → to the user story it serves\n   - If tests requested: Each interface contract → contract test task [P] before implementation in that story's phase\n\n3. **From Data Model**:\n   - Map each entity to the user story(ies) that need it\n   - If entity serves multiple stories: Put in earliest story or Setup phase\n   - Relationships → service layer tasks in appropriate story phase\n\n4. **From Setup/Infrastructure**:\n   - Shared infrastructure → Setup phase (Phase 1)\n   - Foundational/blocking tasks → Foundational phase (Phase 2)\n   - Story-specific setup → within that story's phase\n\n### Phase Structure\n\n- **Phase 1**: Setup (project initialization)\n- **Phase 2**: Foundational (blocking prerequisites - MUST complete before user stories)\n- **Phase 3+**: User Stories in priority order (P1, P2, P3...)\n  - Within each story: Tests (if requested) → Models → Services → Endpoints → Integration\n  - Each phase should be a complete, independently testable increment\n- **Final Phase**: Polish & Cross-Cutting Concerns\n"
  },
  {
    "path": "templates/commands/taskstoissues.md",
    "content": "---\ndescription: Convert existing tasks into actionable, dependency-ordered GitHub issues for the feature based on available design artifacts.\ntools: ['github/github-mcp-server/issue_write']\nscripts:\n  sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks\n  ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks\n---\n\n## User Input\n\n```text\n$ARGUMENTS\n```\n\nYou **MUST** consider the user input before proceeding (if not empty).\n\n## Outline\n\n1. Run `{SCRIPT}` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like \"I'm Groot\", use escape syntax: e.g 'I'\\''m Groot' (or double-quote if possible: \"I'm Groot\").\n1. From the executed script, extract the path to **tasks**.\n1. Get the Git remote by running:\n\n```bash\ngit config --get remote.origin.url\n```\n\n> [!CAUTION]\n> ONLY PROCEED TO NEXT STEPS IF THE REMOTE IS A GITHUB URL\n\n1. For each task in the list, use the GitHub MCP server to create a new issue in the repository that is representative of the Git remote.\n\n> [!CAUTION]\n> UNDER NO CIRCUMSTANCES EVER CREATE ISSUES IN REPOSITORIES THAT DO NOT MATCH THE REMOTE URL\n"
  },
  {
    "path": "templates/constitution-template.md",
    "content": "# [PROJECT_NAME] Constitution\n<!-- Example: Spec Constitution, TaskFlow Constitution, etc. -->\n\n## Core Principles\n\n### [PRINCIPLE_1_NAME]\n<!-- Example: I. Library-First -->\n[PRINCIPLE_1_DESCRIPTION]\n<!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries -->\n\n### [PRINCIPLE_2_NAME]\n<!-- Example: II. CLI Interface -->\n[PRINCIPLE_2_DESCRIPTION]\n<!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args → stdout, errors → stderr; Support JSON + human-readable formats -->\n\n### [PRINCIPLE_3_NAME]\n<!-- Example: III. Test-First (NON-NEGOTIABLE) -->\n[PRINCIPLE_3_DESCRIPTION]\n<!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced -->\n\n### [PRINCIPLE_4_NAME]\n<!-- Example: IV. Integration Testing -->\n[PRINCIPLE_4_DESCRIPTION]\n<!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas -->\n\n### [PRINCIPLE_5_NAME]\n<!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity -->\n[PRINCIPLE_5_DESCRIPTION]\n<!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles -->\n\n## [SECTION_2_NAME]\n<!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. -->\n\n[SECTION_2_CONTENT]\n<!-- Example: Technology stack requirements, compliance standards, deployment policies, etc. -->\n\n## [SECTION_3_NAME]\n<!-- Example: Development Workflow, Review Process, Quality Gates, etc. -->\n\n[SECTION_3_CONTENT]\n<!-- Example: Code review requirements, testing gates, deployment approval process, etc. -->\n\n## Governance\n<!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan -->\n\n[GOVERNANCE_RULES]\n<!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance -->\n\n**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE]\n<!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 -->\n"
  },
  {
    "path": "templates/plan-template.md",
    "content": "# Implementation Plan: [FEATURE]\n\n**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link]\n**Input**: Feature specification from `/specs/[###-feature-name]/spec.md`\n\n**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/plan-template.md` for the execution workflow.\n\n## Summary\n\n[Extract from feature spec: primary requirement + technical approach from research]\n\n## Technical Context\n\n<!--\n  ACTION REQUIRED: Replace the content in this section with the technical details\n  for the project. The structure here is presented in advisory capacity to guide\n  the iteration process.\n-->\n\n**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION]  \n**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION]  \n**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A]  \n**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION]  \n**Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION]\n**Project Type**: [e.g., library/cli/web-service/mobile-app/compiler/desktop-app or NEEDS CLARIFICATION]  \n**Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION]  \n**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION]  \n**Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION]\n\n## Constitution Check\n\n*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*\n\n[Gates determined based on constitution file]\n\n## Project Structure\n\n### Documentation (this feature)\n\n```text\nspecs/[###-feature]/\n├── plan.md              # This file (/speckit.plan command output)\n├── research.md          # Phase 0 output (/speckit.plan command)\n├── data-model.md        # Phase 1 output (/speckit.plan command)\n├── quickstart.md        # Phase 1 output (/speckit.plan command)\n├── contracts/           # Phase 1 output (/speckit.plan command)\n└── tasks.md             # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)\n```\n\n### Source Code (repository root)\n<!--\n  ACTION REQUIRED: Replace the placeholder tree below with the concrete layout\n  for this feature. Delete unused options and expand the chosen structure with\n  real paths (e.g., apps/admin, packages/something). The delivered plan must\n  not include Option labels.\n-->\n\n```text\n# [REMOVE IF UNUSED] Option 1: Single project (DEFAULT)\nsrc/\n├── models/\n├── services/\n├── cli/\n└── lib/\n\ntests/\n├── contract/\n├── integration/\n└── unit/\n\n# [REMOVE IF UNUSED] Option 2: Web application (when \"frontend\" + \"backend\" detected)\nbackend/\n├── src/\n│   ├── models/\n│   ├── services/\n│   └── api/\n└── tests/\n\nfrontend/\n├── src/\n│   ├── components/\n│   ├── pages/\n│   └── services/\n└── tests/\n\n# [REMOVE IF UNUSED] Option 3: Mobile + API (when \"iOS/Android\" detected)\napi/\n└── [same as backend above]\n\nios/ or android/\n└── [platform-specific structure: feature modules, UI flows, platform tests]\n```\n\n**Structure Decision**: [Document the selected structure and reference the real\ndirectories captured above]\n\n## Complexity Tracking\n\n> **Fill ONLY if Constitution Check has violations that must be justified**\n\n| Violation | Why Needed | Simpler Alternative Rejected Because |\n|-----------|------------|-------------------------------------|\n| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |\n| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |\n"
  },
  {
    "path": "templates/spec-template.md",
    "content": "# Feature Specification: [FEATURE NAME]\n\n**Feature Branch**: `[###-feature-name]`  \n**Created**: [DATE]  \n**Status**: Draft  \n**Input**: User description: \"$ARGUMENTS\"\n\n## User Scenarios & Testing *(mandatory)*\n\n<!--\n  IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.\n  Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,\n  you should still have a viable MVP (Minimum Viable Product) that delivers value.\n  \n  Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.\n  Think of each story as a standalone slice of functionality that can be:\n  - Developed independently\n  - Tested independently\n  - Deployed independently\n  - Demonstrated to users independently\n-->\n\n### User Story 1 - [Brief Title] (Priority: P1)\n\n[Describe this user journey in plain language]\n\n**Why this priority**: [Explain the value and why it has this priority level]\n\n**Independent Test**: [Describe how this can be tested independently - e.g., \"Can be fully tested by [specific action] and delivers [specific value]\"]\n\n**Acceptance Scenarios**:\n\n1. **Given** [initial state], **When** [action], **Then** [expected outcome]\n2. **Given** [initial state], **When** [action], **Then** [expected outcome]\n\n---\n\n### User Story 2 - [Brief Title] (Priority: P2)\n\n[Describe this user journey in plain language]\n\n**Why this priority**: [Explain the value and why it has this priority level]\n\n**Independent Test**: [Describe how this can be tested independently]\n\n**Acceptance Scenarios**:\n\n1. **Given** [initial state], **When** [action], **Then** [expected outcome]\n\n---\n\n### User Story 3 - [Brief Title] (Priority: P3)\n\n[Describe this user journey in plain language]\n\n**Why this priority**: [Explain the value and why it has this priority level]\n\n**Independent Test**: [Describe how this can be tested independently]\n\n**Acceptance Scenarios**:\n\n1. **Given** [initial state], **When** [action], **Then** [expected outcome]\n\n---\n\n[Add more user stories as needed, each with an assigned priority]\n\n### Edge Cases\n\n<!--\n  ACTION REQUIRED: The content in this section represents placeholders.\n  Fill them out with the right edge cases.\n-->\n\n- What happens when [boundary condition]?\n- How does system handle [error scenario]?\n\n## Requirements *(mandatory)*\n\n<!--\n  ACTION REQUIRED: The content in this section represents placeholders.\n  Fill them out with the right functional requirements.\n-->\n\n### Functional Requirements\n\n- **FR-001**: System MUST [specific capability, e.g., \"allow users to create accounts\"]\n- **FR-002**: System MUST [specific capability, e.g., \"validate email addresses\"]  \n- **FR-003**: Users MUST be able to [key interaction, e.g., \"reset their password\"]\n- **FR-004**: System MUST [data requirement, e.g., \"persist user preferences\"]\n- **FR-005**: System MUST [behavior, e.g., \"log all security events\"]\n\n*Example of marking unclear requirements:*\n\n- **FR-006**: System MUST authenticate users via [NEEDS CLARIFICATION: auth method not specified - email/password, SSO, OAuth?]\n- **FR-007**: System MUST retain user data for [NEEDS CLARIFICATION: retention period not specified]\n\n### Key Entities *(include if feature involves data)*\n\n- **[Entity 1]**: [What it represents, key attributes without implementation]\n- **[Entity 2]**: [What it represents, relationships to other entities]\n\n## Success Criteria *(mandatory)*\n\n<!--\n  ACTION REQUIRED: Define measurable success criteria.\n  These must be technology-agnostic and measurable.\n-->\n\n### Measurable Outcomes\n\n- **SC-001**: [Measurable metric, e.g., \"Users can complete account creation in under 2 minutes\"]\n- **SC-002**: [Measurable metric, e.g., \"System handles 1000 concurrent users without degradation\"]\n- **SC-003**: [User satisfaction metric, e.g., \"90% of users successfully complete primary task on first attempt\"]\n- **SC-004**: [Business metric, e.g., \"Reduce support tickets related to [X] by 50%\"]\n"
  },
  {
    "path": "templates/tasks-template.md",
    "content": "---\n\ndescription: \"Task list template for feature implementation\"\n---\n\n# Tasks: [FEATURE NAME]\n\n**Input**: Design documents from `/specs/[###-feature-name]/`\n**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/\n\n**Tests**: The examples below include test tasks. Tests are OPTIONAL - only include them if explicitly requested in the feature specification.\n\n**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.\n\n## Format: `[ID] [P?] [Story] Description`\n\n- **[P]**: Can run in parallel (different files, no dependencies)\n- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)\n- Include exact file paths in descriptions\n\n## Path Conventions\n\n- **Single project**: `src/`, `tests/` at repository root\n- **Web app**: `backend/src/`, `frontend/src/`\n- **Mobile**: `api/src/`, `ios/src/` or `android/src/`\n- Paths shown below assume single project - adjust based on plan.md structure\n\n<!-- \n  ============================================================================\n  IMPORTANT: The tasks below are SAMPLE TASKS for illustration purposes only.\n  \n  The /speckit.tasks command MUST replace these with actual tasks based on:\n  - User stories from spec.md (with their priorities P1, P2, P3...)\n  - Feature requirements from plan.md\n  - Entities from data-model.md\n  - Endpoints from contracts/\n  \n  Tasks MUST be organized by user story so each story can be:\n  - Implemented independently\n  - Tested independently\n  - Delivered as an MVP increment\n  \n  DO NOT keep these sample tasks in the generated tasks.md file.\n  ============================================================================\n-->\n\n## Phase 1: Setup (Shared Infrastructure)\n\n**Purpose**: Project initialization and basic structure\n\n- [ ] T001 Create project structure per implementation plan\n- [ ] T002 Initialize [language] project with [framework] dependencies\n- [ ] T003 [P] Configure linting and formatting tools\n\n---\n\n## Phase 2: Foundational (Blocking Prerequisites)\n\n**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented\n\n**⚠️ CRITICAL**: No user story work can begin until this phase is complete\n\nExamples of foundational tasks (adjust based on your project):\n\n- [ ] T004 Setup database schema and migrations framework\n- [ ] T005 [P] Implement authentication/authorization framework\n- [ ] T006 [P] Setup API routing and middleware structure\n- [ ] T007 Create base models/entities that all stories depend on\n- [ ] T008 Configure error handling and logging infrastructure\n- [ ] T009 Setup environment configuration management\n\n**Checkpoint**: Foundation ready - user story implementation can now begin in parallel\n\n---\n\n## Phase 3: User Story 1 - [Title] (Priority: P1) 🎯 MVP\n\n**Goal**: [Brief description of what this story delivers]\n\n**Independent Test**: [How to verify this story works on its own]\n\n### Tests for User Story 1 (OPTIONAL - only if tests requested) ⚠️\n\n> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**\n\n- [ ] T010 [P] [US1] Contract test for [endpoint] in tests/contract/test_[name].py\n- [ ] T011 [P] [US1] Integration test for [user journey] in tests/integration/test_[name].py\n\n### Implementation for User Story 1\n\n- [ ] T012 [P] [US1] Create [Entity1] model in src/models/[entity1].py\n- [ ] T013 [P] [US1] Create [Entity2] model in src/models/[entity2].py\n- [ ] T014 [US1] Implement [Service] in src/services/[service].py (depends on T012, T013)\n- [ ] T015 [US1] Implement [endpoint/feature] in src/[location]/[file].py\n- [ ] T016 [US1] Add validation and error handling\n- [ ] T017 [US1] Add logging for user story 1 operations\n\n**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently\n\n---\n\n## Phase 4: User Story 2 - [Title] (Priority: P2)\n\n**Goal**: [Brief description of what this story delivers]\n\n**Independent Test**: [How to verify this story works on its own]\n\n### Tests for User Story 2 (OPTIONAL - only if tests requested) ⚠️\n\n- [ ] T018 [P] [US2] Contract test for [endpoint] in tests/contract/test_[name].py\n- [ ] T019 [P] [US2] Integration test for [user journey] in tests/integration/test_[name].py\n\n### Implementation for User Story 2\n\n- [ ] T020 [P] [US2] Create [Entity] model in src/models/[entity].py\n- [ ] T021 [US2] Implement [Service] in src/services/[service].py\n- [ ] T022 [US2] Implement [endpoint/feature] in src/[location]/[file].py\n- [ ] T023 [US2] Integrate with User Story 1 components (if needed)\n\n**Checkpoint**: At this point, User Stories 1 AND 2 should both work independently\n\n---\n\n## Phase 5: User Story 3 - [Title] (Priority: P3)\n\n**Goal**: [Brief description of what this story delivers]\n\n**Independent Test**: [How to verify this story works on its own]\n\n### Tests for User Story 3 (OPTIONAL - only if tests requested) ⚠️\n\n- [ ] T024 [P] [US3] Contract test for [endpoint] in tests/contract/test_[name].py\n- [ ] T025 [P] [US3] Integration test for [user journey] in tests/integration/test_[name].py\n\n### Implementation for User Story 3\n\n- [ ] T026 [P] [US3] Create [Entity] model in src/models/[entity].py\n- [ ] T027 [US3] Implement [Service] in src/services/[service].py\n- [ ] T028 [US3] Implement [endpoint/feature] in src/[location]/[file].py\n\n**Checkpoint**: All user stories should now be independently functional\n\n---\n\n[Add more user story phases as needed, following the same pattern]\n\n---\n\n## Phase N: Polish & Cross-Cutting Concerns\n\n**Purpose**: Improvements that affect multiple user stories\n\n- [ ] TXXX [P] Documentation updates in docs/\n- [ ] TXXX Code cleanup and refactoring\n- [ ] TXXX Performance optimization across all stories\n- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/\n- [ ] TXXX Security hardening\n- [ ] TXXX Run quickstart.md validation\n\n---\n\n## Dependencies & Execution Order\n\n### Phase Dependencies\n\n- **Setup (Phase 1)**: No dependencies - can start immediately\n- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories\n- **User Stories (Phase 3+)**: All depend on Foundational phase completion\n  - User stories can then proceed in parallel (if staffed)\n  - Or sequentially in priority order (P1 → P2 → P3)\n- **Polish (Final Phase)**: Depends on all desired user stories being complete\n\n### User Story Dependencies\n\n- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories\n- **User Story 2 (P2)**: Can start after Foundational (Phase 2) - May integrate with US1 but should be independently testable\n- **User Story 3 (P3)**: Can start after Foundational (Phase 2) - May integrate with US1/US2 but should be independently testable\n\n### Within Each User Story\n\n- Tests (if included) MUST be written and FAIL before implementation\n- Models before services\n- Services before endpoints\n- Core implementation before integration\n- Story complete before moving to next priority\n\n### Parallel Opportunities\n\n- All Setup tasks marked [P] can run in parallel\n- All Foundational tasks marked [P] can run in parallel (within Phase 2)\n- Once Foundational phase completes, all user stories can start in parallel (if team capacity allows)\n- All tests for a user story marked [P] can run in parallel\n- Models within a story marked [P] can run in parallel\n- Different user stories can be worked on in parallel by different team members\n\n---\n\n## Parallel Example: User Story 1\n\n```bash\n# Launch all tests for User Story 1 together (if tests requested):\nTask: \"Contract test for [endpoint] in tests/contract/test_[name].py\"\nTask: \"Integration test for [user journey] in tests/integration/test_[name].py\"\n\n# Launch all models for User Story 1 together:\nTask: \"Create [Entity1] model in src/models/[entity1].py\"\nTask: \"Create [Entity2] model in src/models/[entity2].py\"\n```\n\n---\n\n## Implementation Strategy\n\n### MVP First (User Story 1 Only)\n\n1. Complete Phase 1: Setup\n2. Complete Phase 2: Foundational (CRITICAL - blocks all stories)\n3. Complete Phase 3: User Story 1\n4. **STOP and VALIDATE**: Test User Story 1 independently\n5. Deploy/demo if ready\n\n### Incremental Delivery\n\n1. Complete Setup + Foundational → Foundation ready\n2. Add User Story 1 → Test independently → Deploy/Demo (MVP!)\n3. Add User Story 2 → Test independently → Deploy/Demo\n4. Add User Story 3 → Test independently → Deploy/Demo\n5. Each story adds value without breaking previous stories\n\n### Parallel Team Strategy\n\nWith multiple developers:\n\n1. Team completes Setup + Foundational together\n2. Once Foundational is done:\n   - Developer A: User Story 1\n   - Developer B: User Story 2\n   - Developer C: User Story 3\n3. Stories complete and integrate independently\n\n---\n\n## Notes\n\n- [P] tasks = different files, no dependencies\n- [Story] label maps task to specific user story for traceability\n- Each user story should be independently completable and testable\n- Verify tests fail before implementing\n- Commit after each task or logical group\n- Stop at any checkpoint to validate story independently\n- Avoid: vague tasks, same file conflicts, cross-story dependencies that break independence\n"
  },
  {
    "path": "templates/vscode-settings.json",
    "content": "{\n    \"chat.promptFilesRecommendations\": {\n        \"speckit.constitution\": true,\n        \"speckit.specify\": true,\n        \"speckit.plan\": true,\n        \"speckit.tasks\": true,\n        \"speckit.implement\": true\n    },\n    \"chat.tools.terminal.autoApprove\": {\n        \".specify/scripts/bash/\": true,\n        \".specify/scripts/powershell/\": true\n    }\n}\n\n"
  },
  {
    "path": "tests/__init__.py",
    "content": "\"\"\"Unit tests for Spec Kit.\"\"\"\n"
  },
  {
    "path": "tests/hooks/.specify/extensions.yml",
    "content": "hooks:\n  before_implement:\n    - id: pre_test\n      enabled: true\n      optional: false\n      extension: \"test-extension\"\n      command: \"pre_implement_test\"\n      description: \"Test before implement hook execution\"\n      \n  after_implement:\n    - id: post_test\n      enabled: true\n      optional: true\n      extension: \"test-extension\"\n      command: \"post_implement_test\"\n      description: \"Test after implement hook execution\"\n      prompt: \"Would you like to run the post-implement test?\"\n\n  before_tasks:\n    - id: pre_tasks_test\n      enabled: true\n      optional: false\n      extension: \"test-extension\"\n      command: \"pre_tasks_test\"\n      description: \"Test before tasks hook execution\"\n\n  after_tasks:\n    - id: post_tasks_test\n      enabled: true\n      optional: true\n      extension: \"test-extension\"\n      command: \"post_tasks_test\"\n      description: \"Test after tasks hook execution\"\n      prompt: \"Would you like to run the post-tasks test?\"\n"
  },
  {
    "path": "tests/hooks/TESTING.md",
    "content": "# Testing Extension Hooks\n\nThis directory contains a mock project to verify that LLM agents correctly identify and execute hook commands defined in `.specify/extensions.yml`.\n\n## Test 1: Testing `before_tasks` and `after_tasks`\n\n1. Open a chat with an LLM (like GitHub Copilot) in this project.\n2. Ask it to generate tasks for the current directory:\n   > \"Please follow `/speckit.tasks` for the `./tests/hooks` directory.\"\n3. **Expected Behavior**: \n   - Before doing any generation, the LLM should notice the `AUTOMATIC Pre-Hook` in `.specify/extensions.yml` under `before_tasks`.\n   - It should state it is executing `EXECUTE_COMMAND: pre_tasks_test`.\n   - It should then proceed to read the `.md` docs and produce a `tasks.md`.\n   - After generation, it should output the optional `after_tasks` hook (`post_tasks_test`) block, asking if you want to run it.\n\n## Test 2: Testing `before_implement` and `after_implement`\n\n*(Requires `tasks.md` from Test 1 to exist)*\n\n1. In the same (or new) chat, ask the LLM to implement the tasks:\n   > \"Please follow `/speckit.implement` for the `./tests/hooks` directory.\"\n2. **Expected Behavior**:\n   - The LLM should first check for `before_implement` hooks.\n   - It should state it is executing `EXECUTE_COMMAND: pre_implement_test` BEFORE doing any actual task execution.\n   - It should evaluate the checklists and execute the code writing tasks.\n   - Upon completion, it should output the optional `after_implement` hook (`post_implement_test`) block.\n\n## How it works\n\nThe templates for these commands in `templates/commands/tasks.md` and `templates/commands/implement.md` contains strict ordered lists. The new `before_*` hooks are explicitly formulated in a **Pre-Execution Checks** section prior to the outline to ensure they're evaluated first without breaking template step numbers.\n"
  },
  {
    "path": "tests/hooks/plan.md",
    "content": "# Test Setup for Hooks\n\nThis feature is designed to test if LLMs correctly invoke Spec Kit extensions hooks when generating tasks and implementing code.\n"
  },
  {
    "path": "tests/hooks/spec.md",
    "content": "- **User Story 1:** I want a test script that prints \"Hello hooks!\".\n"
  },
  {
    "path": "tests/hooks/tasks.md",
    "content": "- [ ] T001 [US1] Create script that prints 'Hello hooks!' in hello.py\n"
  },
  {
    "path": "tests/test_agent_config_consistency.py",
    "content": "\"\"\"Consistency checks for agent configuration across runtime and packaging scripts.\"\"\"\n\nimport re\nfrom pathlib import Path\n\nfrom specify_cli import AGENT_CONFIG, AI_ASSISTANT_ALIASES, AI_ASSISTANT_HELP\nfrom specify_cli.extensions import CommandRegistrar\n\n\nREPO_ROOT = Path(__file__).resolve().parent.parent\n\n\nclass TestAgentConfigConsistency:\n    \"\"\"Ensure kiro-cli migration stays synchronized across key surfaces.\"\"\"\n\n    def test_runtime_config_uses_kiro_cli_and_removes_q(self):\n        \"\"\"AGENT_CONFIG should include kiro-cli and exclude legacy q.\"\"\"\n        assert \"kiro-cli\" in AGENT_CONFIG\n        assert AGENT_CONFIG[\"kiro-cli\"][\"folder\"] == \".kiro/\"\n        assert AGENT_CONFIG[\"kiro-cli\"][\"commands_subdir\"] == \"prompts\"\n        assert \"q\" not in AGENT_CONFIG\n\n    def test_extension_registrar_uses_kiro_cli_and_removes_q(self):\n        \"\"\"Extension command registrar should target .kiro/prompts.\"\"\"\n        cfg = CommandRegistrar.AGENT_CONFIGS\n\n        assert \"kiro-cli\" in cfg\n        assert cfg[\"kiro-cli\"][\"dir\"] == \".kiro/prompts\"\n        assert \"q\" not in cfg\n\n    def test_extension_registrar_includes_codex(self):\n        \"\"\"Extension command registrar should include codex targeting .agents/skills.\"\"\"\n        cfg = CommandRegistrar.AGENT_CONFIGS\n\n        assert \"codex\" in cfg\n        assert cfg[\"codex\"][\"dir\"] == \".agents/skills\"\n        assert cfg[\"codex\"][\"extension\"] == \"/SKILL.md\"\n\n    def test_runtime_codex_uses_native_skills(self):\n        \"\"\"Codex runtime config should point at .agents/skills.\"\"\"\n        assert AGENT_CONFIG[\"codex\"][\"folder\"] == \".agents/\"\n        assert AGENT_CONFIG[\"codex\"][\"commands_subdir\"] == \"skills\"\n\n    def test_release_agent_lists_include_kiro_cli_and_exclude_q(self):\n        \"\"\"Bash and PowerShell release scripts should agree on agent key set for Kiro.\"\"\"\n        sh_text = (REPO_ROOT / \".github\" / \"workflows\" / \"scripts\" / \"create-release-packages.sh\").read_text(encoding=\"utf-8\")\n        ps_text = (REPO_ROOT / \".github\" / \"workflows\" / \"scripts\" / \"create-release-packages.ps1\").read_text(encoding=\"utf-8\")\n\n        sh_match = re.search(r\"ALL_AGENTS=\\(([^)]*)\\)\", sh_text)\n        assert sh_match is not None\n        sh_agents = sh_match.group(1).split()\n\n        ps_match = re.search(r\"\\$AllAgents = @\\(([^)]*)\\)\", ps_text)\n        assert ps_match is not None\n        ps_agents = re.findall(r\"'([^']+)'\", ps_match.group(1))\n\n        assert \"kiro-cli\" in sh_agents\n        assert \"kiro-cli\" in ps_agents\n        assert \"shai\" in sh_agents\n        assert \"shai\" in ps_agents\n        assert \"agy\" in sh_agents\n        assert \"agy\" in ps_agents\n        assert \"q\" not in sh_agents\n        assert \"q\" not in ps_agents\n\n    def test_release_ps_switch_has_shai_and_agy_generation(self):\n        \"\"\"PowerShell release builder must generate files for shai and agy agents.\"\"\"\n        ps_text = (REPO_ROOT / \".github\" / \"workflows\" / \"scripts\" / \"create-release-packages.ps1\").read_text(encoding=\"utf-8\")\n\n        assert re.search(r\"'shai'\\s*\\{.*?\\.shai/commands\", ps_text, re.S) is not None\n        assert re.search(r\"'agy'\\s*\\{.*?\\.agent/commands\", ps_text, re.S) is not None\n\n    def test_release_sh_switch_has_shai_and_agy_generation(self):\n        \"\"\"Bash release builder must generate files for shai and agy agents.\"\"\"\n        sh_text = (REPO_ROOT / \".github\" / \"workflows\" / \"scripts\" / \"create-release-packages.sh\").read_text(encoding=\"utf-8\")\n\n        assert re.search(r\"shai\\)\\s*\\n.*?\\.shai/commands\", sh_text, re.S) is not None\n        assert re.search(r\"agy\\)\\s*\\n.*?\\.agent/commands\", sh_text, re.S) is not None\n\n    def test_release_scripts_generate_codex_skills(self):\n        \"\"\"Release scripts should generate Codex skills in .agents/skills.\"\"\"\n        sh_text = (REPO_ROOT / \".github\" / \"workflows\" / \"scripts\" / \"create-release-packages.sh\").read_text(encoding=\"utf-8\")\n        ps_text = (REPO_ROOT / \".github\" / \"workflows\" / \"scripts\" / \"create-release-packages.ps1\").read_text(encoding=\"utf-8\")\n\n        assert \".agents/skills\" in sh_text\n        assert \".agents/skills\" in ps_text\n        assert re.search(r\"codex\\)\\s*\\n.*?create_skills.*?\\.agents/skills.*?\\\"-\\\"\", sh_text, re.S) is not None\n        assert re.search(r\"'codex'\\s*\\{.*?\\.agents/skills.*?New-Skills.*?-Separator '-'\", ps_text, re.S) is not None\n\n    def test_init_ai_help_includes_roo_and_kiro_alias(self):\n        \"\"\"CLI help text for --ai should stay in sync with agent config and alias guidance.\"\"\"\n        assert \"roo\" in AI_ASSISTANT_HELP\n        for alias, target in AI_ASSISTANT_ALIASES.items():\n            assert alias in AI_ASSISTANT_HELP\n            assert target in AI_ASSISTANT_HELP\n\n    def test_devcontainer_kiro_installer_uses_pinned_checksum(self):\n        \"\"\"Devcontainer installer should always verify Kiro installer via pinned SHA256.\"\"\"\n        post_create_text = (REPO_ROOT / \".devcontainer\" / \"post-create.sh\").read_text(encoding=\"utf-8\")\n\n        assert 'KIRO_INSTALLER_SHA256=\"7487a65cf310b7fb59b357c4b5e6e3f3259d383f4394ecedb39acf70f307cffb\"' in post_create_text\n        assert \"sha256sum -c -\" in post_create_text\n        assert \"KIRO_SKIP_KIRO_INSTALLER_VERIFY\" not in post_create_text\n\n    def test_release_output_targets_kiro_prompt_dir(self):\n        \"\"\"Packaging and release scripts should no longer emit amazonq artifacts.\"\"\"\n        sh_text = (REPO_ROOT / \".github\" / \"workflows\" / \"scripts\" / \"create-release-packages.sh\").read_text(encoding=\"utf-8\")\n        ps_text = (REPO_ROOT / \".github\" / \"workflows\" / \"scripts\" / \"create-release-packages.ps1\").read_text(encoding=\"utf-8\")\n        gh_release_text = (REPO_ROOT / \".github\" / \"workflows\" / \"scripts\" / \"create-github-release.sh\").read_text(encoding=\"utf-8\")\n\n        assert \".kiro/prompts\" in sh_text\n        assert \".kiro/prompts\" in ps_text\n        assert \".amazonq/prompts\" not in sh_text\n        assert \".amazonq/prompts\" not in ps_text\n\n        assert \"spec-kit-template-kiro-cli-sh-\" in gh_release_text\n        assert \"spec-kit-template-kiro-cli-ps-\" in gh_release_text\n        assert \"spec-kit-template-q-sh-\" not in gh_release_text\n        assert \"spec-kit-template-q-ps-\" not in gh_release_text\n\n    def test_agent_context_scripts_use_kiro_cli(self):\n        \"\"\"Agent context scripts should advertise kiro-cli and not legacy q agent key.\"\"\"\n        bash_text = (REPO_ROOT / \"scripts\" / \"bash\" / \"update-agent-context.sh\").read_text(encoding=\"utf-8\")\n        pwsh_text = (REPO_ROOT / \"scripts\" / \"powershell\" / \"update-agent-context.ps1\").read_text(encoding=\"utf-8\")\n\n        assert \"kiro-cli\" in bash_text\n        assert \"kiro-cli\" in pwsh_text\n        assert \"Amazon Q Developer CLI\" not in bash_text\n        assert \"Amazon Q Developer CLI\" not in pwsh_text\n\n    # --- Tabnine CLI consistency checks ---\n\n    def test_runtime_config_includes_tabnine(self):\n        \"\"\"AGENT_CONFIG should include tabnine with correct folder and subdir.\"\"\"\n        assert \"tabnine\" in AGENT_CONFIG\n        assert AGENT_CONFIG[\"tabnine\"][\"folder\"] == \".tabnine/agent/\"\n        assert AGENT_CONFIG[\"tabnine\"][\"commands_subdir\"] == \"commands\"\n        assert AGENT_CONFIG[\"tabnine\"][\"requires_cli\"] is True\n        assert AGENT_CONFIG[\"tabnine\"][\"install_url\"] is not None\n\n    def test_extension_registrar_includes_tabnine(self):\n        \"\"\"CommandRegistrar.AGENT_CONFIGS should include tabnine with correct TOML config.\"\"\"\n        from specify_cli.extensions import CommandRegistrar\n\n        assert \"tabnine\" in CommandRegistrar.AGENT_CONFIGS\n        cfg = CommandRegistrar.AGENT_CONFIGS[\"tabnine\"]\n        assert cfg[\"dir\"] == \".tabnine/agent/commands\"\n        assert cfg[\"format\"] == \"toml\"\n        assert cfg[\"args\"] == \"{{args}}\"\n        assert cfg[\"extension\"] == \".toml\"\n\n    def test_release_agent_lists_include_tabnine(self):\n        \"\"\"Bash and PowerShell release scripts should include tabnine in agent lists.\"\"\"\n        sh_text = (REPO_ROOT / \".github\" / \"workflows\" / \"scripts\" / \"create-release-packages.sh\").read_text(encoding=\"utf-8\")\n        ps_text = (REPO_ROOT / \".github\" / \"workflows\" / \"scripts\" / \"create-release-packages.ps1\").read_text(encoding=\"utf-8\")\n\n        sh_match = re.search(r\"ALL_AGENTS=\\(([^)]*)\\)\", sh_text)\n        assert sh_match is not None\n        sh_agents = sh_match.group(1).split()\n\n        ps_match = re.search(r\"\\$AllAgents = @\\(([^)]*)\\)\", ps_text)\n        assert ps_match is not None\n        ps_agents = re.findall(r\"'([^']+)'\", ps_match.group(1))\n\n        assert \"tabnine\" in sh_agents\n        assert \"tabnine\" in ps_agents\n\n    def test_release_scripts_generate_tabnine_toml_commands(self):\n        \"\"\"Release scripts should generate TOML commands for tabnine in .tabnine/agent/commands.\"\"\"\n        sh_text = (REPO_ROOT / \".github\" / \"workflows\" / \"scripts\" / \"create-release-packages.sh\").read_text(encoding=\"utf-8\")\n        ps_text = (REPO_ROOT / \".github\" / \"workflows\" / \"scripts\" / \"create-release-packages.ps1\").read_text(encoding=\"utf-8\")\n\n        assert \".tabnine/agent/commands\" in sh_text\n        assert \".tabnine/agent/commands\" in ps_text\n        assert re.search(r\"'tabnine'\\s*\\{.*?\\.tabnine/agent/commands\", ps_text, re.S) is not None\n\n    def test_github_release_includes_tabnine_packages(self):\n        \"\"\"GitHub release script should include tabnine template packages.\"\"\"\n        gh_release_text = (REPO_ROOT / \".github\" / \"workflows\" / \"scripts\" / \"create-github-release.sh\").read_text(encoding=\"utf-8\")\n\n        assert \"spec-kit-template-tabnine-sh-\" in gh_release_text\n        assert \"spec-kit-template-tabnine-ps-\" in gh_release_text\n\n    def test_agent_context_scripts_include_tabnine(self):\n        \"\"\"Agent context scripts should support tabnine agent type.\"\"\"\n        bash_text = (REPO_ROOT / \"scripts\" / \"bash\" / \"update-agent-context.sh\").read_text(encoding=\"utf-8\")\n        pwsh_text = (REPO_ROOT / \"scripts\" / \"powershell\" / \"update-agent-context.ps1\").read_text(encoding=\"utf-8\")\n\n        assert \"tabnine\" in bash_text\n        assert \"TABNINE_FILE\" in bash_text\n        assert \"tabnine\" in pwsh_text\n        assert \"TABNINE_FILE\" in pwsh_text\n\n    def test_ai_help_includes_tabnine(self):\n        \"\"\"CLI help text for --ai should include tabnine.\"\"\"\n        assert \"tabnine\" in AI_ASSISTANT_HELP\n\n    # --- Kimi Code CLI consistency checks ---\n\n    def test_kimi_in_agent_config(self):\n        \"\"\"AGENT_CONFIG should include kimi with correct folder and commands_subdir.\"\"\"\n        assert \"kimi\" in AGENT_CONFIG\n        assert AGENT_CONFIG[\"kimi\"][\"folder\"] == \".kimi/\"\n        assert AGENT_CONFIG[\"kimi\"][\"commands_subdir\"] == \"skills\"\n        assert AGENT_CONFIG[\"kimi\"][\"requires_cli\"] is True\n\n    def test_kimi_in_extension_registrar(self):\n        \"\"\"Extension command registrar should include kimi using .kimi/skills and SKILL.md.\"\"\"\n        cfg = CommandRegistrar.AGENT_CONFIGS\n\n        assert \"kimi\" in cfg\n        kimi_cfg = cfg[\"kimi\"]\n        assert kimi_cfg[\"dir\"] == \".kimi/skills\"\n        assert kimi_cfg[\"extension\"] == \"/SKILL.md\"\n\n    def test_kimi_in_release_agent_lists(self):\n        \"\"\"Bash and PowerShell release scripts should include kimi in agent lists.\"\"\"\n        sh_text = (REPO_ROOT / \".github\" / \"workflows\" / \"scripts\" / \"create-release-packages.sh\").read_text(encoding=\"utf-8\")\n        ps_text = (REPO_ROOT / \".github\" / \"workflows\" / \"scripts\" / \"create-release-packages.ps1\").read_text(encoding=\"utf-8\")\n\n        sh_match = re.search(r\"ALL_AGENTS=\\(([^)]*)\\)\", sh_text)\n        assert sh_match is not None\n        sh_agents = sh_match.group(1).split()\n\n        ps_match = re.search(r\"\\$AllAgents = @\\(([^)]*)\\)\", ps_text)\n        assert ps_match is not None\n        ps_agents = re.findall(r\"'([^']+)'\", ps_match.group(1))\n\n        assert \"kimi\" in sh_agents\n        assert \"kimi\" in ps_agents\n\n    def test_kimi_in_powershell_validate_set(self):\n        \"\"\"PowerShell update-agent-context script should include 'kimi' in ValidateSet.\"\"\"\n        ps_text = (REPO_ROOT / \"scripts\" / \"powershell\" / \"update-agent-context.ps1\").read_text(encoding=\"utf-8\")\n\n        validate_set_match = re.search(r\"\\[ValidateSet\\(([^)]*)\\)\\]\", ps_text)\n        assert validate_set_match is not None\n        validate_set_values = re.findall(r\"'([^']+)'\", validate_set_match.group(1))\n\n        assert \"kimi\" in validate_set_values\n\n    def test_kimi_in_github_release_output(self):\n        \"\"\"GitHub release script should include kimi template packages.\"\"\"\n        gh_release_text = (REPO_ROOT / \".github\" / \"workflows\" / \"scripts\" / \"create-github-release.sh\").read_text(encoding=\"utf-8\")\n\n        assert \"spec-kit-template-kimi-sh-\" in gh_release_text\n        assert \"spec-kit-template-kimi-ps-\" in gh_release_text\n\n    def test_ai_help_includes_kimi(self):\n        \"\"\"CLI help text for --ai should include kimi.\"\"\"\n        assert \"kimi\" in AI_ASSISTANT_HELP\n\n    # --- Trae IDE consistency checks ---\n\n    def test_trae_in_agent_config(self):\n        \"\"\"AGENT_CONFIG should include trae with correct folder and commands_subdir.\"\"\"\n        assert \"trae\" in AGENT_CONFIG\n        assert AGENT_CONFIG[\"trae\"][\"folder\"] == \".trae/\"\n        assert AGENT_CONFIG[\"trae\"][\"commands_subdir\"] == \"rules\"\n        assert AGENT_CONFIG[\"trae\"][\"requires_cli\"] is False\n        assert AGENT_CONFIG[\"trae\"][\"install_url\"] is None\n\n    def test_trae_in_extension_registrar(self):\n        \"\"\"Extension command registrar should include trae using .trae/rules and markdown, if present.\"\"\"\n        cfg = CommandRegistrar.AGENT_CONFIGS\n\n        assert \"trae\" in cfg\n        trae_cfg = cfg[\"trae\"]\n        assert trae_cfg[\"format\"] == \"markdown\"\n        assert trae_cfg[\"args\"] == \"$ARGUMENTS\"\n        assert trae_cfg[\"extension\"] == \".md\"\n\n    def test_trae_in_release_agent_lists(self):\n        \"\"\"Bash and PowerShell release scripts should include trae in agent lists.\"\"\"\n        sh_text = (REPO_ROOT / \".github\" / \"workflows\" / \"scripts\" / \"create-release-packages.sh\").read_text(encoding=\"utf-8\")\n        ps_text = (REPO_ROOT / \".github\" / \"workflows\" / \"scripts\" / \"create-release-packages.ps1\").read_text(encoding=\"utf-8\")\n\n        sh_match = re.search(r\"ALL_AGENTS=\\(([^)]*)\\)\", sh_text)\n        assert sh_match is not None\n        sh_agents = sh_match.group(1).split()\n\n        ps_match = re.search(r\"\\$AllAgents = @\\(([^)]*)\\)\", ps_text)\n        assert ps_match is not None\n        ps_agents = re.findall(r\"'([^']+)'\", ps_match.group(1))\n\n        assert \"trae\" in sh_agents\n        assert \"trae\" in ps_agents\n\n    def test_trae_in_release_scripts_generate_commands(self):\n        \"\"\"Release scripts should generate markdown commands for trae in .trae/rules.\"\"\"\n        sh_text = (REPO_ROOT / \".github\" / \"workflows\" / \"scripts\" / \"create-release-packages.sh\").read_text(encoding=\"utf-8\")\n        ps_text = (REPO_ROOT / \".github\" / \"workflows\" / \"scripts\" / \"create-release-packages.ps1\").read_text(encoding=\"utf-8\")\n\n        assert \".trae/rules\" in sh_text\n        assert \".trae/rules\" in ps_text\n        assert re.search(r\"'trae'\\s*\\{.*?\\.trae/rules\", ps_text, re.S) is not None\n\n    def test_trae_in_github_release_output(self):\n        \"\"\"GitHub release script should include trae template packages.\"\"\"\n        gh_release_text = (REPO_ROOT / \".github\" / \"workflows\" / \"scripts\" / \"create-github-release.sh\").read_text(encoding=\"utf-8\")\n\n        assert \"spec-kit-template-trae-sh-\" in gh_release_text\n        assert \"spec-kit-template-trae-ps-\" in gh_release_text\n\n    def test_trae_in_agent_context_scripts(self):\n        \"\"\"Agent context scripts should support trae agent type.\"\"\"\n        bash_text = (REPO_ROOT / \"scripts\" / \"bash\" / \"update-agent-context.sh\").read_text(encoding=\"utf-8\")\n        pwsh_text = (REPO_ROOT / \"scripts\" / \"powershell\" / \"update-agent-context.ps1\").read_text(encoding=\"utf-8\")\n\n        assert \"trae\" in bash_text\n        assert \"TRAE_FILE\" in bash_text\n        assert \"trae\" in pwsh_text\n        assert \"TRAE_FILE\" in pwsh_text\n\n    def test_trae_in_powershell_validate_set(self):\n        \"\"\"PowerShell update-agent-context script should include 'trae' in ValidateSet.\"\"\"\n        ps_text = (REPO_ROOT / \"scripts\" / \"powershell\" / \"update-agent-context.ps1\").read_text(encoding=\"utf-8\")\n\n        validate_set_match = re.search(r\"\\[ValidateSet\\(([^)]*)\\)\\]\", ps_text)\n        assert validate_set_match is not None\n        validate_set_values = re.findall(r\"'([^']+)'\", validate_set_match.group(1))\n\n        assert \"trae\" in validate_set_values\n\n    def test_ai_help_includes_trae(self):\n        \"\"\"CLI help text for --ai should include trae.\"\"\"\n        assert \"trae\" in AI_ASSISTANT_HELP\n\n    # --- Pi Coding Agent consistency checks ---\n\n    def test_pi_in_agent_config(self):\n        \"\"\"AGENT_CONFIG should include pi with correct folder and commands_subdir.\"\"\"\n        assert \"pi\" in AGENT_CONFIG\n        assert AGENT_CONFIG[\"pi\"][\"folder\"] == \".pi/\"\n        assert AGENT_CONFIG[\"pi\"][\"commands_subdir\"] == \"prompts\"\n        assert AGENT_CONFIG[\"pi\"][\"requires_cli\"] is True\n        assert AGENT_CONFIG[\"pi\"][\"install_url\"] is not None\n\n    def test_pi_in_extension_registrar(self):\n        \"\"\"Extension command registrar should include pi using .pi/prompts.\"\"\"\n        cfg = CommandRegistrar.AGENT_CONFIGS\n\n        assert \"pi\" in cfg\n        pi_cfg = cfg[\"pi\"]\n        assert pi_cfg[\"dir\"] == \".pi/prompts\"\n        assert pi_cfg[\"format\"] == \"markdown\"\n        assert pi_cfg[\"args\"] == \"$ARGUMENTS\"\n        assert pi_cfg[\"extension\"] == \".md\"\n\n    def test_pi_in_release_agent_lists(self):\n        \"\"\"Bash and PowerShell release scripts should include pi in agent lists.\"\"\"\n        sh_text = (REPO_ROOT / \".github\" / \"workflows\" / \"scripts\" / \"create-release-packages.sh\").read_text(encoding=\"utf-8\")\n        ps_text = (REPO_ROOT / \".github\" / \"workflows\" / \"scripts\" / \"create-release-packages.ps1\").read_text(encoding=\"utf-8\")\n\n        sh_match = re.search(r\"ALL_AGENTS=\\(([^)]*)\\)\", sh_text)\n        assert sh_match is not None\n        sh_agents = sh_match.group(1).split()\n\n        ps_match = re.search(r\"\\$AllAgents = @\\(([^)]*)\\)\", ps_text)\n        assert ps_match is not None\n        ps_agents = re.findall(r\"'([^']+)'\", ps_match.group(1))\n\n        assert \"pi\" in sh_agents\n        assert \"pi\" in ps_agents\n\n    def test_release_scripts_generate_pi_prompt_templates(self):\n        \"\"\"Release scripts should generate Markdown prompt templates for pi in .pi/prompts.\"\"\"\n        sh_text = (REPO_ROOT / \".github\" / \"workflows\" / \"scripts\" / \"create-release-packages.sh\").read_text(encoding=\"utf-8\")\n        ps_text = (REPO_ROOT / \".github\" / \"workflows\" / \"scripts\" / \"create-release-packages.ps1\").read_text(encoding=\"utf-8\")\n\n        assert \".pi/prompts\" in sh_text\n        assert \".pi/prompts\" in ps_text\n        assert re.search(r\"pi\\)\\s*\\n.*?\\.pi/prompts\", sh_text, re.S) is not None\n        assert re.search(r\"'pi'\\s*\\{.*?\\.pi/prompts\", ps_text, re.S) is not None\n\n    def test_pi_in_powershell_validate_set(self):\n        \"\"\"PowerShell update-agent-context script should include 'pi' in ValidateSet.\"\"\"\n        ps_text = (REPO_ROOT / \"scripts\" / \"powershell\" / \"update-agent-context.ps1\").read_text(encoding=\"utf-8\")\n\n        validate_set_match = re.search(r\"\\[ValidateSet\\(([^)]*)\\)\\]\", ps_text)\n        assert validate_set_match is not None\n        validate_set_values = re.findall(r\"'([^']+)'\", validate_set_match.group(1))\n\n        assert \"pi\" in validate_set_values\n\n    def test_pi_in_github_release_output(self):\n        \"\"\"GitHub release script should include pi template packages.\"\"\"\n        gh_release_text = (REPO_ROOT / \".github\" / \"workflows\" / \"scripts\" / \"create-github-release.sh\").read_text(encoding=\"utf-8\")\n\n        assert \"spec-kit-template-pi-sh-\" in gh_release_text\n        assert \"spec-kit-template-pi-ps-\" in gh_release_text\n\n    def test_agent_context_scripts_include_pi(self):\n        \"\"\"Agent context scripts should support pi agent type.\"\"\"\n        bash_text = (REPO_ROOT / \"scripts\" / \"bash\" / \"update-agent-context.sh\").read_text(encoding=\"utf-8\")\n        pwsh_text = (REPO_ROOT / \"scripts\" / \"powershell\" / \"update-agent-context.ps1\").read_text(encoding=\"utf-8\")\n\n        assert \"pi\" in bash_text\n        assert \"Pi Coding Agent\" in bash_text\n        assert \"pi\" in pwsh_text\n        assert \"Pi Coding Agent\" in pwsh_text\n\n    def test_ai_help_includes_pi(self):\n        \"\"\"CLI help text for --ai should include pi.\"\"\"\n        assert \"pi\" in AI_ASSISTANT_HELP\n\n    # --- iFlow CLI consistency checks ---\n\n    def test_iflow_in_agent_config(self):\n        \"\"\"AGENT_CONFIG should include iflow with correct folder and commands_subdir.\"\"\"\n        assert \"iflow\" in AGENT_CONFIG\n        assert AGENT_CONFIG[\"iflow\"][\"folder\"] == \".iflow/\"\n        assert AGENT_CONFIG[\"iflow\"][\"commands_subdir\"] == \"commands\"\n        assert AGENT_CONFIG[\"iflow\"][\"requires_cli\"] is True\n\n    def test_iflow_in_extension_registrar(self):\n        \"\"\"Extension command registrar should include iflow targeting .iflow/commands.\"\"\"\n        cfg = CommandRegistrar.AGENT_CONFIGS\n\n        assert \"iflow\" in cfg\n        assert cfg[\"iflow\"][\"dir\"] == \".iflow/commands\"\n        assert cfg[\"iflow\"][\"format\"] == \"markdown\"\n        assert cfg[\"iflow\"][\"args\"] == \"$ARGUMENTS\"\n\n    def test_iflow_in_release_agent_lists(self):\n        \"\"\"Bash and PowerShell release scripts should include iflow in agent lists.\"\"\"\n        sh_text = (REPO_ROOT / \".github\" / \"workflows\" / \"scripts\" / \"create-release-packages.sh\").read_text(encoding=\"utf-8\")\n        ps_text = (REPO_ROOT / \".github\" / \"workflows\" / \"scripts\" / \"create-release-packages.ps1\").read_text(encoding=\"utf-8\")\n\n        sh_match = re.search(r\"ALL_AGENTS=\\(([^)]*)\\)\", sh_text)\n        assert sh_match is not None\n        sh_agents = sh_match.group(1).split()\n\n        ps_match = re.search(r\"\\$AllAgents = @\\(([^)]*)\\)\", ps_text)\n        assert ps_match is not None\n        ps_agents = re.findall(r\"'([^']+)'\", ps_match.group(1))\n\n        assert \"iflow\" in sh_agents\n        assert \"iflow\" in ps_agents\n\n    def test_iflow_in_release_scripts_build_variant(self):\n        \"\"\"Release scripts should generate Markdown commands for iflow in .iflow/commands.\"\"\"\n        sh_text = (REPO_ROOT / \".github\" / \"workflows\" / \"scripts\" / \"create-release-packages.sh\").read_text(encoding=\"utf-8\")\n        ps_text = (REPO_ROOT / \".github\" / \"workflows\" / \"scripts\" / \"create-release-packages.ps1\").read_text(encoding=\"utf-8\")\n\n        assert \".iflow/commands\" in sh_text\n        assert \".iflow/commands\" in ps_text\n        assert re.search(r\"'iflow'\\s*\\{.*?\\.iflow/commands\", ps_text, re.S) is not None\n\n    def test_iflow_in_github_release_output(self):\n        \"\"\"GitHub release script should include iflow template packages.\"\"\"\n        gh_release_text = (REPO_ROOT / \".github\" / \"workflows\" / \"scripts\" / \"create-github-release.sh\").read_text(encoding=\"utf-8\")\n\n        assert \"spec-kit-template-iflow-sh-\" in gh_release_text\n        assert \"spec-kit-template-iflow-ps-\" in gh_release_text\n\n    def test_iflow_in_agent_context_scripts(self):\n        \"\"\"Agent context scripts should support iflow agent type.\"\"\"\n        bash_text = (REPO_ROOT / \"scripts\" / \"bash\" / \"update-agent-context.sh\").read_text(encoding=\"utf-8\")\n        pwsh_text = (REPO_ROOT / \"scripts\" / \"powershell\" / \"update-agent-context.ps1\").read_text(encoding=\"utf-8\")\n\n        assert \"iflow\" in bash_text\n        assert \"IFLOW_FILE\" in bash_text\n        assert \"iflow\" in pwsh_text\n        assert \"IFLOW_FILE\" in pwsh_text\n\n    def test_ai_help_includes_iflow(self):\n        \"\"\"CLI help text for --ai should include iflow.\"\"\"\n        assert \"iflow\" in AI_ASSISTANT_HELP\n"
  },
  {
    "path": "tests/test_ai_skills.py",
    "content": "\"\"\"\nUnit tests for AI agent skills installation.\n\nTests cover:\n- Skills directory resolution for different agents (_get_skills_dir)\n- YAML frontmatter parsing and SKILL.md generation (install_ai_skills)\n- Cleanup of duplicate command files when --ai-skills is used\n- Missing templates directory handling\n- Malformed template error handling\n- CLI validation: --ai-skills requires --ai\n\"\"\"\n\nimport re\nimport pytest\nimport tempfile\nimport shutil\nimport yaml\nfrom pathlib import Path\nfrom unittest.mock import patch\n\nimport specify_cli\n\nfrom specify_cli import (\n    _get_skills_dir,\n    install_ai_skills,\n    AGENT_SKILLS_DIR_OVERRIDES,\n    DEFAULT_SKILLS_DIR,\n    SKILL_DESCRIPTIONS,\n    AGENT_CONFIG,\n    app,\n)\n\n\n# ===== Fixtures =====\n\n@pytest.fixture\ndef temp_dir():\n    \"\"\"Create a temporary directory for tests.\"\"\"\n    tmpdir = tempfile.mkdtemp()\n    yield Path(tmpdir)\n    shutil.rmtree(tmpdir)\n\n\n@pytest.fixture\ndef project_dir(temp_dir):\n    \"\"\"Create a mock project directory.\"\"\"\n    proj_dir = temp_dir / \"test-project\"\n    proj_dir.mkdir()\n    return proj_dir\n\n\n@pytest.fixture\ndef templates_dir(project_dir):\n    \"\"\"Create mock command templates in the project's agent commands directory.\n\n    This simulates what download_and_extract_template() does: it places\n    command .md files into project_path/<agent_folder>/commands/.\n    install_ai_skills() now reads from here instead of from the repo\n    source tree.\n    \"\"\"\n    tpl_root = project_dir / \".claude\" / \"commands\"\n    tpl_root.mkdir(parents=True, exist_ok=True)\n\n    # Template with valid YAML frontmatter\n    (tpl_root / \"speckit.specify.md\").write_text(\n        \"---\\n\"\n        \"description: Create or update the feature specification.\\n\"\n        \"handoffs:\\n\"\n        \"  - label: Build Plan\\n\"\n        \"    agent: speckit.plan\\n\"\n        \"scripts:\\n\"\n        \"  sh: scripts/bash/create-new-feature.sh\\n\"\n        \"---\\n\"\n        \"\\n\"\n        \"# Specify Command\\n\"\n        \"\\n\"\n        \"Run this to create a spec.\\n\",\n        encoding=\"utf-8\",\n    )\n\n    # Template with minimal frontmatter\n    (tpl_root / \"speckit.plan.md\").write_text(\n        \"---\\n\"\n        \"description: Generate implementation plan.\\n\"\n        \"---\\n\"\n        \"\\n\"\n        \"# Plan Command\\n\"\n        \"\\n\"\n        \"Plan body content.\\n\",\n        encoding=\"utf-8\",\n    )\n\n    # Template with no frontmatter\n    (tpl_root / \"speckit.tasks.md\").write_text(\n        \"# Tasks Command\\n\"\n        \"\\n\"\n        \"Body without frontmatter.\\n\",\n        encoding=\"utf-8\",\n    )\n\n    # Template with empty YAML frontmatter (yaml.safe_load returns None)\n    (tpl_root / \"speckit.empty_fm.md\").write_text(\n        \"---\\n\"\n        \"---\\n\"\n        \"\\n\"\n        \"# Empty Frontmatter Command\\n\"\n        \"\\n\"\n        \"Body with empty frontmatter.\\n\",\n        encoding=\"utf-8\",\n    )\n\n    return tpl_root\n\n\n@pytest.fixture\ndef commands_dir_claude(project_dir):\n    \"\"\"Create a populated .claude/commands directory simulating template extraction.\"\"\"\n    cmd_dir = project_dir / \".claude\" / \"commands\"\n    cmd_dir.mkdir(parents=True, exist_ok=True)\n    for name in [\"speckit.specify.md\", \"speckit.plan.md\", \"speckit.tasks.md\"]:\n        (cmd_dir / name).write_text(f\"# {name}\\nContent here\\n\")\n    return cmd_dir\n\n\n@pytest.fixture\ndef commands_dir_gemini(project_dir):\n    \"\"\"Create a populated .gemini/commands directory (TOML format).\"\"\"\n    cmd_dir = project_dir / \".gemini\" / \"commands\"\n    cmd_dir.mkdir(parents=True)\n    for name in [\"speckit.specify.toml\", \"speckit.plan.toml\", \"speckit.tasks.toml\"]:\n        (cmd_dir / name).write_text(f'[command]\\nname = \"{name}\"\\n')\n    return cmd_dir\n\n\n@pytest.fixture\ndef commands_dir_qwen(project_dir):\n    \"\"\"Create a populated .qwen/commands directory (Markdown format).\"\"\"\n    cmd_dir = project_dir / \".qwen\" / \"commands\"\n    cmd_dir.mkdir(parents=True, exist_ok=True)\n    for name in [\"speckit.specify.md\", \"speckit.plan.md\", \"speckit.tasks.md\"]:\n        (cmd_dir / name).write_text(f\"# {name}\\nContent here\\n\")\n    return cmd_dir\n\n\n# ===== _get_skills_dir Tests =====\n\nclass TestGetSkillsDir:\n    \"\"\"Test the _get_skills_dir() helper function.\"\"\"\n\n    def test_claude_skills_dir(self, project_dir):\n        \"\"\"Claude should use .claude/skills/.\"\"\"\n        result = _get_skills_dir(project_dir, \"claude\")\n        assert result == project_dir / \".claude\" / \"skills\"\n\n    def test_gemini_skills_dir(self, project_dir):\n        \"\"\"Gemini should use .gemini/skills/.\"\"\"\n        result = _get_skills_dir(project_dir, \"gemini\")\n        assert result == project_dir / \".gemini\" / \"skills\"\n\n    def test_tabnine_skills_dir(self, project_dir):\n        \"\"\"Tabnine should use .tabnine/agent/skills/.\"\"\"\n        result = _get_skills_dir(project_dir, \"tabnine\")\n        assert result == project_dir / \".tabnine\" / \"agent\" / \"skills\"\n\n    def test_copilot_skills_dir(self, project_dir):\n        \"\"\"Copilot should use .github/skills/.\"\"\"\n        result = _get_skills_dir(project_dir, \"copilot\")\n        assert result == project_dir / \".github\" / \"skills\"\n\n    def test_codex_uses_override(self, project_dir):\n        \"\"\"Codex should use the AGENT_SKILLS_DIR_OVERRIDES value.\"\"\"\n        result = _get_skills_dir(project_dir, \"codex\")\n        assert result == project_dir / \".agents\" / \"skills\"\n\n    def test_cursor_agent_skills_dir(self, project_dir):\n        \"\"\"Cursor should use .cursor/skills/.\"\"\"\n        result = _get_skills_dir(project_dir, \"cursor-agent\")\n        assert result == project_dir / \".cursor\" / \"skills\"\n\n    def test_kiro_cli_skills_dir(self, project_dir):\n        \"\"\"Kiro CLI should use .kiro/skills/.\"\"\"\n        result = _get_skills_dir(project_dir, \"kiro-cli\")\n        assert result == project_dir / \".kiro\" / \"skills\"\n\n    def test_pi_skills_dir(self, project_dir):\n        \"\"\"Pi should use .pi/skills/.\"\"\"\n        result = _get_skills_dir(project_dir, \"pi\")\n        assert result == project_dir / \".pi\" / \"skills\"\n\n    def test_unknown_agent_uses_default(self, project_dir):\n        \"\"\"Unknown agents should fall back to DEFAULT_SKILLS_DIR.\"\"\"\n        result = _get_skills_dir(project_dir, \"nonexistent-agent\")\n        assert result == project_dir / DEFAULT_SKILLS_DIR\n\n    def test_all_configured_agents_resolve(self, project_dir):\n        \"\"\"Every agent in AGENT_CONFIG should resolve to a valid path.\"\"\"\n        for agent_key in AGENT_CONFIG:\n            result = _get_skills_dir(project_dir, agent_key)\n            assert result is not None\n            assert str(result).startswith(str(project_dir))\n            # Should always end with \"skills\"\n            assert result.name == \"skills\"\n\n    def test_override_takes_precedence_over_config(self, project_dir):\n        \"\"\"AGENT_SKILLS_DIR_OVERRIDES should take precedence over AGENT_CONFIG.\"\"\"\n        for agent_key in AGENT_SKILLS_DIR_OVERRIDES:\n            result = _get_skills_dir(project_dir, agent_key)\n            expected = project_dir / AGENT_SKILLS_DIR_OVERRIDES[agent_key]\n            assert result == expected\n\n\n# ===== install_ai_skills Tests =====\n\nclass TestInstallAiSkills:\n    \"\"\"Test SKILL.md generation and installation logic.\"\"\"\n\n    def test_skills_installed_with_correct_structure(self, project_dir, templates_dir):\n        \"\"\"Verify SKILL.md files have correct agentskills.io structure.\"\"\"\n        result = install_ai_skills(project_dir, \"claude\")\n\n        assert result is True\n\n        skills_dir = project_dir / \".claude\" / \"skills\"\n        assert skills_dir.exists()\n\n        # Check that skill directories were created\n        skill_dirs = sorted([d.name for d in skills_dir.iterdir() if d.is_dir()])\n        assert \"speckit-plan\" in skill_dirs\n        assert \"speckit-specify\" in skill_dirs\n        assert \"speckit-tasks\" in skill_dirs\n        assert \"speckit-empty_fm\" in skill_dirs\n\n        # Verify SKILL.md content for speckit-specify\n        skill_file = skills_dir / \"speckit-specify\" / \"SKILL.md\"\n        assert skill_file.exists()\n        content = skill_file.read_text()\n\n        # Check agentskills.io frontmatter\n        assert content.startswith(\"---\\n\")\n        assert \"name: speckit-specify\" in content\n        assert \"description:\" in content\n        assert \"compatibility:\" in content\n        assert \"metadata:\" in content\n        assert \"author: github-spec-kit\" in content\n        assert \"source: templates/commands/specify.md\" in content\n\n        # Check body content is included\n        assert \"# Speckit Specify Skill\" in content\n        assert \"Run this to create a spec.\" in content\n\n    def test_generated_skill_has_parseable_yaml(self, project_dir, templates_dir):\n        \"\"\"Generated SKILL.md should contain valid, parseable YAML frontmatter.\"\"\"\n        install_ai_skills(project_dir, \"claude\")\n\n        skill_file = project_dir / \".claude\" / \"skills\" / \"speckit-specify\" / \"SKILL.md\"\n        content = skill_file.read_text()\n\n        # Extract and parse frontmatter\n        assert content.startswith(\"---\\n\")\n        parts = content.split(\"---\", 2)\n        assert len(parts) >= 3\n        parsed = yaml.safe_load(parts[1])\n        assert isinstance(parsed, dict)\n        assert \"name\" in parsed\n        assert parsed[\"name\"] == \"speckit-specify\"\n        assert \"description\" in parsed\n\n    def test_empty_yaml_frontmatter(self, project_dir, templates_dir):\n        \"\"\"Templates with empty YAML frontmatter (---\\\\n---) should not crash.\"\"\"\n        result = install_ai_skills(project_dir, \"claude\")\n\n        assert result is True\n\n        skill_file = project_dir / \".claude\" / \"skills\" / \"speckit-empty_fm\" / \"SKILL.md\"\n        assert skill_file.exists()\n        content = skill_file.read_text()\n        assert \"name: speckit-empty_fm\" in content\n        assert \"Body with empty frontmatter.\" in content\n\n    def test_enhanced_descriptions_used_when_available(self, project_dir, templates_dir):\n        \"\"\"SKILL_DESCRIPTIONS take precedence over template frontmatter descriptions.\"\"\"\n        install_ai_skills(project_dir, \"claude\")\n\n        skill_file = project_dir / \".claude\" / \"skills\" / \"speckit-specify\" / \"SKILL.md\"\n        content = skill_file.read_text()\n\n        # Parse the generated YAML to compare the description value\n        # (yaml.safe_dump may wrap long strings across multiple lines)\n        parts = content.split(\"---\", 2)\n        parsed = yaml.safe_load(parts[1])\n\n        if \"specify\" in SKILL_DESCRIPTIONS:\n            assert parsed[\"description\"] == SKILL_DESCRIPTIONS[\"specify\"]\n\n    def test_template_without_frontmatter(self, project_dir, templates_dir):\n        \"\"\"Templates without YAML frontmatter should still produce valid skills.\"\"\"\n        install_ai_skills(project_dir, \"claude\")\n\n        skill_file = project_dir / \".claude\" / \"skills\" / \"speckit-tasks\" / \"SKILL.md\"\n        assert skill_file.exists()\n        content = skill_file.read_text()\n\n        # Should still have valid SKILL.md structure\n        assert \"name: speckit-tasks\" in content\n        assert \"Body without frontmatter.\" in content\n\n    def test_missing_templates_directory(self, project_dir):\n        \"\"\"Returns False when no command templates exist anywhere.\"\"\"\n        # No .claude/commands/ exists, and __file__ fallback won't find anything\n        fake_init = project_dir / \"nonexistent\" / \"src\" / \"specify_cli\" / \"__init__.py\"\n        fake_init.parent.mkdir(parents=True, exist_ok=True)\n        fake_init.touch()\n\n        with patch.object(specify_cli, \"__file__\", str(fake_init)):\n            result = install_ai_skills(project_dir, \"claude\")\n\n        assert result is False\n\n        # Skills directory should not exist\n        skills_dir = project_dir / \".claude\" / \"skills\"\n        assert not skills_dir.exists()\n\n    def test_empty_templates_directory(self, project_dir):\n        \"\"\"Returns False when commands directory has no .md files.\"\"\"\n        # Create empty .claude/commands/\n        empty_cmds = project_dir / \".claude\" / \"commands\"\n        empty_cmds.mkdir(parents=True)\n\n        # Block the __file__ fallback so it can't find real templates\n        fake_init = project_dir / \"nowhere\" / \"src\" / \"specify_cli\" / \"__init__.py\"\n        fake_init.parent.mkdir(parents=True, exist_ok=True)\n        fake_init.touch()\n\n        with patch.object(specify_cli, \"__file__\", str(fake_init)):\n            result = install_ai_skills(project_dir, \"claude\")\n\n        assert result is False\n\n    def test_malformed_yaml_frontmatter(self, project_dir):\n        \"\"\"Malformed YAML in a template should be handled gracefully, not crash.\"\"\"\n        # Create .claude/commands/ with a broken template\n        cmds_dir = project_dir / \".claude\" / \"commands\"\n        cmds_dir.mkdir(parents=True)\n\n        (cmds_dir / \"speckit.broken.md\").write_text(\n            \"---\\n\"\n            \"description: [unclosed bracket\\n\"\n            \"  invalid: yaml: content: here\\n\"\n            \"---\\n\"\n            \"\\n\"\n            \"# Broken\\n\",\n            encoding=\"utf-8\",\n        )\n\n        # Should not raise — errors are caught per-file\n        result = install_ai_skills(project_dir, \"claude\")\n\n        # The broken template should be skipped but not crash the process\n        assert result is False\n\n    def test_additive_does_not_overwrite_other_files(self, project_dir, templates_dir):\n        \"\"\"Installing skills should not remove non-speckit files in the skills dir.\"\"\"\n        # Pre-create a custom skill\n        custom_dir = project_dir / \".claude\" / \"skills\" / \"my-custom-skill\"\n        custom_dir.mkdir(parents=True)\n        custom_file = custom_dir / \"SKILL.md\"\n        custom_file.write_text(\"# My Custom Skill\\n\")\n\n        install_ai_skills(project_dir, \"claude\")\n\n        # Custom skill should still exist\n        assert custom_file.exists()\n        assert custom_file.read_text() == \"# My Custom Skill\\n\"\n\n    def test_return_value(self, project_dir, templates_dir):\n        \"\"\"install_ai_skills returns True when skills installed, False otherwise.\"\"\"\n        assert install_ai_skills(project_dir, \"claude\") is True\n\n    def test_return_false_when_no_templates(self, project_dir):\n        \"\"\"install_ai_skills returns False when no templates found.\"\"\"\n        fake_init = project_dir / \"missing\" / \"src\" / \"specify_cli\" / \"__init__.py\"\n        fake_init.parent.mkdir(parents=True, exist_ok=True)\n        fake_init.touch()\n\n        with patch.object(specify_cli, \"__file__\", str(fake_init)):\n            assert install_ai_skills(project_dir, \"claude\") is False\n\n    def test_non_md_commands_dir_falls_back(self, project_dir):\n        \"\"\"When extracted commands are .toml (e.g. gemini), fall back to repo templates.\"\"\"\n        # Simulate gemini template extraction: .gemini/commands/ with .toml files only\n        cmds_dir = project_dir / \".gemini\" / \"commands\"\n        cmds_dir.mkdir(parents=True)\n        (cmds_dir / \"speckit.specify.toml\").write_text('[command]\\nname = \"specify\"\\n')\n        (cmds_dir / \"speckit.plan.toml\").write_text('[command]\\nname = \"plan\"\\n')\n\n        # The __file__ fallback should find the real repo templates/commands/*.md\n        result = install_ai_skills(project_dir, \"gemini\")\n\n        assert result is True\n        skills_dir = project_dir / \".gemini\" / \"skills\"\n        assert skills_dir.exists()\n        # Should have installed skills from the fallback .md templates\n        skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]\n        assert len(skill_dirs) >= 1\n        # .toml commands should be untouched\n        assert (cmds_dir / \"speckit.specify.toml\").exists()\n\n    def test_qwen_md_commands_dir_installs_skills(self, project_dir):\n        \"\"\"Qwen now uses Markdown format; skills should install directly from .qwen/commands/.\"\"\"\n        cmds_dir = project_dir / \".qwen\" / \"commands\"\n        cmds_dir.mkdir(parents=True)\n        (cmds_dir / \"speckit.specify.md\").write_text(\n            \"---\\ndescription: Create or update the feature specification.\\n---\\n\\n# Specify\\n\\nBody.\\n\"\n        )\n        (cmds_dir / \"speckit.plan.md\").write_text(\n            \"---\\ndescription: Generate implementation plan.\\n---\\n\\n# Plan\\n\\nBody.\\n\"\n        )\n\n        result = install_ai_skills(project_dir, \"qwen\")\n\n        assert result is True\n        skills_dir = project_dir / \".qwen\" / \"skills\"\n        assert skills_dir.exists()\n        skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]\n        assert len(skill_dirs) >= 1\n        # .md commands should be untouched\n        assert (cmds_dir / \"speckit.specify.md\").exists()\n        assert (cmds_dir / \"speckit.plan.md\").exists()\n\n    def test_pi_prompt_dir_installs_skills(self, project_dir):\n        \"\"\"Pi should install skills directly from .pi/prompts/.\"\"\"\n        prompts_dir = project_dir / \".pi\" / \"prompts\"\n        prompts_dir.mkdir(parents=True)\n        (prompts_dir / \"speckit.specify.md\").write_text(\n            \"---\\ndescription: Create or update the feature specification.\\n---\\n\\n# Specify\\n\\nBody.\\n\"\n        )\n        (prompts_dir / \"speckit.plan.md\").write_text(\n            \"---\\ndescription: Generate implementation plan.\\n---\\n\\n# Plan\\n\\nBody.\\n\"\n        )\n\n        result = install_ai_skills(project_dir, \"pi\")\n\n        assert result is True\n        skills_dir = project_dir / \".pi\" / \"skills\"\n        assert skills_dir.exists()\n        skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]\n        assert len(skill_dirs) >= 1\n        assert (prompts_dir / \"speckit.specify.md\").exists()\n        assert (prompts_dir / \"speckit.plan.md\").exists()\n\n    @pytest.mark.parametrize(\"agent_key\", [k for k in AGENT_CONFIG.keys() if k != \"generic\"])\n    def test_skills_install_for_all_agents(self, temp_dir, agent_key):\n        \"\"\"install_ai_skills should produce skills for every configured agent.\"\"\"\n        proj = temp_dir / f\"proj-{agent_key}\"\n        proj.mkdir()\n\n        # Place .md templates in the agent's commands directory\n        agent_folder = AGENT_CONFIG[agent_key][\"folder\"]\n        commands_subdir = AGENT_CONFIG[agent_key].get(\"commands_subdir\", \"commands\")\n        cmds_dir = proj / agent_folder.rstrip(\"/\") / commands_subdir\n        cmds_dir.mkdir(parents=True)\n        # Copilot uses speckit.*.agent.md templates; other agents use speckit.*.md\n        fname = \"speckit.specify.agent.md\" if agent_key == \"copilot\" else \"speckit.specify.md\"\n        (cmds_dir / fname).write_text(\n            \"---\\ndescription: Test command\\n---\\n\\n# Test\\n\\nBody.\\n\"\n        )\n\n        result = install_ai_skills(proj, agent_key)\n\n        assert result is True\n        skills_dir = _get_skills_dir(proj, agent_key)\n        assert skills_dir.exists()\n        skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]\n        # Kimi uses dotted skill names; other agents use hyphen-separated names.\n        expected_skill_name = \"speckit.specify\" if agent_key == \"kimi\" else \"speckit-specify\"\n        assert expected_skill_name in skill_dirs\n        assert (skills_dir / expected_skill_name / \"SKILL.md\").exists()\n\n    def test_copilot_ignores_non_speckit_agents(self, project_dir):\n        \"\"\"Non-speckit markdown in .github/agents/ must not produce skills.\"\"\"\n        agents_dir = project_dir / \".github\" / \"agents\"\n        agents_dir.mkdir(parents=True, exist_ok=True)\n        (agents_dir / \"speckit.plan.agent.md\").write_text(\n            \"---\\ndescription: Generate implementation plan.\\n---\\n\\n# Plan\\n\\nBody.\\n\"\n        )\n        (agents_dir / \"my-custom-agent.agent.md\").write_text(\n            \"---\\ndescription: A user custom agent\\n---\\n\\n# Custom\\n\\nBody.\\n\"\n        )\n\n        result = install_ai_skills(project_dir, \"copilot\")\n\n        assert result is True\n        skills_dir = _get_skills_dir(project_dir, \"copilot\")\n        assert skills_dir.exists()\n        skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]\n        assert \"speckit-plan\" in skill_dirs\n        assert \"speckit-my-custom-agent.agent\" not in skill_dirs\n        assert \"speckit-my-custom-agent\" not in skill_dirs\n\n    @pytest.mark.parametrize(\"agent_key,custom_file\", [\n        (\"claude\", \"review.md\"),\n        (\"cursor-agent\", \"deploy.md\"),\n        (\"qwen\", \"my-workflow.md\"),\n    ])\n    def test_non_speckit_commands_ignored_for_all_agents(self, temp_dir, agent_key, custom_file):\n        \"\"\"User-authored command files must not produce skills for any agent.\"\"\"\n        proj = temp_dir / f\"proj-{agent_key}\"\n        proj.mkdir()\n\n        agent_folder = AGENT_CONFIG[agent_key][\"folder\"]\n        commands_subdir = AGENT_CONFIG[agent_key].get(\"commands_subdir\", \"commands\")\n        cmds_dir = proj / agent_folder.rstrip(\"/\") / commands_subdir\n        cmds_dir.mkdir(parents=True)\n        (cmds_dir / \"speckit.specify.md\").write_text(\n            \"---\\ndescription: Create spec.\\n---\\n\\n# Specify\\n\\nBody.\\n\"\n        )\n        (cmds_dir / custom_file).write_text(\n            \"---\\ndescription: User custom command\\n---\\n\\n# Custom\\n\\nBody.\\n\"\n        )\n\n        result = install_ai_skills(proj, agent_key)\n\n        assert result is True\n        skills_dir = _get_skills_dir(proj, agent_key)\n        skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]\n        assert \"speckit-specify\" in skill_dirs\n        custom_stem = Path(custom_file).stem\n        assert f\"speckit-{custom_stem}\" not in skill_dirs\n\n    def test_copilot_fallback_when_only_non_speckit_agents(self, project_dir):\n        \"\"\"Fallback to templates/commands/ when .github/agents/ has no speckit.*.md files.\"\"\"\n        agents_dir = project_dir / \".github\" / \"agents\"\n        agents_dir.mkdir(parents=True, exist_ok=True)\n        # Only a user-authored agent, no speckit.* templates\n        (agents_dir / \"my-custom-agent.agent.md\").write_text(\n            \"---\\ndescription: A user custom agent\\n---\\n\\n# Custom\\n\\nBody.\\n\"\n        )\n\n        result = install_ai_skills(project_dir, \"copilot\")\n\n        # Should succeed via fallback to templates/commands/\n        assert result is True\n        skills_dir = _get_skills_dir(project_dir, \"copilot\")\n        assert skills_dir.exists()\n        skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]\n        # Should have skills from fallback templates, not from the custom agent\n        assert \"speckit-plan\" in skill_dirs\n        assert not any(\"my-custom\" in d for d in skill_dirs)\n\n    @pytest.mark.parametrize(\"agent_key\", [\"claude\", \"cursor-agent\", \"qwen\"])\n    def test_fallback_when_only_non_speckit_commands(self, temp_dir, agent_key):\n        \"\"\"Fallback to templates/commands/ when agent dir has no speckit.*.md files.\"\"\"\n        proj = temp_dir / f\"proj-{agent_key}\"\n        proj.mkdir()\n\n        agent_folder = AGENT_CONFIG[agent_key][\"folder\"]\n        commands_subdir = AGENT_CONFIG[agent_key].get(\"commands_subdir\", \"commands\")\n        cmds_dir = proj / agent_folder.rstrip(\"/\") / commands_subdir\n        cmds_dir.mkdir(parents=True)\n        # Only a user-authored command, no speckit.* templates\n        (cmds_dir / \"my-custom-command.md\").write_text(\n            \"---\\ndescription: User custom command\\n---\\n\\n# Custom\\n\\nBody.\\n\"\n        )\n\n        result = install_ai_skills(proj, agent_key)\n\n        # Should succeed via fallback to templates/commands/\n        assert result is True\n        skills_dir = _get_skills_dir(proj, agent_key)\n        assert skills_dir.exists()\n        skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]\n        assert not any(\"my-custom\" in d for d in skill_dirs)\n\nclass TestCommandCoexistence:\n    \"\"\"Verify install_ai_skills never touches command files.\n\n    Cleanup of freshly-extracted commands for NEW projects is handled\n    in init(), not in install_ai_skills().  These tests confirm that\n    install_ai_skills leaves existing commands intact.\n    \"\"\"\n\n    def test_existing_commands_preserved_claude(self, project_dir, templates_dir, commands_dir_claude):\n        \"\"\"install_ai_skills must NOT remove pre-existing .claude/commands files.\"\"\"\n        # Verify commands exist before (templates_dir adds 4 speckit.* files,\n        # commands_dir_claude overlaps with 3 of them)\n        before = list(commands_dir_claude.glob(\"speckit.*\"))\n        assert len(before) >= 3\n\n        install_ai_skills(project_dir, \"claude\")\n\n        # Commands must still be there — install_ai_skills never touches them\n        remaining = list(commands_dir_claude.glob(\"speckit.*\"))\n        assert len(remaining) == len(before)\n\n    def test_existing_commands_preserved_gemini(self, project_dir, templates_dir, commands_dir_gemini):\n        \"\"\"install_ai_skills must NOT remove pre-existing .gemini/commands files.\"\"\"\n        assert len(list(commands_dir_gemini.glob(\"speckit.*\"))) == 3\n\n        install_ai_skills(project_dir, \"gemini\")\n\n        remaining = list(commands_dir_gemini.glob(\"speckit.*\"))\n        assert len(remaining) == 3\n\n    def test_existing_commands_preserved_qwen(self, project_dir, templates_dir, commands_dir_qwen):\n        \"\"\"install_ai_skills must NOT remove pre-existing .qwen/commands files.\"\"\"\n        assert len(list(commands_dir_qwen.glob(\"speckit.*\"))) == 3\n\n        install_ai_skills(project_dir, \"qwen\")\n\n        remaining = list(commands_dir_qwen.glob(\"speckit.*\"))\n        assert len(remaining) == 3\n\n    def test_commands_dir_not_removed(self, project_dir, templates_dir, commands_dir_claude):\n        \"\"\"install_ai_skills must not remove the commands directory.\"\"\"\n        install_ai_skills(project_dir, \"claude\")\n\n        assert commands_dir_claude.exists()\n\n    def test_no_commands_dir_no_error(self, project_dir, templates_dir):\n        \"\"\"No error when installing skills — commands dir has templates and is preserved.\"\"\"\n        result = install_ai_skills(project_dir, \"claude\")\n\n        # Should succeed since templates are in .claude/commands/ via fixture\n        assert result is True\n\n\n# ===== New-Project Command Skip Tests =====\n\nclass TestNewProjectCommandSkip:\n    \"\"\"Test that init() removes extracted commands for new projects only.\n\n    These tests run init() end-to-end via CliRunner with\n    download_and_extract_template patched to create local fixtures.\n    \"\"\"\n\n    def _fake_extract(self, agent, project_path, **_kwargs):\n        \"\"\"Simulate template extraction: create agent commands dir.\"\"\"\n        agent_cfg = AGENT_CONFIG.get(agent, {})\n        agent_folder = agent_cfg.get(\"folder\", \"\")\n        commands_subdir = agent_cfg.get(\"commands_subdir\", \"commands\")\n        if agent_folder:\n            cmds_dir = project_path / agent_folder.rstrip(\"/\") / commands_subdir\n            cmds_dir.mkdir(parents=True, exist_ok=True)\n            (cmds_dir / \"speckit.specify.md\").write_text(\"# spec\")\n\n    def test_new_project_commands_removed_after_skills_succeed(self, tmp_path):\n        \"\"\"For new projects, commands should be removed when skills succeed.\"\"\"\n        from typer.testing import CliRunner\n\n        runner = CliRunner()\n        target = tmp_path / \"new-proj\"\n\n        def fake_download(project_path, *args, **kwargs):\n            self._fake_extract(\"claude\", project_path)\n\n        with patch(\"specify_cli.download_and_extract_template\", side_effect=fake_download), \\\n             patch(\"specify_cli.ensure_executable_scripts\"), \\\n             patch(\"specify_cli.ensure_constitution_from_template\"), \\\n             patch(\"specify_cli.install_ai_skills\", return_value=True) as mock_skills, \\\n             patch(\"specify_cli.is_git_repo\", return_value=False), \\\n             patch(\"specify_cli.shutil.which\", return_value=\"/usr/bin/git\"):\n            result = runner.invoke(app, [\"init\", str(target), \"--ai\", \"claude\", \"--ai-skills\", \"--script\", \"sh\", \"--no-git\"])\n\n        assert result.exit_code == 0\n        # Skills should have been called\n        mock_skills.assert_called_once()\n\n        # Commands dir should have been removed after skills succeeded\n        cmds_dir = target / \".claude\" / \"commands\"\n        assert not cmds_dir.exists()\n\n    def test_new_project_nonstandard_commands_subdir_removed_after_skills_succeed(self, tmp_path):\n        \"\"\"For non-standard agents, configured commands_subdir should be removed on success.\"\"\"\n        from typer.testing import CliRunner\n\n        runner = CliRunner()\n        target = tmp_path / \"new-kiro-proj\"\n\n        def fake_download(project_path, *args, **kwargs):\n            self._fake_extract(\"kiro-cli\", project_path)\n\n        with patch(\"specify_cli.download_and_extract_template\", side_effect=fake_download), \\\n             patch(\"specify_cli.ensure_executable_scripts\"), \\\n             patch(\"specify_cli.ensure_constitution_from_template\"), \\\n             patch(\"specify_cli.install_ai_skills\", return_value=True) as mock_skills, \\\n             patch(\"specify_cli.is_git_repo\", return_value=False), \\\n             patch(\"specify_cli.shutil.which\", return_value=\"/usr/bin/git\"):\n            result = runner.invoke(app, [\"init\", str(target), \"--ai\", \"kiro-cli\", \"--ai-skills\", \"--script\", \"sh\", \"--no-git\"])\n\n        assert result.exit_code == 0\n        mock_skills.assert_called_once()\n\n        prompts_dir = target / \".kiro\" / \"prompts\"\n        assert not prompts_dir.exists()\n\n    def test_codex_native_skills_preserved_without_conversion(self, tmp_path):\n        \"\"\"Codex should keep bundled .agents/skills and skip install_ai_skills conversion.\"\"\"\n        from typer.testing import CliRunner\n\n        runner = CliRunner()\n        target = tmp_path / \"new-codex-proj\"\n\n        def fake_download(project_path, *args, **kwargs):\n            skill_dir = project_path / \".agents\" / \"skills\" / \"speckit-specify\"\n            skill_dir.mkdir(parents=True, exist_ok=True)\n            (skill_dir / \"SKILL.md\").write_text(\"---\\ndescription: Test skill\\n---\\n\\nBody.\\n\")\n\n        with patch(\"specify_cli.download_and_extract_template\", side_effect=fake_download), \\\n             patch(\"specify_cli.ensure_executable_scripts\"), \\\n             patch(\"specify_cli.ensure_constitution_from_template\"), \\\n             patch(\"specify_cli.install_ai_skills\") as mock_skills, \\\n             patch(\"specify_cli.is_git_repo\", return_value=False), \\\n             patch(\"specify_cli.shutil.which\", return_value=\"/usr/bin/codex\"):\n            result = runner.invoke(\n                app,\n                [\"init\", str(target), \"--ai\", \"codex\", \"--ai-skills\", \"--script\", \"sh\", \"--no-git\"],\n            )\n\n        assert result.exit_code == 0\n        mock_skills.assert_not_called()\n        assert (target / \".agents\" / \"skills\" / \"speckit-specify\" / \"SKILL.md\").exists()\n\n    def test_codex_native_skills_missing_fails_clearly(self, tmp_path):\n        \"\"\"Codex native skills init should fail if bundled skills are missing.\"\"\"\n        from typer.testing import CliRunner\n\n        runner = CliRunner()\n        target = tmp_path / \"missing-codex-skills\"\n\n        with patch(\"specify_cli.download_and_extract_template\", lambda *args, **kwargs: None), \\\n             patch(\"specify_cli.ensure_executable_scripts\"), \\\n             patch(\"specify_cli.ensure_constitution_from_template\"), \\\n             patch(\"specify_cli.install_ai_skills\") as mock_skills, \\\n             patch(\"specify_cli.is_git_repo\", return_value=False), \\\n             patch(\"specify_cli.shutil.which\", return_value=\"/usr/bin/codex\"):\n            result = runner.invoke(\n                app,\n                [\"init\", str(target), \"--ai\", \"codex\", \"--ai-skills\", \"--script\", \"sh\", \"--no-git\"],\n            )\n\n        assert result.exit_code == 1\n        mock_skills.assert_not_called()\n        assert \"Expected bundled agent skills\" in result.output\n\n    def test_codex_native_skills_ignores_non_speckit_skill_dirs(self, tmp_path):\n        \"\"\"Non-spec-kit SKILL.md files should not satisfy Codex bundled-skills validation.\"\"\"\n        from typer.testing import CliRunner\n\n        runner = CliRunner()\n        target = tmp_path / \"foreign-codex-skills\"\n\n        def fake_download(project_path, *args, **kwargs):\n            skill_dir = project_path / \".agents\" / \"skills\" / \"other-tool\"\n            skill_dir.mkdir(parents=True, exist_ok=True)\n            (skill_dir / \"SKILL.md\").write_text(\"---\\ndescription: Foreign skill\\n---\\n\\nBody.\\n\")\n\n        with patch(\"specify_cli.download_and_extract_template\", side_effect=fake_download), \\\n             patch(\"specify_cli.ensure_executable_scripts\"), \\\n             patch(\"specify_cli.ensure_constitution_from_template\"), \\\n             patch(\"specify_cli.install_ai_skills\") as mock_skills, \\\n             patch(\"specify_cli.is_git_repo\", return_value=False), \\\n             patch(\"specify_cli.shutil.which\", return_value=\"/usr/bin/codex\"):\n            result = runner.invoke(\n                app,\n                [\"init\", str(target), \"--ai\", \"codex\", \"--ai-skills\", \"--script\", \"sh\", \"--no-git\"],\n            )\n\n        assert result.exit_code == 1\n        mock_skills.assert_not_called()\n        assert \"Expected bundled agent skills\" in result.output\n\n    def test_commands_preserved_when_skills_fail(self, tmp_path):\n        \"\"\"If skills fail, commands should NOT be removed (safety net).\"\"\"\n        from typer.testing import CliRunner\n\n        runner = CliRunner()\n        target = tmp_path / \"fail-proj\"\n\n        def fake_download(project_path, *args, **kwargs):\n            self._fake_extract(\"claude\", project_path)\n\n        with patch(\"specify_cli.download_and_extract_template\", side_effect=fake_download), \\\n             patch(\"specify_cli.ensure_executable_scripts\"), \\\n             patch(\"specify_cli.ensure_constitution_from_template\"), \\\n             patch(\"specify_cli.install_ai_skills\", return_value=False), \\\n             patch(\"specify_cli.is_git_repo\", return_value=False), \\\n             patch(\"specify_cli.shutil.which\", return_value=\"/usr/bin/git\"):\n            result = runner.invoke(app, [\"init\", str(target), \"--ai\", \"claude\", \"--ai-skills\", \"--script\", \"sh\", \"--no-git\"])\n\n        assert result.exit_code == 0\n        # Commands should still exist since skills failed\n        cmds_dir = target / \".claude\" / \"commands\"\n        assert cmds_dir.exists()\n        assert (cmds_dir / \"speckit.specify.md\").exists()\n\n    def test_here_mode_commands_preserved(self, tmp_path, monkeypatch):\n        \"\"\"For --here on existing repos, commands must NOT be removed.\"\"\"\n        from typer.testing import CliRunner\n\n        runner = CliRunner()\n        # Create a mock existing project with commands already present\n        target = tmp_path / \"existing\"\n        target.mkdir()\n        agent_folder = AGENT_CONFIG[\"claude\"][\"folder\"]\n        cmds_dir = target / agent_folder.rstrip(\"/\") / \"commands\"\n        cmds_dir.mkdir(parents=True)\n        (cmds_dir / \"speckit.specify.md\").write_text(\"# spec\")\n\n        # --here uses CWD, so chdir into the target\n        monkeypatch.chdir(target)\n\n        def fake_download(project_path, *args, **kwargs):\n            pass  # commands already exist, no need to re-create\n\n        with patch(\"specify_cli.download_and_extract_template\", side_effect=fake_download), \\\n             patch(\"specify_cli.ensure_executable_scripts\"), \\\n             patch(\"specify_cli.ensure_constitution_from_template\"), \\\n             patch(\"specify_cli.install_ai_skills\", return_value=True), \\\n             patch(\"specify_cli.is_git_repo\", return_value=True), \\\n             patch(\"specify_cli.shutil.which\", return_value=\"/usr/bin/git\"):\n            result = runner.invoke(app, [\"init\", \"--here\", \"--ai\", \"claude\", \"--ai-skills\", \"--script\", \"sh\", \"--no-git\"], input=\"y\\n\")\n\n        assert result.exit_code == 0\n        # Commands must remain for --here\n        assert cmds_dir.exists()\n        assert (cmds_dir / \"speckit.specify.md\").exists()\n\n\n# ===== Skip-If-Exists Tests =====\n\nclass TestSkipIfExists:\n    \"\"\"Test that install_ai_skills does not overwrite existing SKILL.md files.\"\"\"\n\n    def test_existing_skill_not_overwritten(self, project_dir, templates_dir):\n        \"\"\"Pre-existing SKILL.md should not be replaced on re-run.\"\"\"\n        # Pre-create a custom SKILL.md for speckit-specify\n        skill_dir = project_dir / \".claude\" / \"skills\" / \"speckit-specify\"\n        skill_dir.mkdir(parents=True)\n        custom_content = \"# My Custom Specify Skill\\nUser-modified content\\n\"\n        (skill_dir / \"SKILL.md\").write_text(custom_content)\n\n        result = install_ai_skills(project_dir, \"claude\")\n\n        # The custom SKILL.md should be untouched\n        assert (skill_dir / \"SKILL.md\").read_text() == custom_content\n\n        # But other skills should still be installed\n        assert result is True\n        assert (project_dir / \".claude\" / \"skills\" / \"speckit-plan\" / \"SKILL.md\").exists()\n        assert (project_dir / \".claude\" / \"skills\" / \"speckit-tasks\" / \"SKILL.md\").exists()\n\n    def test_fresh_install_writes_all_skills(self, project_dir, templates_dir):\n        \"\"\"On first install (no pre-existing skills), all should be written.\"\"\"\n        result = install_ai_skills(project_dir, \"claude\")\n\n        assert result is True\n        skills_dir = project_dir / \".claude\" / \"skills\"\n        skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]\n        # All 4 templates should produce skills (specify, plan, tasks, empty_fm)\n        assert len(skill_dirs) == 4\n\n\n# ===== SKILL_DESCRIPTIONS Coverage Tests =====\n\nclass TestSkillDescriptions:\n    \"\"\"Test SKILL_DESCRIPTIONS constants.\"\"\"\n\n    def test_all_known_commands_have_descriptions(self):\n        \"\"\"All standard spec-kit commands should have enhanced descriptions.\"\"\"\n        expected_commands = [\n            \"specify\", \"plan\", \"tasks\", \"implement\", \"analyze\",\n            \"clarify\", \"constitution\", \"checklist\", \"taskstoissues\",\n        ]\n        for cmd in expected_commands:\n            assert cmd in SKILL_DESCRIPTIONS, f\"Missing description for '{cmd}'\"\n            assert len(SKILL_DESCRIPTIONS[cmd]) > 20, f\"Description for '{cmd}' is too short\"\n\n\n# ===== CLI Validation Tests =====\n\nclass TestCliValidation:\n    \"\"\"Test --ai-skills CLI flag validation.\"\"\"\n\n    def test_ai_skills_without_ai_fails(self):\n        \"\"\"--ai-skills without --ai should fail with exit code 1.\"\"\"\n        from typer.testing import CliRunner\n\n        runner = CliRunner()\n        result = runner.invoke(app, [\"init\", \"test-proj\", \"--ai-skills\"])\n\n        assert result.exit_code == 1\n        assert \"--ai-skills requires --ai\" in result.output\n\n    def test_ai_skills_without_ai_shows_usage(self):\n        \"\"\"Error message should include usage hint.\"\"\"\n        from typer.testing import CliRunner\n\n        runner = CliRunner()\n        result = runner.invoke(app, [\"init\", \"test-proj\", \"--ai-skills\"])\n\n        assert \"Usage:\" in result.output\n        assert \"--ai\" in result.output\n\n    def test_agy_without_ai_skills_fails(self):\n        \"\"\"--ai agy without --ai-skills should fail with exit code 1.\"\"\"\n        from typer.testing import CliRunner\n\n        runner = CliRunner()\n        result = runner.invoke(app, [\"init\", \"test-proj\", \"--ai\", \"agy\"])\n\n        assert result.exit_code == 1\n        assert \"Explicit command support was deprecated in Antigravity version 1.20.5.\" in result.output\n        assert \"--ai-skills\" in result.output\n\n    def test_codex_without_ai_skills_fails(self):\n        \"\"\"--ai codex without --ai-skills should fail with exit code 1.\"\"\"\n        from typer.testing import CliRunner\n\n        runner = CliRunner()\n        result = runner.invoke(app, [\"init\", \"test-proj\", \"--ai\", \"codex\"])\n\n        assert result.exit_code == 1\n        assert \"Custom prompt-based spec-kit initialization is deprecated for Codex CLI\" in result.output\n        assert \"--ai-skills\" in result.output\n\n    def test_interactive_agy_without_ai_skills_prompts_skills(self, monkeypatch):\n        \"\"\"Interactive selector returning agy without --ai-skills should automatically enable --ai-skills.\"\"\"\n        from typer.testing import CliRunner\n\n        # Mock select_with_arrows to simulate the user picking 'agy' for AI,\n        # and return a deterministic default for any other prompts to avoid\n        # calling the real interactive implementation.\n        def _fake_select_with_arrows(*args, **kwargs):\n            options = kwargs.get(\"options\")\n            if options is None and len(args) >= 1:\n                options = args[0]\n\n            # If the options include 'agy', simulate selecting it.\n            if isinstance(options, dict) and \"agy\" in options:\n                return \"agy\"\n            if isinstance(options, (list, tuple)) and \"agy\" in options:\n                return \"agy\"\n\n            # For any other prompt, return a deterministic, non-interactive default:\n            # pick the first option if available.\n            if isinstance(options, dict) and options:\n                return next(iter(options.keys()))\n            if isinstance(options, (list, tuple)) and options:\n                return options[0]\n\n            # If no options are provided, fall back to None (should not occur in normal use).\n            return None\n\n        monkeypatch.setattr(\"specify_cli.select_with_arrows\", _fake_select_with_arrows)\n        \n        # Mock download_and_extract_template to prevent real HTTP downloads during testing\n        monkeypatch.setattr(\"specify_cli.download_and_extract_template\", lambda *args, **kwargs: None)\n        # We need to bypass the `git init` step, wait, it has `--no-git` by default in tests maybe?\n        runner = CliRunner()\n        # Create temp dir to avoid directory already exists errors or whatever\n        with runner.isolated_filesystem():\n            result = runner.invoke(app, [\"init\", \"test-proj\", \"--no-git\"])\n\n            # Interactive selection should NOT raise the deprecation error!\n            assert result.exit_code == 0\n            assert \"Explicit command support was deprecated\" not in result.output\n\n    def test_interactive_codex_without_ai_skills_enables_skills(self, monkeypatch):\n        \"\"\"Interactive selector returning codex without --ai-skills should automatically enable --ai-skills.\"\"\"\n        from typer.testing import CliRunner\n\n        def _fake_select_with_arrows(*args, **kwargs):\n            options = kwargs.get(\"options\")\n            if options is None and len(args) >= 1:\n                options = args[0]\n\n            if isinstance(options, dict) and \"codex\" in options:\n                return \"codex\"\n            if isinstance(options, (list, tuple)) and \"codex\" in options:\n                return \"codex\"\n\n            if isinstance(options, dict) and options:\n                return next(iter(options.keys()))\n            if isinstance(options, (list, tuple)) and options:\n                return options[0]\n\n            return None\n\n        monkeypatch.setattr(\"specify_cli.select_with_arrows\", _fake_select_with_arrows)\n\n        def _fake_download(*args, **kwargs):\n            project_path = Path(args[0])\n            skill_dir = project_path / \".agents\" / \"skills\" / \"speckit-specify\"\n            skill_dir.mkdir(parents=True, exist_ok=True)\n            (skill_dir / \"SKILL.md\").write_text(\"---\\ndescription: Test skill\\n---\\n\\nBody.\\n\")\n\n        monkeypatch.setattr(\"specify_cli.download_and_extract_template\", _fake_download)\n\n        runner = CliRunner()\n        with runner.isolated_filesystem():\n            result = runner.invoke(app, [\"init\", \"test-proj\", \"--no-git\", \"--ignore-agent-tools\"])\n\n            assert result.exit_code == 0\n            assert \"Custom prompt-based spec-kit initialization is deprecated for Codex CLI\" not in result.output\n            assert \".agents/skills\" in result.output\n            assert \"$speckit-constitution\" in result.output\n            assert \"/speckit.constitution\" not in result.output\n            assert \"Optional skills that you can use for your specs\" in result.output\n\n    def test_kimi_next_steps_show_skill_invocation(self, monkeypatch):\n        \"\"\"Kimi next-steps guidance should display /skill:speckit.* usage.\"\"\"\n        from typer.testing import CliRunner\n\n        def _fake_download(*args, **kwargs):\n            project_path = Path(args[0])\n            skill_dir = project_path / \".kimi\" / \"skills\" / \"speckit.specify\"\n            skill_dir.mkdir(parents=True, exist_ok=True)\n            (skill_dir / \"SKILL.md\").write_text(\"---\\ndescription: Test skill\\n---\\n\\nBody.\\n\")\n\n        monkeypatch.setattr(\"specify_cli.download_and_extract_template\", _fake_download)\n\n        runner = CliRunner()\n        with runner.isolated_filesystem():\n            result = runner.invoke(\n                app,\n                [\"init\", \"test-proj\", \"--ai\", \"kimi\", \"--no-git\", \"--ignore-agent-tools\"],\n            )\n\n            assert result.exit_code == 0\n            assert \"/skill:speckit.constitution\" in result.output\n            assert \"/speckit.constitution\" not in result.output\n            assert \"Optional skills that you can use for your specs\" in result.output\n\n    def test_ai_skills_flag_appears_in_help(self):\n        \"\"\"--ai-skills should appear in init --help output.\"\"\"\n        from typer.testing import CliRunner\n\n        runner = CliRunner()\n        result = runner.invoke(app, [\"init\", \"--help\"])\n\n        plain = re.sub(r'\\x1b\\[[0-9;]*m', '', result.output)\n        assert \"--ai-skills\" in plain\n        assert \"agent skills\" in plain.lower()\n\n    def test_kiro_alias_normalized_to_kiro_cli(self, tmp_path):\n        \"\"\"--ai kiro should normalize to canonical kiro-cli agent key.\"\"\"\n        from typer.testing import CliRunner\n\n        runner = CliRunner()\n        target = tmp_path / \"kiro-alias-proj\"\n\n        with patch(\"specify_cli.download_and_extract_template\") as mock_download, \\\n             patch(\"specify_cli.ensure_executable_scripts\"), \\\n             patch(\"specify_cli.ensure_constitution_from_template\"), \\\n             patch(\"specify_cli.is_git_repo\", return_value=False), \\\n             patch(\"specify_cli.shutil.which\", return_value=\"/usr/bin/git\"):\n            result = runner.invoke(\n                app,\n                [\n                    \"init\",\n                    str(target),\n                    \"--ai\",\n                    \"kiro\",\n                    \"--ignore-agent-tools\",\n                    \"--script\",\n                    \"sh\",\n                    \"--no-git\",\n                ],\n            )\n\n        assert result.exit_code == 0\n        assert mock_download.called\n        # download_and_extract_template(project_path, ai_assistant, script_type, ...)\n        assert mock_download.call_args.args[1] == \"kiro-cli\"\n\n    def test_q_removed_from_agent_config(self):\n        \"\"\"Amazon Q legacy key should not remain in AGENT_CONFIG.\"\"\"\n        assert \"q\" not in AGENT_CONFIG\n        assert \"kiro-cli\" in AGENT_CONFIG\n\n\nclass TestParameterOrderingIssue:\n    \"\"\"Test fix for GitHub issue #1641: parameter ordering issues.\"\"\"\n\n    def test_ai_flag_consuming_here_flag(self):\n        \"\"\"--ai without value should not consume --here flag (issue #1641).\"\"\"\n        from typer.testing import CliRunner\n\n        runner = CliRunner()\n        # This used to fail with \"Must specify project name\" because --here was consumed by --ai\n        result = runner.invoke(app, [\"init\", \"--ai-skills\", \"--ai\", \"--here\"])\n\n        assert result.exit_code == 1\n        assert \"Invalid value for --ai\" in result.output\n        assert \"--here\" in result.output  # Should mention the invalid value\n\n    def test_ai_flag_consuming_ai_skills_flag(self):\n        \"\"\"--ai without value should not consume --ai-skills flag.\"\"\"\n        from typer.testing import CliRunner\n\n        runner = CliRunner()\n        # This should fail with helpful error about missing --ai value\n        result = runner.invoke(app, [\"init\", \"--here\", \"--ai\", \"--ai-skills\"])\n\n        assert result.exit_code == 1\n        assert \"Invalid value for --ai\" in result.output\n        assert \"--ai-skills\" in result.output  # Should mention the invalid value\n\n    def test_error_message_provides_hint(self):\n        \"\"\"Error message should provide helpful hint about missing value.\"\"\"\n        from typer.testing import CliRunner\n\n        runner = CliRunner()\n        result = runner.invoke(app, [\"init\", \"--ai\", \"--here\"])\n\n        assert result.exit_code == 1\n        assert \"Hint:\" in result.output or \"hint\" in result.output.lower()\n        assert \"forget to provide a value\" in result.output.lower()\n\n    def test_error_message_lists_available_agents(self):\n        \"\"\"Error message should list available agents.\"\"\"\n        from typer.testing import CliRunner\n\n        runner = CliRunner()\n        result = runner.invoke(app, [\"init\", \"--ai\", \"--here\"])\n\n        assert result.exit_code == 1\n        # Should mention some known agents\n        output_lower = result.output.lower()\n        assert any(agent in output_lower for agent in [\"claude\", \"copilot\", \"gemini\"])\n\n    def test_ai_commands_dir_consuming_flag(self):\n        \"\"\"--ai-commands-dir without value should not consume next flag.\"\"\"\n        from typer.testing import CliRunner\n\n        runner = CliRunner()\n        result = runner.invoke(app, [\"init\", \"myproject\", \"--ai\", \"generic\", \"--ai-commands-dir\", \"--here\"])\n\n        assert result.exit_code == 1\n        assert \"Invalid value for --ai-commands-dir\" in result.output\n        assert \"--here\" in result.output\n"
  },
  {
    "path": "tests/test_cursor_frontmatter.py",
    "content": "\"\"\"\nTests for Cursor .mdc frontmatter generation (issue #669).\n\nVerifies that update-agent-context.sh properly prepends YAML frontmatter\nto .mdc files so that Cursor IDE auto-includes the rules.\n\"\"\"\n\nimport os\nimport shutil\nimport subprocess\nimport textwrap\n\nimport pytest\n\nSCRIPT_PATH = os.path.join(\n    os.path.dirname(__file__),\n    os.pardir,\n    \"scripts\",\n    \"bash\",\n    \"update-agent-context.sh\",\n)\n\nEXPECTED_FRONTMATTER_LINES = [\n    \"---\",\n    \"description: Project Development Guidelines\",\n    'globs: [\"**/*\"]',\n    \"alwaysApply: true\",\n    \"---\",\n]\n\nrequires_git = pytest.mark.skipif(\n    shutil.which(\"git\") is None,\n    reason=\"git is not installed\",\n)\n\n\nclass TestScriptFrontmatterPattern:\n    \"\"\"Static analysis — no git required.\"\"\"\n\n    def test_create_new_has_mdc_frontmatter_logic(self):\n        \"\"\"create_new_agent_file() must contain .mdc frontmatter logic.\"\"\"\n        with open(SCRIPT_PATH, encoding=\"utf-8\") as f:\n            content = f.read()\n        assert 'if [[ \"$target_file\" == *.mdc ]]' in content\n        assert \"alwaysApply: true\" in content\n\n    def test_update_existing_has_mdc_frontmatter_logic(self):\n        \"\"\"update_existing_agent_file() must also handle .mdc frontmatter.\"\"\"\n        with open(SCRIPT_PATH, encoding=\"utf-8\") as f:\n            content = f.read()\n        # There should be two occurrences of the .mdc check — one per function\n        occurrences = content.count('if [[ \"$target_file\" == *.mdc ]]')\n        assert occurrences >= 2, (\n            f\"Expected at least 2 .mdc frontmatter checks, found {occurrences}\"\n        )\n\n    def test_powershell_script_has_mdc_frontmatter_logic(self):\n        \"\"\"PowerShell script must also handle .mdc frontmatter.\"\"\"\n        ps_path = os.path.join(\n            os.path.dirname(__file__),\n            os.pardir,\n            \"scripts\",\n            \"powershell\",\n            \"update-agent-context.ps1\",\n        )\n        with open(ps_path, encoding=\"utf-8\") as f:\n            content = f.read()\n        assert \"alwaysApply: true\" in content\n        occurrences = content.count(r\"\\.mdc$\")\n        assert occurrences >= 2, (\n            f\"Expected at least 2 .mdc frontmatter checks in PS script, found {occurrences}\"\n        )\n\n\n@requires_git\nclass TestCursorFrontmatterIntegration:\n    \"\"\"Integration tests using a real git repo.\"\"\"\n\n    @pytest.fixture\n    def git_repo(self, tmp_path):\n        \"\"\"Create a minimal git repo with the spec-kit structure.\"\"\"\n        repo = tmp_path / \"repo\"\n        repo.mkdir()\n\n        # Init git repo\n        subprocess.run(\n            [\"git\", \"init\"], cwd=str(repo), capture_output=True, check=True\n        )\n        subprocess.run(\n            [\"git\", \"config\", \"user.email\", \"test@test.com\"],\n            cwd=str(repo),\n            capture_output=True,\n            check=True,\n        )\n        subprocess.run(\n            [\"git\", \"config\", \"user.name\", \"Test\"],\n            cwd=str(repo),\n            capture_output=True,\n            check=True,\n        )\n\n        # Create .specify dir with config\n        specify_dir = repo / \".specify\"\n        specify_dir.mkdir()\n        (specify_dir / \"config.yaml\").write_text(\n            textwrap.dedent(\"\"\"\\\n                project_type: webapp\n                language: python\n                framework: fastapi\n                database: N/A\n            \"\"\")\n        )\n\n        # Create template\n        templates_dir = specify_dir / \"templates\"\n        templates_dir.mkdir()\n        (templates_dir / \"agent-file-template.md\").write_text(\n            \"# [PROJECT NAME] Development Guidelines\\n\\n\"\n            \"Auto-generated from all feature plans. Last updated: [DATE]\\n\\n\"\n            \"## Active Technologies\\n\\n\"\n            \"[EXTRACTED FROM ALL PLAN.MD FILES]\\n\\n\"\n            \"## Project Structure\\n\\n\"\n            \"[ACTUAL STRUCTURE FROM PLANS]\\n\\n\"\n            \"## Development Commands\\n\\n\"\n            \"[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES]\\n\\n\"\n            \"## Coding Conventions\\n\\n\"\n            \"[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE]\\n\\n\"\n            \"## Recent Changes\\n\\n\"\n            \"[LAST 3 FEATURES AND WHAT THEY ADDED]\\n\"\n        )\n\n        # Create initial commit\n        subprocess.run(\n            [\"git\", \"add\", \"-A\"], cwd=str(repo), capture_output=True, check=True\n        )\n        subprocess.run(\n            [\"git\", \"commit\", \"-m\", \"init\"],\n            cwd=str(repo),\n            capture_output=True,\n            check=True,\n        )\n\n        # Create a feature branch so CURRENT_BRANCH detection works\n        subprocess.run(\n            [\"git\", \"checkout\", \"-b\", \"001-test-feature\"],\n            cwd=str(repo),\n            capture_output=True,\n            check=True,\n        )\n\n        # Create a spec so the script detects the feature\n        spec_dir = repo / \"specs\" / \"001-test-feature\"\n        spec_dir.mkdir(parents=True)\n        (spec_dir / \"plan.md\").write_text(\n            \"# Test Feature Plan\\n\\n\"\n            \"## Technology Stack\\n\\n\"\n            \"- Language: Python\\n\"\n            \"- Framework: FastAPI\\n\"\n        )\n\n        return repo\n\n    def _run_update(self, repo, agent_type=\"cursor-agent\"):\n        \"\"\"Run update-agent-context.sh for a specific agent type.\"\"\"\n        script = os.path.abspath(SCRIPT_PATH)\n        result = subprocess.run(\n            [\"bash\", script, agent_type],\n            cwd=str(repo),\n            capture_output=True,\n            text=True,\n            timeout=30,\n        )\n        return result\n\n    def test_new_mdc_file_has_frontmatter(self, git_repo):\n        \"\"\"Creating a new .mdc file must include YAML frontmatter.\"\"\"\n        result = self._run_update(git_repo)\n        assert result.returncode == 0, f\"Script failed: {result.stderr}\"\n\n        mdc_file = git_repo / \".cursor\" / \"rules\" / \"specify-rules.mdc\"\n        assert mdc_file.exists(), \"Cursor .mdc file was not created\"\n\n        content = mdc_file.read_text()\n        lines = content.splitlines()\n\n        # First line must be the opening ---\n        assert lines[0] == \"---\", f\"Expected frontmatter start, got: {lines[0]}\"\n\n        # Check all frontmatter lines are present\n        for expected in EXPECTED_FRONTMATTER_LINES:\n            assert expected in content, f\"Missing frontmatter line: {expected}\"\n\n        # Content after frontmatter should be the template content\n        assert \"Development Guidelines\" in content\n\n    def test_existing_mdc_without_frontmatter_gets_it_added(self, git_repo):\n        \"\"\"Updating an existing .mdc file that lacks frontmatter must add it.\"\"\"\n        # First, create the file WITHOUT frontmatter (simulating pre-fix state)\n        cursor_dir = git_repo / \".cursor\" / \"rules\"\n        cursor_dir.mkdir(parents=True, exist_ok=True)\n        mdc_file = cursor_dir / \"specify-rules.mdc\"\n        mdc_file.write_text(\n            \"# repo Development Guidelines\\n\\n\"\n            \"Auto-generated from all feature plans. Last updated: 2025-01-01\\n\\n\"\n            \"## Active Technologies\\n\\n\"\n            \"- Python + FastAPI (main)\\n\\n\"\n            \"## Recent Changes\\n\\n\"\n            \"- main: Added Python + FastAPI\\n\"\n        )\n\n        result = self._run_update(git_repo)\n        assert result.returncode == 0, f\"Script failed: {result.stderr}\"\n\n        content = mdc_file.read_text()\n        lines = content.splitlines()\n\n        assert lines[0] == \"---\", f\"Expected frontmatter start, got: {lines[0]}\"\n        for expected in EXPECTED_FRONTMATTER_LINES:\n            assert expected in content, f\"Missing frontmatter line: {expected}\"\n\n    def test_existing_mdc_with_frontmatter_not_duplicated(self, git_repo):\n        \"\"\"Updating an .mdc file that already has frontmatter must not duplicate it.\"\"\"\n        cursor_dir = git_repo / \".cursor\" / \"rules\"\n        cursor_dir.mkdir(parents=True, exist_ok=True)\n        mdc_file = cursor_dir / \"specify-rules.mdc\"\n\n        frontmatter = (\n            \"---\\n\"\n            \"description: Project Development Guidelines\\n\"\n            'globs: [\"**/*\"]\\n'\n            \"alwaysApply: true\\n\"\n            \"---\\n\\n\"\n        )\n        body = (\n            \"# repo Development Guidelines\\n\\n\"\n            \"Auto-generated from all feature plans. Last updated: 2025-01-01\\n\\n\"\n            \"## Active Technologies\\n\\n\"\n            \"- Python + FastAPI (main)\\n\\n\"\n            \"## Recent Changes\\n\\n\"\n            \"- main: Added Python + FastAPI\\n\"\n        )\n        mdc_file.write_text(frontmatter + body)\n\n        result = self._run_update(git_repo)\n        assert result.returncode == 0, f\"Script failed: {result.stderr}\"\n\n        content = mdc_file.read_text()\n        # Count occurrences of the frontmatter delimiter\n        assert content.count(\"alwaysApply: true\") == 1, (\n            \"Frontmatter was duplicated\"\n        )\n\n    def test_non_mdc_file_has_no_frontmatter(self, git_repo):\n        \"\"\"Non-.mdc agent files (e.g., Claude) must NOT get frontmatter.\"\"\"\n        result = self._run_update(git_repo, agent_type=\"claude\")\n        assert result.returncode == 0, f\"Script failed: {result.stderr}\"\n\n        claude_file = git_repo / \".claude\" / \"CLAUDE.md\"\n        if claude_file.exists():\n            content = claude_file.read_text()\n            assert not content.startswith(\"---\"), (\n                \"Non-mdc file should not have frontmatter\"\n            )\n"
  },
  {
    "path": "tests/test_extensions.py",
    "content": "\"\"\"\nUnit tests for the extension system.\n\nTests cover:\n- Extension manifest validation\n- Extension registry operations\n- Extension manager installation/removal\n- Command registration\n- Catalog stack (multi-catalog support)\n\"\"\"\n\nimport pytest\nimport json\nimport tempfile\nimport shutil\nfrom pathlib import Path\nfrom datetime import datetime, timezone\n\nfrom specify_cli.extensions import (\n    CatalogEntry,\n    ExtensionManifest,\n    ExtensionRegistry,\n    ExtensionManager,\n    CommandRegistrar,\n    ExtensionCatalog,\n    ExtensionError,\n    ValidationError,\n    CompatibilityError,\n    normalize_priority,\n    version_satisfies,\n)\n\n\n# ===== Fixtures =====\n\n@pytest.fixture\ndef temp_dir():\n    \"\"\"Create a temporary directory for tests.\"\"\"\n    tmpdir = tempfile.mkdtemp()\n    yield Path(tmpdir)\n    shutil.rmtree(tmpdir)\n\n\n@pytest.fixture\ndef valid_manifest_data():\n    \"\"\"Valid extension manifest data.\"\"\"\n    return {\n        \"schema_version\": \"1.0\",\n        \"extension\": {\n            \"id\": \"test-ext\",\n            \"name\": \"Test Extension\",\n            \"version\": \"1.0.0\",\n            \"description\": \"A test extension\",\n            \"author\": \"Test Author\",\n            \"repository\": \"https://github.com/test/test-ext\",\n            \"license\": \"MIT\",\n        },\n        \"requires\": {\n            \"speckit_version\": \">=0.1.0\",\n            \"commands\": [\"speckit.tasks\"],\n        },\n        \"provides\": {\n            \"commands\": [\n                {\n                    \"name\": \"speckit.test.hello\",\n                    \"file\": \"commands/hello.md\",\n                    \"description\": \"Test command\",\n                }\n            ]\n        },\n        \"hooks\": {\n            \"after_tasks\": {\n                \"command\": \"speckit.test.hello\",\n                \"optional\": True,\n                \"prompt\": \"Run test?\",\n            }\n        },\n        \"tags\": [\"testing\", \"example\"],\n    }\n\n\n@pytest.fixture\ndef extension_dir(temp_dir, valid_manifest_data):\n    \"\"\"Create a complete extension directory structure.\"\"\"\n    ext_dir = temp_dir / \"test-ext\"\n    ext_dir.mkdir()\n\n    # Write manifest\n    import yaml\n    manifest_path = ext_dir / \"extension.yml\"\n    with open(manifest_path, 'w') as f:\n        yaml.dump(valid_manifest_data, f)\n\n    # Create commands directory\n    commands_dir = ext_dir / \"commands\"\n    commands_dir.mkdir()\n\n    # Write command file\n    cmd_file = commands_dir / \"hello.md\"\n    cmd_file.write_text(\"\"\"---\ndescription: \"Test hello command\"\n---\n\n# Test Hello Command\n\n$ARGUMENTS\n\"\"\")\n\n    return ext_dir\n\n\n@pytest.fixture\ndef project_dir(temp_dir):\n    \"\"\"Create a mock spec-kit project directory.\"\"\"\n    proj_dir = temp_dir / \"project\"\n    proj_dir.mkdir()\n\n    # Create .specify directory\n    specify_dir = proj_dir / \".specify\"\n    specify_dir.mkdir()\n\n    return proj_dir\n\n\n# ===== normalize_priority Tests =====\n\nclass TestNormalizePriority:\n    \"\"\"Test normalize_priority helper function.\"\"\"\n\n    def test_valid_integer(self):\n        \"\"\"Test with valid integer priority.\"\"\"\n        assert normalize_priority(5) == 5\n        assert normalize_priority(1) == 1\n        assert normalize_priority(100) == 100\n\n    def test_valid_string_number(self):\n        \"\"\"Test with string that can be converted to int.\"\"\"\n        assert normalize_priority(\"5\") == 5\n        assert normalize_priority(\"10\") == 10\n\n    def test_zero_returns_default(self):\n        \"\"\"Test that zero priority returns default.\"\"\"\n        assert normalize_priority(0) == 10\n        assert normalize_priority(0, default=5) == 5\n\n    def test_negative_returns_default(self):\n        \"\"\"Test that negative priority returns default.\"\"\"\n        assert normalize_priority(-1) == 10\n        assert normalize_priority(-100, default=5) == 5\n\n    def test_none_returns_default(self):\n        \"\"\"Test that None returns default.\"\"\"\n        assert normalize_priority(None) == 10\n        assert normalize_priority(None, default=5) == 5\n\n    def test_invalid_string_returns_default(self):\n        \"\"\"Test that non-numeric string returns default.\"\"\"\n        assert normalize_priority(\"invalid\") == 10\n        assert normalize_priority(\"abc\", default=5) == 5\n\n    def test_float_truncates(self):\n        \"\"\"Test that float is truncated to int.\"\"\"\n        assert normalize_priority(5.9) == 5\n        assert normalize_priority(3.1) == 3\n\n    def test_empty_string_returns_default(self):\n        \"\"\"Test that empty string returns default.\"\"\"\n        assert normalize_priority(\"\") == 10\n\n    def test_custom_default(self):\n        \"\"\"Test custom default value.\"\"\"\n        assert normalize_priority(None, default=20) == 20\n        assert normalize_priority(\"invalid\", default=1) == 1\n\n\n# ===== ExtensionManifest Tests =====\n\nclass TestExtensionManifest:\n    \"\"\"Test ExtensionManifest validation and parsing.\"\"\"\n\n    def test_valid_manifest(self, extension_dir):\n        \"\"\"Test loading a valid manifest.\"\"\"\n        manifest_path = extension_dir / \"extension.yml\"\n        manifest = ExtensionManifest(manifest_path)\n\n        assert manifest.id == \"test-ext\"\n        assert manifest.name == \"Test Extension\"\n        assert manifest.version == \"1.0.0\"\n        assert manifest.description == \"A test extension\"\n        assert len(manifest.commands) == 1\n        assert manifest.commands[0][\"name\"] == \"speckit.test.hello\"\n\n    def test_missing_required_field(self, temp_dir):\n        \"\"\"Test manifest missing required field.\"\"\"\n        import yaml\n\n        manifest_path = temp_dir / \"extension.yml\"\n        with open(manifest_path, 'w') as f:\n            yaml.dump({\"schema_version\": \"1.0\"}, f)  # Missing 'extension'\n\n        with pytest.raises(ValidationError, match=\"Missing required field\"):\n            ExtensionManifest(manifest_path)\n\n    def test_invalid_extension_id(self, temp_dir, valid_manifest_data):\n        \"\"\"Test manifest with invalid extension ID format.\"\"\"\n        import yaml\n\n        valid_manifest_data[\"extension\"][\"id\"] = \"Invalid_ID\"  # Uppercase not allowed\n\n        manifest_path = temp_dir / \"extension.yml\"\n        with open(manifest_path, 'w') as f:\n            yaml.dump(valid_manifest_data, f)\n\n        with pytest.raises(ValidationError, match=\"Invalid extension ID\"):\n            ExtensionManifest(manifest_path)\n\n    def test_invalid_version(self, temp_dir, valid_manifest_data):\n        \"\"\"Test manifest with invalid semantic version.\"\"\"\n        import yaml\n\n        valid_manifest_data[\"extension\"][\"version\"] = \"invalid\"\n\n        manifest_path = temp_dir / \"extension.yml\"\n        with open(manifest_path, 'w') as f:\n            yaml.dump(valid_manifest_data, f)\n\n        with pytest.raises(ValidationError, match=\"Invalid version\"):\n            ExtensionManifest(manifest_path)\n\n    def test_invalid_command_name(self, temp_dir, valid_manifest_data):\n        \"\"\"Test manifest with invalid command name format.\"\"\"\n        import yaml\n\n        valid_manifest_data[\"provides\"][\"commands\"][0][\"name\"] = \"invalid-name\"\n\n        manifest_path = temp_dir / \"extension.yml\"\n        with open(manifest_path, 'w') as f:\n            yaml.dump(valid_manifest_data, f)\n\n        with pytest.raises(ValidationError, match=\"Invalid command name\"):\n            ExtensionManifest(manifest_path)\n\n    def test_no_commands(self, temp_dir, valid_manifest_data):\n        \"\"\"Test manifest with no commands provided.\"\"\"\n        import yaml\n\n        valid_manifest_data[\"provides\"][\"commands\"] = []\n\n        manifest_path = temp_dir / \"extension.yml\"\n        with open(manifest_path, 'w') as f:\n            yaml.dump(valid_manifest_data, f)\n\n        with pytest.raises(ValidationError, match=\"must provide at least one command\"):\n            ExtensionManifest(manifest_path)\n\n    def test_manifest_hash(self, extension_dir):\n        \"\"\"Test manifest hash calculation.\"\"\"\n        manifest_path = extension_dir / \"extension.yml\"\n        manifest = ExtensionManifest(manifest_path)\n\n        hash_value = manifest.get_hash()\n        assert hash_value.startswith(\"sha256:\")\n        assert len(hash_value) > 10\n\n\n# ===== ExtensionRegistry Tests =====\n\nclass TestExtensionRegistry:\n    \"\"\"Test ExtensionRegistry operations.\"\"\"\n\n    def test_empty_registry(self, temp_dir):\n        \"\"\"Test creating a new empty registry.\"\"\"\n        extensions_dir = temp_dir / \"extensions\"\n        extensions_dir.mkdir()\n\n        registry = ExtensionRegistry(extensions_dir)\n\n        assert registry.data[\"schema_version\"] == \"1.0\"\n        assert registry.data[\"extensions\"] == {}\n        assert len(registry.list()) == 0\n\n    def test_add_extension(self, temp_dir):\n        \"\"\"Test adding an extension to registry.\"\"\"\n        extensions_dir = temp_dir / \"extensions\"\n        extensions_dir.mkdir()\n\n        registry = ExtensionRegistry(extensions_dir)\n\n        metadata = {\n            \"version\": \"1.0.0\",\n            \"source\": \"local\",\n            \"enabled\": True,\n        }\n        registry.add(\"test-ext\", metadata)\n\n        assert registry.is_installed(\"test-ext\")\n        ext_data = registry.get(\"test-ext\")\n        assert ext_data[\"version\"] == \"1.0.0\"\n        assert \"installed_at\" in ext_data\n\n    def test_remove_extension(self, temp_dir):\n        \"\"\"Test removing an extension from registry.\"\"\"\n        extensions_dir = temp_dir / \"extensions\"\n        extensions_dir.mkdir()\n\n        registry = ExtensionRegistry(extensions_dir)\n        registry.add(\"test-ext\", {\"version\": \"1.0.0\"})\n\n        assert registry.is_installed(\"test-ext\")\n\n        registry.remove(\"test-ext\")\n\n        assert not registry.is_installed(\"test-ext\")\n        assert registry.get(\"test-ext\") is None\n\n    def test_registry_persistence(self, temp_dir):\n        \"\"\"Test that registry persists to disk.\"\"\"\n        extensions_dir = temp_dir / \"extensions\"\n        extensions_dir.mkdir()\n\n        # Create registry and add extension\n        registry1 = ExtensionRegistry(extensions_dir)\n        registry1.add(\"test-ext\", {\"version\": \"1.0.0\"})\n\n        # Load new registry instance\n        registry2 = ExtensionRegistry(extensions_dir)\n\n        # Should still have the extension\n        assert registry2.is_installed(\"test-ext\")\n        assert registry2.get(\"test-ext\")[\"version\"] == \"1.0.0\"\n\n    def test_update_preserves_installed_at(self, temp_dir):\n        \"\"\"Test that update() preserves the original installed_at timestamp.\"\"\"\n        extensions_dir = temp_dir / \"extensions\"\n        extensions_dir.mkdir()\n\n        registry = ExtensionRegistry(extensions_dir)\n        registry.add(\"test-ext\", {\"version\": \"1.0.0\", \"enabled\": True})\n\n        # Get original installed_at\n        original_data = registry.get(\"test-ext\")\n        original_installed_at = original_data[\"installed_at\"]\n\n        # Update with new metadata\n        registry.update(\"test-ext\", {\"version\": \"2.0.0\", \"enabled\": False})\n\n        # Verify installed_at is preserved\n        updated_data = registry.get(\"test-ext\")\n        assert updated_data[\"installed_at\"] == original_installed_at\n        assert updated_data[\"version\"] == \"2.0.0\"\n        assert updated_data[\"enabled\"] is False\n\n    def test_update_merges_with_existing(self, temp_dir):\n        \"\"\"Test that update() merges new metadata with existing fields.\"\"\"\n        extensions_dir = temp_dir / \"extensions\"\n        extensions_dir.mkdir()\n\n        registry = ExtensionRegistry(extensions_dir)\n        registry.add(\"test-ext\", {\n            \"version\": \"1.0.0\",\n            \"enabled\": True,\n            \"registered_commands\": {\"claude\": [\"cmd1\", \"cmd2\"]},\n        })\n\n        # Update with partial metadata (only enabled field)\n        registry.update(\"test-ext\", {\"enabled\": False})\n\n        # Verify existing fields are preserved\n        updated_data = registry.get(\"test-ext\")\n        assert updated_data[\"enabled\"] is False\n        assert updated_data[\"version\"] == \"1.0.0\"  # Preserved\n        assert updated_data[\"registered_commands\"] == {\"claude\": [\"cmd1\", \"cmd2\"]}  # Preserved\n\n    def test_update_raises_for_missing_extension(self, temp_dir):\n        \"\"\"Test that update() raises KeyError for non-installed extension.\"\"\"\n        extensions_dir = temp_dir / \"extensions\"\n        extensions_dir.mkdir()\n\n        registry = ExtensionRegistry(extensions_dir)\n\n        with pytest.raises(KeyError, match=\"not installed\"):\n            registry.update(\"nonexistent-ext\", {\"enabled\": False})\n\n    def test_restore_overwrites_completely(self, temp_dir):\n        \"\"\"Test that restore() overwrites the registry entry completely.\"\"\"\n        extensions_dir = temp_dir / \"extensions\"\n        extensions_dir.mkdir()\n\n        registry = ExtensionRegistry(extensions_dir)\n        registry.add(\"test-ext\", {\"version\": \"2.0.0\", \"enabled\": True})\n\n        # Restore with complete backup data\n        backup_data = {\n            \"version\": \"1.0.0\",\n            \"enabled\": False,\n            \"installed_at\": \"2024-01-01T00:00:00+00:00\",\n            \"registered_commands\": {\"claude\": [\"old-cmd\"]},\n        }\n        registry.restore(\"test-ext\", backup_data)\n\n        # Verify entry is exactly as restored\n        restored_data = registry.get(\"test-ext\")\n        assert restored_data == backup_data\n\n    def test_restore_can_recreate_removed_entry(self, temp_dir):\n        \"\"\"Test that restore() can recreate an entry after remove().\"\"\"\n        extensions_dir = temp_dir / \"extensions\"\n        extensions_dir.mkdir()\n\n        registry = ExtensionRegistry(extensions_dir)\n        registry.add(\"test-ext\", {\"version\": \"1.0.0\"})\n\n        # Save backup and remove\n        backup = registry.get(\"test-ext\").copy()\n        registry.remove(\"test-ext\")\n        assert not registry.is_installed(\"test-ext\")\n\n        # Restore should recreate the entry\n        registry.restore(\"test-ext\", backup)\n        assert registry.is_installed(\"test-ext\")\n        assert registry.get(\"test-ext\")[\"version\"] == \"1.0.0\"\n\n    def test_restore_rejects_none_metadata(self, temp_dir):\n        \"\"\"Test restore() raises ValueError for None metadata.\"\"\"\n        extensions_dir = temp_dir / \"extensions\"\n        extensions_dir.mkdir()\n        registry = ExtensionRegistry(extensions_dir)\n\n        with pytest.raises(ValueError, match=\"metadata must be a dict\"):\n            registry.restore(\"test-ext\", None)\n\n    def test_restore_rejects_non_dict_metadata(self, temp_dir):\n        \"\"\"Test restore() raises ValueError for non-dict metadata.\"\"\"\n        extensions_dir = temp_dir / \"extensions\"\n        extensions_dir.mkdir()\n        registry = ExtensionRegistry(extensions_dir)\n\n        with pytest.raises(ValueError, match=\"metadata must be a dict\"):\n            registry.restore(\"test-ext\", \"not-a-dict\")\n\n        with pytest.raises(ValueError, match=\"metadata must be a dict\"):\n            registry.restore(\"test-ext\", [\"list\", \"not\", \"dict\"])\n\n    def test_restore_uses_deep_copy(self, temp_dir):\n        \"\"\"Test restore() deep copies metadata to prevent mutation.\"\"\"\n        extensions_dir = temp_dir / \"extensions\"\n        extensions_dir.mkdir()\n        registry = ExtensionRegistry(extensions_dir)\n\n        original_metadata = {\n            \"version\": \"1.0.0\",\n            \"nested\": {\"key\": \"original\"},\n        }\n        registry.restore(\"test-ext\", original_metadata)\n\n        # Mutate the original metadata after restore\n        original_metadata[\"version\"] = \"MUTATED\"\n        original_metadata[\"nested\"][\"key\"] = \"MUTATED\"\n\n        # Registry should have the original values\n        stored = registry.get(\"test-ext\")\n        assert stored[\"version\"] == \"1.0.0\"\n        assert stored[\"nested\"][\"key\"] == \"original\"\n\n    def test_get_returns_deep_copy(self, temp_dir):\n        \"\"\"Test that get() returns deep copies for nested structures.\"\"\"\n        extensions_dir = temp_dir / \"extensions\"\n        extensions_dir.mkdir()\n\n        registry = ExtensionRegistry(extensions_dir)\n        metadata = {\n            \"version\": \"1.0.0\",\n            \"registered_commands\": {\"claude\": [\"cmd1\"]},\n        }\n        registry.add(\"test-ext\", metadata)\n\n        fetched = registry.get(\"test-ext\")\n        fetched[\"registered_commands\"][\"claude\"].append(\"cmd2\")\n\n        # Internal registry must remain unchanged.\n        internal = registry.data[\"extensions\"][\"test-ext\"]\n        assert internal[\"registered_commands\"] == {\"claude\": [\"cmd1\"]}\n\n    def test_get_returns_none_for_corrupted_entry(self, temp_dir):\n        \"\"\"Test that get() returns None for corrupted (non-dict) entries.\"\"\"\n        extensions_dir = temp_dir / \"extensions\"\n        extensions_dir.mkdir()\n\n        registry = ExtensionRegistry(extensions_dir)\n\n        # Directly corrupt the registry with non-dict entries\n        registry.data[\"extensions\"][\"corrupted-string\"] = \"not a dict\"\n        registry.data[\"extensions\"][\"corrupted-list\"] = [\"not\", \"a\", \"dict\"]\n        registry.data[\"extensions\"][\"corrupted-int\"] = 42\n        registry._save()\n\n        # All corrupted entries should return None\n        assert registry.get(\"corrupted-string\") is None\n        assert registry.get(\"corrupted-list\") is None\n        assert registry.get(\"corrupted-int\") is None\n        # Non-existent should also return None\n        assert registry.get(\"nonexistent\") is None\n\n    def test_list_returns_deep_copy(self, temp_dir):\n        \"\"\"Test that list() returns deep copies for nested structures.\"\"\"\n        extensions_dir = temp_dir / \"extensions\"\n        extensions_dir.mkdir()\n\n        registry = ExtensionRegistry(extensions_dir)\n        metadata = {\n            \"version\": \"1.0.0\",\n            \"registered_commands\": {\"claude\": [\"cmd1\"]},\n        }\n        registry.add(\"test-ext\", metadata)\n\n        listed = registry.list()\n        listed[\"test-ext\"][\"registered_commands\"][\"claude\"].append(\"cmd2\")\n\n        # Internal registry must remain unchanged.\n        internal = registry.data[\"extensions\"][\"test-ext\"]\n        assert internal[\"registered_commands\"] == {\"claude\": [\"cmd1\"]}\n\n    def test_list_returns_empty_dict_for_corrupted_registry(self, temp_dir):\n        \"\"\"Test that list() returns empty dict when extensions is not a dict.\"\"\"\n        extensions_dir = temp_dir / \"extensions\"\n        extensions_dir.mkdir()\n        registry = ExtensionRegistry(extensions_dir)\n\n        # Corrupt the registry - extensions is a list instead of dict\n        registry.data[\"extensions\"] = [\"not\", \"a\", \"dict\"]\n        registry._save()\n\n        # list() should return empty dict, not crash\n        result = registry.list()\n        assert result == {}\n\n\n# ===== ExtensionManager Tests =====\n\nclass TestExtensionManager:\n    \"\"\"Test ExtensionManager installation and removal.\"\"\"\n\n    def test_check_compatibility_valid(self, extension_dir, project_dir):\n        \"\"\"Test compatibility check with valid version.\"\"\"\n        manager = ExtensionManager(project_dir)\n        manifest = ExtensionManifest(extension_dir / \"extension.yml\")\n\n        # Should not raise\n        result = manager.check_compatibility(manifest, \"0.1.0\")\n        assert result is True\n\n    def test_check_compatibility_invalid(self, extension_dir, project_dir):\n        \"\"\"Test compatibility check with invalid version.\"\"\"\n        manager = ExtensionManager(project_dir)\n        manifest = ExtensionManifest(extension_dir / \"extension.yml\")\n\n        # Requires >=0.1.0, but we have 0.0.1\n        with pytest.raises(CompatibilityError, match=\"Extension requires spec-kit\"):\n            manager.check_compatibility(manifest, \"0.0.1\")\n\n    def test_install_from_directory(self, extension_dir, project_dir):\n        \"\"\"Test installing extension from directory.\"\"\"\n        manager = ExtensionManager(project_dir)\n\n        manifest = manager.install_from_directory(\n            extension_dir,\n            \"0.1.0\",\n            register_commands=False  # Skip command registration for now\n        )\n\n        assert manifest.id == \"test-ext\"\n        assert manager.registry.is_installed(\"test-ext\")\n\n        # Check extension directory was copied\n        ext_dir = project_dir / \".specify\" / \"extensions\" / \"test-ext\"\n        assert ext_dir.exists()\n        assert (ext_dir / \"extension.yml\").exists()\n        assert (ext_dir / \"commands\" / \"hello.md\").exists()\n\n    def test_install_duplicate(self, extension_dir, project_dir):\n        \"\"\"Test installing already installed extension.\"\"\"\n        manager = ExtensionManager(project_dir)\n\n        # Install once\n        manager.install_from_directory(extension_dir, \"0.1.0\", register_commands=False)\n\n        # Try to install again\n        with pytest.raises(ExtensionError, match=\"already installed\"):\n            manager.install_from_directory(extension_dir, \"0.1.0\", register_commands=False)\n\n    def test_remove_extension(self, extension_dir, project_dir):\n        \"\"\"Test removing an installed extension.\"\"\"\n        manager = ExtensionManager(project_dir)\n\n        # Install extension\n        manager.install_from_directory(extension_dir, \"0.1.0\", register_commands=False)\n\n        ext_dir = project_dir / \".specify\" / \"extensions\" / \"test-ext\"\n        assert ext_dir.exists()\n\n        # Remove extension\n        result = manager.remove(\"test-ext\", keep_config=False)\n\n        assert result is True\n        assert not manager.registry.is_installed(\"test-ext\")\n        assert not ext_dir.exists()\n\n    def test_remove_nonexistent(self, project_dir):\n        \"\"\"Test removing non-existent extension.\"\"\"\n        manager = ExtensionManager(project_dir)\n\n        result = manager.remove(\"nonexistent\")\n        assert result is False\n\n    def test_list_installed(self, extension_dir, project_dir):\n        \"\"\"Test listing installed extensions.\"\"\"\n        manager = ExtensionManager(project_dir)\n\n        # Initially empty\n        assert len(manager.list_installed()) == 0\n\n        # Install extension\n        manager.install_from_directory(extension_dir, \"0.1.0\", register_commands=False)\n\n        # Should have one extension\n        installed = manager.list_installed()\n        assert len(installed) == 1\n        assert installed[0][\"id\"] == \"test-ext\"\n        assert installed[0][\"name\"] == \"Test Extension\"\n        assert installed[0][\"version\"] == \"1.0.0\"\n        assert installed[0][\"command_count\"] == 1\n        assert installed[0][\"hook_count\"] == 1\n\n    def test_config_backup_on_remove(self, extension_dir, project_dir):\n        \"\"\"Test that config files are backed up on removal.\"\"\"\n        manager = ExtensionManager(project_dir)\n\n        # Install extension\n        manager.install_from_directory(extension_dir, \"0.1.0\", register_commands=False)\n\n        # Create a config file\n        ext_dir = project_dir / \".specify\" / \"extensions\" / \"test-ext\"\n        config_file = ext_dir / \"test-ext-config.yml\"\n        config_file.write_text(\"test: config\")\n\n        # Remove extension (without keep_config)\n        manager.remove(\"test-ext\", keep_config=False)\n\n        # Check backup was created (now in subdirectory per extension)\n        backup_dir = project_dir / \".specify\" / \"extensions\" / \".backup\" / \"test-ext\"\n        backup_file = backup_dir / \"test-ext-config.yml\"\n        assert backup_file.exists()\n        assert backup_file.read_text() == \"test: config\"\n\n\n# ===== CommandRegistrar Tests =====\n\nclass TestCommandRegistrar:\n    \"\"\"Test CommandRegistrar command registration.\"\"\"\n\n    def test_kiro_cli_agent_config_present(self):\n        \"\"\"Kiro CLI should be mapped to .kiro/prompts and legacy q removed.\"\"\"\n        assert \"kiro-cli\" in CommandRegistrar.AGENT_CONFIGS\n        assert CommandRegistrar.AGENT_CONFIGS[\"kiro-cli\"][\"dir\"] == \".kiro/prompts\"\n        assert \"q\" not in CommandRegistrar.AGENT_CONFIGS\n\n    def test_codex_agent_config_present(self):\n        \"\"\"Codex should be mapped to .agents/skills.\"\"\"\n        assert \"codex\" in CommandRegistrar.AGENT_CONFIGS\n        assert CommandRegistrar.AGENT_CONFIGS[\"codex\"][\"dir\"] == \".agents/skills\"\n        assert CommandRegistrar.AGENT_CONFIGS[\"codex\"][\"extension\"] == \"/SKILL.md\"\n\n    def test_pi_agent_config_present(self):\n        \"\"\"Pi should be mapped to .pi/prompts.\"\"\"\n        assert \"pi\" in CommandRegistrar.AGENT_CONFIGS\n        cfg = CommandRegistrar.AGENT_CONFIGS[\"pi\"]\n        assert cfg[\"dir\"] == \".pi/prompts\"\n        assert cfg[\"format\"] == \"markdown\"\n        assert cfg[\"args\"] == \"$ARGUMENTS\"\n        assert cfg[\"extension\"] == \".md\"\n\n    def test_qwen_agent_config_is_markdown(self):\n        \"\"\"Qwen should use Markdown format with $ARGUMENTS (not TOML).\"\"\"\n        assert \"qwen\" in CommandRegistrar.AGENT_CONFIGS\n        cfg = CommandRegistrar.AGENT_CONFIGS[\"qwen\"]\n        assert cfg[\"dir\"] == \".qwen/commands\"\n        assert cfg[\"format\"] == \"markdown\"\n        assert cfg[\"args\"] == \"$ARGUMENTS\"\n        assert cfg[\"extension\"] == \".md\"\n\n    def test_parse_frontmatter_valid(self):\n        \"\"\"Test parsing valid YAML frontmatter.\"\"\"\n        content = \"\"\"---\ndescription: \"Test command\"\ntools:\n  - tool1\n  - tool2\n---\n\n# Command body\n$ARGUMENTS\n\"\"\"\n        registrar = CommandRegistrar()\n        frontmatter, body = registrar.parse_frontmatter(content)\n\n        assert frontmatter[\"description\"] == \"Test command\"\n        assert frontmatter[\"tools\"] == [\"tool1\", \"tool2\"]\n        assert \"Command body\" in body\n        assert \"$ARGUMENTS\" in body\n\n    def test_parse_frontmatter_no_frontmatter(self):\n        \"\"\"Test parsing content without frontmatter.\"\"\"\n        content = \"# Just a command\\n$ARGUMENTS\"\n\n        registrar = CommandRegistrar()\n        frontmatter, body = registrar.parse_frontmatter(content)\n\n        assert frontmatter == {}\n        assert body == content\n\n    def test_parse_frontmatter_non_mapping_returns_empty_dict(self):\n        \"\"\"Non-mapping YAML frontmatter should not crash downstream renderers.\"\"\"\n        content = \"\"\"---\n- item1\n- item2\n---\n\n# Command body\n\"\"\"\n        registrar = CommandRegistrar()\n        frontmatter, body = registrar.parse_frontmatter(content)\n\n        assert frontmatter == {}\n        assert \"Command body\" in body\n\n    def test_render_frontmatter(self):\n        \"\"\"Test rendering frontmatter to YAML.\"\"\"\n        frontmatter = {\n            \"description\": \"Test command\",\n            \"tools\": [\"tool1\", \"tool2\"]\n        }\n\n        registrar = CommandRegistrar()\n        output = registrar.render_frontmatter(frontmatter)\n\n        assert output.startswith(\"---\\n\")\n        assert output.endswith(\"---\\n\")\n        assert \"description: Test command\" in output\n\n    def test_register_commands_for_claude(self, extension_dir, project_dir):\n        \"\"\"Test registering commands for Claude agent.\"\"\"\n        # Create .claude directory\n        claude_dir = project_dir / \".claude\" / \"commands\"\n        claude_dir.mkdir(parents=True)\n\n        ExtensionManager(project_dir)  # Initialize manager (side effects only)\n        manifest = ExtensionManifest(extension_dir / \"extension.yml\")\n\n        registrar = CommandRegistrar()\n        registered = registrar.register_commands_for_claude(\n            manifest,\n            extension_dir,\n            project_dir\n        )\n\n        assert len(registered) == 1\n        assert \"speckit.test.hello\" in registered\n\n        # Check command file was created\n        cmd_file = claude_dir / \"speckit.test.hello.md\"\n        assert cmd_file.exists()\n\n        content = cmd_file.read_text()\n        assert \"description: Test hello command\" in content\n        assert \"<!-- Extension: test-ext -->\" in content\n        assert \"<!-- Config: .specify/extensions/test-ext/ -->\" in content\n\n    def test_command_with_aliases(self, project_dir, temp_dir):\n        \"\"\"Test registering a command with aliases.\"\"\"\n        import yaml\n\n        # Create extension with command alias\n        ext_dir = temp_dir / \"ext-alias\"\n        ext_dir.mkdir()\n\n        manifest_data = {\n            \"schema_version\": \"1.0\",\n            \"extension\": {\n                \"id\": \"ext-alias\",\n                \"name\": \"Extension with Alias\",\n                \"version\": \"1.0.0\",\n                \"description\": \"Test\",\n            },\n            \"requires\": {\n                \"speckit_version\": \">=0.1.0\",\n            },\n            \"provides\": {\n                \"commands\": [\n                    {\n                        \"name\": \"speckit.alias.cmd\",\n                        \"file\": \"commands/cmd.md\",\n                        \"aliases\": [\"speckit.shortcut\"],\n                    }\n                ]\n            },\n        }\n\n        with open(ext_dir / \"extension.yml\", 'w') as f:\n            yaml.dump(manifest_data, f)\n\n        (ext_dir / \"commands\").mkdir()\n        (ext_dir / \"commands\" / \"cmd.md\").write_text(\"---\\ndescription: Test\\n---\\n\\nTest\")\n\n        claude_dir = project_dir / \".claude\" / \"commands\"\n        claude_dir.mkdir(parents=True)\n\n        manifest = ExtensionManifest(ext_dir / \"extension.yml\")\n        registrar = CommandRegistrar()\n        registered = registrar.register_commands_for_claude(manifest, ext_dir, project_dir)\n\n        assert len(registered) == 2\n        assert \"speckit.alias.cmd\" in registered\n        assert \"speckit.shortcut\" in registered\n        assert (claude_dir / \"speckit.alias.cmd.md\").exists()\n        assert (claude_dir / \"speckit.shortcut.md\").exists()\n\n    def test_unregister_commands_for_codex_skills_uses_mapped_names(self, project_dir):\n        \"\"\"Codex skill cleanup should use the same mapped names as registration.\"\"\"\n        skills_dir = project_dir / \".agents\" / \"skills\"\n        (skills_dir / \"speckit-specify\").mkdir(parents=True)\n        (skills_dir / \"speckit-specify\" / \"SKILL.md\").write_text(\"body\")\n        (skills_dir / \"speckit-shortcut\").mkdir(parents=True)\n        (skills_dir / \"speckit-shortcut\" / \"SKILL.md\").write_text(\"body\")\n\n        registrar = CommandRegistrar()\n        registrar.unregister_commands(\n            {\"codex\": [\"speckit.specify\", \"speckit.shortcut\"]},\n            project_dir,\n        )\n\n        assert not (skills_dir / \"speckit-specify\" / \"SKILL.md\").exists()\n        assert not (skills_dir / \"speckit-shortcut\" / \"SKILL.md\").exists()\n\n    def test_register_commands_for_all_agents_distinguishes_codex_from_amp(self, extension_dir, project_dir):\n        \"\"\"A Codex project under .agents/skills should not implicitly activate Amp.\"\"\"\n        skills_dir = project_dir / \".agents\" / \"skills\"\n        skills_dir.mkdir(parents=True)\n\n        manifest = ExtensionManifest(extension_dir / \"extension.yml\")\n        registrar = CommandRegistrar()\n        registered = registrar.register_commands_for_all_agents(manifest, extension_dir, project_dir)\n\n        assert \"codex\" in registered\n        assert \"amp\" not in registered\n        assert not (project_dir / \".agents\" / \"commands\").exists()\n\n    def test_codex_skill_registration_writes_skill_frontmatter(self, extension_dir, project_dir):\n        \"\"\"Codex SKILL.md output should use skills-oriented frontmatter.\"\"\"\n        skills_dir = project_dir / \".agents\" / \"skills\"\n        skills_dir.mkdir(parents=True)\n\n        manifest = ExtensionManifest(extension_dir / \"extension.yml\")\n        registrar = CommandRegistrar()\n        registrar.register_commands_for_agent(\"codex\", manifest, extension_dir, project_dir)\n\n        skill_file = skills_dir / \"speckit-test.hello\" / \"SKILL.md\"\n        assert skill_file.exists()\n\n        content = skill_file.read_text()\n        assert \"name: speckit-test.hello\" in content\n        assert \"description: Test hello command\" in content\n        assert \"compatibility:\" in content\n        assert \"metadata:\" in content\n        assert \"source: test-ext:commands/hello.md\" in content\n        assert \"<!-- Extension:\" not in content\n\n    def test_codex_skill_registration_resolves_script_placeholders(self, project_dir, temp_dir):\n        \"\"\"Codex SKILL.md overrides should resolve script placeholders.\"\"\"\n        import yaml\n\n        ext_dir = temp_dir / \"ext-scripted\"\n        ext_dir.mkdir()\n        (ext_dir / \"commands\").mkdir()\n\n        manifest_data = {\n            \"schema_version\": \"1.0\",\n            \"extension\": {\n                \"id\": \"ext-scripted\",\n                \"name\": \"Scripted Extension\",\n                \"version\": \"1.0.0\",\n                \"description\": \"Test\",\n            },\n            \"requires\": {\"speckit_version\": \">=0.1.0\"},\n            \"provides\": {\n                \"commands\": [\n                    {\n                        \"name\": \"speckit.test.plan\",\n                        \"file\": \"commands/plan.md\",\n                        \"description\": \"Scripted command\",\n                    }\n                ]\n            },\n        }\n        with open(ext_dir / \"extension.yml\", \"w\") as f:\n            yaml.dump(manifest_data, f)\n\n        (ext_dir / \"commands\" / \"plan.md\").write_text(\n            \"\"\"---\ndescription: \"Scripted command\"\nscripts:\n  sh: ../../scripts/bash/setup-plan.sh --json \"{ARGS}\"\n  ps: ../../scripts/powershell/setup-plan.ps1 -Json\nagent_scripts:\n  sh: ../../scripts/bash/update-agent-context.sh __AGENT__\n  ps: ../../scripts/powershell/update-agent-context.ps1 -AgentType __AGENT__\n---\n\nRun {SCRIPT}\nThen {AGENT_SCRIPT}\nAgent __AGENT__\n\"\"\"\n        )\n\n        init_options = project_dir / \".specify\" / \"init-options.json\"\n        init_options.parent.mkdir(parents=True, exist_ok=True)\n        init_options.write_text('{\"ai\":\"codex\",\"ai_skills\":true,\"script\":\"sh\"}')\n\n        skills_dir = project_dir / \".agents\" / \"skills\"\n        skills_dir.mkdir(parents=True)\n\n        manifest = ExtensionManifest(ext_dir / \"extension.yml\")\n        registrar = CommandRegistrar()\n        registrar.register_commands_for_agent(\"codex\", manifest, ext_dir, project_dir)\n\n        skill_file = skills_dir / \"speckit-test.plan\" / \"SKILL.md\"\n        assert skill_file.exists()\n\n        content = skill_file.read_text()\n        assert \"{SCRIPT}\" not in content\n        assert \"{AGENT_SCRIPT}\" not in content\n        assert \"__AGENT__\" not in content\n        assert \"{ARGS}\" not in content\n        assert '.specify/scripts/bash/setup-plan.sh --json \"$ARGUMENTS\"' in content\n        assert \".specify/scripts/bash/update-agent-context.sh codex\" in content\n\n    def test_codex_skill_alias_frontmatter_matches_alias_name(self, project_dir, temp_dir):\n        \"\"\"Codex alias skills should render their own matching `name:` frontmatter.\"\"\"\n        import yaml\n\n        ext_dir = temp_dir / \"ext-alias-skill\"\n        ext_dir.mkdir()\n        (ext_dir / \"commands\").mkdir()\n\n        manifest_data = {\n            \"schema_version\": \"1.0\",\n            \"extension\": {\n                \"id\": \"ext-alias-skill\",\n                \"name\": \"Alias Skill Extension\",\n                \"version\": \"1.0.0\",\n                \"description\": \"Test\",\n            },\n            \"requires\": {\"speckit_version\": \">=0.1.0\"},\n            \"provides\": {\n                \"commands\": [\n                    {\n                        \"name\": \"speckit.alias.cmd\",\n                        \"file\": \"commands/cmd.md\",\n                        \"aliases\": [\"speckit.shortcut\"],\n                    }\n                ]\n            },\n        }\n        with open(ext_dir / \"extension.yml\", \"w\") as f:\n            yaml.dump(manifest_data, f)\n\n        (ext_dir / \"commands\" / \"cmd.md\").write_text(\"---\\ndescription: Alias skill\\n---\\n\\nBody\\n\")\n\n        skills_dir = project_dir / \".agents\" / \"skills\"\n        skills_dir.mkdir(parents=True)\n\n        manifest = ExtensionManifest(ext_dir / \"extension.yml\")\n        registrar = CommandRegistrar()\n        registrar.register_commands_for_agent(\"codex\", manifest, ext_dir, project_dir)\n\n        primary = skills_dir / \"speckit-alias.cmd\" / \"SKILL.md\"\n        alias = skills_dir / \"speckit-shortcut\" / \"SKILL.md\"\n\n        assert primary.exists()\n        assert alias.exists()\n        assert \"name: speckit-alias.cmd\" in primary.read_text()\n        assert \"name: speckit-shortcut\" in alias.read_text()\n\n    def test_codex_skill_registration_uses_fallback_script_variant_without_init_options(\n        self, project_dir, temp_dir\n    ):\n        \"\"\"Codex placeholder substitution should still work without init-options.json.\"\"\"\n        import yaml\n\n        ext_dir = temp_dir / \"ext-script-fallback\"\n        ext_dir.mkdir()\n        (ext_dir / \"commands\").mkdir()\n\n        manifest_data = {\n            \"schema_version\": \"1.0\",\n            \"extension\": {\n                \"id\": \"ext-script-fallback\",\n                \"name\": \"Script fallback\",\n                \"version\": \"1.0.0\",\n                \"description\": \"Test\",\n            },\n            \"requires\": {\"speckit_version\": \">=0.1.0\"},\n            \"provides\": {\n                \"commands\": [\n                    {\n                        \"name\": \"speckit.fallback.plan\",\n                        \"file\": \"commands/plan.md\",\n                    }\n                ]\n            },\n        }\n        with open(ext_dir / \"extension.yml\", \"w\") as f:\n            yaml.dump(manifest_data, f)\n\n        (ext_dir / \"commands\" / \"plan.md\").write_text(\n            \"\"\"---\ndescription: \"Fallback scripted command\"\nscripts:\n  sh: ../../scripts/bash/setup-plan.sh --json \"{ARGS}\"\n  ps: ../../scripts/powershell/setup-plan.ps1 -Json\nagent_scripts:\n  sh: ../../scripts/bash/update-agent-context.sh __AGENT__\n---\n\nRun {SCRIPT}\nThen {AGENT_SCRIPT}\n\"\"\"\n        )\n\n        # Intentionally do NOT create .specify/init-options.json\n        skills_dir = project_dir / \".agents\" / \"skills\"\n        skills_dir.mkdir(parents=True)\n\n        manifest = ExtensionManifest(ext_dir / \"extension.yml\")\n        registrar = CommandRegistrar()\n        registrar.register_commands_for_agent(\"codex\", manifest, ext_dir, project_dir)\n\n        skill_file = skills_dir / \"speckit-fallback.plan\" / \"SKILL.md\"\n        assert skill_file.exists()\n\n        content = skill_file.read_text()\n        assert \"{SCRIPT}\" not in content\n        assert \"{AGENT_SCRIPT}\" not in content\n        assert '.specify/scripts/bash/setup-plan.sh --json \"$ARGUMENTS\"' in content\n        assert \".specify/scripts/bash/update-agent-context.sh codex\" in content\n\n    def test_codex_skill_registration_fallback_prefers_powershell_on_windows(\n        self, project_dir, temp_dir, monkeypatch\n    ):\n        \"\"\"Without init metadata, Windows fallback should prefer ps scripts over sh.\"\"\"\n        import yaml\n\n        monkeypatch.setattr(\"specify_cli.agents.platform.system\", lambda: \"Windows\")\n\n        ext_dir = temp_dir / \"ext-script-windows-fallback\"\n        ext_dir.mkdir()\n        (ext_dir / \"commands\").mkdir()\n\n        manifest_data = {\n            \"schema_version\": \"1.0\",\n            \"extension\": {\n                \"id\": \"ext-script-windows-fallback\",\n                \"name\": \"Script fallback windows\",\n                \"version\": \"1.0.0\",\n                \"description\": \"Test\",\n            },\n            \"requires\": {\"speckit_version\": \">=0.1.0\"},\n            \"provides\": {\n                \"commands\": [\n                    {\n                        \"name\": \"speckit.windows.plan\",\n                        \"file\": \"commands/plan.md\",\n                    }\n                ]\n            },\n        }\n        with open(ext_dir / \"extension.yml\", \"w\") as f:\n            yaml.dump(manifest_data, f)\n\n        (ext_dir / \"commands\" / \"plan.md\").write_text(\n            \"\"\"---\ndescription: \"Windows fallback scripted command\"\nscripts:\n  sh: ../../scripts/bash/setup-plan.sh --json \"{ARGS}\"\n  ps: ../../scripts/powershell/setup-plan.ps1 -Json\nagent_scripts:\n  sh: ../../scripts/bash/update-agent-context.sh __AGENT__\n  ps: ../../scripts/powershell/update-agent-context.ps1 -AgentType __AGENT__\n---\n\nRun {SCRIPT}\nThen {AGENT_SCRIPT}\n\"\"\"\n        )\n\n        skills_dir = project_dir / \".agents\" / \"skills\"\n        skills_dir.mkdir(parents=True)\n\n        manifest = ExtensionManifest(ext_dir / \"extension.yml\")\n        registrar = CommandRegistrar()\n        registrar.register_commands_for_agent(\"codex\", manifest, ext_dir, project_dir)\n\n        skill_file = skills_dir / \"speckit-windows.plan\" / \"SKILL.md\"\n        assert skill_file.exists()\n\n        content = skill_file.read_text()\n        assert \".specify/scripts/powershell/setup-plan.ps1 -Json\" in content\n        assert \".specify/scripts/powershell/update-agent-context.ps1 -AgentType codex\" in content\n        assert \".specify/scripts/bash/setup-plan.sh\" not in content\n\n    def test_register_commands_for_copilot(self, extension_dir, project_dir):\n        \"\"\"Test registering commands for Copilot agent with .agent.md extension.\"\"\"\n        # Create .github/agents directory (Copilot project)\n        agents_dir = project_dir / \".github\" / \"agents\"\n        agents_dir.mkdir(parents=True)\n\n        manifest = ExtensionManifest(extension_dir / \"extension.yml\")\n\n        registrar = CommandRegistrar()\n        registered = registrar.register_commands_for_agent(\n            \"copilot\", manifest, extension_dir, project_dir\n        )\n\n        assert len(registered) == 1\n        assert \"speckit.test.hello\" in registered\n\n        # Verify command file uses .agent.md extension\n        cmd_file = agents_dir / \"speckit.test.hello.agent.md\"\n        assert cmd_file.exists()\n\n        # Verify NO plain .md file was created\n        plain_md_file = agents_dir / \"speckit.test.hello.md\"\n        assert not plain_md_file.exists()\n\n        content = cmd_file.read_text()\n        assert \"description: Test hello command\" in content\n        assert \"<!-- Extension: test-ext -->\" in content\n\n    def test_copilot_companion_prompt_created(self, extension_dir, project_dir):\n        \"\"\"Test that companion .prompt.md files are created in .github/prompts/.\"\"\"\n        agents_dir = project_dir / \".github\" / \"agents\"\n        agents_dir.mkdir(parents=True)\n\n        manifest = ExtensionManifest(extension_dir / \"extension.yml\")\n\n        registrar = CommandRegistrar()\n        registrar.register_commands_for_agent(\n            \"copilot\", manifest, extension_dir, project_dir\n        )\n\n        # Verify companion .prompt.md file exists\n        prompt_file = project_dir / \".github\" / \"prompts\" / \"speckit.test.hello.prompt.md\"\n        assert prompt_file.exists()\n\n        # Verify content has correct agent frontmatter\n        content = prompt_file.read_text()\n        assert content == \"---\\nagent: speckit.test.hello\\n---\\n\"\n\n    def test_copilot_aliases_get_companion_prompts(self, project_dir, temp_dir):\n        \"\"\"Test that aliases also get companion .prompt.md files for Copilot.\"\"\"\n        import yaml\n\n        ext_dir = temp_dir / \"ext-alias-copilot\"\n        ext_dir.mkdir()\n\n        manifest_data = {\n            \"schema_version\": \"1.0\",\n            \"extension\": {\n                \"id\": \"ext-alias-copilot\",\n                \"name\": \"Extension with Alias\",\n                \"version\": \"1.0.0\",\n                \"description\": \"Test\",\n            },\n            \"requires\": {\"speckit_version\": \">=0.1.0\"},\n            \"provides\": {\n                \"commands\": [\n                    {\n                        \"name\": \"speckit.alias-copilot.cmd\",\n                        \"file\": \"commands/cmd.md\",\n                        \"aliases\": [\"speckit.shortcut-copilot\"],\n                    }\n                ]\n            },\n        }\n\n        with open(ext_dir / \"extension.yml\", \"w\") as f:\n            yaml.dump(manifest_data, f)\n\n        (ext_dir / \"commands\").mkdir()\n        (ext_dir / \"commands\" / \"cmd.md\").write_text(\n            \"---\\ndescription: Test\\n---\\n\\nTest\"\n        )\n\n        # Set up Copilot project\n        (project_dir / \".github\" / \"agents\").mkdir(parents=True)\n\n        manifest = ExtensionManifest(ext_dir / \"extension.yml\")\n        registrar = CommandRegistrar()\n        registered = registrar.register_commands_for_agent(\n            \"copilot\", manifest, ext_dir, project_dir\n        )\n\n        assert len(registered) == 2\n\n        # Both primary and alias get companion .prompt.md\n        prompts_dir = project_dir / \".github\" / \"prompts\"\n        assert (prompts_dir / \"speckit.alias-copilot.cmd.prompt.md\").exists()\n        assert (prompts_dir / \"speckit.shortcut-copilot.prompt.md\").exists()\n\n    def test_non_copilot_agent_no_companion_file(self, extension_dir, project_dir):\n        \"\"\"Test that non-copilot agents do NOT create .prompt.md files.\"\"\"\n        claude_dir = project_dir / \".claude\" / \"commands\"\n        claude_dir.mkdir(parents=True)\n\n        manifest = ExtensionManifest(extension_dir / \"extension.yml\")\n\n        registrar = CommandRegistrar()\n        registrar.register_commands_for_agent(\n            \"claude\", manifest, extension_dir, project_dir\n        )\n\n        # No .github/prompts directory should exist\n        prompts_dir = project_dir / \".github\" / \"prompts\"\n        assert not prompts_dir.exists()\n\n\n# ===== Utility Function Tests =====\n\nclass TestVersionSatisfies:\n    \"\"\"Test version_satisfies utility function.\"\"\"\n\n    def test_version_satisfies_simple(self):\n        \"\"\"Test simple version comparison.\"\"\"\n        assert version_satisfies(\"1.0.0\", \">=1.0.0\")\n        assert version_satisfies(\"1.0.1\", \">=1.0.0\")\n        assert not version_satisfies(\"0.9.9\", \">=1.0.0\")\n\n    def test_version_satisfies_range(self):\n        \"\"\"Test version range.\"\"\"\n        assert version_satisfies(\"1.5.0\", \">=1.0.0,<2.0.0\")\n        assert not version_satisfies(\"2.0.0\", \">=1.0.0,<2.0.0\")\n        assert not version_satisfies(\"0.9.0\", \">=1.0.0,<2.0.0\")\n\n    def test_version_satisfies_complex(self):\n        \"\"\"Test complex version specifier.\"\"\"\n        assert version_satisfies(\"1.0.5\", \">=1.0.0,!=1.0.3\")\n        assert not version_satisfies(\"1.0.3\", \">=1.0.0,!=1.0.3\")\n\n    def test_version_satisfies_invalid(self):\n        \"\"\"Test invalid version strings.\"\"\"\n        assert not version_satisfies(\"invalid\", \">=1.0.0\")\n        assert not version_satisfies(\"1.0.0\", \"invalid specifier\")\n\n\n# ===== Integration Tests =====\n\nclass TestIntegration:\n    \"\"\"Integration tests for complete workflows.\"\"\"\n\n    def test_full_install_and_remove_workflow(self, extension_dir, project_dir):\n        \"\"\"Test complete installation and removal workflow.\"\"\"\n        # Create Claude directory\n        (project_dir / \".claude\" / \"commands\").mkdir(parents=True)\n\n        manager = ExtensionManager(project_dir)\n\n        # Install\n        manager.install_from_directory(\n            extension_dir,\n            \"0.1.0\",\n            register_commands=True\n        )\n\n        # Verify installation\n        assert manager.registry.is_installed(\"test-ext\")\n        installed = manager.list_installed()\n        assert len(installed) == 1\n        assert installed[0][\"id\"] == \"test-ext\"\n\n        # Verify command registered\n        cmd_file = project_dir / \".claude\" / \"commands\" / \"speckit.test.hello.md\"\n        assert cmd_file.exists()\n\n        # Verify registry has registered commands (now a dict keyed by agent)\n        metadata = manager.registry.get(\"test-ext\")\n        registered_commands = metadata[\"registered_commands\"]\n        # Check that the command is registered for at least one agent\n        assert any(\n            \"speckit.test.hello\" in cmds\n            for cmds in registered_commands.values()\n        )\n\n        # Remove\n        result = manager.remove(\"test-ext\")\n        assert result is True\n\n        # Verify removal\n        assert not manager.registry.is_installed(\"test-ext\")\n        assert not cmd_file.exists()\n        assert len(manager.list_installed()) == 0\n\n    def test_copilot_cleanup_removes_prompt_files(self, extension_dir, project_dir):\n        \"\"\"Test that removing a Copilot extension also removes .prompt.md files.\"\"\"\n        agents_dir = project_dir / \".github\" / \"agents\"\n        agents_dir.mkdir(parents=True)\n\n        manager = ExtensionManager(project_dir)\n        manager.install_from_directory(extension_dir, \"0.1.0\", register_commands=True)\n\n        # Verify copilot was detected and registered\n        metadata = manager.registry.get(\"test-ext\")\n        assert \"copilot\" in metadata[\"registered_commands\"]\n\n        # Verify files exist before cleanup\n        agent_file = agents_dir / \"speckit.test.hello.agent.md\"\n        prompt_file = project_dir / \".github\" / \"prompts\" / \"speckit.test.hello.prompt.md\"\n        assert agent_file.exists()\n        assert prompt_file.exists()\n\n        # Use the extension manager to remove — exercises the copilot prompt cleanup code\n        result = manager.remove(\"test-ext\")\n        assert result is True\n\n        assert not agent_file.exists()\n        assert not prompt_file.exists()\n\n    def test_multiple_extensions(self, temp_dir, project_dir):\n        \"\"\"Test installing multiple extensions.\"\"\"\n        import yaml\n\n        # Create two extensions\n        for i in range(1, 3):\n            ext_dir = temp_dir / f\"ext{i}\"\n            ext_dir.mkdir()\n\n            manifest_data = {\n                \"schema_version\": \"1.0\",\n                \"extension\": {\n                    \"id\": f\"ext{i}\",\n                    \"name\": f\"Extension {i}\",\n                    \"version\": \"1.0.0\",\n                    \"description\": f\"Extension {i}\",\n                },\n                \"requires\": {\"speckit_version\": \">=0.1.0\"},\n                \"provides\": {\n                    \"commands\": [\n                        {\n                            \"name\": f\"speckit.ext{i}.cmd\",\n                            \"file\": \"commands/cmd.md\",\n                        }\n                    ]\n                },\n            }\n\n            with open(ext_dir / \"extension.yml\", 'w') as f:\n                yaml.dump(manifest_data, f)\n\n            (ext_dir / \"commands\").mkdir()\n            (ext_dir / \"commands\" / \"cmd.md\").write_text(\"---\\ndescription: Test\\n---\\nTest\")\n\n        manager = ExtensionManager(project_dir)\n\n        # Install both\n        manager.install_from_directory(temp_dir / \"ext1\", \"0.1.0\", register_commands=False)\n        manager.install_from_directory(temp_dir / \"ext2\", \"0.1.0\", register_commands=False)\n\n        # Verify both installed\n        installed = manager.list_installed()\n        assert len(installed) == 2\n        assert {ext[\"id\"] for ext in installed} == {\"ext1\", \"ext2\"}\n\n        # Remove first\n        manager.remove(\"ext1\")\n\n        # Verify only second remains\n        installed = manager.list_installed()\n        assert len(installed) == 1\n        assert installed[0][\"id\"] == \"ext2\"\n\n\n# ===== Extension Catalog Tests =====\n\n\nclass TestExtensionCatalog:\n    \"\"\"Test extension catalog functionality.\"\"\"\n\n    def test_catalog_initialization(self, temp_dir):\n        \"\"\"Test catalog initialization.\"\"\"\n        project_dir = temp_dir / \"project\"\n        project_dir.mkdir()\n        (project_dir / \".specify\").mkdir()\n\n        catalog = ExtensionCatalog(project_dir)\n\n        assert catalog.project_root == project_dir\n        assert catalog.cache_dir == project_dir / \".specify\" / \"extensions\" / \".cache\"\n\n    def test_cache_directory_creation(self, temp_dir):\n        \"\"\"Test catalog cache directory is created when fetching.\"\"\"\n        project_dir = temp_dir / \"project\"\n        project_dir.mkdir()\n        (project_dir / \".specify\").mkdir()\n\n        catalog = ExtensionCatalog(project_dir)\n\n        # Create mock catalog data\n        catalog_data = {\n            \"schema_version\": \"1.0\",\n            \"extensions\": {\n                \"test-ext\": {\n                    \"name\": \"Test Extension\",\n                    \"id\": \"test-ext\",\n                    \"version\": \"1.0.0\",\n                    \"description\": \"Test\",\n                }\n            },\n        }\n\n        # Manually save to cache to test cache reading\n        catalog.cache_dir.mkdir(parents=True, exist_ok=True)\n        catalog.cache_file.write_text(json.dumps(catalog_data))\n        catalog.cache_metadata_file.write_text(\n            json.dumps(\n                {\n                    \"cached_at\": datetime.now(timezone.utc).isoformat(),\n                    \"catalog_url\": \"http://test.com/catalog.json\",\n                }\n            )\n        )\n\n        # Should use cache\n        result = catalog.fetch_catalog()\n        assert result == catalog_data\n\n    def test_cache_expiration(self, temp_dir):\n        \"\"\"Test that expired cache is not used.\"\"\"\n        project_dir = temp_dir / \"project\"\n        project_dir.mkdir()\n        (project_dir / \".specify\").mkdir()\n\n        catalog = ExtensionCatalog(project_dir)\n\n        # Create expired cache\n        catalog.cache_dir.mkdir(parents=True, exist_ok=True)\n        catalog_data = {\"schema_version\": \"1.0\", \"extensions\": {}}\n        catalog.cache_file.write_text(json.dumps(catalog_data))\n\n        # Set cache time to 2 hours ago (expired)\n        expired_time = datetime.now(timezone.utc).timestamp() - 7200\n        expired_datetime = datetime.fromtimestamp(expired_time, tz=timezone.utc)\n        catalog.cache_metadata_file.write_text(\n            json.dumps(\n                {\n                    \"cached_at\": expired_datetime.isoformat(),\n                    \"catalog_url\": \"http://test.com/catalog.json\",\n                }\n            )\n        )\n\n        # Cache should be invalid\n        assert not catalog.is_cache_valid()\n\n    def test_search_all_extensions(self, temp_dir):\n        \"\"\"Test searching all extensions without filters.\"\"\"\n        import yaml as yaml_module\n\n        project_dir = temp_dir / \"project\"\n        project_dir.mkdir()\n        (project_dir / \".specify\").mkdir()\n\n        # Use a single-catalog config so community extensions don't interfere\n        config_path = project_dir / \".specify\" / \"extension-catalogs.yml\"\n        with open(config_path, \"w\") as f:\n            yaml_module.dump(\n                {\n                    \"catalogs\": [\n                        {\n                            \"name\": \"test-catalog\",\n                            \"url\": ExtensionCatalog.DEFAULT_CATALOG_URL,\n                            \"priority\": 1,\n                            \"install_allowed\": True,\n                        }\n                    ]\n                },\n                f,\n            )\n\n        catalog = ExtensionCatalog(project_dir)\n\n        # Create mock catalog\n        catalog_data = {\n            \"schema_version\": \"1.0\",\n            \"extensions\": {\n                \"jira\": {\n                    \"name\": \"Jira Integration\",\n                    \"id\": \"jira\",\n                    \"version\": \"1.0.0\",\n                    \"description\": \"Jira integration\",\n                    \"author\": \"Stats Perform\",\n                    \"tags\": [\"issue-tracking\", \"jira\"],\n                    \"verified\": True,\n                },\n                \"linear\": {\n                    \"name\": \"Linear Integration\",\n                    \"id\": \"linear\",\n                    \"version\": \"0.9.0\",\n                    \"description\": \"Linear integration\",\n                    \"author\": \"Community\",\n                    \"tags\": [\"issue-tracking\"],\n                    \"verified\": False,\n                },\n            },\n        }\n\n        # Save to cache\n        catalog.cache_dir.mkdir(parents=True, exist_ok=True)\n        catalog.cache_file.write_text(json.dumps(catalog_data))\n        catalog.cache_metadata_file.write_text(\n            json.dumps(\n                {\n                    \"cached_at\": datetime.now(timezone.utc).isoformat(),\n                    \"catalog_url\": \"http://test.com\",\n                }\n            )\n        )\n\n        # Search without filters\n        results = catalog.search()\n        assert len(results) == 2\n\n    def test_search_by_query(self, temp_dir):\n        \"\"\"Test searching by query text.\"\"\"\n        import yaml as yaml_module\n\n        project_dir = temp_dir / \"project\"\n        project_dir.mkdir()\n        (project_dir / \".specify\").mkdir()\n\n        # Use a single-catalog config so community extensions don't interfere\n        config_path = project_dir / \".specify\" / \"extension-catalogs.yml\"\n        with open(config_path, \"w\") as f:\n            yaml_module.dump(\n                {\n                    \"catalogs\": [\n                        {\n                            \"name\": \"test-catalog\",\n                            \"url\": ExtensionCatalog.DEFAULT_CATALOG_URL,\n                            \"priority\": 1,\n                            \"install_allowed\": True,\n                        }\n                    ]\n                },\n                f,\n            )\n\n        catalog = ExtensionCatalog(project_dir)\n\n        # Create mock catalog\n        catalog_data = {\n            \"schema_version\": \"1.0\",\n            \"extensions\": {\n                \"jira\": {\n                    \"name\": \"Jira Integration\",\n                    \"id\": \"jira\",\n                    \"version\": \"1.0.0\",\n                    \"description\": \"Jira issue tracking\",\n                    \"tags\": [\"jira\"],\n                },\n                \"linear\": {\n                    \"name\": \"Linear Integration\",\n                    \"id\": \"linear\",\n                    \"version\": \"1.0.0\",\n                    \"description\": \"Linear project management\",\n                    \"tags\": [\"linear\"],\n                },\n            },\n        }\n\n        catalog.cache_dir.mkdir(parents=True, exist_ok=True)\n        catalog.cache_file.write_text(json.dumps(catalog_data))\n        catalog.cache_metadata_file.write_text(\n            json.dumps(\n                {\n                    \"cached_at\": datetime.now(timezone.utc).isoformat(),\n                    \"catalog_url\": \"http://test.com\",\n                }\n            )\n        )\n\n        # Search for \"jira\"\n        results = catalog.search(query=\"jira\")\n        assert len(results) == 1\n        assert results[0][\"id\"] == \"jira\"\n\n    def test_search_by_tag(self, temp_dir):\n        \"\"\"Test searching by tag.\"\"\"\n        import yaml as yaml_module\n\n        project_dir = temp_dir / \"project\"\n        project_dir.mkdir()\n        (project_dir / \".specify\").mkdir()\n\n        # Use a single-catalog config so community extensions don't interfere\n        config_path = project_dir / \".specify\" / \"extension-catalogs.yml\"\n        with open(config_path, \"w\") as f:\n            yaml_module.dump(\n                {\n                    \"catalogs\": [\n                        {\n                            \"name\": \"test-catalog\",\n                            \"url\": ExtensionCatalog.DEFAULT_CATALOG_URL,\n                            \"priority\": 1,\n                            \"install_allowed\": True,\n                        }\n                    ]\n                },\n                f,\n            )\n\n        catalog = ExtensionCatalog(project_dir)\n\n        # Create mock catalog\n        catalog_data = {\n            \"schema_version\": \"1.0\",\n            \"extensions\": {\n                \"jira\": {\n                    \"name\": \"Jira\",\n                    \"id\": \"jira\",\n                    \"version\": \"1.0.0\",\n                    \"description\": \"Jira\",\n                    \"tags\": [\"issue-tracking\", \"jira\"],\n                },\n                \"linear\": {\n                    \"name\": \"Linear\",\n                    \"id\": \"linear\",\n                    \"version\": \"1.0.0\",\n                    \"description\": \"Linear\",\n                    \"tags\": [\"issue-tracking\", \"linear\"],\n                },\n                \"github\": {\n                    \"name\": \"GitHub\",\n                    \"id\": \"github\",\n                    \"version\": \"1.0.0\",\n                    \"description\": \"GitHub\",\n                    \"tags\": [\"vcs\", \"github\"],\n                },\n            },\n        }\n\n        catalog.cache_dir.mkdir(parents=True, exist_ok=True)\n        catalog.cache_file.write_text(json.dumps(catalog_data))\n        catalog.cache_metadata_file.write_text(\n            json.dumps(\n                {\n                    \"cached_at\": datetime.now(timezone.utc).isoformat(),\n                    \"catalog_url\": \"http://test.com\",\n                }\n            )\n        )\n\n        # Search by tag \"issue-tracking\"\n        results = catalog.search(tag=\"issue-tracking\")\n        assert len(results) == 2\n        assert {r[\"id\"] for r in results} == {\"jira\", \"linear\"}\n\n    def test_search_verified_only(self, temp_dir):\n        \"\"\"Test searching verified extensions only.\"\"\"\n        import yaml as yaml_module\n\n        project_dir = temp_dir / \"project\"\n        project_dir.mkdir()\n        (project_dir / \".specify\").mkdir()\n\n        # Use a single-catalog config so community extensions don't interfere\n        config_path = project_dir / \".specify\" / \"extension-catalogs.yml\"\n        with open(config_path, \"w\") as f:\n            yaml_module.dump(\n                {\n                    \"catalogs\": [\n                        {\n                            \"name\": \"test-catalog\",\n                            \"url\": ExtensionCatalog.DEFAULT_CATALOG_URL,\n                            \"priority\": 1,\n                            \"install_allowed\": True,\n                        }\n                    ]\n                },\n                f,\n            )\n\n        catalog = ExtensionCatalog(project_dir)\n\n        # Create mock catalog\n        catalog_data = {\n            \"schema_version\": \"1.0\",\n            \"extensions\": {\n                \"jira\": {\n                    \"name\": \"Jira\",\n                    \"id\": \"jira\",\n                    \"version\": \"1.0.0\",\n                    \"description\": \"Jira\",\n                    \"verified\": True,\n                },\n                \"linear\": {\n                    \"name\": \"Linear\",\n                    \"id\": \"linear\",\n                    \"version\": \"1.0.0\",\n                    \"description\": \"Linear\",\n                    \"verified\": False,\n                },\n            },\n        }\n\n        catalog.cache_dir.mkdir(parents=True, exist_ok=True)\n        catalog.cache_file.write_text(json.dumps(catalog_data))\n        catalog.cache_metadata_file.write_text(\n            json.dumps(\n                {\n                    \"cached_at\": datetime.now(timezone.utc).isoformat(),\n                    \"catalog_url\": \"http://test.com\",\n                }\n            )\n        )\n\n        # Search verified only\n        results = catalog.search(verified_only=True)\n        assert len(results) == 1\n        assert results[0][\"id\"] == \"jira\"\n\n    def test_get_extension_info(self, temp_dir):\n        \"\"\"Test getting specific extension info.\"\"\"\n        import yaml as yaml_module\n\n        project_dir = temp_dir / \"project\"\n        project_dir.mkdir()\n        (project_dir / \".specify\").mkdir()\n\n        # Use a single-catalog config so community extensions don't interfere\n        config_path = project_dir / \".specify\" / \"extension-catalogs.yml\"\n        with open(config_path, \"w\") as f:\n            yaml_module.dump(\n                {\n                    \"catalogs\": [\n                        {\n                            \"name\": \"test-catalog\",\n                            \"url\": ExtensionCatalog.DEFAULT_CATALOG_URL,\n                            \"priority\": 1,\n                            \"install_allowed\": True,\n                        }\n                    ]\n                },\n                f,\n            )\n\n        catalog = ExtensionCatalog(project_dir)\n\n        # Create mock catalog\n        catalog_data = {\n            \"schema_version\": \"1.0\",\n            \"extensions\": {\n                \"jira\": {\n                    \"name\": \"Jira Integration\",\n                    \"id\": \"jira\",\n                    \"version\": \"1.0.0\",\n                    \"description\": \"Jira integration\",\n                    \"author\": \"Stats Perform\",\n                },\n            },\n        }\n\n        catalog.cache_dir.mkdir(parents=True, exist_ok=True)\n        catalog.cache_file.write_text(json.dumps(catalog_data))\n        catalog.cache_metadata_file.write_text(\n            json.dumps(\n                {\n                    \"cached_at\": datetime.now(timezone.utc).isoformat(),\n                    \"catalog_url\": \"http://test.com\",\n                }\n            )\n        )\n\n        # Get extension info\n        info = catalog.get_extension_info(\"jira\")\n        assert info is not None\n        assert info[\"id\"] == \"jira\"\n        assert info[\"name\"] == \"Jira Integration\"\n\n        # Non-existent extension\n        info = catalog.get_extension_info(\"nonexistent\")\n        assert info is None\n\n    def test_clear_cache(self, temp_dir):\n        \"\"\"Test clearing catalog cache.\"\"\"\n        project_dir = temp_dir / \"project\"\n        project_dir.mkdir()\n        (project_dir / \".specify\").mkdir()\n\n        catalog = ExtensionCatalog(project_dir)\n\n        # Create cache\n        catalog.cache_dir.mkdir(parents=True, exist_ok=True)\n        catalog.cache_file.write_text(\"{}\")\n        catalog.cache_metadata_file.write_text(\"{}\")\n\n        assert catalog.cache_file.exists()\n        assert catalog.cache_metadata_file.exists()\n\n        # Clear cache\n        catalog.clear_cache()\n\n        assert not catalog.cache_file.exists()\n        assert not catalog.cache_metadata_file.exists()\n\n\n# ===== CatalogEntry Tests =====\n\nclass TestCatalogEntry:\n    \"\"\"Test CatalogEntry dataclass.\"\"\"\n\n    def test_catalog_entry_creation(self):\n        \"\"\"Test creating a CatalogEntry.\"\"\"\n        entry = CatalogEntry(\n            url=\"https://example.com/catalog.json\",\n            name=\"test\",\n            priority=1,\n            install_allowed=True,\n        )\n        assert entry.url == \"https://example.com/catalog.json\"\n        assert entry.name == \"test\"\n        assert entry.priority == 1\n        assert entry.install_allowed is True\n\n\n# ===== Catalog Stack Tests =====\n\nclass TestCatalogStack:\n    \"\"\"Test multi-catalog stack support.\"\"\"\n\n    def _make_project(self, temp_dir: Path) -> Path:\n        \"\"\"Create a minimal spec-kit project directory.\"\"\"\n        project_dir = temp_dir / \"project\"\n        project_dir.mkdir()\n        (project_dir / \".specify\").mkdir()\n        return project_dir\n\n    def _write_catalog_config(self, project_dir: Path, catalogs: list) -> None:\n        \"\"\"Write extension-catalogs.yml to project .specify dir.\"\"\"\n        import yaml as yaml_module\n\n        config_path = project_dir / \".specify\" / \"extension-catalogs.yml\"\n        with open(config_path, \"w\") as f:\n            yaml_module.dump({\"catalogs\": catalogs}, f)\n\n    def _write_valid_cache(\n        self, catalog: ExtensionCatalog, extensions: dict, url: str = \"http://test.com\"\n    ) -> None:\n        \"\"\"Populate the primary cache file with mock extension data.\"\"\"\n        catalog_data = {\"schema_version\": \"1.0\", \"extensions\": extensions}\n        catalog.cache_dir.mkdir(parents=True, exist_ok=True)\n        catalog.cache_file.write_text(json.dumps(catalog_data))\n        catalog.cache_metadata_file.write_text(\n            json.dumps(\n                {\n                    \"cached_at\": datetime.now(timezone.utc).isoformat(),\n                    \"catalog_url\": url,\n                }\n            )\n        )\n\n    # --- get_active_catalogs ---\n\n    def test_default_stack(self, temp_dir):\n        \"\"\"Default stack includes default and community catalogs.\"\"\"\n        project_dir = self._make_project(temp_dir)\n        catalog = ExtensionCatalog(project_dir)\n\n        entries = catalog.get_active_catalogs()\n\n        assert len(entries) == 2\n        assert entries[0].url == ExtensionCatalog.DEFAULT_CATALOG_URL\n        assert entries[0].name == \"default\"\n        assert entries[0].priority == 1\n        assert entries[0].install_allowed is True\n        assert entries[1].url == ExtensionCatalog.COMMUNITY_CATALOG_URL\n        assert entries[1].name == \"community\"\n        assert entries[1].priority == 2\n        assert entries[1].install_allowed is False\n\n    def test_env_var_overrides_default_stack(self, temp_dir, monkeypatch):\n        \"\"\"SPECKIT_CATALOG_URL replaces the entire default stack.\"\"\"\n        project_dir = self._make_project(temp_dir)\n        custom_url = \"https://example.com/catalog.json\"\n        monkeypatch.setenv(\"SPECKIT_CATALOG_URL\", custom_url)\n\n        catalog = ExtensionCatalog(project_dir)\n        entries = catalog.get_active_catalogs()\n\n        assert len(entries) == 1\n        assert entries[0].url == custom_url\n        assert entries[0].install_allowed is True\n\n    def test_env_var_invalid_url_raises(self, temp_dir, monkeypatch):\n        \"\"\"SPECKIT_CATALOG_URL with http:// (non-localhost) raises ValidationError.\"\"\"\n        project_dir = self._make_project(temp_dir)\n        monkeypatch.setenv(\"SPECKIT_CATALOG_URL\", \"http://example.com/catalog.json\")\n\n        catalog = ExtensionCatalog(project_dir)\n        with pytest.raises(ValidationError, match=\"HTTPS\"):\n            catalog.get_active_catalogs()\n\n    def test_project_config_overrides_defaults(self, temp_dir):\n        \"\"\"Project-level extension-catalogs.yml overrides default stack.\"\"\"\n        project_dir = self._make_project(temp_dir)\n        self._write_catalog_config(\n            project_dir,\n            [\n                {\n                    \"name\": \"custom\",\n                    \"url\": \"https://example.com/catalog.json\",\n                    \"priority\": 1,\n                    \"install_allowed\": True,\n                }\n            ],\n        )\n\n        catalog = ExtensionCatalog(project_dir)\n        entries = catalog.get_active_catalogs()\n\n        assert len(entries) == 1\n        assert entries[0].url == \"https://example.com/catalog.json\"\n        assert entries[0].name == \"custom\"\n\n    def test_project_config_sorted_by_priority(self, temp_dir):\n        \"\"\"Catalog entries are sorted by priority (ascending).\"\"\"\n        project_dir = self._make_project(temp_dir)\n        self._write_catalog_config(\n            project_dir,\n            [\n                {\n                    \"name\": \"secondary\",\n                    \"url\": \"https://example.com/secondary.json\",\n                    \"priority\": 5,\n                    \"install_allowed\": False,\n                },\n                {\n                    \"name\": \"primary\",\n                    \"url\": \"https://example.com/primary.json\",\n                    \"priority\": 1,\n                    \"install_allowed\": True,\n                },\n            ],\n        )\n\n        catalog = ExtensionCatalog(project_dir)\n        entries = catalog.get_active_catalogs()\n\n        assert len(entries) == 2\n        assert entries[0].name == \"primary\"\n        assert entries[1].name == \"secondary\"\n\n    def test_project_config_invalid_url_raises(self, temp_dir):\n        \"\"\"Project config with HTTP (non-localhost) URL raises ValidationError.\"\"\"\n        project_dir = self._make_project(temp_dir)\n        self._write_catalog_config(\n            project_dir,\n            [\n                {\n                    \"name\": \"bad\",\n                    \"url\": \"http://example.com/catalog.json\",\n                    \"priority\": 1,\n                    \"install_allowed\": True,\n                }\n            ],\n        )\n\n        catalog = ExtensionCatalog(project_dir)\n        with pytest.raises(ValidationError, match=\"HTTPS\"):\n            catalog.get_active_catalogs()\n\n    def test_empty_project_config_raises_error(self, temp_dir):\n        \"\"\"Empty catalogs list in config raises ValidationError (fail-closed for security).\"\"\"\n        import yaml as yaml_module\n\n        project_dir = self._make_project(temp_dir)\n        config_path = project_dir / \".specify\" / \"extension-catalogs.yml\"\n        with open(config_path, \"w\") as f:\n            yaml_module.dump({\"catalogs\": []}, f)\n\n        catalog = ExtensionCatalog(project_dir)\n\n        # Fail-closed: empty config should raise, not fall back to defaults\n        with pytest.raises(ValidationError) as exc_info:\n            catalog.get_active_catalogs()\n        assert \"contains no 'catalogs' entries\" in str(exc_info.value)\n\n    def test_catalog_entries_without_urls_raises_error(self, temp_dir):\n        \"\"\"Catalog entries without URLs raise ValidationError (fail-closed for security).\"\"\"\n        import yaml as yaml_module\n\n        project_dir = self._make_project(temp_dir)\n        config_path = project_dir / \".specify\" / \"extension-catalogs.yml\"\n        with open(config_path, \"w\") as f:\n            yaml_module.dump({\n                \"catalogs\": [\n                    {\"name\": \"no-url-catalog\", \"priority\": 1},\n                    {\"name\": \"another-no-url\", \"description\": \"Also missing URL\"},\n                ]\n            }, f)\n\n        catalog = ExtensionCatalog(project_dir)\n\n        # Fail-closed: entries without URLs should raise, not fall back to defaults\n        with pytest.raises(ValidationError) as exc_info:\n            catalog.get_active_catalogs()\n        assert \"none have valid URLs\" in str(exc_info.value)\n\n    # --- _load_catalog_config ---\n\n    def test_load_catalog_config_missing_file(self, temp_dir):\n        \"\"\"Returns None when config file doesn't exist.\"\"\"\n        project_dir = self._make_project(temp_dir)\n        catalog = ExtensionCatalog(project_dir)\n\n        result = catalog._load_catalog_config(project_dir / \".specify\" / \"nonexistent.yml\")\n        assert result is None\n\n    def test_load_catalog_config_localhost_allowed(self, temp_dir):\n        \"\"\"Localhost HTTP URLs are allowed in config.\"\"\"\n        project_dir = self._make_project(temp_dir)\n        self._write_catalog_config(\n            project_dir,\n            [\n                {\n                    \"name\": \"local\",\n                    \"url\": \"http://localhost:8000/catalog.json\",\n                    \"priority\": 1,\n                    \"install_allowed\": True,\n                }\n            ],\n        )\n\n        catalog = ExtensionCatalog(project_dir)\n        entries = catalog.get_active_catalogs()\n\n        assert len(entries) == 1\n        assert entries[0].url == \"http://localhost:8000/catalog.json\"\n\n    # --- Merge conflict resolution ---\n\n    def test_merge_conflict_higher_priority_wins(self, temp_dir):\n        \"\"\"When same extension id is in two catalogs, higher priority wins.\"\"\"\n        project_dir = self._make_project(temp_dir)\n\n        # Write project config with two catalogs\n        self._write_catalog_config(\n            project_dir,\n            [\n                {\n                    \"name\": \"primary\",\n                    \"url\": ExtensionCatalog.DEFAULT_CATALOG_URL,\n                    \"priority\": 1,\n                    \"install_allowed\": True,\n                },\n                {\n                    \"name\": \"secondary\",\n                    \"url\": ExtensionCatalog.COMMUNITY_CATALOG_URL,\n                    \"priority\": 2,\n                    \"install_allowed\": False,\n                },\n            ],\n        )\n\n        catalog = ExtensionCatalog(project_dir)\n\n        # Write primary cache with jira v2.0.0\n        primary_data = {\n            \"schema_version\": \"1.0\",\n            \"extensions\": {\n                \"jira\": {\n                    \"name\": \"Jira Integration\",\n                    \"id\": \"jira\",\n                    \"version\": \"2.0.0\",\n                    \"description\": \"Primary Jira\",\n                }\n            },\n        }\n        catalog.cache_dir.mkdir(parents=True, exist_ok=True)\n        catalog.cache_file.write_text(json.dumps(primary_data))\n        catalog.cache_metadata_file.write_text(\n            json.dumps({\"cached_at\": datetime.now(timezone.utc).isoformat(), \"catalog_url\": \"http://test.com\"})\n        )\n\n        # Write secondary cache (URL-hash-based) with jira v1.0.0 (should lose)\n        import hashlib\n\n        url_hash = hashlib.sha256(ExtensionCatalog.COMMUNITY_CATALOG_URL.encode()).hexdigest()[:16]\n        secondary_cache = catalog.cache_dir / f\"catalog-{url_hash}.json\"\n        secondary_meta = catalog.cache_dir / f\"catalog-{url_hash}-metadata.json\"\n        secondary_data = {\n            \"schema_version\": \"1.0\",\n            \"extensions\": {\n                \"jira\": {\n                    \"name\": \"Jira Integration Community\",\n                    \"id\": \"jira\",\n                    \"version\": \"1.0.0\",\n                    \"description\": \"Community Jira\",\n                },\n                \"linear\": {\n                    \"name\": \"Linear\",\n                    \"id\": \"linear\",\n                    \"version\": \"0.9.0\",\n                    \"description\": \"Linear from secondary\",\n                },\n            },\n        }\n        secondary_cache.write_text(json.dumps(secondary_data))\n        secondary_meta.write_text(\n            json.dumps({\"cached_at\": datetime.now(timezone.utc).isoformat(), \"catalog_url\": ExtensionCatalog.COMMUNITY_CATALOG_URL})\n        )\n\n        results = catalog.search()\n        jira_results = [r for r in results if r[\"id\"] == \"jira\"]\n        assert len(jira_results) == 1\n        # Primary catalog wins\n        assert jira_results[0][\"version\"] == \"2.0.0\"\n        assert jira_results[0][\"_catalog_name\"] == \"primary\"\n        assert jira_results[0][\"_install_allowed\"] is True\n\n        # linear comes from secondary\n        linear_results = [r for r in results if r[\"id\"] == \"linear\"]\n        assert len(linear_results) == 1\n        assert linear_results[0][\"_catalog_name\"] == \"secondary\"\n        assert linear_results[0][\"_install_allowed\"] is False\n\n    def test_install_allowed_false_from_get_extension_info(self, temp_dir):\n        \"\"\"get_extension_info includes _install_allowed from source catalog.\"\"\"\n        project_dir = self._make_project(temp_dir)\n\n        # Single catalog that is install_allowed=False\n        self._write_catalog_config(\n            project_dir,\n            [\n                {\n                    \"name\": \"discovery\",\n                    \"url\": ExtensionCatalog.DEFAULT_CATALOG_URL,\n                    \"priority\": 1,\n                    \"install_allowed\": False,\n                }\n            ],\n        )\n\n        catalog = ExtensionCatalog(project_dir)\n        self._write_valid_cache(\n            catalog,\n            {\n                \"jira\": {\n                    \"name\": \"Jira Integration\",\n                    \"id\": \"jira\",\n                    \"version\": \"1.0.0\",\n                    \"description\": \"Jira integration\",\n                }\n            },\n        )\n\n        info = catalog.get_extension_info(\"jira\")\n        assert info is not None\n        assert info[\"_install_allowed\"] is False\n        assert info[\"_catalog_name\"] == \"discovery\"\n\n    def test_search_results_include_catalog_metadata(self, temp_dir):\n        \"\"\"Search results include _catalog_name and _install_allowed.\"\"\"\n        project_dir = self._make_project(temp_dir)\n        self._write_catalog_config(\n            project_dir,\n            [\n                {\n                    \"name\": \"org\",\n                    \"url\": ExtensionCatalog.DEFAULT_CATALOG_URL,\n                    \"priority\": 1,\n                    \"install_allowed\": True,\n                }\n            ],\n        )\n\n        catalog = ExtensionCatalog(project_dir)\n        self._write_valid_cache(\n            catalog,\n            {\n                \"jira\": {\n                    \"name\": \"Jira Integration\",\n                    \"id\": \"jira\",\n                    \"version\": \"1.0.0\",\n                    \"description\": \"Jira integration\",\n                }\n            },\n        )\n\n        results = catalog.search()\n        assert len(results) == 1\n        assert results[0][\"_catalog_name\"] == \"org\"\n        assert results[0][\"_install_allowed\"] is True\n\n\nclass TestExtensionIgnore:\n    \"\"\"Test .extensionignore support during extension installation.\"\"\"\n\n    def _make_extension(self, temp_dir, valid_manifest_data, extra_files=None, ignore_content=None):\n        \"\"\"Helper to create an extension directory with optional extra files and .extensionignore.\"\"\"\n        import yaml\n\n        ext_dir = temp_dir / \"ignored-ext\"\n        ext_dir.mkdir()\n\n        # Write manifest\n        with open(ext_dir / \"extension.yml\", \"w\") as f:\n            yaml.dump(valid_manifest_data, f)\n\n        # Create commands directory with a command file\n        commands_dir = ext_dir / \"commands\"\n        commands_dir.mkdir()\n        (commands_dir / \"hello.md\").write_text(\n            \"---\\ndescription: \\\"Test hello command\\\"\\n---\\n\\n# Hello\\n\\n$ARGUMENTS\\n\"\n        )\n\n        # Create any extra files/dirs\n        if extra_files:\n            for rel_path, content in extra_files.items():\n                p = ext_dir / rel_path\n                p.parent.mkdir(parents=True, exist_ok=True)\n                if content is None:\n                    # Create directory\n                    p.mkdir(parents=True, exist_ok=True)\n                else:\n                    p.write_text(content)\n\n        # Write .extensionignore\n        if ignore_content is not None:\n            (ext_dir / \".extensionignore\").write_text(ignore_content)\n\n        return ext_dir\n\n    def test_no_extensionignore(self, temp_dir, valid_manifest_data):\n        \"\"\"Without .extensionignore, all files are copied.\"\"\"\n        ext_dir = self._make_extension(\n            temp_dir,\n            valid_manifest_data,\n            extra_files={\"README.md\": \"# Hello\", \"tests/test_foo.py\": \"pass\"},\n        )\n\n        proj_dir = temp_dir / \"project\"\n        proj_dir.mkdir()\n        (proj_dir / \".specify\").mkdir()\n\n        manager = ExtensionManager(proj_dir)\n        manager.install_from_directory(ext_dir, \"0.1.0\", register_commands=False)\n\n        dest = proj_dir / \".specify\" / \"extensions\" / \"test-ext\"\n        assert (dest / \"README.md\").exists()\n        assert (dest / \"tests\" / \"test_foo.py\").exists()\n\n    def test_extensionignore_excludes_files(self, temp_dir, valid_manifest_data):\n        \"\"\"Files matching .extensionignore patterns are excluded.\"\"\"\n        ext_dir = self._make_extension(\n            temp_dir,\n            valid_manifest_data,\n            extra_files={\n                \"README.md\": \"# Hello\",\n                \"tests/test_foo.py\": \"pass\",\n                \"tests/test_bar.py\": \"pass\",\n                \".github/workflows/ci.yml\": \"on: push\",\n            },\n            ignore_content=\"tests/\\n.github/\\n\",\n        )\n\n        proj_dir = temp_dir / \"project\"\n        proj_dir.mkdir()\n        (proj_dir / \".specify\").mkdir()\n\n        manager = ExtensionManager(proj_dir)\n        manager.install_from_directory(ext_dir, \"0.1.0\", register_commands=False)\n\n        dest = proj_dir / \".specify\" / \"extensions\" / \"test-ext\"\n        # Included\n        assert (dest / \"README.md\").exists()\n        assert (dest / \"extension.yml\").exists()\n        assert (dest / \"commands\" / \"hello.md\").exists()\n        # Excluded\n        assert not (dest / \"tests\").exists()\n        assert not (dest / \".github\").exists()\n\n    def test_extensionignore_glob_patterns(self, temp_dir, valid_manifest_data):\n        \"\"\"Glob patterns like *.pyc are respected.\"\"\"\n        ext_dir = self._make_extension(\n            temp_dir,\n            valid_manifest_data,\n            extra_files={\n                \"README.md\": \"# Hello\",\n                \"helpers.pyc\": b\"\\x00\".decode(\"latin-1\"),\n                \"commands/cache.pyc\": b\"\\x00\".decode(\"latin-1\"),\n            },\n            ignore_content=\"*.pyc\\n\",\n        )\n\n        proj_dir = temp_dir / \"project\"\n        proj_dir.mkdir()\n        (proj_dir / \".specify\").mkdir()\n\n        manager = ExtensionManager(proj_dir)\n        manager.install_from_directory(ext_dir, \"0.1.0\", register_commands=False)\n\n        dest = proj_dir / \".specify\" / \"extensions\" / \"test-ext\"\n        assert (dest / \"README.md\").exists()\n        assert not (dest / \"helpers.pyc\").exists()\n        assert not (dest / \"commands\" / \"cache.pyc\").exists()\n\n    def test_extensionignore_comments_and_blanks(self, temp_dir, valid_manifest_data):\n        \"\"\"Comments and blank lines in .extensionignore are ignored.\"\"\"\n        ext_dir = self._make_extension(\n            temp_dir,\n            valid_manifest_data,\n            extra_files={\"README.md\": \"# Hello\", \"notes.txt\": \"some notes\"},\n            ignore_content=\"# This is a comment\\n\\nnotes.txt\\n\\n# Another comment\\n\",\n        )\n\n        proj_dir = temp_dir / \"project\"\n        proj_dir.mkdir()\n        (proj_dir / \".specify\").mkdir()\n\n        manager = ExtensionManager(proj_dir)\n        manager.install_from_directory(ext_dir, \"0.1.0\", register_commands=False)\n\n        dest = proj_dir / \".specify\" / \"extensions\" / \"test-ext\"\n        assert (dest / \"README.md\").exists()\n        assert not (dest / \"notes.txt\").exists()\n\n    def test_extensionignore_itself_excluded(self, temp_dir, valid_manifest_data):\n        \"\"\".extensionignore is never copied to the destination.\"\"\"\n        ext_dir = self._make_extension(\n            temp_dir,\n            valid_manifest_data,\n            ignore_content=\"# nothing special here\\n\",\n        )\n\n        proj_dir = temp_dir / \"project\"\n        proj_dir.mkdir()\n        (proj_dir / \".specify\").mkdir()\n\n        manager = ExtensionManager(proj_dir)\n        manager.install_from_directory(ext_dir, \"0.1.0\", register_commands=False)\n\n        dest = proj_dir / \".specify\" / \"extensions\" / \"test-ext\"\n        assert (dest / \"extension.yml\").exists()\n        assert not (dest / \".extensionignore\").exists()\n\n    def test_extensionignore_relative_path_match(self, temp_dir, valid_manifest_data):\n        \"\"\"Patterns matching relative paths work correctly.\"\"\"\n        ext_dir = self._make_extension(\n            temp_dir,\n            valid_manifest_data,\n            extra_files={\n                \"docs/guide.md\": \"# Guide\",\n                \"docs/internal/draft.md\": \"draft\",\n                \"README.md\": \"# Hello\",\n            },\n            ignore_content=\"docs/internal/draft.md\\n\",\n        )\n\n        proj_dir = temp_dir / \"project\"\n        proj_dir.mkdir()\n        (proj_dir / \".specify\").mkdir()\n\n        manager = ExtensionManager(proj_dir)\n        manager.install_from_directory(ext_dir, \"0.1.0\", register_commands=False)\n\n        dest = proj_dir / \".specify\" / \"extensions\" / \"test-ext\"\n        assert (dest / \"docs\" / \"guide.md\").exists()\n        assert not (dest / \"docs\" / \"internal\" / \"draft.md\").exists()\n\n    def test_extensionignore_dotdot_pattern_is_noop(self, temp_dir, valid_manifest_data):\n        \"\"\"Patterns with '..' should not escape the extension root.\"\"\"\n        ext_dir = self._make_extension(\n            temp_dir,\n            valid_manifest_data,\n            extra_files={\"README.md\": \"# Hello\"},\n            ignore_content=\"../sibling/\\n\",\n        )\n\n        proj_dir = temp_dir / \"project\"\n        proj_dir.mkdir()\n        (proj_dir / \".specify\").mkdir()\n\n        manager = ExtensionManager(proj_dir)\n        manager.install_from_directory(ext_dir, \"0.1.0\", register_commands=False)\n\n        dest = proj_dir / \".specify\" / \"extensions\" / \"test-ext\"\n        # Everything should still be copied — the '..' pattern matches nothing inside\n        assert (dest / \"README.md\").exists()\n        assert (dest / \"extension.yml\").exists()\n        assert (dest / \"commands\" / \"hello.md\").exists()\n\n    def test_extensionignore_absolute_path_pattern_is_noop(self, temp_dir, valid_manifest_data):\n        \"\"\"Absolute path patterns should not match anything.\"\"\"\n        ext_dir = self._make_extension(\n            temp_dir,\n            valid_manifest_data,\n            extra_files={\"README.md\": \"# Hello\", \"passwd\": \"sensitive\"},\n            ignore_content=\"/etc/passwd\\n\",\n        )\n\n        proj_dir = temp_dir / \"project\"\n        proj_dir.mkdir()\n        (proj_dir / \".specify\").mkdir()\n\n        manager = ExtensionManager(proj_dir)\n        manager.install_from_directory(ext_dir, \"0.1.0\", register_commands=False)\n\n        dest = proj_dir / \".specify\" / \"extensions\" / \"test-ext\"\n        # Nothing matches — /etc/passwd is anchored to root and there's no 'etc' dir\n        assert (dest / \"README.md\").exists()\n        assert (dest / \"passwd\").exists()\n\n    def test_extensionignore_empty_file(self, temp_dir, valid_manifest_data):\n        \"\"\"An empty .extensionignore should exclude only itself.\"\"\"\n        ext_dir = self._make_extension(\n            temp_dir,\n            valid_manifest_data,\n            extra_files={\"README.md\": \"# Hello\", \"notes.txt\": \"notes\"},\n            ignore_content=\"\",\n        )\n\n        proj_dir = temp_dir / \"project\"\n        proj_dir.mkdir()\n        (proj_dir / \".specify\").mkdir()\n\n        manager = ExtensionManager(proj_dir)\n        manager.install_from_directory(ext_dir, \"0.1.0\", register_commands=False)\n\n        dest = proj_dir / \".specify\" / \"extensions\" / \"test-ext\"\n        assert (dest / \"README.md\").exists()\n        assert (dest / \"notes.txt\").exists()\n        assert (dest / \"extension.yml\").exists()\n        # .extensionignore itself is still excluded\n        assert not (dest / \".extensionignore\").exists()\n\n    def test_extensionignore_windows_backslash_patterns(self, temp_dir, valid_manifest_data):\n        \"\"\"Backslash patterns (Windows-style) are normalised to forward slashes.\"\"\"\n        ext_dir = self._make_extension(\n            temp_dir,\n            valid_manifest_data,\n            extra_files={\n                \"docs/internal/draft.md\": \"draft\",\n                \"docs/guide.md\": \"# Guide\",\n            },\n            ignore_content=\"docs\\\\internal\\\\draft.md\\n\",\n        )\n\n        proj_dir = temp_dir / \"project\"\n        proj_dir.mkdir()\n        (proj_dir / \".specify\").mkdir()\n\n        manager = ExtensionManager(proj_dir)\n        manager.install_from_directory(ext_dir, \"0.1.0\", register_commands=False)\n\n        dest = proj_dir / \".specify\" / \"extensions\" / \"test-ext\"\n        assert (dest / \"docs\" / \"guide.md\").exists()\n        assert not (dest / \"docs\" / \"internal\" / \"draft.md\").exists()\n\n    def test_extensionignore_star_does_not_cross_directories(self, temp_dir, valid_manifest_data):\n        \"\"\"'*' should NOT match across directory boundaries (gitignore semantics).\"\"\"\n        ext_dir = self._make_extension(\n            temp_dir,\n            valid_manifest_data,\n            extra_files={\n                \"docs/api.draft.md\": \"draft\",\n                \"docs/sub/api.draft.md\": \"nested draft\",\n            },\n            ignore_content=\"docs/*.draft.md\\n\",\n        )\n\n        proj_dir = temp_dir / \"project\"\n        proj_dir.mkdir()\n        (proj_dir / \".specify\").mkdir()\n\n        manager = ExtensionManager(proj_dir)\n        manager.install_from_directory(ext_dir, \"0.1.0\", register_commands=False)\n\n        dest = proj_dir / \".specify\" / \"extensions\" / \"test-ext\"\n        # docs/*.draft.md should only match directly inside docs/, NOT subdirs\n        assert not (dest / \"docs\" / \"api.draft.md\").exists()\n        assert (dest / \"docs\" / \"sub\" / \"api.draft.md\").exists()\n\n    def test_extensionignore_doublestar_crosses_directories(self, temp_dir, valid_manifest_data):\n        \"\"\"'**' should match across directory boundaries.\"\"\"\n        ext_dir = self._make_extension(\n            temp_dir,\n            valid_manifest_data,\n            extra_files={\n                \"docs/api.draft.md\": \"draft\",\n                \"docs/sub/api.draft.md\": \"nested draft\",\n                \"docs/guide.md\": \"guide\",\n            },\n            ignore_content=\"docs/**/*.draft.md\\n\",\n        )\n\n        proj_dir = temp_dir / \"project\"\n        proj_dir.mkdir()\n        (proj_dir / \".specify\").mkdir()\n\n        manager = ExtensionManager(proj_dir)\n        manager.install_from_directory(ext_dir, \"0.1.0\", register_commands=False)\n\n        dest = proj_dir / \".specify\" / \"extensions\" / \"test-ext\"\n        assert not (dest / \"docs\" / \"api.draft.md\").exists()\n        assert not (dest / \"docs\" / \"sub\" / \"api.draft.md\").exists()\n        assert (dest / \"docs\" / \"guide.md\").exists()\n\n    def test_extensionignore_negation_pattern(self, temp_dir, valid_manifest_data):\n        \"\"\"'!' negation re-includes a previously excluded file.\"\"\"\n        ext_dir = self._make_extension(\n            temp_dir,\n            valid_manifest_data,\n            extra_files={\n                \"docs/guide.md\": \"# Guide\",\n                \"docs/internal.md\": \"internal\",\n                \"docs/api.md\": \"api\",\n            },\n            ignore_content=\"docs/*.md\\n!docs/api.md\\n\",\n        )\n\n        proj_dir = temp_dir / \"project\"\n        proj_dir.mkdir()\n        (proj_dir / \".specify\").mkdir()\n\n        manager = ExtensionManager(proj_dir)\n        manager.install_from_directory(ext_dir, \"0.1.0\", register_commands=False)\n\n        dest = proj_dir / \".specify\" / \"extensions\" / \"test-ext\"\n        # docs/*.md excludes all .md in docs, but !docs/api.md re-includes it\n        assert not (dest / \"docs\" / \"guide.md\").exists()\n        assert not (dest / \"docs\" / \"internal.md\").exists()\n        assert (dest / \"docs\" / \"api.md\").exists()\n\n\nclass TestExtensionAddCLI:\n    \"\"\"CLI integration tests for extension add command.\"\"\"\n\n    def test_add_by_display_name_uses_resolved_id_for_download(self, tmp_path):\n        \"\"\"extension add by display name should use resolved ID for download_extension().\"\"\"\n        from typer.testing import CliRunner\n        from unittest.mock import patch, MagicMock\n        from specify_cli import app\n\n        runner = CliRunner()\n\n        # Create project structure\n        project_dir = tmp_path / \"test-project\"\n        project_dir.mkdir()\n        (project_dir / \".specify\").mkdir()\n        (project_dir / \".specify\" / \"extensions\").mkdir(parents=True)\n\n        # Mock catalog that returns extension by display name\n        mock_catalog = MagicMock()\n        mock_catalog.get_extension_info.return_value = None  # ID lookup fails\n        mock_catalog.search.return_value = [\n            {\n                \"id\": \"acme-jira-integration\",\n                \"name\": \"Jira Integration\",\n                \"version\": \"1.0.0\",\n                \"description\": \"Jira integration extension\",\n                \"_install_allowed\": True,\n            }\n        ]\n\n        # Track what ID was passed to download_extension\n        download_called_with = []\n        def mock_download(extension_id):\n            download_called_with.append(extension_id)\n            # Return a path that will fail install (we just want to verify the ID)\n            raise ExtensionError(\"Mock download - checking ID was resolved\")\n\n        mock_catalog.download_extension.side_effect = mock_download\n\n        with patch(\"specify_cli.extensions.ExtensionCatalog\", return_value=mock_catalog), \\\n             patch.object(Path, \"cwd\", return_value=project_dir):\n            result = runner.invoke(\n                app,\n                [\"extension\", \"add\", \"Jira Integration\"],\n                catch_exceptions=True,\n            )\n\n        assert result.exit_code != 0, (\n            f\"Expected non-zero exit code since mock download raises, got {result.exit_code}\"\n        )\n\n        # Verify download_extension was called with the resolved ID, not the display name\n        assert len(download_called_with) == 1\n        assert download_called_with[0] == \"acme-jira-integration\", (\n            f\"Expected download_extension to be called with resolved ID 'acme-jira-integration', \"\n            f\"but was called with '{download_called_with[0]}'\"\n        )\n\n\nclass TestExtensionUpdateCLI:\n    \"\"\"CLI integration tests for extension update command.\"\"\"\n\n    @staticmethod\n    def _create_extension_source(base_dir: Path, version: str, include_config: bool = False) -> Path:\n        \"\"\"Create a minimal extension source directory for install tests.\"\"\"\n        import yaml\n\n        ext_dir = base_dir / f\"test-ext-{version}\"\n        ext_dir.mkdir(parents=True, exist_ok=True)\n\n        manifest = {\n            \"schema_version\": \"1.0\",\n            \"extension\": {\n                \"id\": \"test-ext\",\n                \"name\": \"Test Extension\",\n                \"version\": version,\n                \"description\": \"A test extension\",\n            },\n            \"requires\": {\"speckit_version\": \">=0.1.0\"},\n            \"provides\": {\n                \"commands\": [\n                    {\n                        \"name\": \"speckit.test.hello\",\n                        \"file\": \"commands/hello.md\",\n                        \"description\": \"Test command\",\n                    }\n                ]\n            },\n            \"hooks\": {\n                \"after_tasks\": {\n                    \"command\": \"speckit.test.hello\",\n                    \"optional\": True,\n                }\n            },\n        }\n\n        (ext_dir / \"extension.yml\").write_text(yaml.dump(manifest, sort_keys=False))\n        commands_dir = ext_dir / \"commands\"\n        commands_dir.mkdir(exist_ok=True)\n        (commands_dir / \"hello.md\").write_text(\"---\\ndescription: Test\\n---\\n\\n$ARGUMENTS\\n\")\n        if include_config:\n            (ext_dir / \"linear-config.yml\").write_text(\"custom: true\\nvalue: original\\n\")\n        return ext_dir\n\n    @staticmethod\n    def _create_catalog_zip(zip_path: Path, version: str):\n        \"\"\"Create a minimal ZIP that passes extension_update ID validation.\"\"\"\n        import zipfile\n        import yaml\n\n        manifest = {\n            \"schema_version\": \"1.0\",\n            \"extension\": {\n                \"id\": \"test-ext\",\n                \"name\": \"Test Extension\",\n                \"version\": version,\n                \"description\": \"A test extension\",\n            },\n            \"requires\": {\"speckit_version\": \">=0.1.0\"},\n            \"provides\": {\"commands\": [{\"name\": \"speckit.test.hello\", \"file\": \"commands/hello.md\"}]},\n        }\n\n        with zipfile.ZipFile(zip_path, \"w\") as zf:\n            zf.writestr(\"extension.yml\", yaml.dump(manifest, sort_keys=False))\n\n    def test_update_success_preserves_installed_at(self, tmp_path):\n        \"\"\"Successful update should keep original installed_at and apply new version.\"\"\"\n        from typer.testing import CliRunner\n        from unittest.mock import patch\n        from specify_cli import app\n\n        runner = CliRunner()\n        project_dir = tmp_path / \"project\"\n        project_dir.mkdir()\n        (project_dir / \".specify\").mkdir()\n        (project_dir / \".claude\" / \"commands\").mkdir(parents=True)\n\n        manager = ExtensionManager(project_dir)\n        v1_dir = self._create_extension_source(tmp_path, \"1.0.0\", include_config=True)\n        manager.install_from_directory(v1_dir, \"0.1.0\")\n        original_installed_at = manager.registry.get(\"test-ext\")[\"installed_at\"]\n        original_config_content = (\n            project_dir / \".specify\" / \"extensions\" / \"test-ext\" / \"linear-config.yml\"\n        ).read_text()\n\n        zip_path = tmp_path / \"test-ext-update.zip\"\n        self._create_catalog_zip(zip_path, \"2.0.0\")\n        v2_dir = self._create_extension_source(tmp_path, \"2.0.0\")\n\n        def fake_install_from_zip(self_obj, _zip_path, speckit_version):\n            return self_obj.install_from_directory(v2_dir, speckit_version)\n\n        with patch.object(Path, \"cwd\", return_value=project_dir), \\\n             patch.object(ExtensionCatalog, \"get_extension_info\", return_value={\n                 \"id\": \"test-ext\",\n                 \"name\": \"Test Extension\",\n                 \"version\": \"2.0.0\",\n                 \"_install_allowed\": True,\n             }), \\\n             patch.object(ExtensionCatalog, \"download_extension\", return_value=zip_path), \\\n             patch.object(ExtensionManager, \"install_from_zip\", fake_install_from_zip):\n            result = runner.invoke(app, [\"extension\", \"update\", \"test-ext\"], input=\"y\\n\", catch_exceptions=True)\n\n        assert result.exit_code == 0, result.output\n\n        updated = ExtensionManager(project_dir).registry.get(\"test-ext\")\n        assert updated[\"version\"] == \"2.0.0\"\n        assert updated[\"installed_at\"] == original_installed_at\n        restored_config_content = (\n            project_dir / \".specify\" / \"extensions\" / \"test-ext\" / \"linear-config.yml\"\n        ).read_text()\n        assert restored_config_content == original_config_content\n\n    def test_update_failure_rolls_back_registry_hooks_and_commands(self, tmp_path):\n        \"\"\"Failed update should restore original registry, hooks, and command files.\"\"\"\n        from typer.testing import CliRunner\n        from unittest.mock import patch\n        from specify_cli import app\n        import yaml\n\n        runner = CliRunner()\n        project_dir = tmp_path / \"project\"\n        project_dir.mkdir()\n        (project_dir / \".specify\").mkdir()\n        (project_dir / \".claude\" / \"commands\").mkdir(parents=True)\n\n        manager = ExtensionManager(project_dir)\n        v1_dir = self._create_extension_source(tmp_path, \"1.0.0\")\n        manager.install_from_directory(v1_dir, \"0.1.0\")\n\n        backup_registry_entry = manager.registry.get(\"test-ext\")\n        hooks_before = yaml.safe_load((project_dir / \".specify\" / \"extensions.yml\").read_text())\n\n        registered_commands = backup_registry_entry.get(\"registered_commands\", {})\n        command_files = []\n        registrar = CommandRegistrar()\n        for agent_name, cmd_names in registered_commands.items():\n            if agent_name not in registrar.AGENT_CONFIGS:\n                continue\n            agent_cfg = registrar.AGENT_CONFIGS[agent_name]\n            commands_dir = project_dir / agent_cfg[\"dir\"]\n            for cmd_name in cmd_names:\n                cmd_path = commands_dir / f\"{cmd_name}{agent_cfg['extension']}\"\n                command_files.append(cmd_path)\n\n        assert command_files, \"Expected at least one registered command file\"\n        for cmd_file in command_files:\n            assert cmd_file.exists(), f\"Expected command file to exist before update: {cmd_file}\"\n\n        zip_path = tmp_path / \"test-ext-update.zip\"\n        self._create_catalog_zip(zip_path, \"2.0.0\")\n\n        with patch.object(Path, \"cwd\", return_value=project_dir), \\\n             patch.object(ExtensionCatalog, \"get_extension_info\", return_value={\n                 \"id\": \"test-ext\",\n                 \"name\": \"Test Extension\",\n                 \"version\": \"2.0.0\",\n                 \"_install_allowed\": True,\n             }), \\\n             patch.object(ExtensionCatalog, \"download_extension\", return_value=zip_path), \\\n             patch.object(ExtensionManager, \"install_from_zip\", side_effect=RuntimeError(\"install failed\")):\n            result = runner.invoke(app, [\"extension\", \"update\", \"test-ext\"], input=\"y\\n\", catch_exceptions=True)\n\n        assert result.exit_code == 1, result.output\n\n        restored_entry = ExtensionManager(project_dir).registry.get(\"test-ext\")\n        assert restored_entry == backup_registry_entry\n\n        hooks_after = yaml.safe_load((project_dir / \".specify\" / \"extensions.yml\").read_text())\n        assert hooks_after == hooks_before\n\n        for cmd_file in command_files:\n            assert cmd_file.exists(), f\"Expected command file to be restored after rollback: {cmd_file}\"\n\n\nclass TestExtensionListCLI:\n    \"\"\"Test extension list CLI output format.\"\"\"\n\n    def test_list_shows_extension_id(self, extension_dir, project_dir):\n        \"\"\"extension list should display the extension ID.\"\"\"\n        from typer.testing import CliRunner\n        from unittest.mock import patch\n        from specify_cli import app\n\n        runner = CliRunner()\n\n        # Install the extension using the manager\n        manager = ExtensionManager(project_dir)\n        manager.install_from_directory(extension_dir, \"0.1.0\", register_commands=False)\n\n        with patch.object(Path, \"cwd\", return_value=project_dir):\n            result = runner.invoke(app, [\"extension\", \"list\"])\n\n        assert result.exit_code == 0, result.output\n        # Verify the extension ID is shown in the output\n        assert \"test-ext\" in result.output\n        # Verify name and version are also shown\n        assert \"Test Extension\" in result.output\n        assert \"1.0.0\" in result.output\n\n\nclass TestExtensionPriority:\n    \"\"\"Test extension priority-based resolution.\"\"\"\n\n    def test_list_by_priority_empty(self, temp_dir):\n        \"\"\"Test list_by_priority on empty registry.\"\"\"\n        extensions_dir = temp_dir / \"extensions\"\n        extensions_dir.mkdir()\n\n        registry = ExtensionRegistry(extensions_dir)\n        result = registry.list_by_priority()\n\n        assert result == []\n\n    def test_list_by_priority_single(self, temp_dir):\n        \"\"\"Test list_by_priority with single extension.\"\"\"\n        extensions_dir = temp_dir / \"extensions\"\n        extensions_dir.mkdir()\n\n        registry = ExtensionRegistry(extensions_dir)\n        registry.add(\"test-ext\", {\"version\": \"1.0.0\", \"priority\": 5})\n\n        result = registry.list_by_priority()\n\n        assert len(result) == 1\n        assert result[0][0] == \"test-ext\"\n        assert result[0][1][\"priority\"] == 5\n\n    def test_list_by_priority_ordering(self, temp_dir):\n        \"\"\"Test list_by_priority returns extensions sorted by priority.\"\"\"\n        extensions_dir = temp_dir / \"extensions\"\n        extensions_dir.mkdir()\n\n        registry = ExtensionRegistry(extensions_dir)\n        # Add in non-priority order\n        registry.add(\"ext-low\", {\"version\": \"1.0.0\", \"priority\": 20})\n        registry.add(\"ext-high\", {\"version\": \"1.0.0\", \"priority\": 1})\n        registry.add(\"ext-mid\", {\"version\": \"1.0.0\", \"priority\": 10})\n\n        result = registry.list_by_priority()\n\n        assert len(result) == 3\n        # Lower priority number = higher precedence (first)\n        assert result[0][0] == \"ext-high\"\n        assert result[1][0] == \"ext-mid\"\n        assert result[2][0] == \"ext-low\"\n\n    def test_list_by_priority_default(self, temp_dir):\n        \"\"\"Test list_by_priority uses default priority of 10.\"\"\"\n        extensions_dir = temp_dir / \"extensions\"\n        extensions_dir.mkdir()\n\n        registry = ExtensionRegistry(extensions_dir)\n        # Add without explicit priority\n        registry.add(\"ext-default\", {\"version\": \"1.0.0\"})\n        registry.add(\"ext-high\", {\"version\": \"1.0.0\", \"priority\": 1})\n        registry.add(\"ext-low\", {\"version\": \"1.0.0\", \"priority\": 20})\n\n        result = registry.list_by_priority()\n\n        assert len(result) == 3\n        # ext-high (1), ext-default (10), ext-low (20)\n        assert result[0][0] == \"ext-high\"\n        assert result[1][0] == \"ext-default\"\n        assert result[2][0] == \"ext-low\"\n\n    def test_list_by_priority_invalid_priority_defaults(self, temp_dir):\n        \"\"\"Malformed priority values fall back to the default priority.\"\"\"\n        extensions_dir = temp_dir / \"extensions\"\n        extensions_dir.mkdir()\n\n        registry = ExtensionRegistry(extensions_dir)\n        registry.add(\"ext-high\", {\"version\": \"1.0.0\", \"priority\": 1})\n        registry.data[\"extensions\"][\"ext-invalid\"] = {\n            \"version\": \"1.0.0\",\n            \"priority\": \"high\",\n        }\n        registry._save()\n\n        result = registry.list_by_priority()\n\n        assert [item[0] for item in result] == [\"ext-high\", \"ext-invalid\"]\n        assert result[1][1][\"priority\"] == 10\n\n    def test_list_by_priority_excludes_disabled(self, temp_dir):\n        \"\"\"Test that list_by_priority excludes disabled extensions by default.\"\"\"\n        extensions_dir = temp_dir / \"extensions\"\n        extensions_dir.mkdir()\n\n        registry = ExtensionRegistry(extensions_dir)\n        registry.add(\"ext-enabled\", {\"version\": \"1.0.0\", \"enabled\": True, \"priority\": 5})\n        registry.add(\"ext-disabled\", {\"version\": \"1.0.0\", \"enabled\": False, \"priority\": 1})\n        registry.add(\"ext-default\", {\"version\": \"1.0.0\", \"priority\": 10})  # no enabled field = True\n\n        # Default: exclude disabled\n        by_priority = registry.list_by_priority()\n        ext_ids = [p[0] for p in by_priority]\n        assert \"ext-enabled\" in ext_ids\n        assert \"ext-default\" in ext_ids\n        assert \"ext-disabled\" not in ext_ids\n\n    def test_list_by_priority_includes_disabled_when_requested(self, temp_dir):\n        \"\"\"Test that list_by_priority includes disabled extensions when requested.\"\"\"\n        extensions_dir = temp_dir / \"extensions\"\n        extensions_dir.mkdir()\n\n        registry = ExtensionRegistry(extensions_dir)\n        registry.add(\"ext-enabled\", {\"version\": \"1.0.0\", \"enabled\": True, \"priority\": 5})\n        registry.add(\"ext-disabled\", {\"version\": \"1.0.0\", \"enabled\": False, \"priority\": 1})\n\n        # Include disabled\n        by_priority = registry.list_by_priority(include_disabled=True)\n        ext_ids = [p[0] for p in by_priority]\n        assert \"ext-enabled\" in ext_ids\n        assert \"ext-disabled\" in ext_ids\n        # Disabled ext has lower priority number, so it comes first when included\n        assert ext_ids[0] == \"ext-disabled\"\n\n    def test_install_with_priority(self, extension_dir, project_dir):\n        \"\"\"Test that install_from_directory stores priority.\"\"\"\n        manager = ExtensionManager(project_dir)\n        manager.install_from_directory(extension_dir, \"0.1.0\", register_commands=False, priority=5)\n\n        metadata = manager.registry.get(\"test-ext\")\n        assert metadata[\"priority\"] == 5\n\n    def test_install_default_priority(self, extension_dir, project_dir):\n        \"\"\"Test that install_from_directory uses default priority of 10.\"\"\"\n        manager = ExtensionManager(project_dir)\n        manager.install_from_directory(extension_dir, \"0.1.0\", register_commands=False)\n\n        metadata = manager.registry.get(\"test-ext\")\n        assert metadata[\"priority\"] == 10\n\n    def test_list_installed_includes_priority(self, extension_dir, project_dir):\n        \"\"\"Test that list_installed includes priority in returned data.\"\"\"\n        manager = ExtensionManager(project_dir)\n        manager.install_from_directory(extension_dir, \"0.1.0\", register_commands=False, priority=3)\n\n        installed = manager.list_installed()\n\n        assert len(installed) == 1\n        assert installed[0][\"priority\"] == 3\n\n    def test_priority_preserved_on_update(self, temp_dir):\n        \"\"\"Test that registry update preserves priority.\"\"\"\n        extensions_dir = temp_dir / \"extensions\"\n        extensions_dir.mkdir()\n\n        registry = ExtensionRegistry(extensions_dir)\n        registry.add(\"test-ext\", {\"version\": \"1.0.0\", \"priority\": 5, \"enabled\": True})\n\n        # Update with new metadata (no priority specified)\n        registry.update(\"test-ext\", {\"enabled\": False})\n\n        updated = registry.get(\"test-ext\")\n        assert updated[\"priority\"] == 5  # Preserved\n        assert updated[\"enabled\"] is False  # Updated\n\n    def test_corrupted_extension_entry_not_picked_up_as_unregistered(self, project_dir):\n        \"\"\"Corrupted registry entries are still tracked and NOT picked up as unregistered.\"\"\"\n        extensions_dir = project_dir / \".specify\" / \"extensions\"\n\n        valid_dir = extensions_dir / \"valid-ext\" / \"templates\"\n        valid_dir.mkdir(parents=True)\n        (valid_dir / \"other-template.md\").write_text(\"# Valid\\n\")\n\n        broken_dir = extensions_dir / \"broken-ext\" / \"templates\"\n        broken_dir.mkdir(parents=True)\n        (broken_dir / \"target-template.md\").write_text(\"# Broken Target\\n\")\n\n        registry = ExtensionRegistry(extensions_dir)\n        registry.add(\"valid-ext\", {\"version\": \"1.0.0\", \"priority\": 10})\n        # Corrupt the entry - should still be tracked, not picked up as unregistered\n        registry.data[\"extensions\"][\"broken-ext\"] = \"corrupted\"\n        registry._save()\n\n        from specify_cli.presets import PresetResolver\n\n        resolver = PresetResolver(project_dir)\n        # Corrupted extension templates should NOT be resolved\n        resolved = resolver.resolve(\"target-template\")\n        assert resolved is None\n\n        # Valid extension template should still resolve\n        valid_resolved = resolver.resolve(\"other-template\")\n        assert valid_resolved is not None\n        assert \"Valid\" in valid_resolved.read_text()\n\n\nclass TestExtensionPriorityCLI:\n    \"\"\"Test extension priority CLI integration.\"\"\"\n\n    def test_add_with_priority_option(self, extension_dir, project_dir):\n        \"\"\"Test extension add command with --priority option.\"\"\"\n        from typer.testing import CliRunner\n        from unittest.mock import patch\n        from specify_cli import app\n\n        runner = CliRunner()\n\n        with patch.object(Path, \"cwd\", return_value=project_dir):\n            result = runner.invoke(app, [\n                \"extension\", \"add\", str(extension_dir), \"--dev\", \"--priority\", \"3\"\n            ])\n\n        assert result.exit_code == 0, result.output\n\n        manager = ExtensionManager(project_dir)\n        metadata = manager.registry.get(\"test-ext\")\n        assert metadata[\"priority\"] == 3\n\n    def test_list_shows_priority(self, extension_dir, project_dir):\n        \"\"\"Test extension list shows priority.\"\"\"\n        from typer.testing import CliRunner\n        from unittest.mock import patch\n        from specify_cli import app\n\n        runner = CliRunner()\n\n        # Install extension with priority\n        manager = ExtensionManager(project_dir)\n        manager.install_from_directory(extension_dir, \"0.1.0\", register_commands=False, priority=7)\n\n        with patch.object(Path, \"cwd\", return_value=project_dir):\n            result = runner.invoke(app, [\"extension\", \"list\"])\n\n        assert result.exit_code == 0, result.output\n        assert \"Priority: 7\" in result.output\n\n    def test_set_priority_changes_priority(self, extension_dir, project_dir):\n        \"\"\"Test set-priority command changes extension priority.\"\"\"\n        from typer.testing import CliRunner\n        from unittest.mock import patch\n        from specify_cli import app\n\n        runner = CliRunner()\n\n        # Install extension with default priority\n        manager = ExtensionManager(project_dir)\n        manager.install_from_directory(extension_dir, \"0.1.0\", register_commands=False)\n\n        # Verify default priority\n        assert manager.registry.get(\"test-ext\")[\"priority\"] == 10\n\n        with patch.object(Path, \"cwd\", return_value=project_dir):\n            result = runner.invoke(app, [\"extension\", \"set-priority\", \"test-ext\", \"5\"])\n\n        assert result.exit_code == 0, result.output\n        assert \"priority changed: 10 → 5\" in result.output\n\n        # Reload registry to see updated value\n        manager2 = ExtensionManager(project_dir)\n        assert manager2.registry.get(\"test-ext\")[\"priority\"] == 5\n\n    def test_set_priority_same_value_no_change(self, extension_dir, project_dir):\n        \"\"\"Test set-priority with same value shows already set message.\"\"\"\n        from typer.testing import CliRunner\n        from unittest.mock import patch\n        from specify_cli import app\n\n        runner = CliRunner()\n\n        # Install extension with priority 5\n        manager = ExtensionManager(project_dir)\n        manager.install_from_directory(extension_dir, \"0.1.0\", register_commands=False, priority=5)\n\n        with patch.object(Path, \"cwd\", return_value=project_dir):\n            result = runner.invoke(app, [\"extension\", \"set-priority\", \"test-ext\", \"5\"])\n\n        assert result.exit_code == 0, result.output\n        assert \"already has priority 5\" in result.output\n\n    def test_set_priority_invalid_value(self, extension_dir, project_dir):\n        \"\"\"Test set-priority rejects invalid priority values.\"\"\"\n        from typer.testing import CliRunner\n        from unittest.mock import patch\n        from specify_cli import app\n\n        runner = CliRunner()\n\n        # Install extension\n        manager = ExtensionManager(project_dir)\n        manager.install_from_directory(extension_dir, \"0.1.0\", register_commands=False)\n\n        with patch.object(Path, \"cwd\", return_value=project_dir):\n            result = runner.invoke(app, [\"extension\", \"set-priority\", \"test-ext\", \"0\"])\n\n        assert result.exit_code == 1, result.output\n        assert \"Priority must be a positive integer\" in result.output\n\n    def test_set_priority_not_installed(self, project_dir):\n        \"\"\"Test set-priority fails for non-installed extension.\"\"\"\n        from typer.testing import CliRunner\n        from unittest.mock import patch\n        from specify_cli import app\n\n        runner = CliRunner()\n\n        # Ensure .specify exists\n        (project_dir / \".specify\").mkdir(parents=True, exist_ok=True)\n\n        with patch.object(Path, \"cwd\", return_value=project_dir):\n            result = runner.invoke(app, [\"extension\", \"set-priority\", \"nonexistent\", \"5\"])\n\n        assert result.exit_code == 1, result.output\n        assert \"not installed\" in result.output.lower() or \"no extensions installed\" in result.output.lower()\n\n    def test_set_priority_by_display_name(self, extension_dir, project_dir):\n        \"\"\"Test set-priority works with extension display name.\"\"\"\n        from typer.testing import CliRunner\n        from unittest.mock import patch\n        from specify_cli import app\n\n        runner = CliRunner()\n\n        # Install extension\n        manager = ExtensionManager(project_dir)\n        manager.install_from_directory(extension_dir, \"0.1.0\", register_commands=False)\n\n        # Use display name \"Test Extension\" instead of ID \"test-ext\"\n        with patch.object(Path, \"cwd\", return_value=project_dir):\n            result = runner.invoke(app, [\"extension\", \"set-priority\", \"Test Extension\", \"3\"])\n\n        assert result.exit_code == 0, result.output\n        assert \"priority changed\" in result.output\n\n        # Reload registry to see updated value\n        manager2 = ExtensionManager(project_dir)\n        assert manager2.registry.get(\"test-ext\")[\"priority\"] == 3\n\n\nclass TestExtensionPriorityBackwardsCompatibility:\n    \"\"\"Test backwards compatibility for extensions installed before priority feature.\"\"\"\n\n    def test_legacy_extension_without_priority_field(self, temp_dir):\n        \"\"\"Extensions installed before priority feature should default to 10.\"\"\"\n        extensions_dir = temp_dir / \"extensions\"\n        extensions_dir.mkdir()\n\n        # Simulate legacy registry entry without priority field\n        registry = ExtensionRegistry(extensions_dir)\n        registry.data[\"extensions\"][\"legacy-ext\"] = {\n            \"version\": \"1.0.0\",\n            \"source\": \"local\",\n            \"enabled\": True,\n            \"installed_at\": \"2025-01-01T00:00:00Z\",\n            # No \"priority\" field - simulates pre-feature extension\n        }\n        registry._save()\n\n        # Reload registry\n        registry2 = ExtensionRegistry(extensions_dir)\n\n        # list_by_priority should use default of 10\n        result = registry2.list_by_priority()\n        assert len(result) == 1\n        assert result[0][0] == \"legacy-ext\"\n        # Priority defaults to 10 and is normalized in returned metadata\n        assert result[0][1][\"priority\"] == 10\n\n    def test_legacy_extension_in_list_installed(self, extension_dir, project_dir):\n        \"\"\"list_installed returns priority=10 for legacy extensions without priority field.\"\"\"\n        manager = ExtensionManager(project_dir)\n\n        # Install extension normally\n        manager.install_from_directory(extension_dir, \"0.1.0\", register_commands=False)\n\n        # Manually remove priority to simulate legacy extension\n        ext_data = manager.registry.data[\"extensions\"][\"test-ext\"]\n        del ext_data[\"priority\"]\n        manager.registry._save()\n\n        # list_installed should still return priority=10\n        installed = manager.list_installed()\n        assert len(installed) == 1\n        assert installed[0][\"priority\"] == 10\n\n    def test_mixed_legacy_and_new_extensions_ordering(self, temp_dir):\n        \"\"\"Legacy extensions (no priority) sort with default=10 among prioritized extensions.\"\"\"\n        extensions_dir = temp_dir / \"extensions\"\n        extensions_dir.mkdir()\n\n        registry = ExtensionRegistry(extensions_dir)\n\n        # Add extension with explicit priority=5\n        registry.add(\"ext-with-priority\", {\"version\": \"1.0.0\", \"priority\": 5})\n\n        # Add legacy extension without priority (manually)\n        registry.data[\"extensions\"][\"legacy-ext\"] = {\n            \"version\": \"1.0.0\",\n            \"source\": \"local\",\n            \"enabled\": True,\n            # No priority field\n        }\n        registry._save()\n\n        # Add extension with priority=15\n        registry.add(\"ext-low-priority\", {\"version\": \"1.0.0\", \"priority\": 15})\n\n        # Reload and check ordering\n        registry2 = ExtensionRegistry(extensions_dir)\n        result = registry2.list_by_priority()\n\n        assert len(result) == 3\n        # Order: ext-with-priority (5), legacy-ext (defaults to 10), ext-low-priority (15)\n        assert result[0][0] == \"ext-with-priority\"\n        assert result[1][0] == \"legacy-ext\"\n        assert result[2][0] == \"ext-low-priority\"\n"
  },
  {
    "path": "tests/test_merge.py",
    "content": "import stat\n\nfrom specify_cli import merge_json_files\nfrom specify_cli import handle_vscode_settings\n\n# --- Dimension 2: Polite Deep Merge Strategy ---\n\ndef test_merge_json_files_type_mismatch_preservation(tmp_path):\n    \"\"\"If user has a string but template wants a dict, PRESERVE user's string.\"\"\"\n    existing_file = tmp_path / \"settings.json\"\n    # User might have overridden a setting with a simple string or different type\n    existing_file.write_text('{\"chat.editor.fontFamily\": \"CustomFont\"}')\n    \n    # Template might expect a dict for the same key (hypothetically)\n    new_settings = {\n        \"chat.editor.fontFamily\": {\"font\": \"TemplateFont\"}\n    }\n    \n    merged = merge_json_files(existing_file, new_settings)\n    # Result is None because user settings were preserved and nothing else changed\n    assert merged is None\n\ndef test_merge_json_files_deep_nesting(tmp_path):\n    \"\"\"Verify deep recursive merging of new keys.\"\"\"\n    existing_file = tmp_path / \"settings.json\"\n    existing_file.write_text(\"\"\"\n    {\n        \"a\": {\n            \"b\": {\n                \"c\": 1\n            }\n        }\n    }\n    \"\"\")\n    \n    new_settings = {\n        \"a\": {\n            \"b\": {\n                \"d\": 2  # New nested key\n            },\n            \"e\": 3      # New mid-level key\n        }\n    }\n    \n    merged = merge_json_files(existing_file, new_settings)\n    assert merged[\"a\"][\"b\"][\"c\"] == 1\n    assert merged[\"a\"][\"b\"][\"d\"] == 2\n    assert merged[\"a\"][\"e\"] == 3\n\ndef test_merge_json_files_empty_existing(tmp_path):\n    \"\"\"Merging into an empty/new file.\"\"\"\n    existing_file = tmp_path / \"empty.json\"\n    existing_file.write_text(\"{}\")\n    \n    new_settings = {\"a\": 1}\n    merged = merge_json_files(existing_file, new_settings)\n    assert merged == {\"a\": 1}\n\n# --- Dimension 3: Real-world Simulation ---\n\ndef test_merge_vscode_realistic_scenario(tmp_path):\n    \"\"\"A realistic VSCode settings.json with many existing preferences, comments, and trailing commas.\"\"\"\n    existing_file = tmp_path / \"vscode_settings.json\"\n    existing_file.write_text(\"\"\"\n    {\n        \"editor.fontSize\": 12,\n        \"editor.formatOnSave\": true, /* block comment */\n        \"files.exclude\": {\n            \"**/.git\": true,\n            \"**/node_modules\": true,\n        },\n        \"chat.promptFilesRecommendations\": {\n            \"existing.tool\": true,\n        } // User comment\n    }\n    \"\"\")\n    \n    template_settings = {\n        \"chat.promptFilesRecommendations\": {\n            \"speckit.specify\": True,\n            \"speckit.plan\": True\n        },\n        \"chat.tools.terminal.autoApprove\": {\n            \".specify/scripts/bash/\": True\n        }\n    }\n    \n    merged = merge_json_files(existing_file, template_settings)\n    \n    # Check preservation\n    assert merged[\"editor.fontSize\"] == 12\n    assert merged[\"files.exclude\"][\"**/.git\"] is True\n    assert merged[\"chat.promptFilesRecommendations\"][\"existing.tool\"] is True\n    \n    # Check additions\n    assert merged[\"chat.promptFilesRecommendations\"][\"speckit.specify\"] is True\n    assert merged[\"chat.tools.terminal.autoApprove\"][\".specify/scripts/bash/\"] is True\n\n# --- Dimension 4: Error Handling & Robustness ---\n\ndef test_merge_json_files_with_bom(tmp_path):\n    \"\"\"Test files with UTF-8 BOM (sometimes created on Windows).\"\"\"\n    existing_file = tmp_path / \"bom.json\"\n    content = '{\"a\": 1}'\n    # Prepend UTF-8 BOM\n    existing_file.write_bytes(b'\\xef\\xbb\\xbf' + content.encode('utf-8'))\n    \n    new_settings = {\"b\": 2}\n    merged = merge_json_files(existing_file, new_settings)\n    assert merged == {\"a\": 1, \"b\": 2}\n\ndef test_merge_json_files_not_a_dictionary_template(tmp_path):\n    \"\"\"If for some reason new_content is not a dict, PRESERVE existing settings by returning None.\"\"\"\n    existing_file = tmp_path / \"ok.json\"\n    existing_file.write_text('{\"a\": 1}')\n    \n    # Secure fallback: return None to skip writing and avoid clobbering\n    assert merge_json_files(existing_file, [\"not\", \"a\", \"dict\"]) is None\n\ndef test_merge_json_files_unparseable_existing(tmp_path):\n    \"\"\"If the existing file is unparseable JSON, return None to avoid overwriting it.\"\"\"\n    bad_file = tmp_path / \"bad.json\"\n    bad_file.write_text('{\"a\": 1, missing_value}') # Invalid JSON\n    \n    assert merge_json_files(bad_file, {\"b\": 2}) is None\n\n\ndef test_merge_json_files_list_preservation(tmp_path):\n    \"\"\"Verify that existing list values are preserved and NOT merged or overwritten.\"\"\"\n    existing_file = tmp_path / \"list.json\"\n    existing_file.write_text('{\"my.list\": [\"user_item\"]}')\n    \n    template_settings = {\n        \"my.list\": [\"template_item\"]\n    }\n    \n    merged = merge_json_files(existing_file, template_settings)\n    # The polite merge policy says: keep existing values if they exist and aren't both dicts.\n    # Since nothing changed, it returns None.\n    assert merged is None\n\ndef test_merge_json_files_no_changes(tmp_path):\n    \"\"\"If the merge doesn't introduce any new keys or changes, return None to skip rewrite.\"\"\"\n    existing_file = tmp_path / \"no_change.json\"\n    existing_file.write_text('{\"a\": 1, \"b\": {\"c\": 2}}')\n    \n    template_settings = {\n        \"a\": 1,          # Already exists\n        \"b\": {\"c\": 2}    # Already exists nested\n    }\n    \n    # Should return None because result == existing\n    assert merge_json_files(existing_file, template_settings) is None\n\ndef test_merge_json_files_type_mismatch_no_op(tmp_path):\n    \"\"\"If a key exists with different type and we preserve it, it might still result in no change.\"\"\"\n    existing_file = tmp_path / \"mismatch_no_op.json\"\n    existing_file.write_text('{\"a\": \"user_string\"}')\n    \n    template_settings = {\n        \"a\": {\"key\": \"template_dict\"} # Mismatch, will be ignored\n    }\n    \n    # Should return None because we preserved the user's string and nothing else changed\n    assert merge_json_files(existing_file, template_settings) is None\n\n\ndef test_handle_vscode_settings_preserves_mode_on_atomic_write(tmp_path):\n    \"\"\"Atomic rewrite should preserve existing file mode bits.\"\"\"\n    vscode_dir = tmp_path / \".vscode\"\n    vscode_dir.mkdir()\n    dest_file = vscode_dir / \"settings.json\"\n    template_file = tmp_path / \"template_settings.json\"\n\n    dest_file.write_text('{\"a\": 1}\\n', encoding=\"utf-8\")\n    dest_file.chmod(0o640)\n    before_mode = stat.S_IMODE(dest_file.stat().st_mode)\n\n    template_file.write_text('{\"b\": 2}\\n', encoding=\"utf-8\")\n\n    handle_vscode_settings(\n        template_file,\n        dest_file,\n        \"settings.json\",\n        verbose=False,\n        tracker=None,\n    )\n\n    after_mode = stat.S_IMODE(dest_file.stat().st_mode)\n    assert after_mode == before_mode\n"
  },
  {
    "path": "tests/test_presets.py",
    "content": "\"\"\"\nUnit tests for the preset system.\n\nTests cover:\n- Preset manifest validation\n- Preset registry operations\n- Preset manager installation/removal\n- Template catalog search\n- Template resolver priority stack\n- Extension-provided templates\n\"\"\"\n\nimport pytest\nimport json\nimport tempfile\nimport shutil\nimport zipfile\nfrom pathlib import Path\nfrom datetime import datetime, timezone\n\nimport yaml\n\nfrom specify_cli.presets import (\n    PresetManifest,\n    PresetRegistry,\n    PresetManager,\n    PresetCatalog,\n    PresetCatalogEntry,\n    PresetResolver,\n    PresetError,\n    PresetValidationError,\n    PresetCompatibilityError,\n    VALID_PRESET_TEMPLATE_TYPES,\n)\nfrom specify_cli.extensions import ExtensionRegistry\n\n\n# ===== Fixtures =====\n\n\n@pytest.fixture\ndef temp_dir():\n    \"\"\"Create a temporary directory for tests.\"\"\"\n    tmpdir = tempfile.mkdtemp()\n    yield Path(tmpdir)\n    shutil.rmtree(tmpdir)\n\n\n@pytest.fixture\ndef valid_pack_data():\n    \"\"\"Valid preset manifest data.\"\"\"\n    return {\n        \"schema_version\": \"1.0\",\n        \"preset\": {\n            \"id\": \"test-pack\",\n            \"name\": \"Test Preset\",\n            \"version\": \"1.0.0\",\n            \"description\": \"A test preset\",\n            \"author\": \"Test Author\",\n            \"repository\": \"https://github.com/test/test-pack\",\n            \"license\": \"MIT\",\n        },\n        \"requires\": {\n            \"speckit_version\": \">=0.1.0\",\n        },\n        \"provides\": {\n            \"templates\": [\n                {\n                    \"type\": \"template\",\n                    \"name\": \"spec-template\",\n                    \"file\": \"templates/spec-template.md\",\n                    \"description\": \"Custom spec template\",\n                    \"replaces\": \"spec-template\",\n                }\n            ]\n        },\n        \"tags\": [\"testing\", \"example\"],\n    }\n\n\n@pytest.fixture\ndef pack_dir(temp_dir, valid_pack_data):\n    \"\"\"Create a complete preset directory structure.\"\"\"\n    p_dir = temp_dir / \"test-pack\"\n    p_dir.mkdir()\n\n    # Write manifest\n    manifest_path = p_dir / \"preset.yml\"\n    with open(manifest_path, 'w') as f:\n        yaml.dump(valid_pack_data, f)\n\n    # Create templates directory\n    templates_dir = p_dir / \"templates\"\n    templates_dir.mkdir()\n\n    # Write template file\n    tmpl_file = templates_dir / \"spec-template.md\"\n    tmpl_file.write_text(\"# Custom Spec Template\\n\\nThis is a custom template.\\n\")\n\n    return p_dir\n\n\n@pytest.fixture\ndef project_dir(temp_dir):\n    \"\"\"Create a mock spec-kit project directory.\"\"\"\n    proj_dir = temp_dir / \"project\"\n    proj_dir.mkdir()\n\n    # Create .specify directory\n    specify_dir = proj_dir / \".specify\"\n    specify_dir.mkdir()\n\n    # Create templates directory with core templates\n    templates_dir = specify_dir / \"templates\"\n    templates_dir.mkdir()\n\n    # Create core spec-template\n    core_spec = templates_dir / \"spec-template.md\"\n    core_spec.write_text(\"# Core Spec Template\\n\")\n\n    # Create core plan-template\n    core_plan = templates_dir / \"plan-template.md\"\n    core_plan.write_text(\"# Core Plan Template\\n\")\n\n    # Create commands subdirectory\n    commands_dir = templates_dir / \"commands\"\n    commands_dir.mkdir()\n\n    return proj_dir\n\n\n# ===== PresetManifest Tests =====\n\n\nclass TestPresetManifest:\n    \"\"\"Test PresetManifest validation and parsing.\"\"\"\n\n    def test_valid_manifest(self, pack_dir):\n        \"\"\"Test loading a valid manifest.\"\"\"\n        manifest = PresetManifest(pack_dir / \"preset.yml\")\n        assert manifest.id == \"test-pack\"\n        assert manifest.name == \"Test Preset\"\n        assert manifest.version == \"1.0.0\"\n        assert manifest.description == \"A test preset\"\n        assert manifest.author == \"Test Author\"\n        assert manifest.requires_speckit_version == \">=0.1.0\"\n        assert len(manifest.templates) == 1\n        assert manifest.tags == [\"testing\", \"example\"]\n\n    def test_missing_manifest(self, temp_dir):\n        \"\"\"Test that missing manifest raises error.\"\"\"\n        with pytest.raises(PresetValidationError, match=\"Manifest not found\"):\n            PresetManifest(temp_dir / \"nonexistent.yml\")\n\n    def test_invalid_yaml(self, temp_dir):\n        \"\"\"Test that invalid YAML raises error.\"\"\"\n        bad_file = temp_dir / \"bad.yml\"\n        bad_file.write_text(\": invalid: yaml: {{{\")\n        with pytest.raises(PresetValidationError, match=\"Invalid YAML\"):\n            PresetManifest(bad_file)\n\n    def test_missing_schema_version(self, temp_dir, valid_pack_data):\n        \"\"\"Test missing schema_version field.\"\"\"\n        del valid_pack_data[\"schema_version\"]\n        manifest_path = temp_dir / \"preset.yml\"\n        with open(manifest_path, 'w') as f:\n            yaml.dump(valid_pack_data, f)\n        with pytest.raises(PresetValidationError, match=\"Missing required field: schema_version\"):\n            PresetManifest(manifest_path)\n\n    def test_wrong_schema_version(self, temp_dir, valid_pack_data):\n        \"\"\"Test unsupported schema version.\"\"\"\n        valid_pack_data[\"schema_version\"] = \"2.0\"\n        manifest_path = temp_dir / \"preset.yml\"\n        with open(manifest_path, 'w') as f:\n            yaml.dump(valid_pack_data, f)\n        with pytest.raises(PresetValidationError, match=\"Unsupported schema version\"):\n            PresetManifest(manifest_path)\n\n    def test_missing_pack_id(self, temp_dir, valid_pack_data):\n        \"\"\"Test missing preset.id field.\"\"\"\n        del valid_pack_data[\"preset\"][\"id\"]\n        manifest_path = temp_dir / \"preset.yml\"\n        with open(manifest_path, 'w') as f:\n            yaml.dump(valid_pack_data, f)\n        with pytest.raises(PresetValidationError, match=\"Missing preset.id\"):\n            PresetManifest(manifest_path)\n\n    def test_invalid_pack_id_format(self, temp_dir, valid_pack_data):\n        \"\"\"Test invalid pack ID format.\"\"\"\n        valid_pack_data[\"preset\"][\"id\"] = \"Invalid_ID\"\n        manifest_path = temp_dir / \"preset.yml\"\n        with open(manifest_path, 'w') as f:\n            yaml.dump(valid_pack_data, f)\n        with pytest.raises(PresetValidationError, match=\"Invalid preset ID\"):\n            PresetManifest(manifest_path)\n\n    def test_invalid_version(self, temp_dir, valid_pack_data):\n        \"\"\"Test invalid semantic version.\"\"\"\n        valid_pack_data[\"preset\"][\"version\"] = \"not-a-version\"\n        manifest_path = temp_dir / \"preset.yml\"\n        with open(manifest_path, 'w') as f:\n            yaml.dump(valid_pack_data, f)\n        with pytest.raises(PresetValidationError, match=\"Invalid version\"):\n            PresetManifest(manifest_path)\n\n    def test_missing_speckit_version(self, temp_dir, valid_pack_data):\n        \"\"\"Test missing requires.speckit_version.\"\"\"\n        del valid_pack_data[\"requires\"][\"speckit_version\"]\n        manifest_path = temp_dir / \"preset.yml\"\n        with open(manifest_path, 'w') as f:\n            yaml.dump(valid_pack_data, f)\n        with pytest.raises(PresetValidationError, match=\"Missing requires.speckit_version\"):\n            PresetManifest(manifest_path)\n\n    def test_no_templates_provided(self, temp_dir, valid_pack_data):\n        \"\"\"Test pack with no templates.\"\"\"\n        valid_pack_data[\"provides\"][\"templates\"] = []\n        manifest_path = temp_dir / \"preset.yml\"\n        with open(manifest_path, 'w') as f:\n            yaml.dump(valid_pack_data, f)\n        with pytest.raises(PresetValidationError, match=\"must provide at least one template\"):\n            PresetManifest(manifest_path)\n\n    def test_invalid_template_type(self, temp_dir, valid_pack_data):\n        \"\"\"Test template with invalid type.\"\"\"\n        valid_pack_data[\"provides\"][\"templates\"][0][\"type\"] = \"invalid\"\n        manifest_path = temp_dir / \"preset.yml\"\n        with open(manifest_path, 'w') as f:\n            yaml.dump(valid_pack_data, f)\n        with pytest.raises(PresetValidationError, match=\"Invalid template type\"):\n            PresetManifest(manifest_path)\n\n    def test_valid_template_types(self):\n        \"\"\"Test that all expected template types are valid.\"\"\"\n        assert \"template\" in VALID_PRESET_TEMPLATE_TYPES\n        assert \"command\" in VALID_PRESET_TEMPLATE_TYPES\n        assert \"script\" in VALID_PRESET_TEMPLATE_TYPES\n\n    def test_template_missing_required_fields(self, temp_dir, valid_pack_data):\n        \"\"\"Test template missing required fields.\"\"\"\n        valid_pack_data[\"provides\"][\"templates\"] = [{\"type\": \"template\"}]\n        manifest_path = temp_dir / \"preset.yml\"\n        with open(manifest_path, 'w') as f:\n            yaml.dump(valid_pack_data, f)\n        with pytest.raises(PresetValidationError, match=\"missing 'type', 'name', or 'file'\"):\n            PresetManifest(manifest_path)\n\n    def test_invalid_template_name_format(self, temp_dir, valid_pack_data):\n        \"\"\"Test template with invalid name format.\"\"\"\n        valid_pack_data[\"provides\"][\"templates\"][0][\"name\"] = \"Invalid Name\"\n        manifest_path = temp_dir / \"preset.yml\"\n        with open(manifest_path, 'w') as f:\n            yaml.dump(valid_pack_data, f)\n        with pytest.raises(PresetValidationError, match=\"Invalid template name\"):\n            PresetManifest(manifest_path)\n\n    def test_get_hash(self, pack_dir):\n        \"\"\"Test manifest hash calculation.\"\"\"\n        manifest = PresetManifest(pack_dir / \"preset.yml\")\n        hash_val = manifest.get_hash()\n        assert hash_val.startswith(\"sha256:\")\n        assert len(hash_val) > 10\n\n    def test_multiple_templates(self, temp_dir, valid_pack_data):\n        \"\"\"Test pack with multiple templates of different types.\"\"\"\n        valid_pack_data[\"provides\"][\"templates\"] = [\n            {\"type\": \"template\", \"name\": \"spec-template\", \"file\": \"templates/spec-template.md\"},\n            {\"type\": \"template\", \"name\": \"plan-template\", \"file\": \"templates/plan-template.md\"},\n            {\"type\": \"command\", \"name\": \"specify\", \"file\": \"commands/specify.md\"},\n            {\"type\": \"script\", \"name\": \"create-new-feature\", \"file\": \"scripts/create-new-feature.sh\"},\n        ]\n        manifest_path = temp_dir / \"preset.yml\"\n        with open(manifest_path, 'w') as f:\n            yaml.dump(valid_pack_data, f)\n        manifest = PresetManifest(manifest_path)\n        assert len(manifest.templates) == 4\n\n\n# ===== PresetRegistry Tests =====\n\n\nclass TestPresetRegistry:\n    \"\"\"Test PresetRegistry operations.\"\"\"\n\n    def test_empty_registry(self, temp_dir):\n        \"\"\"Test empty registry initialization.\"\"\"\n        packs_dir = temp_dir / \"packs\"\n        packs_dir.mkdir()\n        registry = PresetRegistry(packs_dir)\n        assert registry.list() == {}\n        assert not registry.is_installed(\"test-pack\")\n\n    def test_add_and_get(self, temp_dir):\n        \"\"\"Test adding and retrieving a pack.\"\"\"\n        packs_dir = temp_dir / \"packs\"\n        packs_dir.mkdir()\n        registry = PresetRegistry(packs_dir)\n\n        registry.add(\"test-pack\", {\"version\": \"1.0.0\", \"source\": \"local\"})\n        assert registry.is_installed(\"test-pack\")\n\n        metadata = registry.get(\"test-pack\")\n        assert metadata is not None\n        assert metadata[\"version\"] == \"1.0.0\"\n        assert \"installed_at\" in metadata\n\n    def test_remove(self, temp_dir):\n        \"\"\"Test removing a pack.\"\"\"\n        packs_dir = temp_dir / \"packs\"\n        packs_dir.mkdir()\n        registry = PresetRegistry(packs_dir)\n\n        registry.add(\"test-pack\", {\"version\": \"1.0.0\"})\n        assert registry.is_installed(\"test-pack\")\n\n        registry.remove(\"test-pack\")\n        assert not registry.is_installed(\"test-pack\")\n\n    def test_remove_nonexistent(self, temp_dir):\n        \"\"\"Test removing a pack that doesn't exist.\"\"\"\n        packs_dir = temp_dir / \"packs\"\n        packs_dir.mkdir()\n        registry = PresetRegistry(packs_dir)\n        registry.remove(\"nonexistent\")  # Should not raise\n\n    def test_list(self, temp_dir):\n        \"\"\"Test listing all packs.\"\"\"\n        packs_dir = temp_dir / \"packs\"\n        packs_dir.mkdir()\n        registry = PresetRegistry(packs_dir)\n\n        registry.add(\"pack-a\", {\"version\": \"1.0.0\"})\n        registry.add(\"pack-b\", {\"version\": \"2.0.0\"})\n\n        all_packs = registry.list()\n        assert len(all_packs) == 2\n        assert \"pack-a\" in all_packs\n        assert \"pack-b\" in all_packs\n\n    def test_persistence(self, temp_dir):\n        \"\"\"Test that registry data persists across instances.\"\"\"\n        packs_dir = temp_dir / \"packs\"\n        packs_dir.mkdir()\n\n        # Add with first instance\n        registry1 = PresetRegistry(packs_dir)\n        registry1.add(\"test-pack\", {\"version\": \"1.0.0\"})\n\n        # Load with second instance\n        registry2 = PresetRegistry(packs_dir)\n        assert registry2.is_installed(\"test-pack\")\n\n    def test_corrupted_registry(self, temp_dir):\n        \"\"\"Test recovery from corrupted registry file.\"\"\"\n        packs_dir = temp_dir / \"packs\"\n        packs_dir.mkdir()\n\n        registry_file = packs_dir / \".registry\"\n        registry_file.write_text(\"not valid json{{{\")\n\n        registry = PresetRegistry(packs_dir)\n        assert registry.list() == {}\n\n    def test_get_nonexistent(self, temp_dir):\n        \"\"\"Test getting a nonexistent pack.\"\"\"\n        packs_dir = temp_dir / \"packs\"\n        packs_dir.mkdir()\n        registry = PresetRegistry(packs_dir)\n        assert registry.get(\"nonexistent\") is None\n\n    def test_restore(self, temp_dir):\n        \"\"\"Test restore() preserves timestamps exactly.\"\"\"\n        packs_dir = temp_dir / \"packs\"\n        packs_dir.mkdir()\n        registry = PresetRegistry(packs_dir)\n\n        # Create original entry with a specific timestamp\n        original_metadata = {\n            \"version\": \"1.0.0\",\n            \"source\": \"local\",\n            \"installed_at\": \"2025-01-15T10:30:00+00:00\",\n            \"enabled\": True,\n        }\n        registry.restore(\"test-pack\", original_metadata)\n\n        # Verify exact restoration\n        restored = registry.get(\"test-pack\")\n        assert restored[\"installed_at\"] == \"2025-01-15T10:30:00+00:00\"\n        assert restored[\"version\"] == \"1.0.0\"\n        assert restored[\"enabled\"] is True\n\n    def test_restore_rejects_none_metadata(self, temp_dir):\n        \"\"\"Test restore() raises ValueError for None metadata.\"\"\"\n        packs_dir = temp_dir / \"packs\"\n        packs_dir.mkdir()\n        registry = PresetRegistry(packs_dir)\n\n        with pytest.raises(ValueError, match=\"metadata must be a dict\"):\n            registry.restore(\"test-pack\", None)\n\n    def test_restore_rejects_non_dict_metadata(self, temp_dir):\n        \"\"\"Test restore() raises ValueError for non-dict metadata.\"\"\"\n        packs_dir = temp_dir / \"packs\"\n        packs_dir.mkdir()\n        registry = PresetRegistry(packs_dir)\n\n        with pytest.raises(ValueError, match=\"metadata must be a dict\"):\n            registry.restore(\"test-pack\", \"not-a-dict\")\n\n        with pytest.raises(ValueError, match=\"metadata must be a dict\"):\n            registry.restore(\"test-pack\", [\"list\", \"not\", \"dict\"])\n\n    def test_restore_uses_deep_copy(self, temp_dir):\n        \"\"\"Test restore() deep copies metadata to prevent mutation.\"\"\"\n        packs_dir = temp_dir / \"packs\"\n        packs_dir.mkdir()\n        registry = PresetRegistry(packs_dir)\n\n        original_metadata = {\n            \"version\": \"1.0.0\",\n            \"nested\": {\"key\": \"original\"},\n        }\n        registry.restore(\"test-pack\", original_metadata)\n\n        # Mutate the original metadata after restore\n        original_metadata[\"version\"] = \"MUTATED\"\n        original_metadata[\"nested\"][\"key\"] = \"MUTATED\"\n\n        # Registry should have the original values\n        stored = registry.get(\"test-pack\")\n        assert stored[\"version\"] == \"1.0.0\"\n        assert stored[\"nested\"][\"key\"] == \"original\"\n\n    def test_get_returns_deep_copy(self, temp_dir):\n        \"\"\"Test that get() returns a deep copy to prevent mutation.\"\"\"\n        packs_dir = temp_dir / \"packs\"\n        packs_dir.mkdir()\n        registry = PresetRegistry(packs_dir)\n\n        registry.add(\"test-pack\", {\"version\": \"1.0.0\", \"nested\": {\"key\": \"original\"}})\n\n        # Get and mutate the returned copy\n        metadata = registry.get(\"test-pack\")\n        metadata[\"version\"] = \"MUTATED\"\n        metadata[\"nested\"][\"key\"] = \"MUTATED\"\n\n        # Original should be unchanged\n        fresh = registry.get(\"test-pack\")\n        assert fresh[\"version\"] == \"1.0.0\"\n        assert fresh[\"nested\"][\"key\"] == \"original\"\n\n    def test_get_returns_none_for_corrupted_entry(self, temp_dir):\n        \"\"\"Test that get() returns None for corrupted (non-dict) entries.\"\"\"\n        packs_dir = temp_dir / \"packs\"\n        packs_dir.mkdir()\n        registry = PresetRegistry(packs_dir)\n\n        # Directly corrupt the registry with non-dict entries\n        registry.data[\"presets\"][\"corrupted-string\"] = \"not a dict\"\n        registry.data[\"presets\"][\"corrupted-list\"] = [\"not\", \"a\", \"dict\"]\n        registry.data[\"presets\"][\"corrupted-int\"] = 42\n        registry._save()\n\n        # All corrupted entries should return None\n        assert registry.get(\"corrupted-string\") is None\n        assert registry.get(\"corrupted-list\") is None\n        assert registry.get(\"corrupted-int\") is None\n        # Non-existent should also return None\n        assert registry.get(\"nonexistent\") is None\n\n    def test_list_returns_deep_copy(self, temp_dir):\n        \"\"\"Test that list() returns deep copies to prevent mutation.\"\"\"\n        packs_dir = temp_dir / \"packs\"\n        packs_dir.mkdir()\n        registry = PresetRegistry(packs_dir)\n\n        registry.add(\"test-pack\", {\"version\": \"1.0.0\", \"nested\": {\"key\": \"original\"}})\n\n        # Get list and mutate\n        all_packs = registry.list()\n        all_packs[\"test-pack\"][\"version\"] = \"MUTATED\"\n        all_packs[\"test-pack\"][\"nested\"][\"key\"] = \"MUTATED\"\n\n        # Original should be unchanged\n        fresh = registry.get(\"test-pack\")\n        assert fresh[\"version\"] == \"1.0.0\"\n        assert fresh[\"nested\"][\"key\"] == \"original\"\n\n    def test_list_returns_empty_dict_for_corrupted_registry(self, temp_dir):\n        \"\"\"Test that list() returns empty dict when presets is not a dict.\"\"\"\n        packs_dir = temp_dir / \"packs\"\n        packs_dir.mkdir()\n        registry = PresetRegistry(packs_dir)\n\n        # Corrupt the registry - presets is a list instead of dict\n        registry.data[\"presets\"] = [\"not\", \"a\", \"dict\"]\n        registry._save()\n\n        # list() should return empty dict, not crash\n        result = registry.list()\n        assert result == {}\n\n    def test_list_by_priority_excludes_disabled(self, temp_dir):\n        \"\"\"Test that list_by_priority excludes disabled presets by default.\"\"\"\n        packs_dir = temp_dir / \"packs\"\n        packs_dir.mkdir()\n        registry = PresetRegistry(packs_dir)\n\n        registry.add(\"pack-enabled\", {\"version\": \"1.0.0\", \"enabled\": True, \"priority\": 5})\n        registry.add(\"pack-disabled\", {\"version\": \"1.0.0\", \"enabled\": False, \"priority\": 1})\n        registry.add(\"pack-default\", {\"version\": \"1.0.0\", \"priority\": 10})  # no enabled field = True\n\n        # Default: exclude disabled\n        by_priority = registry.list_by_priority()\n        pack_ids = [p[0] for p in by_priority]\n        assert \"pack-enabled\" in pack_ids\n        assert \"pack-default\" in pack_ids\n        assert \"pack-disabled\" not in pack_ids\n\n    def test_list_by_priority_includes_disabled_when_requested(self, temp_dir):\n        \"\"\"Test that list_by_priority includes disabled presets when requested.\"\"\"\n        packs_dir = temp_dir / \"packs\"\n        packs_dir.mkdir()\n        registry = PresetRegistry(packs_dir)\n\n        registry.add(\"pack-enabled\", {\"version\": \"1.0.0\", \"enabled\": True, \"priority\": 5})\n        registry.add(\"pack-disabled\", {\"version\": \"1.0.0\", \"enabled\": False, \"priority\": 1})\n\n        # Include disabled\n        by_priority = registry.list_by_priority(include_disabled=True)\n        pack_ids = [p[0] for p in by_priority]\n        assert \"pack-enabled\" in pack_ids\n        assert \"pack-disabled\" in pack_ids\n        # Disabled pack has lower priority number, so it comes first when included\n        assert pack_ids[0] == \"pack-disabled\"\n\n\n# ===== PresetManager Tests =====\n\n\nclass TestPresetManager:\n    \"\"\"Test PresetManager installation and removal.\"\"\"\n\n    def test_install_from_directory(self, project_dir, pack_dir):\n        \"\"\"Test installing a preset from a directory.\"\"\"\n        manager = PresetManager(project_dir)\n        manifest = manager.install_from_directory(pack_dir, \"0.1.5\")\n\n        assert manifest.id == \"test-pack\"\n        assert manager.registry.is_installed(\"test-pack\")\n\n        # Verify files are copied\n        installed_dir = project_dir / \".specify\" / \"presets\" / \"test-pack\"\n        assert installed_dir.exists()\n        assert (installed_dir / \"preset.yml\").exists()\n        assert (installed_dir / \"templates\" / \"spec-template.md\").exists()\n\n    def test_install_already_installed(self, project_dir, pack_dir):\n        \"\"\"Test installing an already-installed pack raises error.\"\"\"\n        manager = PresetManager(project_dir)\n        manager.install_from_directory(pack_dir, \"0.1.5\")\n\n        with pytest.raises(PresetError, match=\"already installed\"):\n            manager.install_from_directory(pack_dir, \"0.1.5\")\n\n    def test_install_incompatible(self, project_dir, temp_dir, valid_pack_data):\n        \"\"\"Test installing an incompatible pack raises error.\"\"\"\n        valid_pack_data[\"requires\"][\"speckit_version\"] = \">=99.0.0\"\n        incompat_dir = temp_dir / \"incompat-pack\"\n        incompat_dir.mkdir()\n        manifest_path = incompat_dir / \"preset.yml\"\n        with open(manifest_path, 'w') as f:\n            yaml.dump(valid_pack_data, f)\n        (incompat_dir / \"templates\").mkdir()\n        (incompat_dir / \"templates\" / \"spec-template.md\").write_text(\"test\")\n\n        manager = PresetManager(project_dir)\n        with pytest.raises(PresetCompatibilityError):\n            manager.install_from_directory(incompat_dir, \"0.1.5\")\n\n    def test_install_from_zip(self, project_dir, pack_dir, temp_dir):\n        \"\"\"Test installing from a ZIP file.\"\"\"\n        zip_path = temp_dir / \"test-pack.zip\"\n        with zipfile.ZipFile(zip_path, 'w') as zf:\n            for file_path in pack_dir.rglob('*'):\n                if file_path.is_file():\n                    arcname = file_path.relative_to(pack_dir)\n                    zf.write(file_path, arcname)\n\n        manager = PresetManager(project_dir)\n        manifest = manager.install_from_zip(zip_path, \"0.1.5\")\n        assert manifest.id == \"test-pack\"\n        assert manager.registry.is_installed(\"test-pack\")\n\n    def test_install_from_zip_nested(self, project_dir, pack_dir, temp_dir):\n        \"\"\"Test installing from ZIP with nested directory.\"\"\"\n        zip_path = temp_dir / \"test-pack.zip\"\n        with zipfile.ZipFile(zip_path, 'w') as zf:\n            for file_path in pack_dir.rglob('*'):\n                if file_path.is_file():\n                    arcname = Path(\"test-pack-v1.0.0\") / file_path.relative_to(pack_dir)\n                    zf.write(file_path, arcname)\n\n        manager = PresetManager(project_dir)\n        manifest = manager.install_from_zip(zip_path, \"0.1.5\")\n        assert manifest.id == \"test-pack\"\n\n    def test_install_from_zip_no_manifest(self, project_dir, temp_dir):\n        \"\"\"Test installing from ZIP without manifest raises error.\"\"\"\n        zip_path = temp_dir / \"bad.zip\"\n        with zipfile.ZipFile(zip_path, 'w') as zf:\n            zf.writestr(\"readme.txt\", \"no manifest here\")\n\n        manager = PresetManager(project_dir)\n        with pytest.raises(PresetValidationError, match=\"No preset.yml found\"):\n            manager.install_from_zip(zip_path, \"0.1.5\")\n\n    def test_remove(self, project_dir, pack_dir):\n        \"\"\"Test removing a preset.\"\"\"\n        manager = PresetManager(project_dir)\n        manager.install_from_directory(pack_dir, \"0.1.5\")\n        assert manager.registry.is_installed(\"test-pack\")\n\n        result = manager.remove(\"test-pack\")\n        assert result is True\n        assert not manager.registry.is_installed(\"test-pack\")\n\n        installed_dir = project_dir / \".specify\" / \"presets\" / \"test-pack\"\n        assert not installed_dir.exists()\n\n    def test_remove_nonexistent(self, project_dir):\n        \"\"\"Test removing a pack that doesn't exist.\"\"\"\n        manager = PresetManager(project_dir)\n        result = manager.remove(\"nonexistent\")\n        assert result is False\n\n    def test_list_installed(self, project_dir, pack_dir):\n        \"\"\"Test listing installed packs.\"\"\"\n        manager = PresetManager(project_dir)\n        manager.install_from_directory(pack_dir, \"0.1.5\")\n\n        installed = manager.list_installed()\n        assert len(installed) == 1\n        assert installed[0][\"id\"] == \"test-pack\"\n        assert installed[0][\"name\"] == \"Test Preset\"\n        assert installed[0][\"version\"] == \"1.0.0\"\n        assert installed[0][\"template_count\"] == 1\n\n    def test_list_installed_empty(self, project_dir):\n        \"\"\"Test listing when no packs installed.\"\"\"\n        manager = PresetManager(project_dir)\n        assert manager.list_installed() == []\n\n    def test_get_pack(self, project_dir, pack_dir):\n        \"\"\"Test getting a specific installed pack.\"\"\"\n        manager = PresetManager(project_dir)\n        manager.install_from_directory(pack_dir, \"0.1.5\")\n\n        pack = manager.get_pack(\"test-pack\")\n        assert pack is not None\n        assert pack.id == \"test-pack\"\n\n    def test_get_pack_not_installed(self, project_dir):\n        \"\"\"Test getting a non-installed pack returns None.\"\"\"\n        manager = PresetManager(project_dir)\n        assert manager.get_pack(\"nonexistent\") is None\n\n    def test_check_compatibility_valid(self, pack_dir, temp_dir):\n        \"\"\"Test compatibility check with valid version.\"\"\"\n        manager = PresetManager(temp_dir)\n        manifest = PresetManifest(pack_dir / \"preset.yml\")\n        assert manager.check_compatibility(manifest, \"0.1.5\") is True\n\n    def test_check_compatibility_invalid(self, pack_dir, temp_dir):\n        \"\"\"Test compatibility check with invalid specifier.\"\"\"\n        manager = PresetManager(temp_dir)\n        manifest = PresetManifest(pack_dir / \"preset.yml\")\n        manifest.data[\"requires\"][\"speckit_version\"] = \"not-a-specifier\"\n        with pytest.raises(PresetCompatibilityError, match=\"Invalid version specifier\"):\n            manager.check_compatibility(manifest, \"0.1.5\")\n\n    def test_install_with_priority(self, project_dir, pack_dir):\n        \"\"\"Test installing a pack with custom priority.\"\"\"\n        manager = PresetManager(project_dir)\n        manager.install_from_directory(pack_dir, \"0.1.5\", priority=5)\n\n        metadata = manager.registry.get(\"test-pack\")\n        assert metadata is not None\n        assert metadata[\"priority\"] == 5\n\n    def test_install_default_priority(self, project_dir, pack_dir):\n        \"\"\"Test that default priority is 10.\"\"\"\n        manager = PresetManager(project_dir)\n        manager.install_from_directory(pack_dir, \"0.1.5\")\n\n        metadata = manager.registry.get(\"test-pack\")\n        assert metadata is not None\n        assert metadata[\"priority\"] == 10\n\n    def test_list_installed_includes_priority(self, project_dir, pack_dir):\n        \"\"\"Test that list_installed includes priority.\"\"\"\n        manager = PresetManager(project_dir)\n        manager.install_from_directory(pack_dir, \"0.1.5\", priority=3)\n\n        installed = manager.list_installed()\n        assert len(installed) == 1\n        assert installed[0][\"priority\"] == 3\n\n\nclass TestRegistryPriority:\n    \"\"\"Test registry priority sorting.\"\"\"\n\n    def test_list_by_priority(self, temp_dir):\n        \"\"\"Test that list_by_priority sorts by priority number.\"\"\"\n        packs_dir = temp_dir / \"packs\"\n        packs_dir.mkdir()\n        registry = PresetRegistry(packs_dir)\n\n        registry.add(\"pack-high\", {\"version\": \"1.0.0\", \"priority\": 1})\n        registry.add(\"pack-low\", {\"version\": \"1.0.0\", \"priority\": 20})\n        registry.add(\"pack-mid\", {\"version\": \"1.0.0\", \"priority\": 10})\n\n        sorted_packs = registry.list_by_priority()\n        assert len(sorted_packs) == 3\n        assert sorted_packs[0][0] == \"pack-high\"\n        assert sorted_packs[1][0] == \"pack-mid\"\n        assert sorted_packs[2][0] == \"pack-low\"\n\n    def test_list_by_priority_default(self, temp_dir):\n        \"\"\"Test that packs without priority default to 10.\"\"\"\n        packs_dir = temp_dir / \"packs\"\n        packs_dir.mkdir()\n        registry = PresetRegistry(packs_dir)\n\n        registry.add(\"pack-a\", {\"version\": \"1.0.0\"})  # no priority, defaults to 10\n        registry.add(\"pack-b\", {\"version\": \"1.0.0\", \"priority\": 5})\n\n        sorted_packs = registry.list_by_priority()\n        assert sorted_packs[0][0] == \"pack-b\"\n        assert sorted_packs[1][0] == \"pack-a\"\n\n    def test_list_by_priority_invalid_priority_defaults(self, temp_dir):\n        \"\"\"Malformed priority values fall back to the default priority.\"\"\"\n        packs_dir = temp_dir / \"packs\"\n        packs_dir.mkdir()\n        registry = PresetRegistry(packs_dir)\n\n        registry.add(\"pack-high\", {\"version\": \"1.0.0\", \"priority\": 1})\n        registry.data[\"presets\"][\"pack-invalid\"] = {\n            \"version\": \"1.0.0\",\n            \"priority\": \"high\",\n        }\n        registry._save()\n\n        sorted_packs = registry.list_by_priority()\n\n        assert [item[0] for item in sorted_packs] == [\"pack-high\", \"pack-invalid\"]\n        assert sorted_packs[1][1][\"priority\"] == 10\n\n\n# ===== PresetResolver Tests =====\n\n\nclass TestPresetResolver:\n    \"\"\"Test PresetResolver priority stack.\"\"\"\n\n    def test_resolve_core_template(self, project_dir):\n        \"\"\"Test resolving a core template.\"\"\"\n        resolver = PresetResolver(project_dir)\n        result = resolver.resolve(\"spec-template\")\n        assert result is not None\n        assert result.name == \"spec-template.md\"\n        assert \"Core Spec Template\" in result.read_text()\n\n    def test_resolve_nonexistent(self, project_dir):\n        \"\"\"Test resolving a nonexistent template returns None.\"\"\"\n        resolver = PresetResolver(project_dir)\n        result = resolver.resolve(\"nonexistent-template\")\n        assert result is None\n\n    def test_resolve_higher_priority_pack_wins(self, project_dir, temp_dir, valid_pack_data):\n        \"\"\"Test that a pack with lower priority number wins over higher number.\"\"\"\n        manager = PresetManager(project_dir)\n\n        # Create pack A (priority 10 — lower precedence)\n        pack_a_dir = temp_dir / \"pack-a\"\n        pack_a_dir.mkdir()\n        data_a = {**valid_pack_data}\n        data_a[\"preset\"] = {**valid_pack_data[\"preset\"], \"id\": \"pack-a\", \"name\": \"Pack A\"}\n        with open(pack_a_dir / \"preset.yml\", 'w') as f:\n            yaml.dump(data_a, f)\n        (pack_a_dir / \"templates\").mkdir()\n        (pack_a_dir / \"templates\" / \"spec-template.md\").write_text(\"# From Pack A\\n\")\n\n        # Create pack B (priority 1 — higher precedence)\n        pack_b_dir = temp_dir / \"pack-b\"\n        pack_b_dir.mkdir()\n        data_b = {**valid_pack_data}\n        data_b[\"preset\"] = {**valid_pack_data[\"preset\"], \"id\": \"pack-b\", \"name\": \"Pack B\"}\n        with open(pack_b_dir / \"preset.yml\", 'w') as f:\n            yaml.dump(data_b, f)\n        (pack_b_dir / \"templates\").mkdir()\n        (pack_b_dir / \"templates\" / \"spec-template.md\").write_text(\"# From Pack B\\n\")\n\n        # Install A first (priority 10), B second (priority 1)\n        manager.install_from_directory(pack_a_dir, \"0.1.5\", priority=10)\n        manager.install_from_directory(pack_b_dir, \"0.1.5\", priority=1)\n\n        # Pack B should win because lower priority number\n        resolver = PresetResolver(project_dir)\n        result = resolver.resolve(\"spec-template\")\n        assert result is not None\n        assert \"From Pack B\" in result.read_text()\n\n    def test_resolve_override_takes_priority(self, project_dir):\n        \"\"\"Test that project overrides take priority over core.\"\"\"\n        # Create override\n        overrides_dir = project_dir / \".specify\" / \"templates\" / \"overrides\"\n        overrides_dir.mkdir(parents=True)\n        override = overrides_dir / \"spec-template.md\"\n        override.write_text(\"# Override Spec Template\\n\")\n\n        resolver = PresetResolver(project_dir)\n        result = resolver.resolve(\"spec-template\")\n        assert result is not None\n        assert \"Override Spec Template\" in result.read_text()\n\n    def test_resolve_pack_takes_priority_over_core(self, project_dir, pack_dir):\n        \"\"\"Test that installed packs take priority over core templates.\"\"\"\n        # Install the pack\n        manager = PresetManager(project_dir)\n        manager.install_from_directory(pack_dir, \"0.1.5\")\n\n        resolver = PresetResolver(project_dir)\n        result = resolver.resolve(\"spec-template\")\n        assert result is not None\n        assert \"Custom Spec Template\" in result.read_text()\n\n    def test_resolve_override_takes_priority_over_pack(self, project_dir, pack_dir):\n        \"\"\"Test that overrides take priority over installed packs.\"\"\"\n        # Install the pack\n        manager = PresetManager(project_dir)\n        manager.install_from_directory(pack_dir, \"0.1.5\")\n\n        # Create override\n        overrides_dir = project_dir / \".specify\" / \"templates\" / \"overrides\"\n        overrides_dir.mkdir(parents=True)\n        override = overrides_dir / \"spec-template.md\"\n        override.write_text(\"# Override Spec Template\\n\")\n\n        resolver = PresetResolver(project_dir)\n        result = resolver.resolve(\"spec-template\")\n        assert result is not None\n        assert \"Override Spec Template\" in result.read_text()\n\n    def test_resolve_extension_provided_templates(self, project_dir):\n        \"\"\"Test resolving templates provided by extensions.\"\"\"\n        # Create extension with templates\n        ext_dir = project_dir / \".specify\" / \"extensions\" / \"my-ext\"\n        ext_templates_dir = ext_dir / \"templates\"\n        ext_templates_dir.mkdir(parents=True)\n        ext_template = ext_templates_dir / \"custom-template.md\"\n        ext_template.write_text(\"# Extension Custom Template\\n\")\n\n        # Register extension in registry\n        extensions_dir = project_dir / \".specify\" / \"extensions\"\n        ext_registry = ExtensionRegistry(extensions_dir)\n        ext_registry.add(\"my-ext\", {\"version\": \"1.0.0\", \"priority\": 10})\n\n        resolver = PresetResolver(project_dir)\n        result = resolver.resolve(\"custom-template\")\n        assert result is not None\n        assert \"Extension Custom Template\" in result.read_text()\n\n    def test_resolve_disabled_extension_templates_skipped(self, project_dir):\n        \"\"\"Test that disabled extension templates are not resolved.\"\"\"\n        # Create extension with templates\n        ext_dir = project_dir / \".specify\" / \"extensions\" / \"disabled-ext\"\n        ext_templates_dir = ext_dir / \"templates\"\n        ext_templates_dir.mkdir(parents=True)\n        ext_template = ext_templates_dir / \"disabled-template.md\"\n        ext_template.write_text(\"# Disabled Extension Template\\n\")\n\n        # Register extension as disabled\n        extensions_dir = project_dir / \".specify\" / \"extensions\"\n        ext_registry = ExtensionRegistry(extensions_dir)\n        ext_registry.add(\"disabled-ext\", {\"version\": \"1.0.0\", \"priority\": 1, \"enabled\": False})\n\n        # Template should NOT be resolved because extension is disabled\n        resolver = PresetResolver(project_dir)\n        result = resolver.resolve(\"disabled-template\")\n        assert result is None, \"Disabled extension template should not be resolved\"\n\n    def test_resolve_disabled_extension_not_picked_up_as_unregistered(self, project_dir):\n        \"\"\"Test that disabled extensions are not picked up via unregistered dir scan.\"\"\"\n        # Create extension directory with templates\n        ext_dir = project_dir / \".specify\" / \"extensions\" / \"test-disabled-ext\"\n        ext_templates_dir = ext_dir / \"templates\"\n        ext_templates_dir.mkdir(parents=True)\n        ext_template = ext_templates_dir / \"unique-disabled-template.md\"\n        ext_template.write_text(\"# Should Not Resolve\\n\")\n\n        # Register the extension but disable it\n        extensions_dir = project_dir / \".specify\" / \"extensions\"\n        ext_registry = ExtensionRegistry(extensions_dir)\n        ext_registry.add(\"test-disabled-ext\", {\"version\": \"1.0.0\", \"enabled\": False})\n\n        # Verify the template is NOT resolved (even though the directory exists)\n        resolver = PresetResolver(project_dir)\n        result = resolver.resolve(\"unique-disabled-template\")\n        assert result is None, \"Disabled extension should not be picked up as unregistered\"\n\n    def test_resolve_pack_over_extension(self, project_dir, pack_dir, temp_dir, valid_pack_data):\n        \"\"\"Test that pack templates take priority over extension templates.\"\"\"\n        # Create extension with templates\n        ext_dir = project_dir / \".specify\" / \"extensions\" / \"my-ext\"\n        ext_templates_dir = ext_dir / \"templates\"\n        ext_templates_dir.mkdir(parents=True)\n        ext_template = ext_templates_dir / \"spec-template.md\"\n        ext_template.write_text(\"# Extension Spec Template\\n\")\n\n        # Install a pack with the same template\n        manager = PresetManager(project_dir)\n        manager.install_from_directory(pack_dir, \"0.1.5\")\n\n        resolver = PresetResolver(project_dir)\n        result = resolver.resolve(\"spec-template\")\n        assert result is not None\n        # Pack should win over extension\n        assert \"Custom Spec Template\" in result.read_text()\n\n    def test_resolve_with_source_core(self, project_dir):\n        \"\"\"Test resolve_with_source for core template.\"\"\"\n        resolver = PresetResolver(project_dir)\n        result = resolver.resolve_with_source(\"spec-template\")\n        assert result is not None\n        assert result[\"source\"] == \"core\"\n        assert \"spec-template.md\" in result[\"path\"]\n\n    def test_resolve_with_source_override(self, project_dir):\n        \"\"\"Test resolve_with_source for override template.\"\"\"\n        overrides_dir = project_dir / \".specify\" / \"templates\" / \"overrides\"\n        overrides_dir.mkdir(parents=True)\n        override = overrides_dir / \"spec-template.md\"\n        override.write_text(\"# Override\\n\")\n\n        resolver = PresetResolver(project_dir)\n        result = resolver.resolve_with_source(\"spec-template\")\n        assert result is not None\n        assert result[\"source\"] == \"project override\"\n\n    def test_resolve_with_source_pack(self, project_dir, pack_dir):\n        \"\"\"Test resolve_with_source for pack template.\"\"\"\n        manager = PresetManager(project_dir)\n        manager.install_from_directory(pack_dir, \"0.1.5\")\n\n        resolver = PresetResolver(project_dir)\n        result = resolver.resolve_with_source(\"spec-template\")\n        assert result is not None\n        assert \"test-pack\" in result[\"source\"]\n        assert \"v1.0.0\" in result[\"source\"]\n\n    def test_resolve_with_source_extension(self, project_dir):\n        \"\"\"Test resolve_with_source for extension-provided template.\"\"\"\n        ext_dir = project_dir / \".specify\" / \"extensions\" / \"my-ext\"\n        ext_templates_dir = ext_dir / \"templates\"\n        ext_templates_dir.mkdir(parents=True)\n        ext_template = ext_templates_dir / \"unique-template.md\"\n        ext_template.write_text(\"# Unique\\n\")\n\n        # Register extension in registry\n        extensions_dir = project_dir / \".specify\" / \"extensions\"\n        ext_registry = ExtensionRegistry(extensions_dir)\n        ext_registry.add(\"my-ext\", {\"version\": \"1.0.0\", \"priority\": 10})\n\n        resolver = PresetResolver(project_dir)\n        result = resolver.resolve_with_source(\"unique-template\")\n        assert result is not None\n        assert result[\"source\"] == \"extension:my-ext v1.0.0\"\n\n    def test_resolve_with_source_not_found(self, project_dir):\n        \"\"\"Test resolve_with_source for nonexistent template.\"\"\"\n        resolver = PresetResolver(project_dir)\n        result = resolver.resolve_with_source(\"nonexistent\")\n        assert result is None\n\n    def test_resolve_skips_hidden_extension_dirs(self, project_dir):\n        \"\"\"Test that hidden directories in extensions are skipped.\"\"\"\n        ext_dir = project_dir / \".specify\" / \"extensions\" / \".backup\"\n        ext_templates_dir = ext_dir / \"templates\"\n        ext_templates_dir.mkdir(parents=True)\n        ext_template = ext_templates_dir / \"hidden-template.md\"\n        ext_template.write_text(\"# Hidden\\n\")\n\n        resolver = PresetResolver(project_dir)\n        result = resolver.resolve(\"hidden-template\")\n        assert result is None\n\n\nclass TestExtensionPriorityResolution:\n    \"\"\"Test extension priority resolution with registered and unregistered extensions.\"\"\"\n\n    def test_unregistered_beats_registered_with_lower_precedence(self, project_dir):\n        \"\"\"Unregistered extension (implicit priority 10) beats registered with priority 20.\"\"\"\n        extensions_dir = project_dir / \".specify\" / \"extensions\"\n        extensions_dir.mkdir(parents=True, exist_ok=True)\n\n        # Create registered extension with priority 20 (lower precedence than 10)\n        registered_dir = extensions_dir / \"registered-ext\"\n        (registered_dir / \"templates\").mkdir(parents=True)\n        (registered_dir / \"templates\" / \"test-template.md\").write_text(\"# From Registered\\n\")\n\n        ext_registry = ExtensionRegistry(extensions_dir)\n        ext_registry.add(\"registered-ext\", {\"version\": \"1.0.0\", \"priority\": 20})\n\n        # Create unregistered extension directory (implicit priority 10)\n        unregistered_dir = extensions_dir / \"unregistered-ext\"\n        (unregistered_dir / \"templates\").mkdir(parents=True)\n        (unregistered_dir / \"templates\" / \"test-template.md\").write_text(\"# From Unregistered\\n\")\n\n        # Unregistered (priority 10) should beat registered (priority 20)\n        resolver = PresetResolver(project_dir)\n        result = resolver.resolve(\"test-template\")\n        assert result is not None\n        assert \"From Unregistered\" in result.read_text()\n\n    def test_registered_with_higher_precedence_beats_unregistered(self, project_dir):\n        \"\"\"Registered extension with priority 5 beats unregistered (implicit priority 10).\"\"\"\n        extensions_dir = project_dir / \".specify\" / \"extensions\"\n        extensions_dir.mkdir(parents=True, exist_ok=True)\n\n        # Create registered extension with priority 5 (higher precedence than 10)\n        registered_dir = extensions_dir / \"registered-ext\"\n        (registered_dir / \"templates\").mkdir(parents=True)\n        (registered_dir / \"templates\" / \"test-template.md\").write_text(\"# From Registered\\n\")\n\n        ext_registry = ExtensionRegistry(extensions_dir)\n        ext_registry.add(\"registered-ext\", {\"version\": \"1.0.0\", \"priority\": 5})\n\n        # Create unregistered extension directory (implicit priority 10)\n        unregistered_dir = extensions_dir / \"unregistered-ext\"\n        (unregistered_dir / \"templates\").mkdir(parents=True)\n        (unregistered_dir / \"templates\" / \"test-template.md\").write_text(\"# From Unregistered\\n\")\n\n        # Registered (priority 5) should beat unregistered (priority 10)\n        resolver = PresetResolver(project_dir)\n        result = resolver.resolve(\"test-template\")\n        assert result is not None\n        assert \"From Registered\" in result.read_text()\n\n    def test_unregistered_attribution_with_priority_ordering(self, project_dir):\n        \"\"\"Test resolve_with_source correctly attributes unregistered extension.\"\"\"\n        extensions_dir = project_dir / \".specify\" / \"extensions\"\n        extensions_dir.mkdir(parents=True, exist_ok=True)\n\n        # Create registered extension with priority 20\n        registered_dir = extensions_dir / \"registered-ext\"\n        (registered_dir / \"templates\").mkdir(parents=True)\n        (registered_dir / \"templates\" / \"test-template.md\").write_text(\"# From Registered\\n\")\n\n        ext_registry = ExtensionRegistry(extensions_dir)\n        ext_registry.add(\"registered-ext\", {\"version\": \"1.0.0\", \"priority\": 20})\n\n        # Create unregistered extension (implicit priority 10)\n        unregistered_dir = extensions_dir / \"unregistered-ext\"\n        (unregistered_dir / \"templates\").mkdir(parents=True)\n        (unregistered_dir / \"templates\" / \"test-template.md\").write_text(\"# From Unregistered\\n\")\n\n        # Attribution should show unregistered extension\n        resolver = PresetResolver(project_dir)\n        result = resolver.resolve_with_source(\"test-template\")\n        assert result is not None\n        assert \"unregistered-ext\" in result[\"source\"]\n        assert \"(unregistered)\" in result[\"source\"]\n\n    def test_same_priority_sorted_alphabetically(self, project_dir):\n        \"\"\"Extensions with same priority are sorted alphabetically by ID.\"\"\"\n        extensions_dir = project_dir / \".specify\" / \"extensions\"\n        extensions_dir.mkdir(parents=True, exist_ok=True)\n\n        # Create two unregistered extensions (both implicit priority 10)\n        # \"aaa-ext\" should come before \"zzz-ext\" alphabetically\n        zzz_dir = extensions_dir / \"zzz-ext\"\n        (zzz_dir / \"templates\").mkdir(parents=True)\n        (zzz_dir / \"templates\" / \"test-template.md\").write_text(\"# From ZZZ\\n\")\n\n        aaa_dir = extensions_dir / \"aaa-ext\"\n        (aaa_dir / \"templates\").mkdir(parents=True)\n        (aaa_dir / \"templates\" / \"test-template.md\").write_text(\"# From AAA\\n\")\n\n        # AAA should win due to alphabetical ordering at same priority\n        resolver = PresetResolver(project_dir)\n        result = resolver.resolve(\"test-template\")\n        assert result is not None\n        assert \"From AAA\" in result.read_text()\n\n\n# ===== PresetCatalog Tests =====\n\n\nclass TestPresetCatalog:\n    \"\"\"Test template catalog functionality.\"\"\"\n\n    def test_default_catalog_url(self, project_dir):\n        \"\"\"Test default catalog URL.\"\"\"\n        catalog = PresetCatalog(project_dir)\n        assert catalog.DEFAULT_CATALOG_URL.startswith(\"https://\")\n        assert catalog.DEFAULT_CATALOG_URL.endswith(\"/presets/catalog.json\")\n\n    def test_community_catalog_url(self, project_dir):\n        \"\"\"Test community catalog URL.\"\"\"\n        catalog = PresetCatalog(project_dir)\n        assert \"presets/catalog.community.json\" in catalog.COMMUNITY_CATALOG_URL\n\n    def test_cache_validation_no_cache(self, project_dir):\n        \"\"\"Test cache validation when no cache exists.\"\"\"\n        catalog = PresetCatalog(project_dir)\n        assert catalog.is_cache_valid() is False\n\n    def test_cache_validation_valid(self, project_dir):\n        \"\"\"Test cache validation with valid cache.\"\"\"\n        catalog = PresetCatalog(project_dir)\n        catalog.cache_dir.mkdir(parents=True, exist_ok=True)\n\n        catalog.cache_file.write_text(json.dumps({\n            \"schema_version\": \"1.0\",\n            \"presets\": {},\n        }))\n        catalog.cache_metadata_file.write_text(json.dumps({\n            \"cached_at\": datetime.now(timezone.utc).isoformat(),\n        }))\n\n        assert catalog.is_cache_valid() is True\n\n    def test_cache_validation_expired(self, project_dir):\n        \"\"\"Test cache validation with expired cache.\"\"\"\n        catalog = PresetCatalog(project_dir)\n        catalog.cache_dir.mkdir(parents=True, exist_ok=True)\n\n        catalog.cache_file.write_text(json.dumps({\n            \"schema_version\": \"1.0\",\n            \"presets\": {},\n        }))\n        catalog.cache_metadata_file.write_text(json.dumps({\n            \"cached_at\": \"2020-01-01T00:00:00+00:00\",\n        }))\n\n        assert catalog.is_cache_valid() is False\n\n    def test_cache_validation_corrupted(self, project_dir):\n        \"\"\"Test cache validation with corrupted metadata.\"\"\"\n        catalog = PresetCatalog(project_dir)\n        catalog.cache_dir.mkdir(parents=True, exist_ok=True)\n\n        catalog.cache_file.write_text(\"not json\")\n        catalog.cache_metadata_file.write_text(\"not json\")\n\n        assert catalog.is_cache_valid() is False\n\n    def test_clear_cache(self, project_dir):\n        \"\"\"Test clearing the cache.\"\"\"\n        catalog = PresetCatalog(project_dir)\n        catalog.cache_dir.mkdir(parents=True, exist_ok=True)\n        catalog.cache_file.write_text(\"{}\")\n        catalog.cache_metadata_file.write_text(\"{}\")\n\n        catalog.clear_cache()\n\n        assert not catalog.cache_file.exists()\n        assert not catalog.cache_metadata_file.exists()\n\n    def test_search_with_cached_data(self, project_dir):\n        \"\"\"Test search with cached catalog data.\"\"\"\n        catalog = PresetCatalog(project_dir)\n        catalog.cache_dir.mkdir(parents=True, exist_ok=True)\n\n        catalog_data = {\n            \"schema_version\": \"1.0\",\n            \"presets\": {\n                \"safe-agile\": {\n                    \"name\": \"SAFe Agile Templates\",\n                    \"description\": \"SAFe-aligned templates\",\n                    \"author\": \"agile-community\",\n                    \"version\": \"1.0.0\",\n                    \"tags\": [\"safe\", \"agile\"],\n                },\n                \"healthcare\": {\n                    \"name\": \"Healthcare Compliance\",\n                    \"description\": \"HIPAA-compliant templates\",\n                    \"author\": \"healthcare-org\",\n                    \"version\": \"1.0.0\",\n                    \"tags\": [\"healthcare\", \"hipaa\"],\n                },\n            }\n        }\n\n        catalog.cache_file.write_text(json.dumps(catalog_data))\n        catalog.cache_metadata_file.write_text(json.dumps({\n            \"cached_at\": datetime.now(timezone.utc).isoformat(),\n        }))\n\n        # Search by query\n        results = catalog.search(query=\"agile\")\n        assert len(results) == 1\n        assert results[0][\"id\"] == \"safe-agile\"\n\n        # Search by tag\n        results = catalog.search(tag=\"hipaa\")\n        assert len(results) == 1\n        assert results[0][\"id\"] == \"healthcare\"\n\n        # Search by author\n        results = catalog.search(author=\"agile-community\")\n        assert len(results) == 1\n\n        # Search all\n        results = catalog.search()\n        assert len(results) == 2\n\n    def test_get_pack_info(self, project_dir):\n        \"\"\"Test getting info for a specific pack.\"\"\"\n        catalog = PresetCatalog(project_dir)\n        catalog.cache_dir.mkdir(parents=True, exist_ok=True)\n\n        catalog_data = {\n            \"schema_version\": \"1.0\",\n            \"presets\": {\n                \"test-pack\": {\n                    \"name\": \"Test Pack\",\n                    \"version\": \"1.0.0\",\n                },\n            }\n        }\n\n        catalog.cache_file.write_text(json.dumps(catalog_data))\n        catalog.cache_metadata_file.write_text(json.dumps({\n            \"cached_at\": datetime.now(timezone.utc).isoformat(),\n        }))\n\n        info = catalog.get_pack_info(\"test-pack\")\n        assert info is not None\n        assert info[\"name\"] == \"Test Pack\"\n        assert info[\"id\"] == \"test-pack\"\n\n        assert catalog.get_pack_info(\"nonexistent\") is None\n\n    def test_validate_catalog_url_https(self, project_dir):\n        \"\"\"Test that HTTPS URLs are accepted.\"\"\"\n        catalog = PresetCatalog(project_dir)\n        catalog._validate_catalog_url(\"https://example.com/catalog.json\")\n\n    def test_validate_catalog_url_http_rejected(self, project_dir):\n        \"\"\"Test that HTTP URLs are rejected.\"\"\"\n        catalog = PresetCatalog(project_dir)\n        with pytest.raises(PresetValidationError, match=\"must use HTTPS\"):\n            catalog._validate_catalog_url(\"http://example.com/catalog.json\")\n\n    def test_validate_catalog_url_localhost_http_allowed(self, project_dir):\n        \"\"\"Test that HTTP is allowed for localhost.\"\"\"\n        catalog = PresetCatalog(project_dir)\n        catalog._validate_catalog_url(\"http://localhost:8080/catalog.json\")\n        catalog._validate_catalog_url(\"http://127.0.0.1:8080/catalog.json\")\n\n    def test_env_var_catalog_url(self, project_dir, monkeypatch):\n        \"\"\"Test catalog URL from environment variable.\"\"\"\n        monkeypatch.setenv(\"SPECKIT_PRESET_CATALOG_URL\", \"https://custom.example.com/catalog.json\")\n        catalog = PresetCatalog(project_dir)\n        assert catalog.get_catalog_url() == \"https://custom.example.com/catalog.json\"\n\n\n# ===== Integration Tests =====\n\n\nclass TestIntegration:\n    \"\"\"Integration tests for complete preset workflows.\"\"\"\n\n    def test_full_install_resolve_remove_cycle(self, project_dir, pack_dir):\n        \"\"\"Test complete lifecycle: install → resolve → remove.\"\"\"\n        # Install\n        manager = PresetManager(project_dir)\n        manifest = manager.install_from_directory(pack_dir, \"0.1.5\")\n        assert manifest.id == \"test-pack\"\n\n        # Resolve — pack template should win over core\n        resolver = PresetResolver(project_dir)\n        result = resolver.resolve(\"spec-template\")\n        assert result is not None\n        assert \"Custom Spec Template\" in result.read_text()\n\n        # Remove\n        manager.remove(\"test-pack\")\n\n        # Resolve — should fall back to core\n        result = resolver.resolve(\"spec-template\")\n        assert result is not None\n        assert \"Core Spec Template\" in result.read_text()\n\n    def test_override_beats_pack_beats_extension_beats_core(self, project_dir, pack_dir):\n        \"\"\"Test the full priority stack: override > pack > extension > core.\"\"\"\n        resolver = PresetResolver(project_dir)\n\n        # Core should resolve\n        result = resolver.resolve_with_source(\"spec-template\")\n        assert result[\"source\"] == \"core\"\n\n        # Add extension template\n        ext_dir = project_dir / \".specify\" / \"extensions\" / \"my-ext\"\n        ext_templates_dir = ext_dir / \"templates\"\n        ext_templates_dir.mkdir(parents=True)\n        (ext_templates_dir / \"spec-template.md\").write_text(\"# Extension\\n\")\n\n        # Register extension in registry\n        extensions_dir = project_dir / \".specify\" / \"extensions\"\n        ext_registry = ExtensionRegistry(extensions_dir)\n        ext_registry.add(\"my-ext\", {\"version\": \"1.0.0\", \"priority\": 10})\n\n        result = resolver.resolve_with_source(\"spec-template\")\n        assert result[\"source\"] == \"extension:my-ext v1.0.0\"\n\n        # Install pack — should win over extension\n        manager = PresetManager(project_dir)\n        manager.install_from_directory(pack_dir, \"0.1.5\")\n\n        result = resolver.resolve_with_source(\"spec-template\")\n        assert \"test-pack\" in result[\"source\"]\n\n        # Add override — should win over pack\n        overrides_dir = project_dir / \".specify\" / \"templates\" / \"overrides\"\n        overrides_dir.mkdir(parents=True)\n        (overrides_dir / \"spec-template.md\").write_text(\"# Override\\n\")\n\n        result = resolver.resolve_with_source(\"spec-template\")\n        assert result[\"source\"] == \"project override\"\n\n    def test_install_from_zip_then_resolve(self, project_dir, pack_dir, temp_dir):\n        \"\"\"Test installing from ZIP and then resolving.\"\"\"\n        # Create ZIP\n        zip_path = temp_dir / \"test-pack.zip\"\n        with zipfile.ZipFile(zip_path, 'w') as zf:\n            for file_path in pack_dir.rglob('*'):\n                if file_path.is_file():\n                    arcname = file_path.relative_to(pack_dir)\n                    zf.write(file_path, arcname)\n\n        # Install\n        manager = PresetManager(project_dir)\n        manager.install_from_zip(zip_path, \"0.1.5\")\n\n        # Resolve\n        resolver = PresetResolver(project_dir)\n        result = resolver.resolve(\"spec-template\")\n        assert result is not None\n        assert \"Custom Spec Template\" in result.read_text()\n\n\n# ===== PresetCatalogEntry Tests =====\n\n\nclass TestPresetCatalogEntry:\n    \"\"\"Test PresetCatalogEntry dataclass.\"\"\"\n\n    def test_create_entry(self):\n        \"\"\"Test creating a catalog entry.\"\"\"\n        entry = PresetCatalogEntry(\n            url=\"https://example.com/catalog.json\",\n            name=\"test\",\n            priority=1,\n            install_allowed=True,\n            description=\"Test catalog\",\n        )\n        assert entry.url == \"https://example.com/catalog.json\"\n        assert entry.name == \"test\"\n        assert entry.priority == 1\n        assert entry.install_allowed is True\n        assert entry.description == \"Test catalog\"\n\n    def test_default_description(self):\n        \"\"\"Test default empty description.\"\"\"\n        entry = PresetCatalogEntry(\n            url=\"https://example.com/catalog.json\",\n            name=\"test\",\n            priority=1,\n            install_allowed=False,\n        )\n        assert entry.description == \"\"\n\n\n# ===== Multi-Catalog Tests =====\n\n\nclass TestPresetCatalogMultiCatalog:\n    \"\"\"Test multi-catalog support in PresetCatalog.\"\"\"\n\n    def test_default_active_catalogs(self, project_dir):\n        \"\"\"Test that default catalogs are returned when no config exists.\"\"\"\n        catalog = PresetCatalog(project_dir)\n        active = catalog.get_active_catalogs()\n        assert len(active) == 2\n        assert active[0].name == \"default\"\n        assert active[0].priority == 1\n        assert active[0].install_allowed is True\n        assert active[1].name == \"community\"\n        assert active[1].priority == 2\n        assert active[1].install_allowed is False\n\n    def test_env_var_overrides_catalogs(self, project_dir, monkeypatch):\n        \"\"\"Test that SPECKIT_PRESET_CATALOG_URL env var overrides defaults.\"\"\"\n        monkeypatch.setenv(\n            \"SPECKIT_PRESET_CATALOG_URL\",\n            \"https://custom.example.com/catalog.json\",\n        )\n        catalog = PresetCatalog(project_dir)\n        active = catalog.get_active_catalogs()\n        assert len(active) == 1\n        assert active[0].name == \"custom\"\n        assert active[0].url == \"https://custom.example.com/catalog.json\"\n        assert active[0].install_allowed is True\n\n    def test_project_config_overrides_defaults(self, project_dir):\n        \"\"\"Test that project-level config overrides built-in defaults.\"\"\"\n        config_path = project_dir / \".specify\" / \"preset-catalogs.yml\"\n        config_path.write_text(yaml.dump({\n            \"catalogs\": [\n                {\n                    \"name\": \"my-catalog\",\n                    \"url\": \"https://my.example.com/catalog.json\",\n                    \"priority\": 1,\n                    \"install_allowed\": True,\n                }\n            ]\n        }))\n\n        catalog = PresetCatalog(project_dir)\n        active = catalog.get_active_catalogs()\n        assert len(active) == 1\n        assert active[0].name == \"my-catalog\"\n        assert active[0].url == \"https://my.example.com/catalog.json\"\n\n    def test_load_catalog_config_nonexistent(self, project_dir):\n        \"\"\"Test loading config from nonexistent file returns None.\"\"\"\n        catalog = PresetCatalog(project_dir)\n        result = catalog._load_catalog_config(\n            project_dir / \".specify\" / \"nonexistent.yml\"\n        )\n        assert result is None\n\n    def test_load_catalog_config_empty(self, project_dir):\n        \"\"\"Test loading empty config returns None.\"\"\"\n        config_path = project_dir / \".specify\" / \"preset-catalogs.yml\"\n        config_path.write_text(\"\")\n\n        catalog = PresetCatalog(project_dir)\n        result = catalog._load_catalog_config(config_path)\n        assert result is None\n\n    def test_load_catalog_config_invalid_yaml(self, project_dir):\n        \"\"\"Test loading invalid YAML raises error.\"\"\"\n        config_path = project_dir / \".specify\" / \"preset-catalogs.yml\"\n        config_path.write_text(\": invalid: {{{\")\n\n        catalog = PresetCatalog(project_dir)\n        with pytest.raises(PresetValidationError, match=\"Failed to read\"):\n            catalog._load_catalog_config(config_path)\n\n    def test_load_catalog_config_not_a_list(self, project_dir):\n        \"\"\"Test that non-list catalogs key raises error.\"\"\"\n        config_path = project_dir / \".specify\" / \"preset-catalogs.yml\"\n        config_path.write_text(yaml.dump({\"catalogs\": \"not-a-list\"}))\n\n        catalog = PresetCatalog(project_dir)\n        with pytest.raises(PresetValidationError, match=\"must be a list\"):\n            catalog._load_catalog_config(config_path)\n\n    def test_load_catalog_config_invalid_entry(self, project_dir):\n        \"\"\"Test that non-dict entry raises error.\"\"\"\n        config_path = project_dir / \".specify\" / \"preset-catalogs.yml\"\n        config_path.write_text(yaml.dump({\"catalogs\": [\"not-a-dict\"]}))\n\n        catalog = PresetCatalog(project_dir)\n        with pytest.raises(PresetValidationError, match=\"expected a mapping\"):\n            catalog._load_catalog_config(config_path)\n\n    def test_load_catalog_config_http_url_rejected(self, project_dir):\n        \"\"\"Test that HTTP URLs are rejected.\"\"\"\n        config_path = project_dir / \".specify\" / \"preset-catalogs.yml\"\n        config_path.write_text(yaml.dump({\n            \"catalogs\": [\n                {\n                    \"name\": \"bad\",\n                    \"url\": \"http://insecure.example.com/catalog.json\",\n                    \"priority\": 1,\n                }\n            ]\n        }))\n\n        catalog = PresetCatalog(project_dir)\n        with pytest.raises(PresetValidationError, match=\"must use HTTPS\"):\n            catalog._load_catalog_config(config_path)\n\n    def test_load_catalog_config_priority_sorting(self, project_dir):\n        \"\"\"Test that catalogs are sorted by priority.\"\"\"\n        config_path = project_dir / \".specify\" / \"preset-catalogs.yml\"\n        config_path.write_text(yaml.dump({\n            \"catalogs\": [\n                {\n                    \"name\": \"low-priority\",\n                    \"url\": \"https://low.example.com/catalog.json\",\n                    \"priority\": 10,\n                    \"install_allowed\": False,\n                },\n                {\n                    \"name\": \"high-priority\",\n                    \"url\": \"https://high.example.com/catalog.json\",\n                    \"priority\": 1,\n                    \"install_allowed\": True,\n                },\n            ]\n        }))\n\n        catalog = PresetCatalog(project_dir)\n        entries = catalog._load_catalog_config(config_path)\n        assert entries is not None\n        assert len(entries) == 2\n        assert entries[0].name == \"high-priority\"\n        assert entries[1].name == \"low-priority\"\n\n    def test_load_catalog_config_invalid_priority(self, project_dir):\n        \"\"\"Test that invalid priority raises error.\"\"\"\n        config_path = project_dir / \".specify\" / \"preset-catalogs.yml\"\n        config_path.write_text(yaml.dump({\n            \"catalogs\": [\n                {\n                    \"name\": \"bad\",\n                    \"url\": \"https://example.com/catalog.json\",\n                    \"priority\": \"not-a-number\",\n                }\n            ]\n        }))\n\n        catalog = PresetCatalog(project_dir)\n        with pytest.raises(PresetValidationError, match=\"Invalid priority\"):\n            catalog._load_catalog_config(config_path)\n\n    def test_load_catalog_config_install_allowed_string(self, project_dir):\n        \"\"\"Test that install_allowed accepts string values.\"\"\"\n        config_path = project_dir / \".specify\" / \"preset-catalogs.yml\"\n        config_path.write_text(yaml.dump({\n            \"catalogs\": [\n                {\n                    \"name\": \"test\",\n                    \"url\": \"https://example.com/catalog.json\",\n                    \"priority\": 1,\n                    \"install_allowed\": \"true\",\n                }\n            ]\n        }))\n\n        catalog = PresetCatalog(project_dir)\n        entries = catalog._load_catalog_config(config_path)\n        assert entries is not None\n        assert entries[0].install_allowed is True\n\n    def test_get_catalog_url_uses_highest_priority(self, project_dir):\n        \"\"\"Test that get_catalog_url returns URL of highest priority catalog.\"\"\"\n        config_path = project_dir / \".specify\" / \"preset-catalogs.yml\"\n        config_path.write_text(yaml.dump({\n            \"catalogs\": [\n                {\n                    \"name\": \"secondary\",\n                    \"url\": \"https://secondary.example.com/catalog.json\",\n                    \"priority\": 5,\n                },\n                {\n                    \"name\": \"primary\",\n                    \"url\": \"https://primary.example.com/catalog.json\",\n                    \"priority\": 1,\n                },\n            ]\n        }))\n\n        catalog = PresetCatalog(project_dir)\n        assert catalog.get_catalog_url() == \"https://primary.example.com/catalog.json\"\n\n    def test_cache_paths_default_url(self, project_dir):\n        \"\"\"Test cache paths for default catalog URL use legacy locations.\"\"\"\n        catalog = PresetCatalog(project_dir)\n        cache_file, metadata_file = catalog._get_cache_paths(\n            PresetCatalog.DEFAULT_CATALOG_URL\n        )\n        assert cache_file == catalog.cache_file\n        assert metadata_file == catalog.cache_metadata_file\n\n    def test_cache_paths_custom_url(self, project_dir):\n        \"\"\"Test cache paths for custom URLs use hash-based files.\"\"\"\n        catalog = PresetCatalog(project_dir)\n        cache_file, metadata_file = catalog._get_cache_paths(\n            \"https://custom.example.com/catalog.json\"\n        )\n        assert cache_file != catalog.cache_file\n        assert \"catalog-\" in cache_file.name\n        assert cache_file.name.endswith(\".json\")\n\n    def test_url_cache_valid(self, project_dir):\n        \"\"\"Test URL-specific cache validation.\"\"\"\n        catalog = PresetCatalog(project_dir)\n        url = \"https://custom.example.com/catalog.json\"\n        cache_file, metadata_file = catalog._get_cache_paths(url)\n\n        catalog.cache_dir.mkdir(parents=True, exist_ok=True)\n        cache_file.write_text(json.dumps({\"schema_version\": \"1.0\", \"presets\": {}}))\n        metadata_file.write_text(json.dumps({\n            \"cached_at\": datetime.now(timezone.utc).isoformat(),\n        }))\n\n        assert catalog._is_url_cache_valid(url) is True\n\n    def test_url_cache_expired(self, project_dir):\n        \"\"\"Test URL-specific cache expiration.\"\"\"\n        catalog = PresetCatalog(project_dir)\n        url = \"https://custom.example.com/catalog.json\"\n        cache_file, metadata_file = catalog._get_cache_paths(url)\n\n        catalog.cache_dir.mkdir(parents=True, exist_ok=True)\n        cache_file.write_text(json.dumps({\"schema_version\": \"1.0\", \"presets\": {}}))\n        metadata_file.write_text(json.dumps({\n            \"cached_at\": \"2020-01-01T00:00:00+00:00\",\n        }))\n\n        assert catalog._is_url_cache_valid(url) is False\n\n\n# ===== Self-Test Preset Tests =====\n\n\nSELF_TEST_PRESET_DIR = Path(__file__).parent.parent / \"presets\" / \"self-test\"\n\nCORE_TEMPLATE_NAMES = [\n    \"spec-template\",\n    \"plan-template\",\n    \"tasks-template\",\n    \"checklist-template\",\n    \"constitution-template\",\n    \"agent-file-template\",\n]\n\n\nclass TestSelfTestPreset:\n    \"\"\"Tests using the self-test preset that ships with the repo.\"\"\"\n\n    def test_self_test_preset_exists(self):\n        \"\"\"Verify the self-test preset directory and manifest exist.\"\"\"\n        assert SELF_TEST_PRESET_DIR.exists()\n        assert (SELF_TEST_PRESET_DIR / \"preset.yml\").exists()\n\n    def test_self_test_manifest_valid(self):\n        \"\"\"Verify the self-test preset manifest is valid.\"\"\"\n        manifest = PresetManifest(SELF_TEST_PRESET_DIR / \"preset.yml\")\n        assert manifest.id == \"self-test\"\n        assert manifest.name == \"Self-Test Preset\"\n        assert manifest.version == \"1.0.0\"\n        assert len(manifest.templates) == 7  # 6 templates + 1 command\n\n    def test_self_test_provides_all_core_templates(self):\n        \"\"\"Verify the self-test preset provides an override for every core template.\"\"\"\n        manifest = PresetManifest(SELF_TEST_PRESET_DIR / \"preset.yml\")\n        provided_names = {t[\"name\"] for t in manifest.templates}\n        for name in CORE_TEMPLATE_NAMES:\n            assert name in provided_names, f\"Self-test preset missing template: {name}\"\n\n    def test_self_test_template_files_exist(self):\n        \"\"\"Verify that all declared template files actually exist on disk.\"\"\"\n        manifest = PresetManifest(SELF_TEST_PRESET_DIR / \"preset.yml\")\n        for tmpl in manifest.templates:\n            tmpl_path = SELF_TEST_PRESET_DIR / tmpl[\"file\"]\n            assert tmpl_path.exists(), f\"Missing template file: {tmpl['file']}\"\n\n    def test_self_test_templates_have_marker(self):\n        \"\"\"Verify each template contains the preset:self-test marker.\"\"\"\n        for name in CORE_TEMPLATE_NAMES:\n            tmpl_path = SELF_TEST_PRESET_DIR / \"templates\" / f\"{name}.md\"\n            content = tmpl_path.read_text()\n            assert \"preset:self-test\" in content, f\"{name}.md missing preset:self-test marker\"\n\n    def test_install_self_test_preset(self, project_dir):\n        \"\"\"Test installing the self-test preset from its directory.\"\"\"\n        manager = PresetManager(project_dir)\n        manifest = manager.install_from_directory(SELF_TEST_PRESET_DIR, \"0.1.5\")\n        assert manifest.id == \"self-test\"\n        assert manager.registry.is_installed(\"self-test\")\n\n    def test_self_test_overrides_all_core_templates(self, project_dir):\n        \"\"\"Test that installing self-test overrides every core template.\"\"\"\n        # Set up core templates in the project\n        templates_dir = project_dir / \".specify\" / \"templates\"\n        for name in CORE_TEMPLATE_NAMES:\n            (templates_dir / f\"{name}.md\").write_text(f\"# Core {name}\\n\")\n\n        # Install self-test preset\n        manager = PresetManager(project_dir)\n        manager.install_from_directory(SELF_TEST_PRESET_DIR, \"0.1.5\")\n\n        # Every core template should now resolve from the preset\n        resolver = PresetResolver(project_dir)\n        for name in CORE_TEMPLATE_NAMES:\n            result = resolver.resolve(name)\n            assert result is not None, f\"{name} did not resolve\"\n            content = result.read_text()\n            assert \"preset:self-test\" in content, (\n                f\"{name} resolved but not from self-test preset\"\n            )\n\n    def test_self_test_resolve_with_source(self, project_dir):\n        \"\"\"Test that resolve_with_source attributes templates to self-test.\"\"\"\n        templates_dir = project_dir / \".specify\" / \"templates\"\n        for name in CORE_TEMPLATE_NAMES:\n            (templates_dir / f\"{name}.md\").write_text(f\"# Core {name}\\n\")\n\n        manager = PresetManager(project_dir)\n        manager.install_from_directory(SELF_TEST_PRESET_DIR, \"0.1.5\")\n\n        resolver = PresetResolver(project_dir)\n        for name in CORE_TEMPLATE_NAMES:\n            result = resolver.resolve_with_source(name)\n            assert result is not None, f\"{name} did not resolve\"\n            assert \"self-test\" in result[\"source\"], (\n                f\"{name} source is '{result['source']}', expected self-test\"\n            )\n\n    def test_self_test_removal_restores_core(self, project_dir):\n        \"\"\"Test that removing self-test falls back to core templates.\"\"\"\n        templates_dir = project_dir / \".specify\" / \"templates\"\n        for name in CORE_TEMPLATE_NAMES:\n            (templates_dir / f\"{name}.md\").write_text(f\"# Core {name}\\n\")\n\n        manager = PresetManager(project_dir)\n        manager.install_from_directory(SELF_TEST_PRESET_DIR, \"0.1.5\")\n        manager.remove(\"self-test\")\n\n        resolver = PresetResolver(project_dir)\n        for name in CORE_TEMPLATE_NAMES:\n            result = resolver.resolve_with_source(name)\n            assert result is not None\n            assert result[\"source\"] == \"core\"\n\n    def test_self_test_not_in_catalog(self):\n        \"\"\"Verify the self-test preset is NOT in the catalog (it's local-only).\"\"\"\n        catalog_path = Path(__file__).parent.parent / \"presets\" / \"catalog.json\"\n        catalog_data = json.loads(catalog_path.read_text())\n        assert \"self-test\" not in catalog_data[\"presets\"]\n\n    def test_self_test_has_command(self):\n        \"\"\"Verify the self-test preset includes a command override.\"\"\"\n        manifest = PresetManifest(SELF_TEST_PRESET_DIR / \"preset.yml\")\n        commands = [t for t in manifest.templates if t[\"type\"] == \"command\"]\n        assert len(commands) >= 1\n        assert commands[0][\"name\"] == \"speckit.specify\"\n\n    def test_self_test_command_file_exists(self):\n        \"\"\"Verify the self-test command file exists on disk.\"\"\"\n        cmd_path = SELF_TEST_PRESET_DIR / \"commands\" / \"speckit.specify.md\"\n        assert cmd_path.exists()\n        content = cmd_path.read_text()\n        assert \"preset:self-test\" in content\n\n    def test_self_test_registers_commands_for_claude(self, project_dir):\n        \"\"\"Test that installing self-test registers commands in .claude/commands/.\"\"\"\n        # Create Claude agent directory to simulate Claude being set up\n        claude_dir = project_dir / \".claude\" / \"commands\"\n        claude_dir.mkdir(parents=True)\n\n        manager = PresetManager(project_dir)\n        manager.install_from_directory(SELF_TEST_PRESET_DIR, \"0.1.5\")\n\n        # Check the command was registered\n        cmd_file = claude_dir / \"speckit.specify.md\"\n        assert cmd_file.exists(), \"Command not registered in .claude/commands/\"\n        content = cmd_file.read_text()\n        assert \"preset:self-test\" in content\n\n    def test_self_test_registers_commands_for_gemini(self, project_dir):\n        \"\"\"Test that installing self-test registers commands in .gemini/commands/ as TOML.\"\"\"\n        # Create Gemini agent directory\n        gemini_dir = project_dir / \".gemini\" / \"commands\"\n        gemini_dir.mkdir(parents=True)\n\n        manager = PresetManager(project_dir)\n        manager.install_from_directory(SELF_TEST_PRESET_DIR, \"0.1.5\")\n\n        # Check the command was registered in TOML format\n        cmd_file = gemini_dir / \"speckit.specify.toml\"\n        assert cmd_file.exists(), \"Command not registered in .gemini/commands/\"\n        content = cmd_file.read_text()\n        assert \"prompt\" in content  # TOML format has a prompt field\n        assert \"{{args}}\" in content  # Gemini uses {{args}} placeholder\n\n    def test_self_test_unregisters_commands_on_remove(self, project_dir):\n        \"\"\"Test that removing self-test cleans up registered commands.\"\"\"\n        claude_dir = project_dir / \".claude\" / \"commands\"\n        claude_dir.mkdir(parents=True)\n\n        manager = PresetManager(project_dir)\n        manager.install_from_directory(SELF_TEST_PRESET_DIR, \"0.1.5\")\n\n        cmd_file = claude_dir / \"speckit.specify.md\"\n        assert cmd_file.exists()\n\n        manager.remove(\"self-test\")\n        assert not cmd_file.exists(), \"Command not cleaned up after preset removal\"\n\n    def test_self_test_no_commands_without_agent_dirs(self, project_dir):\n        \"\"\"Test that no commands are registered when no agent dirs exist.\"\"\"\n        manager = PresetManager(project_dir)\n        manager.install_from_directory(SELF_TEST_PRESET_DIR, \"0.1.5\")\n\n        metadata = manager.registry.get(\"self-test\")\n        assert metadata[\"registered_commands\"] == {}\n\n    def test_extension_command_skipped_when_extension_missing(self, project_dir, temp_dir):\n        \"\"\"Test that extension command overrides are skipped if the extension isn't installed.\"\"\"\n        claude_dir = project_dir / \".claude\" / \"commands\"\n        claude_dir.mkdir(parents=True)\n\n        preset_dir = temp_dir / \"ext-override-preset\"\n        preset_dir.mkdir()\n        (preset_dir / \"commands\").mkdir()\n        (preset_dir / \"commands\" / \"speckit.fakeext.cmd.md\").write_text(\n            \"---\\ndescription: Override fakeext cmd\\n---\\nOverridden content\"\n        )\n        manifest_data = {\n            \"schema_version\": \"1.0\",\n            \"preset\": {\n                \"id\": \"ext-override\",\n                \"name\": \"Ext Override\",\n                \"version\": \"1.0.0\",\n                \"description\": \"Test\",\n            },\n            \"requires\": {\"speckit_version\": \">=0.1.0\"},\n            \"provides\": {\n                \"templates\": [\n                    {\n                        \"type\": \"command\",\n                        \"name\": \"speckit.fakeext.cmd\",\n                        \"file\": \"commands/speckit.fakeext.cmd.md\",\n                        \"description\": \"Override fakeext cmd\",\n                    }\n                ]\n            },\n        }\n        with open(preset_dir / \"preset.yml\", \"w\") as f:\n            yaml.dump(manifest_data, f)\n\n        manager = PresetManager(project_dir)\n        manager.install_from_directory(preset_dir, \"0.1.5\")\n\n        # Extension not installed — command should NOT be registered\n        cmd_file = claude_dir / \"speckit.fakeext.cmd.md\"\n        assert not cmd_file.exists(), \"Command registered for missing extension\"\n        metadata = manager.registry.get(\"ext-override\")\n        assert metadata[\"registered_commands\"] == {}\n\n    def test_extension_command_registered_when_extension_present(self, project_dir, temp_dir):\n        \"\"\"Test that extension command overrides ARE registered when the extension is installed.\"\"\"\n        claude_dir = project_dir / \".claude\" / \"commands\"\n        claude_dir.mkdir(parents=True)\n        (project_dir / \".specify\" / \"extensions\" / \"fakeext\").mkdir(parents=True)\n\n        preset_dir = temp_dir / \"ext-override-preset2\"\n        preset_dir.mkdir()\n        (preset_dir / \"commands\").mkdir()\n        (preset_dir / \"commands\" / \"speckit.fakeext.cmd.md\").write_text(\n            \"---\\ndescription: Override fakeext cmd\\n---\\nOverridden content\"\n        )\n        manifest_data = {\n            \"schema_version\": \"1.0\",\n            \"preset\": {\n                \"id\": \"ext-override2\",\n                \"name\": \"Ext Override\",\n                \"version\": \"1.0.0\",\n                \"description\": \"Test\",\n            },\n            \"requires\": {\"speckit_version\": \">=0.1.0\"},\n            \"provides\": {\n                \"templates\": [\n                    {\n                        \"type\": \"command\",\n                        \"name\": \"speckit.fakeext.cmd\",\n                        \"file\": \"commands/speckit.fakeext.cmd.md\",\n                        \"description\": \"Override fakeext cmd\",\n                    }\n                ]\n            },\n        }\n        with open(preset_dir / \"preset.yml\", \"w\") as f:\n            yaml.dump(manifest_data, f)\n\n        manager = PresetManager(project_dir)\n        manager.install_from_directory(preset_dir, \"0.1.5\")\n\n        cmd_file = claude_dir / \"speckit.fakeext.cmd.md\"\n        assert cmd_file.exists(), \"Command not registered despite extension being present\"\n\n\n# ===== Init Options and Skills Tests =====\n\n\nclass TestInitOptions:\n    \"\"\"Tests for save_init_options / load_init_options helpers.\"\"\"\n\n    def test_save_and_load_round_trip(self, project_dir):\n        from specify_cli import save_init_options, load_init_options\n\n        opts = {\"ai\": \"claude\", \"ai_skills\": True, \"here\": False}\n        save_init_options(project_dir, opts)\n\n        loaded = load_init_options(project_dir)\n        assert loaded[\"ai\"] == \"claude\"\n        assert loaded[\"ai_skills\"] is True\n\n    def test_load_returns_empty_when_missing(self, project_dir):\n        from specify_cli import load_init_options\n\n        assert load_init_options(project_dir) == {}\n\n    def test_load_returns_empty_on_invalid_json(self, project_dir):\n        from specify_cli import load_init_options\n\n        opts_file = project_dir / \".specify\" / \"init-options.json\"\n        opts_file.parent.mkdir(parents=True, exist_ok=True)\n        opts_file.write_text(\"{bad json\")\n\n        assert load_init_options(project_dir) == {}\n\n\nclass TestPresetSkills:\n    \"\"\"Tests for preset skill registration and unregistration.\"\"\"\n\n    def _write_init_options(self, project_dir, ai=\"claude\", ai_skills=True):\n        from specify_cli import save_init_options\n\n        save_init_options(project_dir, {\"ai\": ai, \"ai_skills\": ai_skills})\n\n    def _create_skill(self, skills_dir, skill_name, body=\"original body\"):\n        skill_dir = skills_dir / skill_name\n        skill_dir.mkdir(parents=True, exist_ok=True)\n        (skill_dir / \"SKILL.md\").write_text(\n            f\"---\\nname: {skill_name}\\n---\\n\\n{body}\\n\"\n        )\n        return skill_dir\n\n    def test_skill_overridden_on_preset_install(self, project_dir, temp_dir):\n        \"\"\"When --ai-skills was used, a preset command override should update the skill.\"\"\"\n        # Simulate --ai-skills having been used: write init-options + create skill\n        self._write_init_options(project_dir, ai=\"claude\")\n        skills_dir = project_dir / \".claude\" / \"skills\"\n        self._create_skill(skills_dir, \"speckit-specify\")\n\n        # Also create the claude commands dir so commands get registered\n        (project_dir / \".claude\" / \"commands\").mkdir(parents=True, exist_ok=True)\n\n        # Install self-test preset (has a command override for speckit.specify)\n        manager = PresetManager(project_dir)\n        SELF_TEST_DIR = Path(__file__).parent.parent / \"presets\" / \"self-test\"\n        manager.install_from_directory(SELF_TEST_DIR, \"0.1.5\")\n\n        skill_file = skills_dir / \"speckit-specify\" / \"SKILL.md\"\n        assert skill_file.exists()\n        content = skill_file.read_text()\n        assert \"preset:self-test\" in content, \"Skill should reference preset source\"\n\n        # Verify it was recorded in registry\n        metadata = manager.registry.get(\"self-test\")\n        assert \"speckit-specify\" in metadata.get(\"registered_skills\", [])\n\n    def test_skill_not_updated_when_ai_skills_disabled(self, project_dir, temp_dir):\n        \"\"\"When --ai-skills was NOT used, preset install should not touch skills.\"\"\"\n        self._write_init_options(project_dir, ai=\"claude\", ai_skills=False)\n        skills_dir = project_dir / \".claude\" / \"skills\"\n        self._create_skill(skills_dir, \"speckit-specify\", body=\"untouched\")\n\n        (project_dir / \".claude\" / \"commands\").mkdir(parents=True, exist_ok=True)\n\n        manager = PresetManager(project_dir)\n        SELF_TEST_DIR = Path(__file__).parent.parent / \"presets\" / \"self-test\"\n        manager.install_from_directory(SELF_TEST_DIR, \"0.1.5\")\n\n        skill_file = skills_dir / \"speckit-specify\" / \"SKILL.md\"\n        content = skill_file.read_text()\n        assert \"untouched\" in content, \"Skill should not be modified when ai_skills=False\"\n\n    def test_skill_not_updated_without_init_options(self, project_dir, temp_dir):\n        \"\"\"When no init-options.json exists, preset install should not touch skills.\"\"\"\n        skills_dir = project_dir / \".claude\" / \"skills\"\n        self._create_skill(skills_dir, \"speckit-specify\", body=\"untouched\")\n\n        (project_dir / \".claude\" / \"commands\").mkdir(parents=True, exist_ok=True)\n\n        manager = PresetManager(project_dir)\n        SELF_TEST_DIR = Path(__file__).parent.parent / \"presets\" / \"self-test\"\n        manager.install_from_directory(SELF_TEST_DIR, \"0.1.5\")\n\n        skill_file = skills_dir / \"speckit-specify\" / \"SKILL.md\"\n        content = skill_file.read_text()\n        assert \"untouched\" in content\n\n    def test_skill_restored_on_preset_remove(self, project_dir, temp_dir):\n        \"\"\"When a preset is removed, skills should be restored from core templates.\"\"\"\n        self._write_init_options(project_dir, ai=\"claude\")\n        skills_dir = project_dir / \".claude\" / \"skills\"\n        self._create_skill(skills_dir, \"speckit-specify\")\n\n        (project_dir / \".claude\" / \"commands\").mkdir(parents=True, exist_ok=True)\n\n        # Set up core command template in the project so restoration works\n        core_cmds = project_dir / \".specify\" / \"templates\" / \"commands\"\n        core_cmds.mkdir(parents=True, exist_ok=True)\n        (core_cmds / \"specify.md\").write_text(\"---\\ndescription: Core specify command\\n---\\n\\nCore specify body\\n\")\n\n        manager = PresetManager(project_dir)\n        SELF_TEST_DIR = Path(__file__).parent.parent / \"presets\" / \"self-test\"\n        manager.install_from_directory(SELF_TEST_DIR, \"0.1.5\")\n\n        # Verify preset content is in the skill\n        skill_file = skills_dir / \"speckit-specify\" / \"SKILL.md\"\n        assert \"preset:self-test\" in skill_file.read_text()\n\n        # Remove the preset\n        manager.remove(\"self-test\")\n\n        # Skill should be restored (core specify.md template exists)\n        assert skill_file.exists(), \"Skill should still exist after preset removal\"\n        content = skill_file.read_text()\n        assert \"preset:self-test\" not in content, \"Preset content should be gone\"\n        assert \"templates/commands/specify.md\" in content, \"Should reference core template\"\n\n    def test_no_skills_registered_when_no_skill_dir_exists(self, project_dir, temp_dir):\n        \"\"\"Skills should not be created when no existing skill dir is found.\"\"\"\n        self._write_init_options(project_dir, ai=\"claude\")\n        # Don't create skills dir — simulate --ai-skills never created them\n\n        (project_dir / \".claude\" / \"commands\").mkdir(parents=True, exist_ok=True)\n\n        manager = PresetManager(project_dir)\n        SELF_TEST_DIR = Path(__file__).parent.parent / \"presets\" / \"self-test\"\n        manager.install_from_directory(SELF_TEST_DIR, \"0.1.5\")\n\n        metadata = manager.registry.get(\"self-test\")\n        assert metadata.get(\"registered_skills\", []) == []\n\n\nclass TestPresetSetPriority:\n    \"\"\"Test preset set-priority CLI command.\"\"\"\n\n    def test_set_priority_changes_priority(self, project_dir, pack_dir):\n        \"\"\"Test set-priority command changes preset priority.\"\"\"\n        from typer.testing import CliRunner\n        from unittest.mock import patch\n        from specify_cli import app\n\n        runner = CliRunner()\n\n        # Install preset with default priority\n        manager = PresetManager(project_dir)\n        manager.install_from_directory(pack_dir, \"0.1.5\")\n\n        # Verify default priority\n        assert manager.registry.get(\"test-pack\")[\"priority\"] == 10\n\n        with patch.object(Path, \"cwd\", return_value=project_dir):\n            result = runner.invoke(app, [\"preset\", \"set-priority\", \"test-pack\", \"5\"])\n\n        assert result.exit_code == 0, result.output\n        assert \"priority changed: 10 → 5\" in result.output\n\n        # Reload registry to see updated value\n        manager2 = PresetManager(project_dir)\n        assert manager2.registry.get(\"test-pack\")[\"priority\"] == 5\n\n    def test_set_priority_same_value_no_change(self, project_dir, pack_dir):\n        \"\"\"Test set-priority with same value shows already set message.\"\"\"\n        from typer.testing import CliRunner\n        from unittest.mock import patch\n        from specify_cli import app\n\n        runner = CliRunner()\n\n        # Install preset with priority 5\n        manager = PresetManager(project_dir)\n        manager.install_from_directory(pack_dir, \"0.1.5\", priority=5)\n\n        with patch.object(Path, \"cwd\", return_value=project_dir):\n            result = runner.invoke(app, [\"preset\", \"set-priority\", \"test-pack\", \"5\"])\n\n        assert result.exit_code == 0, result.output\n        assert \"already has priority 5\" in result.output\n\n    def test_set_priority_invalid_value(self, project_dir, pack_dir):\n        \"\"\"Test set-priority rejects invalid priority values.\"\"\"\n        from typer.testing import CliRunner\n        from unittest.mock import patch\n        from specify_cli import app\n\n        runner = CliRunner()\n\n        # Install preset\n        manager = PresetManager(project_dir)\n        manager.install_from_directory(pack_dir, \"0.1.5\")\n\n        with patch.object(Path, \"cwd\", return_value=project_dir):\n            result = runner.invoke(app, [\"preset\", \"set-priority\", \"test-pack\", \"0\"])\n\n        assert result.exit_code == 1, result.output\n        assert \"Priority must be a positive integer\" in result.output\n\n    def test_set_priority_not_installed(self, project_dir):\n        \"\"\"Test set-priority fails for non-installed preset.\"\"\"\n        from typer.testing import CliRunner\n        from unittest.mock import patch\n        from specify_cli import app\n\n        runner = CliRunner()\n\n        with patch.object(Path, \"cwd\", return_value=project_dir):\n            result = runner.invoke(app, [\"preset\", \"set-priority\", \"nonexistent\", \"5\"])\n\n        assert result.exit_code == 1, result.output\n        assert \"not installed\" in result.output.lower()\n\n\nclass TestPresetPriorityBackwardsCompatibility:\n    \"\"\"Test backwards compatibility for presets installed before priority feature.\"\"\"\n\n    def test_legacy_preset_without_priority_field(self, temp_dir):\n        \"\"\"Presets installed before priority feature should default to 10.\"\"\"\n        presets_dir = temp_dir / \".specify\" / \"presets\"\n        presets_dir.mkdir(parents=True)\n\n        # Simulate legacy registry entry without priority field\n        registry = PresetRegistry(presets_dir)\n        registry.data[\"presets\"][\"legacy-pack\"] = {\n            \"version\": \"1.0.0\",\n            \"source\": \"local\",\n            \"enabled\": True,\n            \"installed_at\": \"2025-01-01T00:00:00Z\",\n            # No \"priority\" field - simulates pre-feature preset\n        }\n        registry._save()\n\n        # Reload registry\n        registry2 = PresetRegistry(presets_dir)\n\n        # list_by_priority should use default of 10\n        result = registry2.list_by_priority()\n        assert len(result) == 1\n        assert result[0][0] == \"legacy-pack\"\n        # Priority defaults to 10 and is normalized in returned metadata\n        assert result[0][1][\"priority\"] == 10\n\n    def test_legacy_preset_in_list_installed(self, project_dir, pack_dir):\n        \"\"\"list_installed returns priority=10 for legacy presets without priority field.\"\"\"\n        manager = PresetManager(project_dir)\n\n        # Install preset normally\n        manager.install_from_directory(pack_dir, \"0.1.5\")\n\n        # Manually remove priority to simulate legacy preset\n        pack_data = manager.registry.data[\"presets\"][\"test-pack\"]\n        del pack_data[\"priority\"]\n        manager.registry._save()\n\n        # list_installed should still return priority=10\n        installed = manager.list_installed()\n        assert len(installed) == 1\n        assert installed[0][\"priority\"] == 10\n\n    def test_mixed_legacy_and_new_presets_ordering(self, temp_dir):\n        \"\"\"Legacy presets (no priority) sort with default=10 among prioritized presets.\"\"\"\n        presets_dir = temp_dir / \".specify\" / \"presets\"\n        presets_dir.mkdir(parents=True)\n\n        registry = PresetRegistry(presets_dir)\n\n        # Add preset with explicit priority=5\n        registry.add(\"pack-with-priority\", {\"version\": \"1.0.0\", \"priority\": 5})\n\n        # Add legacy preset without priority (manually)\n        registry.data[\"presets\"][\"legacy-pack\"] = {\n            \"version\": \"1.0.0\",\n            \"source\": \"local\",\n            \"enabled\": True,\n            # No priority field\n        }\n\n        # Add another preset with priority=15\n        registry.add(\"low-priority-pack\", {\"version\": \"1.0.0\", \"priority\": 15})\n        registry._save()\n\n        # Reload and check ordering\n        registry2 = PresetRegistry(presets_dir)\n        sorted_presets = registry2.list_by_priority()\n\n        # Should be: pack-with-priority (5), legacy-pack (default 10), low-priority-pack (15)\n        assert [p[0] for p in sorted_presets] == [\n            \"pack-with-priority\",\n            \"legacy-pack\",\n            \"low-priority-pack\",\n        ]\n\n\nclass TestPresetEnableDisable:\n    \"\"\"Test preset enable/disable CLI commands.\"\"\"\n\n    def test_disable_preset(self, project_dir, pack_dir):\n        \"\"\"Test disable command sets enabled=False.\"\"\"\n        from typer.testing import CliRunner\n        from unittest.mock import patch\n        from specify_cli import app\n\n        runner = CliRunner()\n\n        # Install preset\n        manager = PresetManager(project_dir)\n        manager.install_from_directory(pack_dir, \"0.1.5\")\n\n        # Verify initially enabled\n        assert manager.registry.get(\"test-pack\").get(\"enabled\", True) is True\n\n        with patch.object(Path, \"cwd\", return_value=project_dir):\n            result = runner.invoke(app, [\"preset\", \"disable\", \"test-pack\"])\n\n        assert result.exit_code == 0, result.output\n        assert \"disabled\" in result.output.lower()\n\n        # Reload registry to see updated value\n        manager2 = PresetManager(project_dir)\n        assert manager2.registry.get(\"test-pack\")[\"enabled\"] is False\n\n    def test_enable_preset(self, project_dir, pack_dir):\n        \"\"\"Test enable command sets enabled=True.\"\"\"\n        from typer.testing import CliRunner\n        from unittest.mock import patch\n        from specify_cli import app\n\n        runner = CliRunner()\n\n        # Install preset and disable it\n        manager = PresetManager(project_dir)\n        manager.install_from_directory(pack_dir, \"0.1.5\")\n        manager.registry.update(\"test-pack\", {\"enabled\": False})\n\n        # Verify disabled\n        assert manager.registry.get(\"test-pack\")[\"enabled\"] is False\n\n        with patch.object(Path, \"cwd\", return_value=project_dir):\n            result = runner.invoke(app, [\"preset\", \"enable\", \"test-pack\"])\n\n        assert result.exit_code == 0, result.output\n        assert \"enabled\" in result.output.lower()\n\n        # Reload registry to see updated value\n        manager2 = PresetManager(project_dir)\n        assert manager2.registry.get(\"test-pack\")[\"enabled\"] is True\n\n    def test_disable_already_disabled(self, project_dir, pack_dir):\n        \"\"\"Test disable on already disabled preset shows warning.\"\"\"\n        from typer.testing import CliRunner\n        from unittest.mock import patch\n        from specify_cli import app\n\n        runner = CliRunner()\n\n        # Install preset and disable it\n        manager = PresetManager(project_dir)\n        manager.install_from_directory(pack_dir, \"0.1.5\")\n        manager.registry.update(\"test-pack\", {\"enabled\": False})\n\n        with patch.object(Path, \"cwd\", return_value=project_dir):\n            result = runner.invoke(app, [\"preset\", \"disable\", \"test-pack\"])\n\n        assert result.exit_code == 0, result.output\n        assert \"already disabled\" in result.output.lower()\n\n    def test_enable_already_enabled(self, project_dir, pack_dir):\n        \"\"\"Test enable on already enabled preset shows warning.\"\"\"\n        from typer.testing import CliRunner\n        from unittest.mock import patch\n        from specify_cli import app\n\n        runner = CliRunner()\n\n        # Install preset (enabled by default)\n        manager = PresetManager(project_dir)\n        manager.install_from_directory(pack_dir, \"0.1.5\")\n\n        with patch.object(Path, \"cwd\", return_value=project_dir):\n            result = runner.invoke(app, [\"preset\", \"enable\", \"test-pack\"])\n\n        assert result.exit_code == 0, result.output\n        assert \"already enabled\" in result.output.lower()\n\n    def test_disable_not_installed(self, project_dir):\n        \"\"\"Test disable fails for non-installed preset.\"\"\"\n        from typer.testing import CliRunner\n        from unittest.mock import patch\n        from specify_cli import app\n\n        runner = CliRunner()\n\n        with patch.object(Path, \"cwd\", return_value=project_dir):\n            result = runner.invoke(app, [\"preset\", \"disable\", \"nonexistent\"])\n\n        assert result.exit_code == 1, result.output\n        assert \"not installed\" in result.output.lower()\n\n    def test_enable_not_installed(self, project_dir):\n        \"\"\"Test enable fails for non-installed preset.\"\"\"\n        from typer.testing import CliRunner\n        from unittest.mock import patch\n        from specify_cli import app\n\n        runner = CliRunner()\n\n        with patch.object(Path, \"cwd\", return_value=project_dir):\n            result = runner.invoke(app, [\"preset\", \"enable\", \"nonexistent\"])\n\n        assert result.exit_code == 1, result.output\n        assert \"not installed\" in result.output.lower()\n\n    def test_disabled_preset_excluded_from_resolution(self, project_dir, pack_dir):\n        \"\"\"Test that disabled presets are excluded from template resolution.\"\"\"\n        # Install preset with a template\n        manager = PresetManager(project_dir)\n        manager.install_from_directory(pack_dir, \"0.1.5\")\n\n        # Create a template in the preset directory\n        preset_template = project_dir / \".specify\" / \"presets\" / \"test-pack\" / \"templates\" / \"test-template.md\"\n        preset_template.parent.mkdir(parents=True, exist_ok=True)\n        preset_template.write_text(\"# Template from test-pack\")\n\n        resolver = PresetResolver(project_dir)\n\n        # Template should be found when enabled\n        result = resolver.resolve(\"test-template\", \"template\")\n        assert result is not None\n        assert \"test-pack\" in str(result)\n\n        # Disable the preset\n        manager.registry.update(\"test-pack\", {\"enabled\": False})\n\n        # Template should NOT be found when disabled\n        resolver2 = PresetResolver(project_dir)\n        result2 = resolver2.resolve(\"test-template\", \"template\")\n        assert result2 is None\n\n    def test_enable_corrupted_registry_entry(self, project_dir, pack_dir):\n        \"\"\"Test enable fails gracefully for corrupted registry entry.\"\"\"\n        from typer.testing import CliRunner\n        from unittest.mock import patch\n        from specify_cli import app\n\n        runner = CliRunner()\n\n        # Install preset then corrupt the registry entry\n        manager = PresetManager(project_dir)\n        manager.install_from_directory(pack_dir, \"0.1.5\")\n        manager.registry.data[\"presets\"][\"test-pack\"] = \"corrupted-string\"\n        manager.registry._save()\n\n        with patch.object(Path, \"cwd\", return_value=project_dir):\n            result = runner.invoke(app, [\"preset\", \"enable\", \"test-pack\"])\n\n        assert result.exit_code == 1\n        assert \"corrupted state\" in result.output.lower()\n\n    def test_disable_corrupted_registry_entry(self, project_dir, pack_dir):\n        \"\"\"Test disable fails gracefully for corrupted registry entry.\"\"\"\n        from typer.testing import CliRunner\n        from unittest.mock import patch\n        from specify_cli import app\n\n        runner = CliRunner()\n\n        # Install preset then corrupt the registry entry\n        manager = PresetManager(project_dir)\n        manager.install_from_directory(pack_dir, \"0.1.5\")\n        manager.registry.data[\"presets\"][\"test-pack\"] = \"corrupted-string\"\n        manager.registry._save()\n\n        with patch.object(Path, \"cwd\", return_value=project_dir):\n            result = runner.invoke(app, [\"preset\", \"disable\", \"test-pack\"])\n\n        assert result.exit_code == 1\n        assert \"corrupted state\" in result.output.lower()\n"
  }
]