diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index caf184c1c6..88acdcd5fd 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -50,8 +50,6 @@ "kilocode.Kilo-Code", // Roo Code "RooVeterinaryInc.roo-cline", - // Amazon Developer Q - "AmazonWebServices.amazon-q-vscode", // Claude Code "anthropic.claude-code" ], diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 9608a28a3d..4dd17294e7 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -8,15 +8,15 @@ run_command() { local command_to_run="$*" local output local exit_code - + # Capture all output (stdout and stderr) output=$(eval "$command_to_run" 2>&1) || exit_code=$? exit_code=${exit_code:-0} - + if [ $exit_code -ne 0 ]; then echo -e "\033[0;31m[ERROR] Command failed (Exit Code $exit_code): $command_to_run\033[0m" >&2 echo -e "\033[0;31m$output\033[0m" >&2 - + exit $exit_code fi } @@ -51,32 +51,46 @@ echo -e "\nšŸ¤– Installing OpenCode CLI..." run_command "npm install -g opencode-ai@latest" echo "āœ… Done" -echo -e "\nšŸ¤– Installing Amazon Q CLI..." -# šŸ‘‰šŸ¾ https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/command-line-verify-download.html - -run_command "curl --proto '=https' --tlsv1.2 -sSf 'https://desktop-release.q.us-east-1.amazonaws.com/latest/q-x86_64-linux.zip' -o 'q.zip'" -run_command "curl --proto '=https' --tlsv1.2 -sSf 'https://desktop-release.q.us-east-1.amazonaws.com/latest/q-x86_64-linux.zip.sig' -o 'q.zip.sig'" -cat > amazonq-public-key.asc << 'EOF' ------BEGIN PGP PUBLIC KEY BLOCK----- - -mDMEZig60RYJKwYBBAHaRw8BAQdAy/+G05U5/EOA72WlcD4WkYn5SInri8pc4Z6D -BKNNGOm0JEFtYXpvbiBRIENMSSBUZWFtIDxxLWNsaUBhbWF6b24uY29tPoiZBBMW -CgBBFiEEmvYEF+gnQskUPgPsUNx6jcJMVmcFAmYoOtECGwMFCQPCZwAFCwkIBwIC -IgIGFQoJCAsCBBYCAwECHgcCF4AACgkQUNx6jcJMVmef5QD/QWWEGG/cOnbDnp68 -SJXuFkwiNwlH2rPw9ZRIQMnfAS0A/0V6ZsGB4kOylBfc7CNfzRFGtovdBBgHqA6P -zQ/PNscGuDgEZig60RIKKwYBBAGXVQEFAQEHQC4qleONMBCq3+wJwbZSr0vbuRba -D1xr4wUPn4Avn4AnAwEIB4h+BBgWCgAmFiEEmvYEF+gnQskUPgPsUNx6jcJMVmcF -AmYoOtECGwwFCQPCZwAACgkQUNx6jcJMVmchMgEA6l3RveCM0YHAGQaSFMkguoAo -vK6FgOkDawgP0NPIP2oA/jIAO4gsAntuQgMOsPunEdDeji2t+AhV02+DQIsXZpoB -=f8yY ------END PGP PUBLIC KEY BLOCK----- -EOF -run_command "gpg --batch --import amazonq-public-key.asc" -run_command "gpg --verify q.zip.sig q.zip" -run_command "unzip -q q.zip" -run_command "chmod +x ./q/install.sh" -run_command "./q/install.sh --no-confirm" -run_command "rm -rf ./q q.zip q.zip.sig amazonq-public-key.asc" +echo -e "\nšŸ¤– Installing Junie CLI..." +run_command "npm install -g @jetbrains/junie-cli@latest" +echo "āœ… Done" + +echo -e "\nšŸ¤– Installing Pi Coding Agent..." +run_command "npm install -g @mariozechner/pi-coding-agent@latest" +echo "āœ… Done" + +echo -e "\nšŸ¤– Installing Kiro CLI..." +# https://kiro.dev/docs/cli/ +KIRO_INSTALLER_URL="https://kiro.dev/install.sh" +KIRO_INSTALLER_SHA256="7487a65cf310b7fb59b357c4b5e6e3f3259d383f4394ecedb39acf70f307cffb" +KIRO_INSTALLER_PATH="$(mktemp)" + +cleanup_kiro_installer() { + rm -f "$KIRO_INSTALLER_PATH" +} +trap cleanup_kiro_installer EXIT + +run_command "curl -fsSL \"$KIRO_INSTALLER_URL\" -o \"$KIRO_INSTALLER_PATH\"" +run_command "echo \"$KIRO_INSTALLER_SHA256 $KIRO_INSTALLER_PATH\" | sha256sum -c -" + +run_command "bash \"$KIRO_INSTALLER_PATH\"" + +kiro_binary="" +if command -v kiro-cli >/dev/null 2>&1; then + kiro_binary="kiro-cli" +elif command -v kiro >/dev/null 2>&1; then + kiro_binary="kiro" +else + echo -e "\033[0;31m[ERROR] Kiro CLI installation did not create 'kiro-cli' or 'kiro' in PATH.\033[0m" >&2 + exit 1 +fi + +run_command "$kiro_binary --help > /dev/null" +echo "āœ… Done" + +echo -e "\nšŸ¤– Installing Kimi CLI..." +# https://code.kimi.com +run_command "pipx install kimi-cli" echo "āœ… Done" echo -e "\nšŸ¤– Installing CodeBuddy CLI..." diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index efb95fc0eb..a60b7a0306 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,3 @@ # Global code owner -* @localden +* @mnriem diff --git a/.github/ISSUE_TEMPLATE/agent_request.yml b/.github/ISSUE_TEMPLATE/agent_request.yml new file mode 100644 index 0000000000..37b0fea5bf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/agent_request.yml @@ -0,0 +1,141 @@ +name: Agent Request +description: Request support for a new AI agent/assistant in Spec Kit +title: "[Agent]: Add support for " +labels: ["agent-request", "enhancement", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Thanks for requesting a new agent! Before submitting, please check if the agent is already supported. + + **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 + + - type: input + id: agent-name + attributes: + label: Agent Name + description: What is the name of the AI agent/assistant? + placeholder: "e.g., SuperCoder AI" + validations: + required: true + + - type: input + id: website + attributes: + label: Official Website + description: Link to the agent's official website or documentation + placeholder: "https://..." + validations: + required: true + + - type: dropdown + id: agent-type + attributes: + label: Agent Type + description: How is the agent accessed? + options: + - CLI tool (command-line interface) + - IDE extension/plugin + - Both CLI and IDE + - Other + validations: + required: true + + - type: input + id: cli-command + attributes: + label: CLI Command (if applicable) + description: What command is used to invoke the agent from terminal? + placeholder: "e.g., supercode, ai-assistant" + + - type: input + id: install-method + attributes: + label: Installation Method + description: How is the agent installed? + placeholder: "e.g., npm install -g supercode, pip install supercode, IDE marketplace" + validations: + required: true + + - type: textarea + id: command-structure + attributes: + label: Command/Workflow Structure + description: How does the agent define custom commands or workflows? + placeholder: | + - Command file format (Markdown, YAML, TOML, etc.) + - Directory location (e.g., .supercode/commands/) + - Example command file structure + validations: + required: true + + - type: textarea + id: argument-pattern + attributes: + label: Argument Passing Pattern + description: How does the agent handle arguments in commands? + placeholder: | + e.g., Uses {{args}}, $ARGUMENTS, %ARGS%, or other placeholder format + Example: "Run test suite with {{args}}" + + - type: dropdown + id: popularity + attributes: + label: Popularity/Usage + description: How widely is this agent used? + options: + - Widely used (thousands+ of users) + - Growing adoption (hundreds of users) + - New/emerging (less than 100 users) + - Unknown + validations: + required: true + + - type: textarea + id: documentation + attributes: + label: Documentation Links + description: Links to relevant documentation for custom commands/workflows + placeholder: | + - Command documentation: https://... + - API/CLI reference: https://... + - Examples: https://... + + - type: textarea + id: use-case + attributes: + label: Use Case + description: Why do you want this agent supported in Spec Kit? + placeholder: Explain your workflow and how this agent fits into your development process + validations: + required: true + + - type: textarea + id: example-command + attributes: + label: Example Command File + description: If possible, provide an example of a command file for this agent + render: markdown + placeholder: | + ```toml + description = "Example command" + prompt = "Do something with {{args}}" + ``` + + - type: checkboxes + id: contribution + attributes: + label: Contribution + description: Are you willing to help implement support for this agent? + options: + - label: I can help test the integration + - label: I can provide example command files + - label: I can help with documentation + - label: I can submit a pull request for the integration + + - type: textarea + id: context + attributes: + label: Additional Context + description: Any other relevant information about this agent + placeholder: Screenshots, community links, comparison to existing agents, etc. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000000..dd09f8e02a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,118 @@ +name: Bug Report +description: Report a bug or unexpected behavior in Specify CLI or Spec Kit +title: "[Bug]: " +labels: ["bug", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report a bug! Please fill out the sections below to help us diagnose and fix the issue. + + - type: textarea + id: description + attributes: + label: Bug Description + description: A clear and concise description of what the bug is. + placeholder: What went wrong? + validations: + required: true + + - type: textarea + id: reproduce + attributes: + label: Steps to Reproduce + description: Steps to reproduce the behavior + placeholder: | + 1. Run command '...' + 2. Execute script '...' + 3. See error + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected Behavior + description: What did you expect to happen? + placeholder: Describe the expected outcome + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual Behavior + description: What actually happened? + placeholder: Describe what happened instead + validations: + required: true + + - type: input + id: version + attributes: + label: Specify CLI Version + description: "Run `specify version` or `pip show spec-kit`" + placeholder: "e.g., 1.3.0" + validations: + required: true + + - type: dropdown + id: ai-agent + attributes: + label: AI Agent + description: Which AI agent are you using? + options: + - 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 + - IBM Bob + - Antigravity + - Not applicable + validations: + required: true + + - type: input + id: os + attributes: + label: Operating System + description: Your operating system and version + placeholder: "e.g., macOS 14.2, Ubuntu 22.04, Windows 11" + validations: + required: true + + - type: input + id: python + attributes: + label: Python Version + description: "Run `python --version` or `python3 --version`" + placeholder: "e.g., Python 3.11.5" + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Error Logs + description: Please paste any relevant error messages or logs + render: shell + placeholder: Paste error output here + + - type: textarea + id: context + attributes: + label: Additional Context + description: Add any other context about the problem + placeholder: Screenshots, related issues, workarounds attempted, etc. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..04d1923b5b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,17 @@ +blank_issues_enabled: false +contact_links: + - name: šŸ’¬ General Discussion + url: https://github.com/github/spec-kit/discussions + about: Ask questions, share ideas, or discuss Spec-Driven Development + - name: šŸ“– Documentation + url: https://github.com/github/spec-kit/blob/main/README.md + about: Read the Spec Kit documentation and guides + - name: šŸ› ļø Extension Development Guide + url: https://github.com/github/spec-kit/blob/main/extensions/EXTENSION-DEVELOPMENT-GUIDE.md + about: Learn how to develop and publish Spec Kit extensions + - name: šŸ¤ Contributing Guide + url: https://github.com/github/spec-kit/blob/main/CONTRIBUTING.md + about: Learn how to contribute to Spec Kit + - name: šŸ”’ Security Issues + url: https://github.com/github/spec-kit/blob/main/SECURITY.md + about: Report security vulnerabilities privately diff --git a/.github/ISSUE_TEMPLATE/extension_submission.yml b/.github/ISSUE_TEMPLATE/extension_submission.yml new file mode 100644 index 0000000000..9d3f15872b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/extension_submission.yml @@ -0,0 +1,280 @@ +name: Extension Submission +description: Submit your extension to the Spec Kit catalog +title: "[Extension]: Add " +labels: ["extension-submission", "enhancement", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Thanks for contributing an extension! This template helps you submit your extension to the community catalog. + + **Before submitting:** + - Review the [Extension Publishing Guide](https://github.com/github/spec-kit/blob/main/extensions/EXTENSION-PUBLISHING-GUIDE.md) + - Ensure your extension has a valid `extension.yml` manifest + - Create a GitHub release with a version tag (e.g., v1.0.0) + - Test installation: `specify extension add --from ` + + - type: input + id: extension-id + attributes: + label: Extension ID + description: Unique extension identifier (lowercase with hyphens only) + placeholder: "e.g., jira-integration" + validations: + required: true + + - type: input + id: extension-name + attributes: + label: Extension Name + description: Human-readable extension name + placeholder: "e.g., Jira Integration" + validations: + required: true + + - type: input + id: version + attributes: + label: Version + description: Semantic version number + placeholder: "e.g., 1.0.0" + validations: + required: true + + - type: textarea + id: description + attributes: + label: Description + description: Brief description of what your extension does (under 200 characters) + placeholder: Integrates Jira issue tracking with Spec Kit workflows for seamless task management + validations: + required: true + + - type: input + id: author + attributes: + label: Author + description: Your name or organization + placeholder: "e.g., John Doe or Acme Corp" + validations: + required: true + + - type: input + id: repository + attributes: + label: Repository URL + description: GitHub repository URL for your extension + placeholder: "https://github.com/your-org/spec-kit-your-extension" + validations: + required: true + + - type: input + id: download-url + attributes: + label: Download URL + description: URL to the GitHub release archive (e.g., v1.0.0.zip) + placeholder: "https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip" + validations: + required: true + + - type: input + id: license + attributes: + label: License + description: Open source license type + placeholder: "e.g., MIT, Apache-2.0" + validations: + required: true + + - type: input + id: homepage + attributes: + label: Homepage (optional) + description: Link to extension homepage or documentation site + placeholder: "https://..." + + - type: input + id: documentation + attributes: + label: Documentation URL (optional) + description: Link to detailed documentation + placeholder: "https://github.com/your-org/spec-kit-your-extension/blob/main/docs/" + + - type: input + id: changelog + attributes: + label: Changelog URL (optional) + description: Link to changelog file + placeholder: "https://github.com/your-org/spec-kit-your-extension/blob/main/CHANGELOG.md" + + - type: input + id: speckit-version + attributes: + label: Required Spec Kit Version + description: Minimum Spec Kit version required + placeholder: "e.g., >=0.1.0" + validations: + required: true + + - type: textarea + id: required-tools + attributes: + label: Required Tools (optional) + description: List any external tools or dependencies required + placeholder: | + - jira-cli (>=1.0.0) - required + - python (>=3.8) - optional + render: markdown + + - type: input + id: commands-count + attributes: + label: Number of Commands + description: How many commands does your extension provide? + placeholder: "e.g., 3" + validations: + required: true + + - type: input + id: hooks-count + attributes: + label: Number of Hooks (optional) + description: How many hooks does your extension provide? + placeholder: "e.g., 0" + + - type: textarea + id: tags + attributes: + label: Tags + description: 2-5 relevant tags (lowercase, separated by commas) + placeholder: "issue-tracking, jira, atlassian, automation" + validations: + required: true + + - type: textarea + id: features + attributes: + label: Key Features + description: List the main features and capabilities of your extension + placeholder: | + - Create Jira issues from specs + - Sync task status with Jira + - Link specs to existing issues + - Generate Jira reports + validations: + required: true + + - type: checkboxes + id: testing + attributes: + label: Testing Checklist + description: Confirm that your extension has been tested + options: + - label: Extension installs successfully via download URL + required: true + - label: All commands execute without errors + required: true + - label: Documentation is complete and accurate + required: true + - label: No security vulnerabilities identified + required: true + - label: Tested on at least one real project + required: true + + - type: checkboxes + id: requirements + attributes: + label: Submission Requirements + description: Verify your extension meets all requirements + options: + - label: Valid `extension.yml` manifest included + required: true + - label: README.md with installation and usage instructions + required: true + - label: LICENSE file included + required: true + - label: GitHub release created with version tag + required: true + - label: All command files exist and are properly formatted + required: true + - label: Extension ID follows naming conventions (lowercase-with-hyphens) + required: true + + - type: textarea + id: testing-details + attributes: + label: Testing Details + description: Describe how you tested your extension + placeholder: | + **Tested on:** + - macOS 14.0 with Spec Kit v0.1.0 + - Linux Ubuntu 22.04 with Spec Kit v0.1.0 + + **Test project:** [Link or description] + + **Test scenarios:** + 1. Installed extension + 2. Configured settings + 3. Ran all commands + 4. Verified outputs + validations: + required: true + + - type: textarea + id: example-usage + attributes: + label: Example Usage + description: Provide a simple example of using your extension + render: markdown + placeholder: | + ```bash + # Install extension + specify extension add --from https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip + + # Use a command + /speckit.your-extension.command-name arg1 arg2 + ``` + validations: + required: true + + - type: textarea + id: catalog-entry + attributes: + label: Proposed Catalog Entry + description: Provide the JSON entry for catalog.json (helps reviewers) + render: json + placeholder: | + { + "your-extension": { + "name": "Your Extension", + "id": "your-extension", + "description": "Brief description", + "author": "Your Name", + "version": "1.0.0", + "download_url": "https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/your-org/spec-kit-your-extension", + "homepage": "https://github.com/your-org/spec-kit-your-extension", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 3 + }, + "tags": ["category", "tool"], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-02-20T00:00:00Z", + "updated_at": "2026-02-20T00:00:00Z" + } + } + validations: + required: true + + - type: textarea + id: additional-context + attributes: + label: Additional Context + description: Any other information that would help reviewers + placeholder: Screenshots, demo videos, links to related projects, etc. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000000..3b5889288b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,104 @@ +name: Feature Request +description: Suggest a new feature or enhancement for Specify CLI or Spec Kit +title: "[Feature]: " +labels: ["enhancement", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Thanks for suggesting a feature! Please provide details below to help us understand and evaluate your request. + + - type: textarea + id: problem + attributes: + label: Problem Statement + description: Is your feature request related to a problem? Please describe. + placeholder: "I'm frustrated when..." + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed Solution + description: Describe the solution you'd like + placeholder: What would you like to happen? + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives Considered + description: Have you considered any alternative solutions or workarounds? + placeholder: What other approaches might work? + + - type: dropdown + id: component + attributes: + label: Component + description: Which component does this feature relate to? + options: + - Specify CLI (initialization, commands) + - Spec templates (BDD, Testing Strategy, etc.) + - Agent integrations (command files, workflows) + - Scripts (Bash/PowerShell utilities) + - Documentation + - CI/CD workflows + - Other + validations: + required: true + + - type: dropdown + id: ai-agent + attributes: + label: AI Agent (if applicable) + description: Does this feature relate to a specific AI agent? + options: + - All 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 + - IBM Bob + - Antigravity + - Not applicable + + - type: textarea + id: use-cases + attributes: + label: Use Cases + description: Describe specific use cases where this feature would be valuable + placeholder: | + 1. When working on large projects... + 2. During spec review... + 3. When integrating with CI/CD... + + - type: textarea + id: acceptance + attributes: + label: Acceptance Criteria + description: How would you know this feature is complete and working? + placeholder: | + - [ ] Feature does X + - [ ] Documentation is updated + - [ ] Works with all supported agents + + - type: textarea + id: context + attributes: + label: Additional Context + description: Add any other context, screenshots, or examples + placeholder: Links to similar features, mockups, related discussions, etc. diff --git a/.github/ISSUE_TEMPLATE/preset_submission.yml b/.github/ISSUE_TEMPLATE/preset_submission.yml new file mode 100644 index 0000000000..3a1b963492 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/preset_submission.yml @@ -0,0 +1,169 @@ +name: Preset Submission +description: Submit your preset to the Spec Kit preset catalog +title: "[Preset]: Add " +labels: ["preset-submission", "enhancement", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Thanks for contributing a preset! This template helps you submit your preset to the community catalog. + + **Before submitting:** + - Review the [Preset Publishing Guide](https://github.com/github/spec-kit/blob/main/presets/PUBLISHING.md) + - Ensure your preset has a valid `preset.yml` manifest + - Create a GitHub release with a version tag (e.g., v1.0.0) + - Test installation from the release archive: `specify preset add --from ` + + - type: input + id: preset-id + attributes: + label: Preset ID + description: Unique preset identifier (lowercase with hyphens only) + placeholder: "e.g., healthcare-compliance" + validations: + required: true + + - type: input + id: preset-name + attributes: + label: Preset Name + description: Human-readable preset name + placeholder: "e.g., Healthcare Compliance" + validations: + required: true + + - type: input + id: version + attributes: + label: Version + description: Semantic version number + placeholder: "e.g., 1.0.0" + validations: + required: true + + - type: textarea + id: description + attributes: + label: Description + description: Brief description of what your preset does (under 200 characters) + placeholder: Enforces HIPAA-compliant spec workflows with audit templates and compliance checklists + validations: + required: true + + - type: input + id: author + attributes: + label: Author + description: Your name or organization + placeholder: "e.g., John Doe or Acme Corp" + validations: + required: true + + - type: input + id: repository + attributes: + label: Repository URL + description: GitHub repository URL for your preset + placeholder: "https://github.com/your-org/spec-kit-your-preset" + validations: + required: true + + - type: input + id: download-url + attributes: + label: Download URL + 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) + placeholder: "https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip" + validations: + required: true + + - type: input + id: license + attributes: + label: License + description: Open source license type + placeholder: "e.g., MIT, Apache-2.0" + validations: + required: true + + - type: input + id: speckit-version + attributes: + label: Required Spec Kit Version + description: Minimum Spec Kit version required + placeholder: "e.g., >=0.3.0" + validations: + required: true + + - type: textarea + id: templates-provided + attributes: + label: Templates Provided + description: List the template overrides your preset provides + placeholder: | + - spec-template.md — adds compliance section + - plan-template.md — includes audit checkpoints + - checklist-template.md — HIPAA compliance checklist + validations: + required: true + + - type: textarea + id: commands-provided + attributes: + label: Commands Provided (optional) + description: List any command overrides your preset provides + placeholder: | + - speckit.specify.md — customized for compliance workflows + + - type: textarea + id: tags + attributes: + label: Tags + description: 2-5 relevant tags (lowercase, separated by commas) + placeholder: "compliance, healthcare, hipaa, audit" + validations: + required: true + + - type: textarea + id: features + attributes: + label: Key Features + description: List the main features and capabilities of your preset + placeholder: | + - HIPAA-compliant spec templates + - Audit trail checklists + - Compliance review workflow + validations: + required: true + + - type: checkboxes + id: testing + attributes: + label: Testing Checklist + description: Confirm that your preset has been tested + options: + - label: Preset installs successfully via `specify preset add` + required: true + - label: Template resolution works correctly after installation + required: true + - label: Documentation is complete and accurate + required: true + - label: Tested on at least one real project + required: true + + - type: checkboxes + id: requirements + attributes: + label: Submission Requirements + description: Verify your preset meets all requirements + options: + - label: Valid `preset.yml` manifest included + required: true + - label: README.md with description and usage instructions + required: true + - label: LICENSE file included + required: true + - label: GitHub release created with version tag + required: true + - label: Preset ID follows naming conventions (lowercase-with-hyphens) + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..68c4aeb255 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,22 @@ +## Description + + + +## Testing + + + +- [ ] Tested locally with `uv run specify --help` +- [ ] Ran existing tests with `uv sync && uv run pytest` +- [ ] Tested with a sample project (if applicable) + +## AI Disclosure + + + + +- [ ] I **did not** use AI assistance for this contribution +- [ ] I **did** use AI assistance (describe below) + + + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..47762116a2 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/RELEASE-PROCESS.md b/.github/workflows/RELEASE-PROCESS.md new file mode 100644 index 0000000000..18fe40e858 --- /dev/null +++ b/.github/workflows/RELEASE-PROCESS.md @@ -0,0 +1,191 @@ +# Release Process + +This document describes the automated release process for Spec Kit. + +## Overview + +The release process is split into two workflows to ensure version consistency: + +1. **Release Trigger Workflow** (`release-trigger.yml`) - Manages versioning and triggers release +2. **Release Workflow** (`release.yml`) - Builds and publishes artifacts + +This separation ensures that git tags always point to commits with the correct version in `pyproject.toml`. + +## Before Creating a Release + +**Important**: Write clear, descriptive commit messages! + +### How CHANGELOG.md Works + +The CHANGELOG is **automatically generated** from your git commit messages: + +1. **During Development**: Write clear, descriptive commit messages: + ```bash + git commit -m "feat: Add new authentication feature" + git commit -m "fix: Resolve timeout issue in API client (#123)" + git commit -m "docs: Update installation instructions" + ``` + +2. **When Releasing**: The release trigger workflow automatically: + - Finds all commits since the last release tag + - Formats them as changelog entries + - Inserts them into CHANGELOG.md + - Commits the updated changelog before creating the new tag + +### Commit Message Best Practices + +Good commit messages make good changelogs: +- **Be descriptive**: "Add user authentication" not "Update files" +- **Reference issues/PRs**: Include `(#123)` for automated linking +- **Use conventional commits** (optional): `feat:`, `fix:`, `docs:`, `chore:` +- **Keep it concise**: One line is ideal, details go in commit body + +**Example commits that become good changelog entries:** +``` +fix: prepend YAML frontmatter to Cursor .mdc files (#1699) +feat: add generic agent support with customizable command directories (#1639) +docs: document dual-catalog system for extensions (#1689) +``` + +## Creating a Release + +### Option 1: Auto-Increment (Recommended for patches) + +1. Go to **Actions** → **Release Trigger** +2. Click **Run workflow** +3. Leave the version field **empty** +4. Click **Run workflow** + +The workflow will: +- Auto-increment the patch version (e.g., `0.1.10` → `0.1.11`) +- Update `pyproject.toml` +- Update `CHANGELOG.md` by adding a new section for the release based on commits since the last tag +- Commit changes to a `chore/release-vX.Y.Z` branch +- Create and push the git tag from that branch +- Open a PR to merge the version bump into `main` +- Trigger the release workflow automatically via the tag push + +### Option 2: Manual Version (For major/minor bumps) + +1. Go to **Actions** → **Release Trigger** +2. Click **Run workflow** +3. Enter the desired version (e.g., `0.2.0` or `v0.2.0`) +4. Click **Run workflow** + +The workflow will: +- Use your specified version +- Update `pyproject.toml` +- Update `CHANGELOG.md` by adding a new section for the release based on commits since the last tag +- Commit changes to a `chore/release-vX.Y.Z` branch +- Create and push the git tag from that branch +- Open a PR to merge the version bump into `main` +- Trigger the release workflow automatically via the tag push + +## What Happens Next + +Once the release trigger workflow completes: + +1. A `chore/release-vX.Y.Z` branch is pushed with the version bump commit +2. The git tag is pushed, pointing to that commit +3. The **Release Workflow** is automatically triggered by the tag push +4. Release artifacts are built for all supported agents +5. A GitHub Release is created with all assets +6. A PR is opened to merge the version bump branch into `main` + +> **Note**: Merge the auto-opened PR after the release is published to keep `main` in sync. + +## Workflow Details + +### Release Trigger Workflow + +**File**: `.github/workflows/release-trigger.yml` + +**Trigger**: Manual (`workflow_dispatch`) + +**Permissions Required**: `contents: write` + +**Steps**: +1. Checkout repository +2. Determine version (manual or auto-increment) +3. Check if tag already exists (prevents duplicates) +4. Create `chore/release-vX.Y.Z` branch +5. Update `pyproject.toml` +6. Update `CHANGELOG.md` from git commits +7. Commit changes +8. Push branch and tag +9. Open PR to merge version bump into `main` + +### Release Workflow + +**File**: `.github/workflows/release.yml` + +**Trigger**: Tag push (`v*`) + +**Permissions Required**: `contents: write` + +**Steps**: +1. Checkout repository at tag +2. Extract version from tag name +3. Check if release already exists +4. Build release package variants (all agents Ɨ shell/powershell) +5. Generate release notes from commits +6. Create GitHub Release with all assets + +## Version Constraints + +- Tags must follow format: `v{MAJOR}.{MINOR}.{PATCH}` +- Example valid versions: `v0.1.11`, `v0.2.0`, `v1.0.0` +- Auto-increment only bumps patch version +- Cannot create duplicate tags (workflow will fail) + +## Benefits of This Approach + +āœ… **Version Consistency**: Git tags point to commits with matching `pyproject.toml` version + +āœ… **Single Source of Truth**: Version set once, used everywhere + +āœ… **Prevents Drift**: No more manual version synchronization needed + +āœ… **Clean Separation**: Versioning logic separate from artifact building + +āœ… **Flexibility**: Supports both auto-increment and manual versioning + +## Troubleshooting + +### No Commits Since Last Release + +If you run the release trigger workflow when there are no new commits since the last tag: +- The workflow will still succeed +- The CHANGELOG will show "- Initial release" if it's the first release +- Or it will be empty if there are no commits +- Consider adding meaningful commits before releasing + +**Best Practice**: Use descriptive commit messages - they become your changelog! + +### Tag Already Exists + +If you see "Error: Tag vX.Y.Z already exists!", you need to: +- Choose a different version number, or +- Delete the existing tag if it was created in error + +### Release Workflow Didn't Trigger + +Check that: +- The release trigger workflow completed successfully +- The tag was pushed (check repository tags) +- The release workflow is enabled in Actions settings + +### Version Mismatch + +If `pyproject.toml` doesn't match the latest tag: +- Run the release trigger workflow to sync versions +- Or manually update `pyproject.toml` and push changes before running the release trigger + +## Legacy Behavior (Pre-v0.1.10) + +Before this change, the release workflow: +- Created tags automatically on main branch pushes +- Updated `pyproject.toml` AFTER creating the tag +- Resulted in tags pointing to commits with outdated versions + +This has been fixed in v0.1.10+. diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000000..01e0df4a51 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,32 @@ +name: "CodeQL" + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + security-events: write + contents: read + strategy: + fail-fast: false + matrix: + language: [ 'actions', 'python' ] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index b2811b43bb..6fe87ddce2 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -26,10 +26,11 @@ concurrency: jobs: # Build job build: + if: github.repository == 'github/spec-kit' runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 # Fetch all history for git info @@ -47,15 +48,16 @@ jobs: docfx docfx.json - name: Setup Pages - uses: actions/configure-pages@v5 + uses: actions/configure-pages@v6 - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v5 with: path: 'docs/_site' # Deploy job deploy: + if: github.repository == 'github/spec-kit' environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} @@ -64,5 +66,5 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f6fbd24738..fdece63093 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,10 +12,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Run markdownlint-cli2 - uses: DavidAnson/markdownlint-cli2-action@v19 + uses: DavidAnson/markdownlint-cli2-action@ce4853d43830c74c1753b39f3cf40f71c2031eb9 # v23 with: globs: | '**/*.md' diff --git a/.github/workflows/release-trigger.yml b/.github/workflows/release-trigger.yml new file mode 100644 index 0000000000..a451accfe6 --- /dev/null +++ b/.github/workflows/release-trigger.yml @@ -0,0 +1,178 @@ +name: Release Trigger + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g., 0.1.11). Leave empty to auto-increment patch version.' + required: false + type: string + +jobs: + bump-version: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.RELEASE_PAT }} + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Determine version + id: version + env: + INPUT_VERSION: ${{ github.event.inputs.version }} + run: | + if [[ -n "$INPUT_VERSION" ]]; then + # Manual version specified - strip optional v prefix + VERSION="${INPUT_VERSION#v}" + # Validate strict semver format to prevent injection + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: Invalid version format '$VERSION'. Must be X.Y.Z (e.g. 1.2.3 or v1.2.3)" + exit 1 + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "tag=v$VERSION" >> $GITHUB_OUTPUT + echo "Using manual version: $VERSION" + else + # Auto-increment patch version + LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + echo "Latest tag: $LATEST_TAG" + + # Extract version number and increment + VERSION=$(echo $LATEST_TAG | sed 's/v//') + IFS='.' read -ra VERSION_PARTS <<< "$VERSION" + MAJOR=${VERSION_PARTS[0]:-0} + MINOR=${VERSION_PARTS[1]:-0} + PATCH=${VERSION_PARTS[2]:-0} + + # Increment patch version + PATCH=$((PATCH + 1)) + NEW_VERSION="$MAJOR.$MINOR.$PATCH" + + echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT + echo "tag=v$NEW_VERSION" >> $GITHUB_OUTPUT + echo "Auto-incremented version: $NEW_VERSION" + fi + + - name: Check if tag already exists + run: | + if git rev-parse "${{ steps.version.outputs.tag }}" >/dev/null 2>&1; then + echo "Error: Tag ${{ steps.version.outputs.tag }} already exists!" + exit 1 + fi + + - name: Create release branch + run: | + BRANCH="chore/release-${{ steps.version.outputs.tag }}" + git checkout -b "$BRANCH" + echo "branch=$BRANCH" >> $GITHUB_ENV + + - name: Update pyproject.toml + run: | + sed -i "s/version = \".*\"/version = \"${{ steps.version.outputs.version }}\"/" pyproject.toml + echo "Updated pyproject.toml to version ${{ steps.version.outputs.version }}" + + - name: Update CHANGELOG.md + run: | + if [ -f "CHANGELOG.md" ]; then + DATE=$(date +%Y-%m-%d) + + # Get the previous tag by sorting all version tags numerically + # (git describe --tags only finds tags reachable from HEAD, + # which misses tags on unmerged release branches) + PREVIOUS_TAG=$(git tag -l 'v*' --sort=-version:refname | head -n 1) + + echo "Generating changelog from commits..." + if [[ -n "$PREVIOUS_TAG" ]]; then + echo "Changes since $PREVIOUS_TAG" + COMMITS=$(git log --oneline "$PREVIOUS_TAG"..HEAD --no-merges --pretty=format:"- %s" 2>/dev/null || echo "- Initial release") + else + echo "No previous tag found - this is the first release" + COMMITS="- Initial release" + fi + + # Create new changelog entry — insert after the marker comment + NEW_ENTRY=$(printf '%s\n' \ + "" \ + "## [${{ steps.version.outputs.version }}] - $DATE" \ + "" \ + "### Changed" \ + "" \ + "$COMMITS") + + awk -v entry="$NEW_ENTRY" '// { print; print entry; next } {print}' CHANGELOG.md > CHANGELOG.md.tmp + mv CHANGELOG.md.tmp CHANGELOG.md + + echo "āœ… Updated CHANGELOG.md with commits since $PREVIOUS_TAG" + else + echo "No CHANGELOG.md found" + fi + + - name: Commit version bump + run: | + if [ -f "CHANGELOG.md" ]; then + git add pyproject.toml CHANGELOG.md + else + git add pyproject.toml + fi + + if git diff --cached --quiet; then + echo "No changes to commit" + else + git commit -m "chore: bump version to ${{ steps.version.outputs.version }}" + echo "Changes committed" + fi + + - name: Create and push tag + run: | + git tag -a "${{ steps.version.outputs.tag }}" -m "Release ${{ steps.version.outputs.tag }}" + git push origin "${{ env.branch }}" + git push origin "${{ steps.version.outputs.tag }}" + echo "Branch ${{ env.branch }} and tag ${{ steps.version.outputs.tag }} pushed" + + - name: Bump to dev version + id: dev_version + run: | + IFS='.' read -r MAJOR MINOR PATCH <<< "${{ steps.version.outputs.version }}" + NEXT_DEV="$MAJOR.$MINOR.$((PATCH + 1)).dev0" + echo "dev_version=$NEXT_DEV" >> $GITHUB_OUTPUT + sed -i "s/version = \".*\"/version = \"$NEXT_DEV\"/" pyproject.toml + git add pyproject.toml + if git diff --cached --quiet; then + echo "No dev version changes to commit" + else + git commit -m "chore: begin $NEXT_DEV development" + git push origin "${{ env.branch }}" + echo "Bumped to dev version $NEXT_DEV" + fi + + - name: Open pull request + env: + GITHUB_TOKEN: ${{ secrets.RELEASE_PAT }} + run: | + gh pr create \ + --base main \ + --head "${{ env.branch }}" \ + --title "chore: release ${{ steps.version.outputs.version }}, begin ${{ steps.dev_version.outputs.dev_version }} development" \ + --body "Automated release of ${{ steps.version.outputs.version }}. + + 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. + + Merging this PR will set \`main\` to \`${{ steps.dev_version.outputs.dev_version }}\` so that development installs are clearly marked as pre-release." + + - name: Summary + run: | + echo "āœ… Version bumped to ${{ steps.version.outputs.version }}" + echo "āœ… Tag ${{ steps.version.outputs.tag }} created and pushed" + echo "āœ… Dev version set to ${{ steps.dev_version.outputs.dev_version }}" + echo "āœ… PR opened to merge version bump into main" + echo "šŸš€ Release workflow is building artifacts from the tag" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9ad2087436..7b903cf979 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,59 +2,88 @@ name: Create Release on: push: - branches: [ main ] - paths: - - 'memory/**' - - 'scripts/**' - - 'templates/**' - - '.github/workflows/**' - workflow_dispatch: + tags: + - 'v*' jobs: release: runs-on: ubuntu-latest permissions: contents: write - pull-requests: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - - name: Get latest tag - id: get_tag + + - name: Extract version from tag + id: version run: | - chmod +x .github/workflows/scripts/get-next-version.sh - .github/workflows/scripts/get-next-version.sh + VERSION=${GITHUB_REF#refs/tags/} + echo "tag=$VERSION" >> $GITHUB_OUTPUT + echo "Building release for $VERSION" + - name: Check if release already exists id: check_release run: | - chmod +x .github/workflows/scripts/check-release-exists.sh - .github/workflows/scripts/check-release-exists.sh ${{ steps.get_tag.outputs.new_version }} + VERSION="${{ steps.version.outputs.tag }}" + if gh release view "$VERSION" >/dev/null 2>&1; then + echo "exists=true" >> $GITHUB_OUTPUT + echo "Release $VERSION already exists, skipping..." + else + echo "exists=false" >> $GITHUB_OUTPUT + echo "Release $VERSION does not exist, proceeding..." + fi env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Create release package variants - if: steps.check_release.outputs.exists == 'false' - run: | - chmod +x .github/workflows/scripts/create-release-packages.sh - .github/workflows/scripts/create-release-packages.sh ${{ steps.get_tag.outputs.new_version }} + - name: Generate release notes if: steps.check_release.outputs.exists == 'false' - id: release_notes run: | - chmod +x .github/workflows/scripts/generate-release-notes.sh - .github/workflows/scripts/generate-release-notes.sh ${{ steps.get_tag.outputs.new_version }} ${{ steps.get_tag.outputs.latest_tag }} + VERSION="${{ steps.version.outputs.tag }}" + VERSION_NO_V=${VERSION#v} + + # Find previous tag + PREVIOUS_TAG=$(git tag -l 'v*' --sort=-version:refname | grep -v "^${VERSION}$" | head -n 1) + if [ -z "$PREVIOUS_TAG" ]; then + PREVIOUS_TAG="" + fi + + # Get commits since previous tag + if [ -z "$PREVIOUS_TAG" ]; then + COMMIT_COUNT=$(git rev-list --count HEAD) + if [ "$COMMIT_COUNT" -gt 20 ]; then + COMMITS=$(git log --oneline --pretty=format:"- %s" --no-merges HEAD~20..HEAD) + else + COMMITS=$(git log --oneline --pretty=format:"- %s" --no-merges) + fi + else + COMMITS=$(git log --oneline --pretty=format:"- %s" --no-merges "$PREVIOUS_TAG"..HEAD) + fi + + cat > release_notes.md << NOTES_EOF + ## Install + + \`\`\`bash + uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@${VERSION} + specify init my-project + \`\`\` + + NOTES_EOF + + echo "## What's Changed" >> release_notes.md + echo "" >> release_notes.md + echo "$COMMITS" >> release_notes.md + - name: Create GitHub Release if: steps.check_release.outputs.exists == 'false' run: | - chmod +x .github/workflows/scripts/create-github-release.sh - .github/workflows/scripts/create-github-release.sh ${{ steps.get_tag.outputs.new_version }} + VERSION="${{ steps.version.outputs.tag }}" + VERSION_NO_V=${VERSION#v} + gh release create "$VERSION" \ + --title "Spec Kit - $VERSION_NO_V" \ + --notes-file release_notes.md env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Update version in pyproject.toml (for release artifacts only) - if: steps.check_release.outputs.exists == 'false' - run: | - chmod +x .github/workflows/scripts/update-version.sh - .github/workflows/scripts/update-version.sh ${{ steps.get_tag.outputs.new_version }} diff --git a/.github/workflows/scripts/check-release-exists.sh b/.github/workflows/scripts/check-release-exists.sh deleted file mode 100644 index 88ef174f51..0000000000 --- a/.github/workflows/scripts/check-release-exists.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# check-release-exists.sh -# Check if a GitHub release already exists for the given version -# Usage: check-release-exists.sh - -if [[ $# -ne 1 ]]; then - echo "Usage: $0 " >&2 - exit 1 -fi - -VERSION="$1" - -if gh release view "$VERSION" >/dev/null 2>&1; then - echo "exists=true" >> $GITHUB_OUTPUT - echo "Release $VERSION already exists, skipping..." -else - echo "exists=false" >> $GITHUB_OUTPUT - echo "Release $VERSION does not exist, proceeding..." -fi diff --git a/.github/workflows/scripts/create-github-release.sh b/.github/workflows/scripts/create-github-release.sh deleted file mode 100644 index 56f1bac85d..0000000000 --- a/.github/workflows/scripts/create-github-release.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# create-github-release.sh -# Create a GitHub release with all template zip files -# Usage: create-github-release.sh - -if [[ $# -ne 1 ]]; then - echo "Usage: $0 " >&2 - exit 1 -fi - -VERSION="$1" - -# Remove 'v' prefix from version for release title -VERSION_NO_V=${VERSION#v} - -gh release create "$VERSION" \ - .genreleases/spec-kit-template-copilot-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-copilot-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-claude-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-claude-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-gemini-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-gemini-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-cursor-agent-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-cursor-agent-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-opencode-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-opencode-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-qwen-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-qwen-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-windsurf-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-windsurf-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-codex-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-codex-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-kilocode-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-kilocode-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-auggie-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-auggie-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-roo-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-roo-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-codebuddy-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-codebuddy-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-qoder-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-qoder-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-amp-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-amp-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-shai-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-shai-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-q-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-q-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-agy-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-agy-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-bob-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-bob-ps-"$VERSION".zip \ - --title "Spec Kit Templates - $VERSION_NO_V" \ - --notes-file release_notes.md diff --git a/.github/workflows/scripts/create-release-packages.ps1 b/.github/workflows/scripts/create-release-packages.ps1 deleted file mode 100644 index a59df6e13f..0000000000 --- a/.github/workflows/scripts/create-release-packages.ps1 +++ /dev/null @@ -1,424 +0,0 @@ -#!/usr/bin/env pwsh -#requires -Version 7.0 - -<# -.SYNOPSIS - Build Spec Kit template release archives for each supported AI assistant and script type. - -.DESCRIPTION - create-release-packages.ps1 (workflow-local) - Build Spec Kit template release archives for each supported AI assistant and script type. - -.PARAMETER Version - Version string with leading 'v' (e.g., v0.2.0) - -.PARAMETER Agents - Comma or space separated subset of agents to build (default: all) - Valid agents: claude, gemini, copilot, cursor-agent, qwen, opencode, windsurf, codex, kilocode, auggie, roo, codebuddy, amp, q, bob, qoder - -.PARAMETER Scripts - Comma or space separated subset of script types to build (default: both) - Valid scripts: sh, ps - -.EXAMPLE - .\create-release-packages.ps1 -Version v0.2.0 - -.EXAMPLE - .\create-release-packages.ps1 -Version v0.2.0 -Agents claude,copilot -Scripts sh - -.EXAMPLE - .\create-release-packages.ps1 -Version v0.2.0 -Agents claude -Scripts ps -#> - -param( - [Parameter(Mandatory=$true, Position=0)] - [string]$Version, - - [Parameter(Mandatory=$false)] - [string]$Agents = "", - - [Parameter(Mandatory=$false)] - [string]$Scripts = "" -) - -$ErrorActionPreference = "Stop" - -# Validate version format -if ($Version -notmatch '^v\d+\.\d+\.\d+$') { - Write-Error "Version must look like v0.0.0" - exit 1 -} - -Write-Host "Building release packages for $Version" - -# Create and use .genreleases directory for all build artifacts -$GenReleasesDir = ".genreleases" -if (Test-Path $GenReleasesDir) { - Remove-Item -Path $GenReleasesDir -Recurse -Force -ErrorAction SilentlyContinue -} -New-Item -ItemType Directory -Path $GenReleasesDir -Force | Out-Null - -function Rewrite-Paths { - param([string]$Content) - - $Content = $Content -replace '(/?)\bmemory/', '.specify/memory/' - $Content = $Content -replace '(/?)\bscripts/', '.specify/scripts/' - $Content = $Content -replace '(/?)\btemplates/', '.specify/templates/' - return $Content -} - -function Generate-Commands { - param( - [string]$Agent, - [string]$Extension, - [string]$ArgFormat, - [string]$OutputDir, - [string]$ScriptVariant - ) - - New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null - - $templates = Get-ChildItem -Path "templates/commands/*.md" -File -ErrorAction SilentlyContinue - - foreach ($template in $templates) { - $name = [System.IO.Path]::GetFileNameWithoutExtension($template.Name) - - # Read file content and normalize line endings - $fileContent = (Get-Content -Path $template.FullName -Raw) -replace "`r`n", "`n" - - # Extract description from YAML frontmatter - $description = "" - if ($fileContent -match '(?m)^description:\s*(.+)$') { - $description = $matches[1] - } - - # Extract script command from YAML frontmatter - $scriptCommand = "" - if ($fileContent -match "(?m)^\s*${ScriptVariant}:\s*(.+)$") { - $scriptCommand = $matches[1] - } - - if ([string]::IsNullOrEmpty($scriptCommand)) { - Write-Warning "No script command found for $ScriptVariant in $($template.Name)" - $scriptCommand = "(Missing script command for $ScriptVariant)" - } - - # Extract agent_script command from YAML frontmatter if present - $agentScriptCommand = "" - if ($fileContent -match "(?ms)agent_scripts:.*?^\s*${ScriptVariant}:\s*(.+?)$") { - $agentScriptCommand = $matches[1].Trim() - } - - # Replace {SCRIPT} placeholder with the script command - $body = $fileContent -replace '\{SCRIPT\}', $scriptCommand - - # Replace {AGENT_SCRIPT} placeholder with the agent script command if found - if (-not [string]::IsNullOrEmpty($agentScriptCommand)) { - $body = $body -replace '\{AGENT_SCRIPT\}', $agentScriptCommand - } - - # Remove the scripts: and agent_scripts: sections from frontmatter - $lines = $body -split "`n" - $outputLines = @() - $inFrontmatter = $false - $skipScripts = $false - $dashCount = 0 - - foreach ($line in $lines) { - if ($line -match '^---$') { - $outputLines += $line - $dashCount++ - if ($dashCount -eq 1) { - $inFrontmatter = $true - } else { - $inFrontmatter = $false - } - continue - } - - if ($inFrontmatter) { - if ($line -match '^(scripts|agent_scripts):$') { - $skipScripts = $true - continue - } - if ($line -match '^[a-zA-Z].*:' -and $skipScripts) { - $skipScripts = $false - } - if ($skipScripts -and $line -match '^\s+') { - continue - } - } - - $outputLines += $line - } - - $body = $outputLines -join "`n" - - # Apply other substitutions - $body = $body -replace '\{ARGS\}', $ArgFormat - $body = $body -replace '__AGENT__', $Agent - $body = Rewrite-Paths -Content $body - - # Generate output file based on extension - $outputFile = Join-Path $OutputDir "speckit.$name.$Extension" - - switch ($Extension) { - 'toml' { - $body = $body -replace '\\', '\\' - $output = "description = `"$description`"`n`nprompt = `"`"`"`n$body`n`"`"`"" - Set-Content -Path $outputFile -Value $output -NoNewline - } - 'md' { - Set-Content -Path $outputFile -Value $body -NoNewline - } - 'agent.md' { - Set-Content -Path $outputFile -Value $body -NoNewline - } - } - } -} - -function Generate-CopilotPrompts { - param( - [string]$AgentsDir, - [string]$PromptsDir - ) - - New-Item -ItemType Directory -Path $PromptsDir -Force | Out-Null - - $agentFiles = Get-ChildItem -Path "$AgentsDir/speckit.*.agent.md" -File -ErrorAction SilentlyContinue - - foreach ($agentFile in $agentFiles) { - $basename = $agentFile.Name -replace '\.agent\.md$', '' - $promptFile = Join-Path $PromptsDir "$basename.prompt.md" - - $content = @" ---- -agent: $basename ---- -"@ - Set-Content -Path $promptFile -Value $content - } -} - -function Build-Variant { - param( - [string]$Agent, - [string]$Script - ) - - $baseDir = Join-Path $GenReleasesDir "sdd-${Agent}-package-${Script}" - Write-Host "Building $Agent ($Script) package..." - New-Item -ItemType Directory -Path $baseDir -Force | Out-Null - - # Copy base structure but filter scripts by variant - $specDir = Join-Path $baseDir ".specify" - New-Item -ItemType Directory -Path $specDir -Force | Out-Null - - # Copy memory directory - if (Test-Path "memory") { - Copy-Item -Path "memory" -Destination $specDir -Recurse -Force - Write-Host "Copied memory -> .specify" - } - - # Only copy the relevant script variant directory - if (Test-Path "scripts") { - $scriptsDestDir = Join-Path $specDir "scripts" - New-Item -ItemType Directory -Path $scriptsDestDir -Force | Out-Null - - switch ($Script) { - 'sh' { - if (Test-Path "scripts/bash") { - Copy-Item -Path "scripts/bash" -Destination $scriptsDestDir -Recurse -Force - Write-Host "Copied scripts/bash -> .specify/scripts" - } - } - 'ps' { - if (Test-Path "scripts/powershell") { - Copy-Item -Path "scripts/powershell" -Destination $scriptsDestDir -Recurse -Force - Write-Host "Copied scripts/powershell -> .specify/scripts" - } - } - } - - # Copy any script files that aren't in variant-specific directories - Get-ChildItem -Path "scripts" -File -ErrorAction SilentlyContinue | ForEach-Object { - Copy-Item -Path $_.FullName -Destination $scriptsDestDir -Force - } - } - - # Copy templates (excluding commands directory and vscode-settings.json) - if (Test-Path "templates") { - $templatesDestDir = Join-Path $specDir "templates" - New-Item -ItemType Directory -Path $templatesDestDir -Force | Out-Null - - Get-ChildItem -Path "templates" -Recurse -File | Where-Object { - $_.FullName -notmatch 'templates[/\\]commands[/\\]' -and $_.Name -ne 'vscode-settings.json' - } | ForEach-Object { - $relativePath = $_.FullName.Substring((Resolve-Path "templates").Path.Length + 1) - $destFile = Join-Path $templatesDestDir $relativePath - $destFileDir = Split-Path $destFile -Parent - New-Item -ItemType Directory -Path $destFileDir -Force | Out-Null - Copy-Item -Path $_.FullName -Destination $destFile -Force - } - Write-Host "Copied templates -> .specify/templates" - } - - # Generate agent-specific command files - switch ($Agent) { - 'claude' { - $cmdDir = Join-Path $baseDir ".claude/commands" - Generate-Commands -Agent 'claude' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'gemini' { - $cmdDir = Join-Path $baseDir ".gemini/commands" - Generate-Commands -Agent 'gemini' -Extension 'toml' -ArgFormat '{{args}}' -OutputDir $cmdDir -ScriptVariant $Script - if (Test-Path "agent_templates/gemini/GEMINI.md") { - Copy-Item -Path "agent_templates/gemini/GEMINI.md" -Destination (Join-Path $baseDir "GEMINI.md") - } - } - 'copilot' { - $agentsDir = Join-Path $baseDir ".github/agents" - Generate-Commands -Agent 'copilot' -Extension 'agent.md' -ArgFormat '$ARGUMENTS' -OutputDir $agentsDir -ScriptVariant $Script - - # Generate companion prompt files - $promptsDir = Join-Path $baseDir ".github/prompts" - Generate-CopilotPrompts -AgentsDir $agentsDir -PromptsDir $promptsDir - - # Create VS Code workspace settings - $vscodeDir = Join-Path $baseDir ".vscode" - New-Item -ItemType Directory -Path $vscodeDir -Force | Out-Null - if (Test-Path "templates/vscode-settings.json") { - Copy-Item -Path "templates/vscode-settings.json" -Destination (Join-Path $vscodeDir "settings.json") - } - } - 'cursor-agent' { - $cmdDir = Join-Path $baseDir ".cursor/commands" - Generate-Commands -Agent 'cursor-agent' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'qwen' { - $cmdDir = Join-Path $baseDir ".qwen/commands" - Generate-Commands -Agent 'qwen' -Extension 'toml' -ArgFormat '{{args}}' -OutputDir $cmdDir -ScriptVariant $Script - if (Test-Path "agent_templates/qwen/QWEN.md") { - Copy-Item -Path "agent_templates/qwen/QWEN.md" -Destination (Join-Path $baseDir "QWEN.md") - } - } - 'opencode' { - $cmdDir = Join-Path $baseDir ".opencode/command" - Generate-Commands -Agent 'opencode' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'windsurf' { - $cmdDir = Join-Path $baseDir ".windsurf/workflows" - Generate-Commands -Agent 'windsurf' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'codex' { - $cmdDir = Join-Path $baseDir ".codex/prompts" - Generate-Commands -Agent 'codex' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'kilocode' { - $cmdDir = Join-Path $baseDir ".kilocode/workflows" - Generate-Commands -Agent 'kilocode' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'auggie' { - $cmdDir = Join-Path $baseDir ".augment/commands" - Generate-Commands -Agent 'auggie' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'roo' { - $cmdDir = Join-Path $baseDir ".roo/commands" - Generate-Commands -Agent 'roo' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'codebuddy' { - $cmdDir = Join-Path $baseDir ".codebuddy/commands" - Generate-Commands -Agent 'codebuddy' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'amp' { - $cmdDir = Join-Path $baseDir ".agents/commands" - Generate-Commands -Agent 'amp' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'q' { - $cmdDir = Join-Path $baseDir ".amazonq/prompts" - Generate-Commands -Agent 'q' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'bob' { - $cmdDir = Join-Path $baseDir ".bob/commands" - Generate-Commands -Agent 'bob' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'qoder' { - $cmdDir = Join-Path $baseDir ".qoder/commands" - Generate-Commands -Agent 'qoder' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - } - - # Create zip archive - $zipFile = Join-Path $GenReleasesDir "spec-kit-template-${Agent}-${Script}-${Version}.zip" - Compress-Archive -Path "$baseDir/*" -DestinationPath $zipFile -Force - Write-Host "Created $zipFile" -} - -# Define all agents and scripts -$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'q', 'bob', 'qoder') -$AllScripts = @('sh', 'ps') - -function Normalize-List { - param([string]$Input) - - if ([string]::IsNullOrEmpty($Input)) { - return @() - } - - # Split by comma or space and remove duplicates while preserving order - $items = $Input -split '[,\s]+' | Where-Object { $_ } | Select-Object -Unique - return $items -} - -function Validate-Subset { - param( - [string]$Type, - [string[]]$Allowed, - [string[]]$Items - ) - - $ok = $true - foreach ($item in $Items) { - if ($item -notin $Allowed) { - Write-Error "Unknown $Type '$item' (allowed: $($Allowed -join ', '))" - $ok = $false - } - } - return $ok -} - -# Determine agent list -if (-not [string]::IsNullOrEmpty($Agents)) { - $AgentList = Normalize-List -Input $Agents - if (-not (Validate-Subset -Type 'agent' -Allowed $AllAgents -Items $AgentList)) { - exit 1 - } -} else { - $AgentList = $AllAgents -} - -# Determine script list -if (-not [string]::IsNullOrEmpty($Scripts)) { - $ScriptList = Normalize-List -Input $Scripts - if (-not (Validate-Subset -Type 'script' -Allowed $AllScripts -Items $ScriptList)) { - exit 1 - } -} else { - $ScriptList = $AllScripts -} - -Write-Host "Agents: $($AgentList -join ', ')" -Write-Host "Scripts: $($ScriptList -join ', ')" - -# Build all variants -foreach ($agent in $AgentList) { - foreach ($script in $ScriptList) { - Build-Variant -Agent $agent -Script $script - } -} - -Write-Host "`nArchives in ${GenReleasesDir}:" -Get-ChildItem -Path $GenReleasesDir -Filter "spec-kit-template-*-${Version}.zip" | ForEach-Object { - Write-Host " $($_.Name)" -} \ No newline at end of file diff --git a/.github/workflows/scripts/create-release-packages.sh b/.github/workflows/scripts/create-release-packages.sh deleted file mode 100755 index a825bd7bb9..0000000000 --- a/.github/workflows/scripts/create-release-packages.sh +++ /dev/null @@ -1,277 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# create-release-packages.sh (workflow-local) -# Build Spec Kit template release archives for each supported AI assistant and script type. -# Usage: .github/workflows/scripts/create-release-packages.sh -# Version argument should include leading 'v'. -# Optionally set AGENTS and/or SCRIPTS env vars to limit what gets built. -# AGENTS : space or comma separated subset of: claude gemini copilot cursor-agent qwen opencode windsurf codex amp shai bob (default: all) -# SCRIPTS : space or comma separated subset of: sh ps (default: both) -# Examples: -# AGENTS=claude SCRIPTS=sh $0 v0.2.0 -# AGENTS="copilot,gemini" $0 v0.2.0 -# SCRIPTS=ps $0 v0.2.0 - -if [[ $# -ne 1 ]]; then - echo "Usage: $0 " >&2 - exit 1 -fi -NEW_VERSION="$1" -if [[ ! $NEW_VERSION =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "Version must look like v0.0.0" >&2 - exit 1 -fi - -echo "Building release packages for $NEW_VERSION" - -# Create and use .genreleases directory for all build artifacts -GENRELEASES_DIR=".genreleases" -mkdir -p "$GENRELEASES_DIR" -rm -rf "$GENRELEASES_DIR"/* || true - -rewrite_paths() { - sed -E \ - -e 's@(/?)memory/@.specify/memory/@g' \ - -e 's@(/?)scripts/@.specify/scripts/@g' \ - -e 's@(/?)templates/@.specify/templates/@g' \ - -e 's@\.specify\.specify/@.specify/@g' -} - -generate_commands() { - local agent=$1 ext=$2 arg_format=$3 output_dir=$4 script_variant=$5 - mkdir -p "$output_dir" - for template in templates/commands/*.md; do - [[ -f "$template" ]] || continue - local name description script_command agent_script_command body - name=$(basename "$template" .md) - - # Normalize line endings - file_content=$(tr -d '\r' < "$template") - - # Extract description and script command from YAML frontmatter - description=$(printf '%s\n' "$file_content" | awk '/^description:/ {sub(/^description:[[:space:]]*/, ""); print; exit}') - script_command=$(printf '%s\n' "$file_content" | awk -v sv="$script_variant" '/^[[:space:]]*'"$script_variant"':[[:space:]]*/ {sub(/^[[:space:]]*'"$script_variant"':[[:space:]]*/, ""); print; exit}') - - if [[ -z $script_command ]]; then - echo "Warning: no script command found for $script_variant in $template" >&2 - script_command="(Missing script command for $script_variant)" - fi - - # Extract agent_script command from YAML frontmatter if present - agent_script_command=$(printf '%s\n' "$file_content" | awk ' - /^agent_scripts:$/ { in_agent_scripts=1; next } - in_agent_scripts && /^[[:space:]]*'"$script_variant"':[[:space:]]*/ { - sub(/^[[:space:]]*'"$script_variant"':[[:space:]]*/, "") - print - exit - } - in_agent_scripts && /^[a-zA-Z]/ { in_agent_scripts=0 } - ') - - # Replace {SCRIPT} placeholder with the script command - body=$(printf '%s\n' "$file_content" | sed "s|{SCRIPT}|${script_command}|g") - - # Replace {AGENT_SCRIPT} placeholder with the agent script command if found - if [[ -n $agent_script_command ]]; then - body=$(printf '%s\n' "$body" | sed "s|{AGENT_SCRIPT}|${agent_script_command}|g") - fi - - # Remove the scripts: and agent_scripts: sections from frontmatter while preserving YAML structure - body=$(printf '%s\n' "$body" | awk ' - /^---$/ { print; if (++dash_count == 1) in_frontmatter=1; else in_frontmatter=0; next } - in_frontmatter && /^scripts:$/ { skip_scripts=1; next } - in_frontmatter && /^agent_scripts:$/ { skip_scripts=1; next } - in_frontmatter && /^[a-zA-Z].*:/ && skip_scripts { skip_scripts=0 } - in_frontmatter && skip_scripts && /^[[:space:]]/ { next } - { print } - ') - - # Apply other substitutions - body=$(printf '%s\n' "$body" | sed "s/{ARGS}/$arg_format/g" | sed "s/__AGENT__/$agent/g" | rewrite_paths) - - case $ext in - toml) - body=$(printf '%s\n' "$body" | sed 's/\\/\\\\/g') - { echo "description = \"$description\""; echo; echo "prompt = \"\"\""; echo "$body"; echo "\"\"\""; } > "$output_dir/speckit.$name.$ext" ;; - md) - echo "$body" > "$output_dir/speckit.$name.$ext" ;; - agent.md) - echo "$body" > "$output_dir/speckit.$name.$ext" ;; - esac - done -} - -generate_copilot_prompts() { - local agents_dir=$1 prompts_dir=$2 - mkdir -p "$prompts_dir" - - # Generate a .prompt.md file for each .agent.md file - for agent_file in "$agents_dir"/speckit.*.agent.md; do - [[ -f "$agent_file" ]] || continue - - local basename=$(basename "$agent_file" .agent.md) - local prompt_file="$prompts_dir/${basename}.prompt.md" - - # Create prompt file with agent frontmatter - cat > "$prompt_file" < .specify"; } - - # Only copy the relevant script variant directory - if [[ -d scripts ]]; then - mkdir -p "$SPEC_DIR/scripts" - case $script in - sh) - [[ -d scripts/bash ]] && { cp -r scripts/bash "$SPEC_DIR/scripts/"; echo "Copied scripts/bash -> .specify/scripts"; } - # Copy any script files that aren't in variant-specific directories - find scripts -maxdepth 1 -type f -exec cp {} "$SPEC_DIR/scripts/" \; 2>/dev/null || true - ;; - ps) - [[ -d scripts/powershell ]] && { cp -r scripts/powershell "$SPEC_DIR/scripts/"; echo "Copied scripts/powershell -> .specify/scripts"; } - # Copy any script files that aren't in variant-specific directories - find scripts -maxdepth 1 -type f -exec cp {} "$SPEC_DIR/scripts/" \; 2>/dev/null || true - ;; - esac - fi - - [[ -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"; } - - # NOTE: We substitute {ARGS} internally. Outward tokens differ intentionally: - # * Markdown/prompt (claude, copilot, cursor-agent, opencode): $ARGUMENTS - # * TOML (gemini, qwen): {{args}} - # This keeps formats readable without extra abstraction. - - case $agent in - claude) - mkdir -p "$base_dir/.claude/commands" - generate_commands claude md "\$ARGUMENTS" "$base_dir/.claude/commands" "$script" ;; - gemini) - mkdir -p "$base_dir/.gemini/commands" - generate_commands gemini toml "{{args}}" "$base_dir/.gemini/commands" "$script" - [[ -f agent_templates/gemini/GEMINI.md ]] && cp agent_templates/gemini/GEMINI.md "$base_dir/GEMINI.md" ;; - copilot) - mkdir -p "$base_dir/.github/agents" - generate_commands copilot agent.md "\$ARGUMENTS" "$base_dir/.github/agents" "$script" - # Generate companion prompt files - generate_copilot_prompts "$base_dir/.github/agents" "$base_dir/.github/prompts" - # Create VS Code workspace settings - mkdir -p "$base_dir/.vscode" - [[ -f templates/vscode-settings.json ]] && cp templates/vscode-settings.json "$base_dir/.vscode/settings.json" - ;; - cursor-agent) - mkdir -p "$base_dir/.cursor/commands" - generate_commands cursor-agent md "\$ARGUMENTS" "$base_dir/.cursor/commands" "$script" ;; - qwen) - mkdir -p "$base_dir/.qwen/commands" - generate_commands qwen toml "{{args}}" "$base_dir/.qwen/commands" "$script" - [[ -f agent_templates/qwen/QWEN.md ]] && cp agent_templates/qwen/QWEN.md "$base_dir/QWEN.md" ;; - opencode) - mkdir -p "$base_dir/.opencode/command" - generate_commands opencode md "\$ARGUMENTS" "$base_dir/.opencode/command" "$script" ;; - windsurf) - mkdir -p "$base_dir/.windsurf/workflows" - generate_commands windsurf md "\$ARGUMENTS" "$base_dir/.windsurf/workflows" "$script" ;; - codex) - mkdir -p "$base_dir/.codex/prompts" - generate_commands codex md "\$ARGUMENTS" "$base_dir/.codex/prompts" "$script" ;; - kilocode) - mkdir -p "$base_dir/.kilocode/workflows" - generate_commands kilocode md "\$ARGUMENTS" "$base_dir/.kilocode/workflows" "$script" ;; - auggie) - mkdir -p "$base_dir/.augment/commands" - generate_commands auggie md "\$ARGUMENTS" "$base_dir/.augment/commands" "$script" ;; - roo) - mkdir -p "$base_dir/.roo/commands" - generate_commands roo md "\$ARGUMENTS" "$base_dir/.roo/commands" "$script" ;; - codebuddy) - mkdir -p "$base_dir/.codebuddy/commands" - generate_commands codebuddy md "\$ARGUMENTS" "$base_dir/.codebuddy/commands" "$script" ;; - qoder) - mkdir -p "$base_dir/.qoder/commands" - generate_commands qoder md "\$ARGUMENTS" "$base_dir/.qoder/commands" "$script" ;; - amp) - mkdir -p "$base_dir/.agents/commands" - generate_commands amp md "\$ARGUMENTS" "$base_dir/.agents/commands" "$script" ;; - shai) - mkdir -p "$base_dir/.shai/commands" - generate_commands shai md "\$ARGUMENTS" "$base_dir/.shai/commands" "$script" ;; - q) - mkdir -p "$base_dir/.amazonq/prompts" - generate_commands q md "\$ARGUMENTS" "$base_dir/.amazonq/prompts" "$script" ;; - agy) - mkdir -p "$base_dir/.agent/workflows" - generate_commands agy md "\$ARGUMENTS" "$base_dir/.agent/workflows" "$script" ;; - bob) - mkdir -p "$base_dir/.bob/commands" - generate_commands bob md "\$ARGUMENTS" "$base_dir/.bob/commands" "$script" ;; - esac - ( cd "$base_dir" && zip -r "../spec-kit-template-${agent}-${script}-${NEW_VERSION}.zip" . ) - echo "Created $GENRELEASES_DIR/spec-kit-template-${agent}-${script}-${NEW_VERSION}.zip" -} - -# Determine agent list -ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai q agy bob qoder) -ALL_SCRIPTS=(sh ps) - -norm_list() { - # convert comma+space separated -> line separated unique while preserving order of first occurrence - tr ',\n' ' ' | awk '{for(i=1;i<=NF;i++){if(!seen[$i]++){printf((out?"\n":"") $i);out=1}}}END{printf("\n")}' -} - -validate_subset() { - local type=$1; shift; local -n allowed=$1; shift; local items=("$@") - local invalid=0 - for it in "${items[@]}"; do - local found=0 - for a in "${allowed[@]}"; do [[ $it == "$a" ]] && { found=1; break; }; done - if [[ $found -eq 0 ]]; then - echo "Error: unknown $type '$it' (allowed: ${allowed[*]})" >&2 - invalid=1 - fi - done - return $invalid -} - -if [[ -n ${AGENTS:-} ]]; then - mapfile -t AGENT_LIST < <(printf '%s' "$AGENTS" | norm_list) - validate_subset agent ALL_AGENTS "${AGENT_LIST[@]}" || exit 1 -else - AGENT_LIST=("${ALL_AGENTS[@]}") -fi - -if [[ -n ${SCRIPTS:-} ]]; then - mapfile -t SCRIPT_LIST < <(printf '%s' "$SCRIPTS" | norm_list) - validate_subset script ALL_SCRIPTS "${SCRIPT_LIST[@]}" || exit 1 -else - SCRIPT_LIST=("${ALL_SCRIPTS[@]}") -fi - -echo "Agents: ${AGENT_LIST[*]}" -echo "Scripts: ${SCRIPT_LIST[*]}" - -for agent in "${AGENT_LIST[@]}"; do - for script in "${SCRIPT_LIST[@]}"; do - build_variant "$agent" "$script" - done -done - -echo "Archives in $GENRELEASES_DIR:" -ls -1 "$GENRELEASES_DIR"/spec-kit-template-*-"${NEW_VERSION}".zip - diff --git a/.github/workflows/scripts/generate-release-notes.sh b/.github/workflows/scripts/generate-release-notes.sh deleted file mode 100644 index d8f5dab1fc..0000000000 --- a/.github/workflows/scripts/generate-release-notes.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# generate-release-notes.sh -# Generate release notes from git history -# Usage: generate-release-notes.sh - -if [[ $# -ne 2 ]]; then - echo "Usage: $0 " >&2 - exit 1 -fi - -NEW_VERSION="$1" -LAST_TAG="$2" - -# Get commits since last tag -if [ "$LAST_TAG" = "v0.0.0" ]; then - # Check how many commits we have and use that as the limit - COMMIT_COUNT=$(git rev-list --count HEAD) - if [ "$COMMIT_COUNT" -gt 10 ]; then - COMMITS=$(git log --oneline --pretty=format:"- %s" HEAD~10..HEAD) - else - COMMITS=$(git log --oneline --pretty=format:"- %s" HEAD~$COMMIT_COUNT..HEAD 2>/dev/null || git log --oneline --pretty=format:"- %s") - fi -else - COMMITS=$(git log --oneline --pretty=format:"- %s" $LAST_TAG..HEAD) -fi - -# Create release notes -cat > release_notes.md << EOF -This 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. - -## Changelog - -$COMMITS - -EOF - -echo "Generated release notes:" -cat release_notes.md diff --git a/.github/workflows/scripts/get-next-version.sh b/.github/workflows/scripts/get-next-version.sh deleted file mode 100644 index 9770b9fdc3..0000000000 --- a/.github/workflows/scripts/get-next-version.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# get-next-version.sh -# Calculate the next version based on the latest git tag and output GitHub Actions variables -# Usage: get-next-version.sh - -# Get the latest tag, or use v0.0.0 if no tags exist -LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") -echo "latest_tag=$LATEST_TAG" >> $GITHUB_OUTPUT - -# Extract version number and increment -VERSION=$(echo $LATEST_TAG | sed 's/v//') -IFS='.' read -ra VERSION_PARTS <<< "$VERSION" -MAJOR=${VERSION_PARTS[0]:-0} -MINOR=${VERSION_PARTS[1]:-0} -PATCH=${VERSION_PARTS[2]:-0} - -# Increment patch version -PATCH=$((PATCH + 1)) -NEW_VERSION="v$MAJOR.$MINOR.$PATCH" - -echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT -echo "New version will be: $NEW_VERSION" diff --git a/.github/workflows/scripts/update-version.sh b/.github/workflows/scripts/update-version.sh deleted file mode 100644 index 12bd9cd1d6..0000000000 --- a/.github/workflows/scripts/update-version.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# update-version.sh -# Update version in pyproject.toml (for release artifacts only) -# Usage: update-version.sh - -if [[ $# -ne 1 ]]; then - echo "Usage: $0 " >&2 - exit 1 -fi - -VERSION="$1" - -# Remove 'v' prefix for Python versioning -PYTHON_VERSION=${VERSION#v} - -if [ -f "pyproject.toml" ]; then - sed -i "s/version = \".*\"/version = \"$PYTHON_VERSION\"/" pyproject.toml - echo "Updated pyproject.toml version to $PYTHON_VERSION (for release artifacts only)" -else - echo "Warning: pyproject.toml not found, skipping version update" -fi diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index fd8102ce28..076d05336a 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -6,6 +6,7 @@ on: workflow_dispatch: # Allow manual triggering permissions: + actions: write issues: write pull-requests: write @@ -13,7 +14,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v9 + - uses: actions/stale@v10 with: # Days of inactivity before an issue or PR becomes stale days-before-stale: 150 @@ -39,4 +40,4 @@ jobs: any-of-labels: '' # Operations per run (helps avoid rate limits) - operations-per-run: 100 + operations-per-run: 250 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000000..7354dd8e28 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,55 @@ +name: Test & Lint Python + +permissions: + contents: read + +on: + push: + branches: ["main"] + pull_request: + +jobs: + ruff: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - name: Run ruff check + run: uvx ruff check src/ + + pytest: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + python-version: ["3.11", "3.12", "3.13"] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --extra test + + # On windows-latest, bash tests auto-skip unless Git-for-Windows + # bash (MSYS2/MINGW) is detected. The WSL launcher is rejected + # because it cannot handle native Windows paths in test fixtures. + # See tests/conftest.py::_has_working_bash() for details. + - name: Run tests + run: uv run pytest diff --git a/.zenodo.json b/.zenodo.json new file mode 100644 index 0000000000..72f0569402 --- /dev/null +++ b/.zenodo.json @@ -0,0 +1,29 @@ +{ + "title": "Spec Kit", + "description": "Spec Kit is an open source toolkit for Spec-Driven Development (SDD) — a methodology that helps software teams build high-quality software faster by focusing on product scenarios and predictable outcomes. It provides the Specify CLI, slash-command templates, extensions, presets, workflows, and integrations for popular AI coding agents.", + "creators": [ + { + "name": "Delimarsky, Den" + }, + { + "name": "Riem, Manfred" + } + ], + "license": "MIT", + "upload_type": "software", + "keywords": [ + "spec-driven development", + "ai coding agents", + "software engineering", + "cli", + "copilot", + "specification" + ], + "related_identifiers": [ + { + "identifier": "https://github.com/github/spec-kit", + "relation": "isSupplementTo", + "scheme": "url" + } + ] +} diff --git a/AGENTS.md b/AGENTS.md index d7360487b8..7adfd1d12e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,266 +10,282 @@ The toolkit supports multiple AI coding assistants, allowing teams to use their --- -## General practices +## Integration Architecture -- Any changes to `__init__.py` for the Specify CLI require a version rev in `pyproject.toml` and addition of entries to `CHANGELOG.md`. +Each AI agent is a self-contained **integration subpackage** under `src/specify_cli/integrations//`. The subpackage exposes a single class that declares all metadata and inherits setup/teardown logic from a base class. Built-in integrations are then instantiated and added to the global `INTEGRATION_REGISTRY` by `src/specify_cli/integrations/__init__.py` via `_register_builtins()`. -## Adding New Agent Support - -This 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. - -### Overview +``` +src/specify_cli/integrations/ +ā”œā”€ā”€ __init__.py # INTEGRATION_REGISTRY + _register_builtins() +ā”œā”€ā”€ base.py # IntegrationBase, MarkdownIntegration, TomlIntegration, YamlIntegration, SkillsIntegration +ā”œā”€ā”€ manifest.py # IntegrationManifest (file tracking) +ā”œā”€ā”€ claude/ # Example: SkillsIntegration subclass +│ ā”œā”€ā”€ __init__.py # ClaudeIntegration class +│ └── scripts/ # Thin wrapper scripts +│ ā”œā”€ā”€ update-context.sh +│ └── update-context.ps1 +ā”œā”€ā”€ gemini/ # Example: TomlIntegration subclass +│ ā”œā”€ā”€ __init__.py +│ └── scripts/ +ā”œā”€ā”€ windsurf/ # Example: MarkdownIntegration subclass +│ ā”œā”€ā”€ __init__.py +│ └── scripts/ +ā”œā”€ā”€ copilot/ # Example: IntegrationBase subclass (custom setup) +│ ā”œā”€ā”€ __init__.py +│ └── scripts/ +└── ... # One subpackage per supported agent +``` -Specify supports multiple AI agents by generating agent-specific command files and directory structures when initializing projects. Each agent has its own conventions for: +The registry is the **single source of truth for Python integration metadata**. Supported agents, their directories, formats, and capabilities are derived from the integration classes for the Python integration layer. However, context-update behavior still requires explicit cases in the shared dispatcher scripts (`scripts/bash/update-agent-context.sh` and `scripts/powershell/update-agent-context.ps1`), which currently maintain their own supported-agent lists and agent-key→context-file mappings until they are migrated to registry-based dispatch. -- **Command file formats** (Markdown, TOML, etc.) -- **Directory structures** (`.claude/commands/`, `.windsurf/workflows/`, etc.) -- **Command invocation patterns** (slash commands, CLI tools, etc.) -- **Argument passing conventions** (`$ARGUMENTS`, `{{args}}`, etc.) +--- -### Current Supported Agents +## Adding a New Integration -| Agent | Directory | Format | CLI Tool | Description | -| -------------------------- | ---------------------- | -------- | --------------- | --------------------------- | -| **Claude Code** | `.claude/commands/` | Markdown | `claude` | Anthropic's Claude Code CLI | -| **Gemini CLI** | `.gemini/commands/` | TOML | `gemini` | Google's Gemini CLI | -| **GitHub Copilot** | `.github/agents/` | Markdown | N/A (IDE-based) | GitHub Copilot in VS Code | -| **Cursor** | `.cursor/commands/` | Markdown | `cursor-agent` | Cursor CLI | -| **Qwen Code** | `.qwen/commands/` | TOML | `qwen` | Alibaba's Qwen Code CLI | -| **opencode** | `.opencode/command/` | Markdown | `opencode` | opencode CLI | -| **Codex CLI** | `.codex/commands/` | Markdown | `codex` | Codex CLI | -| **Windsurf** | `.windsurf/workflows/` | Markdown | N/A (IDE-based) | Windsurf IDE workflows | -| **Kilo Code** | `.kilocode/rules/` | Markdown | N/A (IDE-based) | Kilo Code IDE | -| **Auggie CLI** | `.augment/rules/` | Markdown | `auggie` | Auggie CLI | -| **Roo Code** | `.roo/rules/` | Markdown | N/A (IDE-based) | Roo Code IDE | -| **CodeBuddy CLI** | `.codebuddy/commands/` | Markdown | `codebuddy` | CodeBuddy CLI | -| **Qoder CLI** | `.qoder/commands/` | Markdown | `qoder` | Qoder CLI | -| **Amazon Q Developer CLI** | `.amazonq/prompts/` | Markdown | `q` | Amazon Q Developer CLI | -| **Amp** | `.agents/commands/` | Markdown | `amp` | Amp CLI | -| **SHAI** | `.shai/commands/` | Markdown | `shai` | SHAI CLI | -| **IBM Bob** | `.bob/commands/` | Markdown | N/A (IDE-based) | IBM Bob IDE | +### 1. Choose a base class -### Step-by-Step Integration Guide +| Your agent needs… | Subclass | +|---|---| +| Standard markdown commands (`.md`) | `MarkdownIntegration` | +| TOML-format commands (`.toml`) | `TomlIntegration` | +| YAML recipe files (`.yaml`) | `YamlIntegration` | +| Skill directories (`speckit-/SKILL.md`) | `SkillsIntegration` | +| Fully custom output (companion files, settings merge, etc.) | `IntegrationBase` directly | -Follow these steps to add a new agent (using a hypothetical new agent as an example): +Most agents only need `MarkdownIntegration` — a minimal subclass with zero method overrides. -#### 1. Add to AGENT_CONFIG +### 2. Create the subpackage -**IMPORTANT**: Use the actual CLI tool name as the key, not a shortened version. +Create `src/specify_cli/integrations//__init__.py`, where `` is the Python-safe directory name derived from ``: use the key as-is when it contains no hyphens (e.g., key `"gemini"` → `gemini/`), or replace hyphens with underscores when it does (e.g., key `"kiro-cli"` → `kiro_cli/`). The `IntegrationBase.key` class attribute always retains the original hyphenated value, since that is what the CLI and registry use. For CLI-based integrations (`requires_cli: True`), the `key` should match the actual CLI tool name (the executable users install and run) so CLI checks can resolve it correctly. For IDE-based integrations (`requires_cli: False`), use the canonical integration identifier instead. -Add 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: +**Minimal example — Markdown agent (Windsurf):** ```python -AGENT_CONFIG = { - # ... existing agents ... - "new-agent-cli": { # Use the ACTUAL CLI tool name (what users type in terminal) - "name": "New Agent Display Name", - "folder": ".newagent/", # Directory for agent files - "install_url": "https://example.com/install", # URL for installation docs (or None if IDE-based) - "requires_cli": True, # True if CLI tool required, False for IDE-based agents - }, -} -``` - -**Key Design Principle**: The dictionary key should match the actual executable name that users install. For example: - -- āœ… Use `"cursor-agent"` because the CLI tool is literally called `cursor-agent` -- āŒ Don't use `"cursor"` as a shortcut if the tool is `cursor-agent` - -This eliminates the need for special-case mappings throughout the codebase. +"""Windsurf IDE integration.""" -**Field Explanations**: +from ..base import MarkdownIntegration -- `name`: Human-readable display name shown to users -- `folder`: Directory where agent-specific files are stored (relative to project root) -- `install_url`: Installation documentation URL (set to `None` for IDE-based agents) -- `requires_cli`: Whether the agent requires a CLI tool check during initialization -#### 2. Update CLI Help Text +class WindsurfIntegration(MarkdownIntegration): + key = "windsurf" + config = { + "name": "Windsurf", + "folder": ".windsurf/", + "commands_subdir": "workflows", + "install_url": None, + "requires_cli": False, + } + registrar_config = { + "dir": ".windsurf/workflows", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = ".windsurf/rules/specify-rules.md" +``` -Update the `--ai` parameter help text in the `init()` command to include the new agent: +**TOML agent (Gemini):** ```python -ai_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 q"), -``` +"""Gemini CLI integration.""" -Also update any function docstrings, examples, and error messages that list available agents. +from ..base import TomlIntegration -#### 3. Update README Documentation -Update the **Supported AI Agents** section in `README.md` to include the new agent: +class GeminiIntegration(TomlIntegration): + key = "gemini" + config = { + "name": "Gemini CLI", + "folder": ".gemini/", + "commands_subdir": "commands", + "install_url": "https://github.com/google-gemini/gemini-cli", + "requires_cli": True, + } + registrar_config = { + "dir": ".gemini/commands", + "format": "toml", + "args": "{{args}}", + "extension": ".toml", + } + context_file = "GEMINI.md" +``` -- Add the new agent to the table with appropriate support level (Full/Partial) -- Include the agent's official website link -- Add any relevant notes about the agent's implementation -- Ensure the table formatting remains aligned and consistent +**Skills agent (Codex):** -#### 4. Update Release Package Script +```python +"""Codex CLI integration — skills-based agent.""" -Modify `.github/workflows/scripts/create-release-packages.sh`: +from __future__ import annotations -##### Add to ALL_AGENTS array +from ..base import IntegrationOption, SkillsIntegration -```bash -ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf q) -``` -##### Add case statement for directory structure - -```bash -case $agent in - # ... existing cases ... - windsurf) - mkdir -p "$base_dir/.windsurf/workflows" - generate_commands windsurf md "\$ARGUMENTS" "$base_dir/.windsurf/workflows" "$script" ;; -esac +class CodexIntegration(SkillsIntegration): + key = "codex" + config = { + "name": "Codex CLI", + "folder": ".agents/", + "commands_subdir": "skills", + "install_url": "https://github.com/openai/codex", + "requires_cli": True, + } + registrar_config = { + "dir": ".agents/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", + } + context_file = "AGENTS.md" + + @classmethod + def options(cls) -> list[IntegrationOption]: + return [ + IntegrationOption( + "--skills", + is_flag=True, + default=True, + help="Install as agent skills (default for Codex)", + ), + ] ``` -#### 4. Update GitHub Release Script - -Modify `.github/workflows/scripts/create-github-release.sh` to include the new agent's packages: +#### Required fields -```bash -gh release create "$VERSION" \ - # ... existing packages ... - .genreleases/spec-kit-template-windsurf-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-windsurf-ps-"$VERSION".zip \ - # Add new agent packages here -``` +| Field | Location | Purpose | +|---|---|---| +| `key` | Class attribute | Unique identifier; for CLI-based integrations (`requires_cli: True`), must match the CLI executable name | +| `config` | Class attribute (dict) | Agent metadata: `name`, `folder`, `commands_subdir`, `install_url`, `requires_cli` | +| `registrar_config` | Class attribute (dict) | Command output config: `dir`, `format`, `args` placeholder, file `extension` | +| `context_file` | Class attribute (str or None) | Path to agent context/instructions file (e.g., `"CLAUDE.md"`, `".github/copilot-instructions.md"`) | -#### 5. Update Agent Context Scripts +**Key design rule:** For CLI-based integrations (`requires_cli: True`), `key` must be the actual executable name (e.g., `"cursor-agent"` not `"cursor"`). This ensures `shutil.which(key)` works for CLI-tool checks without special-case mappings. IDE-based integrations (`requires_cli: False`) should use their canonical identifier (e.g., `"windsurf"`, `"copilot"`). -##### Bash script (`scripts/bash/update-agent-context.sh`) +### 3. Register it -Add file variable: +In `src/specify_cli/integrations/__init__.py`, add one import and one `_register()` call inside `_register_builtins()`. Both lists are alphabetical: -```bash -WINDSURF_FILE="$REPO_ROOT/.windsurf/rules/specify-rules.md" +```python +def _register_builtins() -> None: + # -- Imports (alphabetical) ------------------------------------------- + from .claude import ClaudeIntegration + # ... + from .newagent import NewAgentIntegration # ← add import + # ... + + # -- Registration (alphabetical) -------------------------------------- + _register(ClaudeIntegration()) + # ... + _register(NewAgentIntegration()) # ← add registration + # ... ``` -Add to case statement: +### 4. Add scripts -```bash -case "$AGENT_TYPE" in - # ... existing cases ... - windsurf) update_agent_file "$WINDSURF_FILE" "Windsurf" ;; - "") - # ... existing checks ... - [ -f "$WINDSURF_FILE" ] && update_agent_file "$WINDSURF_FILE" "Windsurf"; - # Update default creation condition - ;; -esac -``` +Create two thin wrapper scripts in `src/specify_cli/integrations//scripts/` that delegate to the shared context-update scripts. Each is ~25 lines of boilerplate. -##### PowerShell script (`scripts/powershell/update-agent-context.ps1`) +> **Note on `` vs ``:** `` is the Python-safe directory name for your integration — it matches `` exactly when the key contains no hyphens (e.g., key `"gemini"` → `gemini/`), but uses underscores when it does (e.g., key `"kiro-cli"` → `kiro_cli/`). The `IntegrationBase.key` class attribute always retains the original hyphenated value (e.g., `key = "kiro-cli"`), since that is what the CLI and registry use. -Add file variable: +**`update-context.sh`:** -```powershell -$windsurfFile = Join-Path $repoRoot '.windsurf/rules/specify-rules.md' +```bash +#!/usr/bin/env bash +# update-context.sh — integration: create/update +set -euo pipefail + +_script_dir="$(cd "$(dirname "$0")" && pwd)" +_root="$_script_dir" +while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done +if [ -z "${REPO_ROOT:-}" ]; then + if [ -d "$_root/.specify" ]; then + REPO_ROOT="$_root" + else + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then + REPO_ROOT="$git_root" + else + REPO_ROOT="$_root" + fi + fi +fi + +exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" ``` -Add to switch statement: +**`update-context.ps1`:** ```powershell -switch ($AgentType) { - # ... existing cases ... - 'windsurf' { Update-AgentFile $windsurfFile 'Windsurf' } - '' { - foreach ($pair in @( - # ... existing pairs ... - @{file=$windsurfFile; name='Windsurf'} - )) { - if (Test-Path $pair.file) { Update-AgentFile $pair.file $pair.name } - } - # Update default creation condition +# update-context.ps1 — integration: create/update +$ErrorActionPreference = 'Stop' + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } +if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = $scriptDir + $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) + while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = Split-Path -Parent $repoRoot } } -``` - -#### 6. Update CLI Tool Checks (Optional) -For agents that require CLI tools, add checks in the `check()` command and agent validation: - -```python -# In check() command -tracker.add("windsurf", "Windsurf IDE (optional)") -windsurf_ok = check_tool_for_tracker("windsurf", "https://windsurf.com/", tracker) - -# In init validation (only if CLI tool required) -elif selected_ai == "windsurf": - if not check_tool("windsurf", "Install from: https://windsurf.com/"): - console.print("[red]Error:[/red] Windsurf CLI is required for Windsurf projects") - agent_tool_missing = True +& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType ``` -**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. +Replace `` with your integration key and `` / `` with the appropriate values. -## Important Design Decisions +You must also add the agent to the shared context-update scripts so the shared dispatcher recognises the new key: -### Using Actual CLI Tool Names as Keys +- **`scripts/bash/update-agent-context.sh`** — add a file-path variable and a case in `update_specific_agent()`. +- **`scripts/powershell/update-agent-context.ps1`** — add a file-path variable, add the new key to the `AgentType` parameter's `[ValidateSet(...)]`, add a switch case in `Update-SpecificAgent`, and add an entry in `Update-AllExistingAgents`. -**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. +### 5. Test it -**Why this matters:** - -- The `check_tool()` function uses `shutil.which(tool)` to find executables in the system PATH -- If the key doesn't match the actual CLI tool name, you'll need special-case mappings throughout the codebase -- This creates unnecessary complexity and maintenance burden +```bash +# Install into a test project +specify init my-project --integration -**Example - The Cursor Lesson:** +# Verify files were created in the commands directory configured by +# config["folder"] + config["commands_subdir"] (for example, .windsurf/workflows/) +ls -R my-project/.windsurf/workflows/ -āŒ **Wrong approach** (requires special-case mapping): +# Uninstall cleanly +cd my-project && specify integration uninstall +``` -```python -AGENT_CONFIG = { - "cursor": { # Shorthand that doesn't match the actual tool - "name": "Cursor", - # ... - } -} +Each integration also has a dedicated test file at `tests/integrations/test_integration_.py`. Note that hyphens in the key are replaced with underscores in the filename (e.g., key `cursor-agent` → `test_integration_cursor_agent.py`, key `kiro-cli` → `test_integration_kiro_cli.py`). Run it with: -# Then you need special cases everywhere: -cli_tool = agent_key -if agent_key == "cursor": - cli_tool = "cursor-agent" # Map to the real tool name +```bash +pytest tests/integrations/test_integration_.py -v ``` -āœ… **Correct approach** (no mapping needed): +### 6. Optional overrides -```python -AGENT_CONFIG = { - "cursor-agent": { # Matches the actual executable name - "name": "Cursor", - # ... - } -} +The base classes handle most work automatically. Override only when the agent deviates from standard patterns: -# No special cases needed - just use agent_key directly! -``` +| Override | When to use | Example | +|---|---|---| +| `command_filename(template_name)` | Custom file naming or extension | Copilot → `speckit.{name}.agent.md` | +| `options()` | Integration-specific CLI flags via `--integration-options` | Codex → `--skills` flag, Copilot → `--skills` flag | +| `setup()` | Custom install logic (companion files, settings merge) | Copilot → `.agent.md` + `.prompt.md` + `.vscode/settings.json` (default) or `speckit-/SKILL.md` (skills mode) | +| `teardown()` | Custom uninstall logic | Rarely needed; base handles manifest-tracked files | -**Benefits of this approach:** +**Example — Copilot (fully custom `setup`):** -- Eliminates special-case logic scattered throughout the codebase -- Makes the code more maintainable and easier to understand -- Reduces the chance of bugs when adding new agents -- Tool checking "just works" without additional mappings +Copilot extends `IntegrationBase` directly because it creates `.agent.md` commands, companion `.prompt.md` files, and merges `.vscode/settings.json`. It also supports a `--skills` mode that scaffolds `speckit-/SKILL.md` under `.github/skills/` using composition with an internal `_CopilotSkillsHelper`. See `src/specify_cli/integrations/copilot/__init__.py` for the full implementation. -#### 7. Update Devcontainer files (Optional) +### 7. Update Devcontainer files (Optional) For agents that have VS Code extensions or require CLI installation, update the devcontainer configuration files: -##### VS Code Extension-based Agents +#### VS Code Extension-based Agents For agents available as VS Code extensions, add them to `.devcontainer/devcontainer.json`: -```json +```jsonc { "customizations": { "vscode": { "extensions": [ // ... existing extensions ... - // [New Agent Name] "[New Agent Extension ID]" ] } @@ -277,7 +293,7 @@ For agents available as VS Code extensions, add them to `.devcontainer/devcontai } ``` -##### CLI-based Agents +#### CLI-based Agents For agents that require CLI tools, add installation commands to `.devcontainer/post-create.sh`: @@ -287,50 +303,16 @@ For agents that require CLI tools, add installation commands to `.devcontainer/p # Existing installations... echo -e "\nšŸ¤– Installing [New Agent Name] CLI..." -# run_command "npm install -g [agent-cli-package]@latest" # Example for node-based CLI -# or other installation instructions (must be non-interactive and compatible with Linux Debian "Trixie" or later)... +# run_command "npm install -g [agent-cli-package]@latest" echo "āœ… Done" - ``` -**Quick Tips:** - -- **Extension-based agents**: Add to the `extensions` array in `devcontainer.json` -- **CLI-based agents**: Add installation scripts to `post-create.sh` -- **Hybrid agents**: May require both extension and CLI installation -- **Test thoroughly**: Ensure installations work in the devcontainer environment - -## Agent Categories - -### CLI-Based Agents - -Require a command-line tool to be installed: - -- **Claude Code**: `claude` CLI -- **Gemini CLI**: `gemini` CLI -- **Cursor**: `cursor-agent` CLI -- **Qwen Code**: `qwen` CLI -- **opencode**: `opencode` CLI -- **Amazon Q Developer CLI**: `q` CLI -- **CodeBuddy CLI**: `codebuddy` CLI -- **Qoder CLI**: `qoder` CLI -- **Amp**: `amp` CLI -- **SHAI**: `shai` CLI - -### IDE-Based Agents - -Work within integrated development environments: - -- **GitHub Copilot**: Built into VS Code/compatible editors -- **Windsurf**: Built into Windsurf IDE -- **IBM Bob**: Built into IBM Bob IDE +--- ## Command File Formats ### Markdown Format -Used by: Claude, Cursor, opencode, Windsurf, Amazon Q Developer, Amp, SHAI, IBM Bob - **Standard format:** ```markdown @@ -354,8 +336,6 @@ Command content with {SCRIPT} and $ARGUMENTS placeholders. ### TOML Format -Used by: Gemini, Qwen - ```toml description = "Command description" @@ -364,50 +344,108 @@ Command content with {SCRIPT} and {{args}} placeholders. """ ``` -## Directory Conventions +### YAML Format + +Used by: Goose -- **CLI agents**: Usually `./commands/` -- **IDE agents**: Follow IDE-specific patterns: - - Copilot: `.github/agents/` - - Cursor: `.cursor/commands/` - - Windsurf: `.windsurf/workflows/` +```yaml +version: 1.0.0 +title: "Command Title" +description: "Command description" +author: + contact: spec-kit +extensions: + - type: builtin + name: developer +activities: + - Spec-Driven Development +prompt: | + Command content with {SCRIPT} and {{args}} placeholders. +``` ## Argument Patterns -Different agents use different argument placeholders: +Different agents use different argument placeholders. The placeholder used in command files is always taken from `registrar_config["args"]` for each integration — check there first when in doubt: -- **Markdown/prompt-based**: `$ARGUMENTS` -- **TOML-based**: `{{args}}` +- **Markdown/prompt-based**: `$ARGUMENTS` (default for most markdown agents) +- **TOML-based**: `{{args}}` (e.g., Gemini) +- **YAML-based**: `{{args}}` (e.g., Goose) +- **Custom**: some agents override the default (e.g., Forge uses `{{parameters}}`) - **Script placeholders**: `{SCRIPT}` (replaced with actual script path) - **Agent placeholders**: `__AGENT__` (replaced with agent name) -## Testing New Agent Integration +## Special Processing Requirements -1. **Build test**: Run package creation script locally -2. **CLI test**: Test `specify init --ai ` command -3. **File generation**: Verify correct directory structure and files -4. **Command validation**: Ensure generated commands work with the agent -5. **Context update**: Test agent context update scripts +Some agents require custom processing beyond the standard template transformations: -## Common Pitfalls +### Copilot Integration + +GitHub Copilot has unique requirements: +- Commands use `.agent.md` extension (not `.md`) +- Each command gets a companion `.prompt.md` file in `.github/prompts/` +- Installs `.vscode/settings.json` with prompt file recommendations +- Context file lives at `.github/copilot-instructions.md` + +Implementation: Extends `IntegrationBase` with custom `setup()` method that: +1. Processes templates with `process_template()` +2. Generates companion `.prompt.md` files +3. Merges VS Code settings -1. **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. -2. **Forgetting update scripts**: Both bash and PowerShell scripts must be updated when adding new agents. -3. **Incorrect `requires_cli` value**: Set to `True` only for agents that actually have CLI tools to check; set to `False` for IDE-based agents. -4. **Wrong argument format**: Use correct placeholder format for each agent type (`$ARGUMENTS` for Markdown, `{{args}}` for TOML). -5. **Directory naming**: Follow agent-specific conventions exactly (check existing agents for patterns). -6. **Help text inconsistency**: Update all user-facing text consistently (help strings, docstrings, README, error messages). +**Skills mode (`--skills`):** Copilot also supports an alternative skills-based layout +via `--integration-options="--skills"`. When enabled: +- Commands are scaffolded as `speckit-/SKILL.md` under `.github/skills/` +- No companion `.prompt.md` files are generated +- No `.vscode/settings.json` merge +- `post_process_skill_content()` injects a `mode: speckit.` frontmatter field +- `build_command_invocation()` returns `/speckit-` instead of bare args -## Future Considerations +The two modes are mutually exclusive — a project uses one or the other: + +```bash +# Default mode: .agent.md agents + .prompt.md companions + settings merge +specify init my-project --integration copilot -When adding new agents: +# Skills mode: speckit-/SKILL.md under .github/skills/ +specify init my-project --integration copilot --integration-options="--skills" +``` + +### Forge Integration + +Forge has special frontmatter and argument requirements: +- Uses `{{parameters}}` instead of `$ARGUMENTS` +- Strips `handoffs` frontmatter key (Forge-specific collaboration feature) +- Injects `name` field into frontmatter when missing + +Implementation: Extends `MarkdownIntegration` with custom `setup()` method that: +1. Inherits standard template processing from `MarkdownIntegration` +2. Adds extra `$ARGUMENTS` → `{{parameters}}` replacement after template processing +3. Applies Forge-specific transformations via `_apply_forge_transformations()` +4. Strips `handoffs` frontmatter key +5. Injects missing `name` fields +6. Ensures the shared `update-agent-context.*` scripts include a `forge` case that maps context updates to `AGENTS.md` and lists `forge` in their usage/help text + +### Goose Integration + +Goose is a YAML-format agent using Block's recipe system: +- Uses `.goose/recipes/` directory for YAML recipe files +- Uses `{{args}}` argument placeholder +- Produces YAML with `prompt: |` block scalar for command content + +Implementation: Extends `YamlIntegration` (parallel to `TomlIntegration`): +1. Processes templates through the standard placeholder pipeline +2. Extracts title and description from frontmatter +3. Renders output as Goose recipe YAML (version, title, description, author, extensions, activities, prompt) +4. Uses `yaml.safe_dump()` for header fields to ensure proper escaping +5. Context updates map to `AGENTS.md` (shared with opencode/codex/pi/forge) + +## Common Pitfalls -- Consider the agent's native command/workflow patterns -- Ensure compatibility with the Spec-Driven Development process -- Document any special requirements or limitations -- Update this guide with lessons learned -- Verify the actual CLI tool name before adding to AGENT_CONFIG +1. **Using shorthand keys for CLI-based integrations**: For CLI-based integrations (`requires_cli: True`), the `key` must match the executable name (e.g., `"cursor-agent"` not `"cursor"`). `shutil.which(key)` is used for CLI tool checks — mismatches require special-case mappings. IDE-based integrations (`requires_cli: False`) are not subject to this constraint. +2. **Forgetting update scripts**: Both bash and PowerShell thin wrappers and the shared context-update scripts must be updated. +3. **Incorrect `requires_cli` value**: Set to `True` only for agents that have a CLI tool; set to `False` for IDE-based agents. +4. **Wrong argument format**: Use `$ARGUMENTS` for Markdown agents, `{{args}}` for TOML agents. +5. **Skipping registration**: The import and `_register()` call in `_register_builtins()` must both be added. --- -*This documentation should be updated whenever new agents are added to maintain accuracy and completeness.* +*This documentation should be updated whenever new integrations are added to maintain accuracy and completeness.* diff --git a/CHANGELOG.md b/CHANGELOG.md index 174b429cbc..934f7962ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,297 +1,1368 @@ # Changelog - - -All notable changes to the Specify CLI and templates are documented here. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [0.1.0] - 2026-01-28 - -### Added - -- **Extension System**: Introduced modular extension architecture for Spec Kit - - Extensions are self-contained packages that add commands and functionality without bloating core - - Extension manifest schema (`extension.yml`) with validation - - Extension registry (`.specify/extensions/.registry`) for tracking installed extensions - - Extension manager module (`src/specify_cli/extensions.py`) for installation/removal - - New CLI commands: - - `specify extension list` - List installed extensions - - `specify extension add` - Install extension from local directory or URL - - `specify extension remove` - Uninstall extension - - `specify extension search` - Search extension catalog - - `specify extension info` - Show detailed extension information - - Semantic versioning compatibility checks - - Support for extension configuration files - - Command registration system for AI agents (Claude support initially) - - Added dependencies: `pyyaml>=6.0`, `packaging>=23.0` - -- **Extension Catalog**: Extension discovery and distribution system - - Central catalog (`extensions/catalog.json`) for published extensions - - Extension catalog manager (`ExtensionCatalog` class) with: - - Catalog fetching from GitHub - - 1-hour local caching for performance - - Search by query, tag, author, or verification status - - Extension info retrieval - - Catalog cache stored in `.specify/extensions/.cache/` - - Search and info commands with rich console output - - Added 9 catalog-specific unit tests (100% pass rate) - -- **Jira Extension**: First official extension for Jira integration - - Extension ID: `jira` - - Version: 1.0.0 - - Commands: - - `/speckit.jira.specstoissues` - Create Jira hierarchy from spec and tasks - - `/speckit.jira.discover-fields` - Discover Jira custom fields - - `/speckit.jira.sync-status` - Sync task completion status - - Comprehensive documentation (README, usage guide, examples) - - MIT licensed - -- **Hook System**: Extension lifecycle hooks for automation - - `HookExecutor` class for managing extension hooks - - Hooks registered in `.specify/extensions.yml` - - Hook registration during extension installation - - Hook unregistration during extension removal - - Support for optional and mandatory hooks - - Hook execution messages for AI agent integration - - Condition support for conditional hook execution (placeholder) - -- **Extension Management**: Advanced extension management commands - - `specify extension update` - Check and update extensions to latest version - - `specify extension enable` - Enable a disabled extension - - `specify extension disable` - Disable extension without removing it - - Version comparison with catalog - - Update notifications - - Preserve configuration during updates - -- **Multi-Agent Support**: Extensions now work with all supported AI agents (Phase 6) - - Automatic detection and registration for all agents in project - - Support for 16+ AI agents (Claude, Gemini, Copilot, Cursor, Qwen, and more) - - Agent-specific command formats (Markdown and TOML) - - Automatic argument placeholder conversion ($ARGUMENTS → {{args}}) - - Commands registered for all detected agents during installation - - Multi-agent command unregistration on extension removal - - `CommandRegistrar.register_commands_for_agent()` method - - `CommandRegistrar.register_commands_for_all_agents()` method - -- **Configuration Layers**: Full configuration cascade system (Phase 6) - - **Layer 1**: Defaults from extension manifest (`extension.yml`) - - **Layer 2**: Project config (`.specify/extensions/{ext-id}/{ext-id}-config.yml`) - - **Layer 3**: Local config (`.specify/extensions/{ext-id}/local-config.yml`, gitignored) - - **Layer 4**: Environment variables (`SPECKIT_{EXT_ID}_{KEY}` pattern) - - Recursive config merging with proper precedence - - `ConfigManager` class for programmatic config access - - `get_config()`, `get_value()`, `has_value()` methods - - Support for nested configuration paths with dot-notation - -- **Hook Condition Evaluation**: Smart hook execution based on runtime conditions (Phase 6) - - Config conditions: `config.key.path is set`, `config.key == 'value'`, `config.key != 'value'` - - Environment conditions: `env.VAR is set`, `env.VAR == 'value'`, `env.VAR != 'value'` - - Automatic filtering of hooks based on condition evaluation - - Safe fallback behavior on evaluation errors - - Case-insensitive pattern matching - -- **Hook Integration**: Agent-level hook checking and execution (Phase 6) - - `check_hooks_for_event()` method for AI agents to query hooks after core commands - - Condition-aware hook filtering before execution - - `enable_hooks()` and `disable_hooks()` methods per extension - - Formatted hook messages for agent display - - `execute_hook()` method for hook execution information - -- **Documentation Suite**: Comprehensive documentation for users and developers - - **EXTENSION-USER-GUIDE.md**: Complete user guide with installation, usage, configuration, and troubleshooting - - **EXTENSION-API-REFERENCE.md**: Technical API reference with manifest schema, Python API, and CLI commands - - **EXTENSION-PUBLISHING-GUIDE.md**: Publishing guide for extension authors - - **RFC-EXTENSION-SYSTEM.md**: Extension architecture design document - -- **Extension Template**: Starter template in `extensions/template/` for creating new extensions - - Fully commented `extension.yml` manifest template - - Example command file with detailed explanations - - Configuration template with all options - - Complete project structure (README, LICENSE, CHANGELOG, .gitignore) - - EXAMPLE-README.md showing final documentation format - -- **Unit Tests**: Comprehensive test suite with 39 tests covering all extension system components - - Test coverage: 83% of extension module code - - Test dependencies: `pytest>=7.0`, `pytest-cov>=4.0` - - Configured pytest in `pyproject.toml` + + +## [0.8.1] - 2026-04-24 + +### Changed + +- fix(plan): use .specify/feature.json to allow /speckit.plan on custom git branches (#2305) (#2349) +- feat(vibe): migrate to SkillsIntegration from the old prompts-based MarkdownIntegration (#2336) +- docs: move community presets table to docs site, add missing entries (#2341) +- docs(presets): add lean preset README and enrich catalog metadata (#2340) +- fix: resolve command references per integration type (dot vs hyphen) (#2354) +- Update product-forge to v1.5.1 in community catalog (#2352) +- chore(deps): bump astral-sh/setup-uv from 8.0.0 to 8.1.0 (#2345) +- fix: replace xargs trim with sed to handle quotes in descriptions (#2351) +- feat: register jira preset in community catalog (#2224) +- feat: Preset screenwriting (#2332) +- chore: release 0.8.0, begin 0.8.1.dev0 development (#2333) + +## [0.8.0] - 2026-04-23 + +### Changed + +- feat(presets): Composition strategies (prepend, append, wrap) for templates, commands, and scripts (#2133) +- feat(copilot): support `--integration-options="--skills"` for skills-based scaffolding (#2324) +- docs(install): add pipx as alternative installation method (#2288) +- Add Memory MD community extension (#2327) +- Update version-guard to v1.2.0 (#2321) +- fix: `--force` now overwrites shared infra files during init and upgrade (#2320) +- chore: release 0.7.5, begin 0.7.6.dev0 development (#2322) + +## [0.7.5] - 2026-04-22 + +### Changed + +- fix: resolve skill placeholders for all SKILL.md agents, not just codex/kimi (#2313) +- feat(cli): add specify self check and self upgrade stub (#2316) +- Update version-guard to v1.1.0 (#2318) +- docs: move community presets from README to docs/community (#2314) +- catalog: add wireframe extension (v0.1.1) (#2262) +- Move community walkthroughs from README to docs/community (#2312) +- docs(readme): list red-team in community-extensions table (#2311) +- feat(catalog): add red-team extension to community catalog (#2306) +- Add superpowers-bridge community extension (#2309) +- feat: implement preset wrap strategy (#2189) +- fix(agents): block directory traversal in command write paths (#2229) (#2296) +- chore: release 0.7.4, begin 0.7.5.dev0 development (#2299) + +## [0.7.4] - 2026-04-21 + +### Changed + +- fix(copilot): use --yolo to grant all permissions in non-interactive mode (#2298) +- feat: add CITATION.cff and .zenodo.json for academic citation support (#2291) +- Add spec-validate to community catalog (#2274) +- feat: register Ripple in community catalog (#2272) +- Add version-guard to community catalog (#2286) +- Add spec-reference-loader to community catalog (#2285) +- Add memory-loader to community catalog (#2284) +- fix(integrations): strip UTF-8 BOM when reading agent context files (#2283) +- Preset fiction book writing1.6 (#2270) +- fix(integrations): migrate Antigravity (agy) layout to .agents/ and deprecate --skills (#2276) +- chore: release 0.7.3, begin 0.7.4.dev0 development (#2263) + +## [0.7.3] - 2026-04-17 + +### Changed + +- fix: replace shell-based context updates with marker-based upsert (#2259) +- Add Community Friends page to docs site (#2261) +- Add Spec Scope extension to community catalog (#2172) +- docs: add Community-maintained plugin for Claude Code and GitHub Copilot CLI that installs Spec Kit skills via the plugin marketplace to README (#2250) +- fix: suppress CRLF warnings in auto-commit.ps1 (#2258) +- feat: register Blueprint in community catalog (#2252) +- preset: Update preset-fiction-book-writing to community catalog -> v1.5.0 (#2256) +- chore(deps): bump actions/upload-pages-artifact from 3 to 5 (#2251) +- fix: add reference/*.md to docfx content glob (#2248) +- chore: release 0.7.2, begin 0.7.3.dev0 development (#2247) + +## [0.7.2] - 2026-04-16 + +### Changed + +- docs: add core commands reference and simplify README CLI section (#2245) +- docs: add workflows reference, reorganize into docs/reference/, and add --version flag (#2244) +- docs: add presets reference page and rename pack_id to preset_id (#2243) +- docs: add extensions reference page and integrations FAQ (#2242) +- docs: consolidate integration documentation into docs/integrations.md (#2241) +- feat: update memorylint and superpowers-bridge versions to 1.3.0 with new download URLs (#2240) +- feat: Integration catalog — discovery, versioning, and community distribution (#2130) +- Add Catalog CI extension to community catalog (#2239) +- Added issues extension (#2194) +- chore: release 0.7.1, begin 0.7.2.dev0 development (#2235) + +## [0.7.1] - 2026-04-15 + +### Changed + +- ci: add windows-latest to test matrix (#2233) +- docs: remove deprecated --skip-tls references from local-development guide (#2231) +- fix: allow Claude to chain skills for hook execution (#2227) +- docs: merge TESTING.md into CONTRIBUTING.md, remove TESTING.md (#2228) +- Add agent-assign extension to community catalog (#2030) +- fix: unofficial PyPI warning (#1982) and legacy extension command name auto-correction (#2017) (#2027) +- feat: register architect-preview in community catalog (#2214) +- chore: deprecate --ai flag in favor of --integration on specify init (#2218) +- chore: release 0.7.0, begin 0.7.1.dev0 development (#2217) + +## [0.7.0] - 2026-04-14 + +### Changed + +- Add workflow engine with catalog system (#2158) +- docs(catalog): add claude-ask-questions to community preset catalog (#2191) +- Add SFSpeckit — Salesforce SDD Extension (#2208) +- feat(scripts): optional single-segment branch prefix for gitflow (#2202) +- chore: release 0.6.2, begin 0.6.3.dev0 development (#2205) +- Add Worktrees extension to community catalog (#2207) +- feat: Update catalog.community.json for preset-fiction-book-writing (#2199) + +## [0.6.2] - 2026-04-13 + +### Changed + +- feat: Register "What-if Analysis" community extension (#2182) +- feat: add GitHub Issues Integration to community catalog (#2188) +- feat(agents): add Goose AI agent support (#2015) +- Update ralph extension to v1.0.1 in community catalog (#2192) +- fix: skip docs deployment workflow on forks (#2171) +- chore: release 0.6.1, begin 0.6.2.dev0 development (#2162) + +## [0.6.1] - 2026-04-10 + +### Changed + +- feat: add bundled lean preset with minimal workflow commands (#2161) +- Add Brownfield Bootstrap extension to community catalog (#2145) +- Add CI Guard extension to community catalog (#2157) +- Add SpecTest extension to community catalog (#2159) +- fix: bundled extensions should not have download URLs (#2155) +- Add PR Bridge extension to community catalog (#2148) +- feat(cursor-agent): migrate from .cursor/commands to .cursor/skills (#2156) +- Add TinySpec extension to community catalog (#2147) +- chore: bump spec-kit-verify to 1.0.3 and spec-kit-review to 1.0.1 (#2146) +- Add Status Report extension to community catalog (#2123) +- chore: release 0.6.0, begin 0.6.1.dev0 development (#2144) + +## [0.6.0] - 2026-04-09 + +### Changed + +- Add Bugfix Workflow community extension to catalog and README (#2135) +- Add Worktree Isolation extension to community catalog (#2143) +- Add multi-repo-branching preset to community catalog (#2139) +- Readme clarity (#2013) +- Rewrite AGENTS.md for integration architecture (#2119) +- docs: add SpecKit Companion to Community Friends section (#2140) +- feat: add memorylint extension to community catalog (#2138) +- chore: release 0.5.1, begin 0.5.2.dev0 development (#2137) + +## [0.5.1] - 2026-04-08 + +### Changed + +- fix: pin typer>=0.24.0 and click>=8.2.1 to fix import crash (#2136) +- feat: update fleet extension to v1.1.0 (#2029) +- fix(forge): use hyphen notation in frontmatter name field (#2075) +- fix(bash): sed replacement escaping, BSD portability, dead cleanup in update-agent-context.sh (#2090) +- Add Spec Diagram community extension to catalog and README (#2129) +- feat: Git extension stage 2 — GIT_BRANCH_NAME override, --force for existing dirs, auto-install tests (#1940) (#2117) +- fix(git): surface checkout errors for existing branches (#2122) +- Add Branch Convention community extension to catalog and README (#2128) +- docs: lighten March 2026 newsletter for readability (#2127) +- fix: restore alias compatibility for community extensions (#2110) (#2125) +- Added March 2026 newsletter (#2124) +- Add Spec Refine community extension to catalog and README (#2118) +- Add explicit-task-dependencies community preset to catalog and README (#2091) +- Add toc-navigation community preset to catalog and README (#2080) +- fix: prevent ambiguous TOML closing quotes when body ends with `"` (#2113) (#2115) +- fix speckit issue for trae (#2112) +- feat: Git extension stage 1 — bundled `extensions/git` with hooks on all core commands (#1941) +- Upgraded confluence extension to v.1.1.1 (#2109) +- Update V-Model Extension Pack to v0.5.0 (#2108) +- Add canon extension and canon-core preset. (#2022) +- [stage2] fix: serialize multiline descriptions in legacy TOML renderer (#2097) +- [stage1] fix: strip YAML frontmatter from TOML integration prompts (#2096) +- Add Confluence extension (#2028) +- fix: accept 4+ digit spec numbers in tests and docs (#2094) +- fix(scripts): improve git branch creation error handling (#2089) +- Add optimize extension to community catalog (#2088) +- feat: add "VS Code Ask Questions" preset (#2086) +- Add security-review v1.1.1 to community extensions catalog (#2073) +- Add `specify integration` subcommand for post-init integration management (#2083) +- Remove template version info from CLI, fix Claude user-invocable, cleanup dead code (#2081) +- fix: add user-invocable: true to skill frontmatter (#2077) +- fix: add actions:write permission to stale workflow (#2079) +- feat: add argument-hint frontmatter to Claude Code commands (#1951) (#2059) +- Update conduct extension to v1.0.1 (#2078) +- chore(deps): bump astral-sh/setup-uv from 7.6.0 to 8.0.0 (#2072) +- chore(deps): bump actions/configure-pages from 5 to 6 (#2071) +- feat: add spec-kit-fixit extension to community catalog (#2024) +- chore: release 0.5.0, begin 0.5.1.dev0 development (#2070) +- feat: add Forgecode agent support (#2034) + +## [0.5.0] - 2026-04-02 + +### Changed + +- Introduces DEVELOPMENT.md (#2069) +- Update cc-sdd reference to cc-spex in Community Friends (#2007) +- chore: release 0.4.5, begin 0.4.6.dev0 development (#2064) + +## [0.4.5] - 2026-04-02 + +### Changed + +- Stage 6: Complete migration — remove legacy scaffold path (#1924) (#2063) +- Install Claude Code as native skills and align preset/integration flows (#2051) +- Add repoindex 0402 (#2062) +- Stage 5: Skills, Generic & Option-Driven Integrations (#1924) (#2052) +- feat(scripts): add --dry-run flag to create-new-feature (#1998) +- fix: support feature branch numbers with 4+ digits (#2040) +- Add community content disclaimers (#2058) +- docs: add community extensions website link to README and extensions docs (#2014) +- docs: remove dead Cognitive Squad and Understanding extension links and from extensions/catalog.community.json (#2057) +- Add fix-findings extension to community catalog (#2039) +- Stage 4: TOML integrations — gemini and tabnine migrated to plugin architecture (#2050) +- feat: add 5 lifecycle extensions to community catalog (#2049) +- Stage 3: Standard markdown integrations — 19 agents migrated to plugin architecture (#2038) +- chore: release 0.4.4, begin 0.4.5.dev0 development (#2048) + +## [0.4.4] - 2026-04-01 + +### Changed + +- Stage 2: Copilot integration — proof of concept with shared template primitives (#2035) +- docs: sync AGENTS.md with AGENT_CONFIG for missing agents (#2025) +- docs: ensure manual tests use local specify (#2020) +- Stage 1: Integration foundation — base classes, manifest system, and registry (#1925) +- fix: harden GitHub Actions workflows (#2021) +- chore: use PEP 440 .dev0 versions on main after releases (#2032) +- feat: add superpowers bridge extension to community catalog (#2023) +- feat: add product-forge extension to community catalog (#2012) +- feat(scripts): add --allow-existing-branch flag to create-new-feature (#1999) +- fix(scripts): add correct path for copilot-instructions.md (#1997) +- Update README.md (#1995) +- fix: prevent extension command shadowing (#1994) +- Fix Claude Code CLI detection for npm-local installs (#1978) +- fix(scripts): honor PowerShell agent and script filters (#1969) +- feat: add MAQA extension suite (7 extensions) to community catalog (#1981) +- feat: add spec-kit-onboard extension to community catalog (#1991) +- Add plan-review-gate to community catalog (#1993) +- chore(deps): bump actions/deploy-pages from 4 to 5 (#1990) +- chore(deps): bump DavidAnson/markdownlint-cli2-action from 19 to 23 (#1989) +- chore: bump version to 0.4.3 (#1986) + +## [0.4.3] - 2026-03-26 + +### Changed + +- Unify Kimi/Codex skill naming and migrate legacy dotted Kimi dirs (#1971) +- fix(ps1): replace null-conditional operator for PowerShell 5.1 compatibility (#1975) +- chore: bump version to 0.4.2 (#1973) + +## [0.4.2] - 2026-03-25 + +### Changed + +- feat: Auto-register ai-skills for extensions whenever applicable (#1840) +- docs: add manual testing guide for slash command validation (#1955) +- Add AIDE, Extensify, and Presetify to community extensions (#1961) +- docs: add community presets section to main README (#1960) +- docs: move community extensions table to main README for discoverability (#1959) +- docs(readme): consolidate Community Friends sections and fix ToC anchors (#1958) +- fix(commands): rename NFR references to success criteria in analyze and clarify (#1935) +- Add Community Friends section to README (#1956) +- docs: add Community Friends section with Spec Kit Assistant VS Code extension (#1944) + +## [0.4.1] - 2026-03-24 + +### Changed + +- Add checkpoint extension (#1947) +- fix(scripts): prioritize .specify over git for repo root detection (#1933) +- docs: add AIDE extension demo to community projects (#1943) +- fix(templates): add missing Assumptions section to spec template (#1939) + +## [0.4.0] - 2026-03-23 + +### Changed + +- fix(cli): add allow_unicode=True and encoding="utf-8" to YAML I/O (#1936) +- fix(codex): native skills fallback refresh + legacy prompt suppression (#1930) +- feat(cli): embed core pack in wheel for offline/air-gapped deployment (#1803) +- ci: increase stale workflow operations-per-run to 250 (#1922) +- docs: update publishing guide with Category and Effect columns (#1913) +- fix: Align native skills frontmatter with install_ai_skills (#1920) +- feat: add timestamp-based branch naming option for `specify init` (#1911) +- docs: add Extension Comparison Guide for community extensions (#1897) +- docs: update SUPPORT.md, fix issue templates, add preset submission template (#1910) +- Add support for Junie (#1831) +- feat: migrate Codex/agy init to native skills workflow (#1906) + +## [0.3.2] - 2026-03-19 + +### Changed + +- Add conduct extension to community catalog (#1908) +- feat(extensions): add verify-tasks extension to community catalog (#1871) +- feat(presets): add enable/disable toggle and update semantics (#1891) +- feat: add iFlow CLI support (#1875) +- feat(commands): wire before/after hook events into specify and plan templates (#1886) +- docs(catalog): add speckit-utils to community catalog (#1896) +- docs: Add Extensions & Presets section to README (#1898) +- chore: update DocGuard extension to v0.9.11 (#1899) +- Update cognitive-squad catalog entry — Triadic Model, full lifecycle (#1884) +- feat: register spec-kit-iterate extension (#1887) +- fix(scripts): add explicit positional binding to PowerShell create-new-feature params (#1885) +- fix(scripts): encode residual JSON control chars as \uXXXX instead of stripping (#1872) +- chore: update DocGuard extension to v0.9.10 (#1890) +- Feature/spec kit add pi coding agent pullrequest (#1853) +- feat: register spec-kit-learn extension (#1883) + +## [0.3.1] - 2026-03-17 + +### Changed + +- docs: add greenfield Spring Boot pirate-speak preset demo to README (#1878) +- fix(ai-skills): exclude non-speckit copilot agent markdown from skills (#1867) +- feat: add Trae IDE support as a new agent (#1817) +- feat(cli): polite deep merge for settings.json and support JSONC (#1874) +- feat(extensions,presets): add priority-based resolution ordering (#1855) +- fix(scripts): suppress stdout from git fetch in create-new-feature.sh (#1876) +- fix(scripts): harden bash scripts — escape, compat, and error handling (#1869) +- Add cognitive-squad to community extension catalog (#1870) +- docs: add Go / React brownfield walkthrough to community walkthroughs (#1868) +- chore: update DocGuard extension to v0.9.8 (#1859) +- Feature: add specify status command (#1837) +- fix(extensions): show extension ID in list output (#1843) +- feat(extensions): add Archive and Reconcile extensions to community catalog (#1844) +- feat: Add DocGuard CDD enforcement extension to community catalog (#1838) + +## [0.3.0] - 2026-03-13 + +### Changed + +- feat(presets): Pluggable preset system with catalog, resolver, and skills propagation (#1787) +- fix: match 'Last updated' timestamp with or without bold markers (#1836) +- Add specify doctor command for project health diagnostics (#1828) +- fix: harden bash scripts against shell injection and improve robustness (#1809) +- fix: clean up command templates (specify, analyze) (#1810) +- fix: migrate Qwen Code CLI from TOML to Markdown format (#1589) (#1730) +- fix(cli): deprecate explicit command support for agy (#1798) (#1808) +- Add /selftest.extension core extension to test other extensions (#1758) +- feat(extensions): Quality of life improvements for RFC-aligned catalog integration (#1776) +- Add Java brownfield walkthrough to community walkthroughs (#1820) + +## [0.2.1] - 2026-03-11 + +### Changed + +- Added February 2026 newsletter (#1812) +- feat: add Kimi Code CLI agent support (#1790) +- docs: fix broken links in quickstart guide (#1759) (#1797) +- docs: add catalog cli help documentation (#1793) (#1794) +- fix: use quiet checkout to avoid exception on git checkout (#1792) +- feat(extensions): support .extensionignore to exclude files during install (#1781) +- feat: add Codex support for extension command registration (#1767) + +## [0.2.0] - 2026-03-09 + +### Changed + +- fix: sync agent list comments with actual supported agents (#1785) +- feat(extensions): support multiple active catalogs simultaneously (#1720) +- Pavel/add tabnine cli support (#1503) +- Add Understanding extension to community catalog (#1778) +- Add ralph extension to community catalog (#1780) +- Update README with project initialization instructions (#1772) +- feat: add review extension to community catalog (#1775) +- Add fleet extension to community catalog (#1771) +- Integration of Mistral vibe support into speckit (#1725) +- fix: Remove duplicate options in specify.md (#1765) +- fix: use global branch numbering instead of per-short-name detection (#1757) +- Add Community Walkthroughs section to README (#1766) +- feat(extensions): add Jira Integration to community catalog (#1764) +- Add Azure DevOps Integration extension to community catalog (#1734) +- Fix docs: update Antigravity link and add initialization example (#1748) +- fix: wire after_tasks and after_implement hook events into command templates (#1702) +- make c ignores consistent with c++ (#1747) + +## [0.1.13] - 2026-03-03 + +### Changed + +- feat: add kiro-cli and AGENT_CONFIG consistency coverage (#1690) +- feat: add verify extension to community catalog (#1726) +- Add Retrospective Extension to community catalog README table (#1741) +- fix(scripts): add empty description validation and branch checkout error handling (#1559) +- fix: correct Copilot extension command registration (#1724) +- fix(implement): remove Makefile from C ignore patterns (#1558) +- Add sync extension to community catalog (#1728) +- fix(checklist): clarify file handling behavior for append vs create (#1556) +- fix(clarify): correct conflicting question limit from 10 to 5 (#1557) + +## [0.1.12] - 2026-03-02 + +### Changed + +- fix: use RELEASE_PAT so tag push triggers release workflow (#1736) + +## [0.1.11] - 2026-03-02 + +### Changed + +- fix: release-trigger uses release branch + PR instead of direct push to main (#1733) +- fix: Split release process to sync pyproject.toml version with git tags (#1732) + +## [0.1.10] - 2026-02-27 + +### Changed + +- fix: prepend YAML frontmatter to Cursor .mdc files (#1699) + +## [0.1.9] - 2026-02-28 + +### Changed + +- chore(deps): bump astral-sh/setup-uv from 6 to 7 (#1709) + +## [0.1.8] - 2026-02-28 + +### Changed + +- chore(deps): bump actions/setup-python from 5 to 6 (#1710) + +## [0.1.7] - 2026-02-27 + +### Changed + +- chore: Update outdated GitHub Actions versions (#1706) +- docs: Document dual-catalog system for extensions (#1689) +- Fix version command in documentation (#1685) +- Add Cleanup Extension to README (#1678) +- Add retrospective extension to community catalog (#1681) + +## [0.1.6] - 2026-02-23 + +### Changed + +- Add Cleanup Extension to catalog (#1617) +- Fix parameter ordering issues in CLI (#1669) +- Update V-Model Extension Pack to v0.4.0 (#1665) +- docs: Fix doc missing step (#1496) +- Update V-Model Extension Pack to v0.3.0 (#1661) + +## [0.1.5] - 2026-02-21 + +### Changed + +- Fix #1658: Add commands_subdir field to support non-standard agent directory structures (#1660) +- feat: add GitHub issue templates (#1655) +- Update V-Model Extension Pack to v0.2.0 in community catalog (#1656) +- Add V-Model Extension Pack to catalog (#1640) +- refactor: remove OpenAPI/GraphQL bias from templates (#1652) + +## [0.1.4] - 2026-02-20 + +### Changed + +- fix: rename Qoder AGENT_CONFIG key from 'qoder' to 'qodercli' to match actual CLI executable (#1651) + +## [0.1.3] - 2026-02-20 + +### Changed + +- Add generic agent support with customizable command directories (#1639) + +## [0.1.2] - 2026-02-20 + +### Changed + +- fix: pin click>=8.1 to prevent Python 3.14/Homebrew env isolation crash (#1648) + +## [0.0.102] - 2026-02-20 + +### Changed + +- fix: include 'src/**' path in release workflow triggers (#1646) + +## [0.0.101] - 2026-02-19 + +### Changed + +- chore(deps): bump github/codeql-action from 3 to 4 (#1635) + +## [0.0.100] - 2026-02-19 + +### Changed + +- Add pytest and Python linting (ruff) to CI (#1637) +- feat: add pull request template for better contribution guidelines (#1634) + +## [0.0.99] - 2026-02-19 + +### Changed + +- Feat/ai skills (#1632) + +## [0.0.98] - 2026-02-19 + +### Changed + +- chore(deps): bump actions/stale from 9 to 10 (#1623) +- feat: add dependabot configuration for pip and GitHub Actions updates (#1622) + +## [0.0.97] - 2026-02-18 + +### Changed + +- Remove Maintainers section from README.md (#1618) + +## [0.0.96] - 2026-02-17 + +### Changed + +- fix: typo in plan-template.md (#1446) + +## [0.0.95] - 2026-02-12 + +### Changed + +- Feat: add a new agent: Google Anti Gravity (#1220) + +## [0.0.94] - 2026-02-11 + +### Changed + +- Add stale workflow for 180-day inactive issues and PRs (#1594) + +## [0.0.93] - 2026-02-10 + +### Changed + +- Add modular extension system (#1551) + +## [0.0.92] - 2026-02-10 + +### Changed + +- Fixes #1586 - .specify.specify path error (#1588) + +## [0.0.91] - 2026-02-09 + +### Changed + +- fix: preserve constitution.md during reinitialization (#1541) (#1553) +- fix: resolve markdownlint errors across documentation (#1571) + +## [0.0.90] - 2025-12-04 + +### Changed + +- Update Markdown formatting +- Update Markdown formatting +- docs: Add existing project initialization to getting started + +## [0.0.89] - 2025-12-02 + +### Changed + +- Update scripts/bash/create-new-feature.sh +- fix(scripts): prevent octal interpretation in feature number parsing +- fix: remove unused short_name parameter from branch numbering functions +- Update scripts/powershell/create-new-feature.ps1 +- Update scripts/bash/create-new-feature.sh +- fix: use global maximum for branch numbering to prevent collisions + +## [0.0.88] - 2025-12-01 + +### Changed + +- fix the incorrect task-template file path + +## [0.0.87] - 2025-12-01 + +### Changed + +- Limit width and height to 200px to match the small logo +- docs: Switch readme logo to logo_large.webp +- fix:merge +- fix +- fix +- feat:qoder agent +- docs: Enhance quickstart guide with admonitions and examples +- docs: add constitution step to quickstart guide (fixes #906) +- Update supported AI agents in README.md +- cancel:test +- test +- fix:literal bug +- fix:test +- test +- fix:qoder url +- fix:download owner +- test +- feat:support Qoder CLI + +## [0.0.86] - 2025-11-26 + +### Changed + +- feat: add bob to new update-agent-context.ps1 + consistency in comments +- feat: add support for IBM Bob IDE + +## [0.0.85] - 2025-11-14 + +### Changed + +- Unset CDPATH while getting SCRIPT_DIR + +## [0.0.84] - 2025-11-14 + +### Changed + +- docs: fix broken link and improve agent reference +- docs: reorganize upgrade documentation structure +- docs: remove related documentation section from upgrading guide +- fix: remove broken link to existing project guide +- docs: Add comprehensive upgrading guide for Spec Kit +- Refactor ESLint configuration checks in implement.md to address deprecation + +## [0.0.83] - 2025-11-14 + +### Changed + +- feat: Add OVHcloud SHAI AI Agent + +## [0.0.82] - 2025-11-14 + +### Changed + +- fix: incorrect logic to create release packages with subset AGENTS or SCRIPTS + +## [0.0.81] - 2025-11-14 + +### Changed + +- Fix tasktoissues.md to use the 'github/github-mcp-server/issue_write' tool + +## [0.0.80] - 2025-11-14 + +### Changed + +- Refactor feature script logic and update agent context scripts +- Update templates/commands/taskstoissues.md +- Update CHANGELOG.md +- Update agent configuration +- Update scripts/powershell/create-new-feature.ps1 +- Update src/specify_cli/__init__.py +- Create create-release-packages.ps1 +- Script changes +- Update taskstoissues.md +- Create taskstoissues.md +- Update src/specify_cli/__init__.py +- Update CONTRIBUTING.md +- Potential fix for code scanning alert no. 3: Workflow does not contain permissions +- Update src/specify_cli/__init__.py +- Update CHANGELOG.md +- Fixes #970 +- Fixes #975 +- Support for version command +- Exclude generated releases +- Lint fixes +- Prompt updates +- Hand offs with prompts +- Chatmodes are back in vogue +- Let's switch to proper prompts +- Update prompts +- Update with prompt +- Testing hand-offs +- Use VS Code handoffs + +## [0.0.79] - 2025-10-23 ### Changed -- Version bumped to 0.1.0 (minor release for new feature) +- docs: restore important note about JSON output in specify command +- fix: improve branch number detection to check all sources +- feat: check remote branches to prevent duplicate branch numbers -## [0.0.22] - 2025-11-07 +## [0.0.78] - 2025-10-21 -- Support for VS Code/Copilot agents, and moving away from prompts to proper agents with hand-offs. -- Move to use `AGENTS.md` for Copilot workloads, since it's already supported out-of-the-box. -- Adds support for the version command. ([#486](https://github.com/github/spec-kit/issues/486)) -- Fixes potential bug with the `create-new-feature.ps1` script that ignores existing feature branches when determining next feature number ([#975](https://github.com/github/spec-kit/issues/975)) -- Add graceful fallback and logging for GitHub API rate-limiting during template fetch ([#970](https://github.com/github/spec-kit/issues/970)) +### Changed + +- Update CONTRIBUTING.md +- docs: add steps for testing template and command changes locally +- update specify to make "short-name" argu for create-new-feature.sh in the right position + +## [0.0.77] - 2025-10-21 + +### Changed + +- fix: include the latest changelog in the `GitHub Release`'s body + +## [0.0.76] - 2025-10-21 + +### Changed + +- Fix update-agent-context.sh to handle files without Active Technologies/Recent Changes sections + +## [0.0.75] - 2025-10-21 + +### Changed + +- Fixed indentation. +- Added correct `install_url` for Amp agent CLI script. +- Added support for Amp code agent. + +## [0.0.74] - 2025-10-21 + +### Changed + +- feat(ci): add markdownlint-cli2 for consistent markdown formatting + +## [0.0.73] - 2025-10-21 + +### Changed + +- revert vscode auto remove extra space +- fix: correct command references in implement.md +- fix regarding copilot suggestion +- fix: correct command references in speckit.analyze.md +- Support more lang/Devops of Common Patterns by Technology +- chore: replace `bun` by `node/npm` in the `devcontainer` (as many CLI-based agents actually require a `node` runtime) +- chore: add Claude Code extension to devcontainer configuration +- chore: add installation of `codebuddy` CLI in the `devcontainer` +- chore: fix path to powershell script in vscode settings +- fix: correct `run_command` exit behavior and improve installation instructions (for `Amazon Q`) in `post-create.sh` + fix typos in `CONTRIBUTING.md` +- chore: add `specify`'s github copilot chat settings to `devcontainer` +- chore: add `devcontainer` support to ease developer workstation setup + +## [0.0.72] - 2025-10-18 + +### Changed + +- fix: correct argument parsing in create-new-feature.sh script + +## [0.0.71] - 2025-10-18 + +### Changed + +- fix: Skip CLI checks for IDE-based agents in check command +- Change loop condition to include last argument + +## [0.0.70] - 2025-10-18 + +### Changed + +- fix: broken media files +- Update README.md +- The function parameters lack type hints. Consider adding type annotations for better code clarity and IDE support. +- - **Smart JSON Merging for VS Code Settings**: `.vscode/settings.json` is now intelligently merged instead of being overwritten during `specify init --here` or `specify init .` - Existing settings are preserved - New Spec Kit settings are added - Nested objects are merged recursively - Prevents accidental loss of custom VS Code workspace configurations +- Fix: incorrect command formatting in agent context file, refix #895 + +## [0.0.69] - 2025-10-15 + +### Changed + +- Update scripts/bash/create-new-feature.sh +- Update create-new-feature.sh +- Update files +- Update files +- Create .gitattributes +- Update wording +- Update logic for arguments +- Update script logic + +## [0.0.68] - 2025-10-15 + +### Changed + +- format content as copilot suggest +- Ruby, PHP, Rust, Kotlin, C, C++ + +## [0.0.67] - 2025-10-15 + +### Changed + +- Use the number prefix to find the right spec + +## [0.0.66] - 2025-10-15 + +### Changed + +- Update CodeBuddy agent name to 'CodeBuddy CLI' +- Rename CodeBuddy to CodeBuddy CLI in update script +- Update AI coding agent references in installation guide +- Rename CodeBuddy to CodeBuddy CLI in AGENTS.md +- Update README.md +- Update CodeBuddy link in README.md +- update codebuddyCli + +## [0.0.65] - 2025-10-15 + +### Changed + +- Fix: Fix incorrect command formatting in agent context file +- docs: fix heading capitalization for consistency +- Update README.md + +## [0.0.64] - 2025-10-14 + +### Changed + +- Update tasks.md +- Update README.md + +## [0.0.63] - 2025-10-14 + +### Changed + +- fix: update CODEBUDDY file path in agent context scripts +- docs(readme): add /speckit.tasks step and renumber walkthrough + +## [0.0.62] - 2025-10-11 + +### Changed + +- A few more places to update from code review +- fix: align Cursor agent naming to use 'cursor-agent' consistently + +## [0.0.61] - 2025-10-10 + +### Changed + +- Update clarify.md +- add how to upgrade specify installation + +## [0.0.60] - 2025-10-10 + +### Changed + +- Update vscode-settings.json +- Update instructions and bug fix + +## [0.0.59] - 2025-10-10 + +### Changed + +- Update __init__.py +- Consolidate Cursor naming +- Update CHANGELOG.md +- Git errors are now highlighted. +- Update __init__.py +- Refactor agent configuration +- Update src/specify_cli/__init__.py +- Update scripts/powershell/update-agent-context.ps1 +- Update AGENTS.md +- Update templates/commands/implement.md +- Update templates/commands/implement.md +- Update CHANGELOG.md +- Update changelog +- Update plan.md +- Add ignore file verification step to /speckit.implement command +- Escape backslashes in TOML outputs +- update CodeBuddy to international site +- feat: support codebuddy ai +- feat: support codebuddy ai + +## [0.0.58] - 2025-10-08 + +### Changed + +- Add escaping guidelines to command templates +- Update README.md +- Update README.md + +## [0.0.57] - 2025-10-06 + +### Changed + +- Update CHANGELOG.md +- Update command reference +- Package up VS Code settings for Copilot +- Update tasks-template.md +- Update templates/tasks-template.md +- Cleanup +- Update CLI changes +- Update template and docs +- Update checklist.md +- Update templates +- Cleanup redundancies +- Update checklist.md +- Codex CLI is now fully supported +- Update specify.md +- Prompt updates +- Update prompt prefix +- Update .github/workflows/scripts/create-release-packages.sh +- Consistency updates to commands +- Update commands. +- Update logs +- Template cleanup and reorganization +- Remove Codex named args limitation warning +- Remove Codex named args limitation from README.md + +## [0.0.56] - 2025-10-02 + +### Changed + +- docs(readme): link Amazon Q slash command limitation issue +- docs: clarify Amazon Q limitation and update init docstring +- feat(agent): Added Amazon Q Developer CLI Integration + +## [0.0.55] - 2025-09-30 + +### Changed + +- Update URLs to Contributing and Support Guides in Docs +- fix: add UTF-8 encoding to file read/write operations in update-agent-context.ps1 +- Update __init__.py +- Update src/specify_cli/__init__.py +- docs: fix the paths of generated files (moved under a `.specify/` folder) +- Update src/specify_cli/__init__.py +- feat: support 'specify init .' for current directory initialization +- feat: Add emacs-style up/down keys + +## [0.0.54] - 2025-09-25 + +### Changed + +- Update CONTRIBUTING.md +- Refine `plan-template.md` with improved project type detection, clarified structure decision process, and enhanced research task guidance. +- Update __init__.py + +## [0.0.53] - 2025-09-24 + +### Changed + +- Update template path for spec file creation +- Update template path for spec file creation +- docs: remove constitution_update_checklist from README + +## [0.0.52] - 2025-09-22 + +### Changed + +- Update analyze.md +- Update templates/commands/analyze.md +- Update templates/commands/clarify.md +- Update templates/commands/plan.md +- Update with extra commands +- Update with --force flag +- feat: add uv tool install instructions to README + +## [0.0.51] - 2025-09-21 + +### Changed + +- Update with Roo Code support + +## [0.0.50] - 2025-09-21 + +### Changed + +- Update generate-release-notes.sh +- Update error messages +- Auggie folder fix + +## [0.0.49] - 2025-09-21 + +### Changed -## [0.0.21] - 2025-10-21 +- Update scripts/powershell/update-agent-context.ps1 +- Update templates/commands/implement.md +- Cleanup the check command +- Add support for Auggie +- Update AGENTS.md +- Updates with Kilo Code support +- Update README.md +- Update templates/commands/constitution.md +- Update templates/commands/implement.md +- Update templates/commands/plan.md +- Update templates/commands/specify.md +- Update templates/commands/tasks.md +- Update README.md +- Stop splitting the warning over multiple lines +- Update templates based on #419 +- docs: Update README with codex in check command + +## [0.0.48] - 2025-09-21 -- Fixes [#975](https://github.com/github/spec-kit/issues/975) (thank you [@fgalarraga](https://github.com/fgalarraga)). -- Adds support for Amp CLI. -- Adds support for VS Code hand-offs and moves prompts to be full-fledged chat modes. -- Adds support for `version` command (addresses [#811](https://github.com/github/spec-kit/issues/811) and [#486](https://github.com/github/spec-kit/issues/486), thank you [@mcasalaina](https://github.com/mcasalaina) and [@dentity007](https://github.com/dentity007)). -- Adds support for rendering the rate limit errors from the CLI when encountered ([#970](https://github.com/github/spec-kit/issues/970), thank you [@psmman](https://github.com/psmman)). +### Changed + +- Update scripts/powershell/check-prerequisites.ps1 +- Update CHANGELOG.md +- Update CHANGELOG.md +- Update changelog +- Update scripts/bash/update-agent-context.sh +- Fix script config +- Update scripts/bash/common.sh +- Update scripts/powershell/update-agent-context.ps1 +- Update scripts/powershell/update-agent-context.ps1 +- Clarification +- Update prompts +- Update update-agent-context.ps1 +- Update CONTRIBUTING.md +- Update CONTRIBUTING.md +- Update CONTRIBUTING.md +- Update CONTRIBUTING.md +- Update CONTRIBUTING.md +- Update contribution guidelines. +- Root detection logic +- Update templates/plan-template.md +- Update scripts/bash/update-agent-context.sh +- Update scripts/powershell/create-new-feature.ps1 +- Simplification +- Script and template tweaks +- Update config +- Update scripts/powershell/check-prerequisites.ps1 +- Update scripts/bash/check-prerequisites.sh +- Fix script path +- Script cleanup +- Update scripts/bash/check-prerequisites.sh +- Update scripts/powershell/check-prerequisites.ps1 +- Update script delegation from GitHub Action +- Cleanup the setup for generated packages +- Use proper line endings +- Consolidate scripts + +## [0.0.47] - 2025-09-20 + +### Changed + +- Updating agent context files + +## [0.0.46] - 2025-09-20 + +### Changed + +- Update update-agent-context.ps1 +- Update package release +- Update config +- Update __init__.py +- Update __init__.py +- Remove Codex-specific logic in the initialization script +- Update version rev +- Update __init__.py +- Enhance Codex support by auto-syncing prompt files, allowing spec generation without git, and documenting clearer /specify usage. +- Consistency tweaks +- Consistent step coloring +- Update __init__.py +- Update __init__.py +- Quick UI tweak +- Update package release +- Limit workspace command seeding to Codex init and update Codex documentation accordingly. +- Clarify Codex-specific README note with rationale for its different workflow. +- Bump to 0.0.7 and document Codex support +- Normalize Codex command templates to the scripts-based schema and auto-upgrade generated commands. +- Fix remaining merge conflict markers in __init__.py +- Add Codex CLI support with AGENTS.md and commands bootstrap + +## [0.0.45] - 2025-09-19 + +### Changed + +- Update with Windsurf support +- expose token as an argument through cli --github-token +- add github auth headers if there are GITHUB_TOKEN/GH_TOKEN set + +## [0.0.44] - 2025-09-18 + +### Changed + +- Update specify.md +- Update __init__.py + +## [0.0.43] - 2025-09-18 + +### Changed + +- Update with support for /implement + +## [0.0.42] - 2025-09-18 + +### Changed + +- Update constitution.md + +## [0.0.41] - 2025-09-18 + +### Changed + +- Update constitution.md + +## [0.0.40] - 2025-09-18 + +### Changed + +- Update constitution command + +## [0.0.39] - 2025-09-18 + +### Changed + +- Cleanup +- fix: commands format for qwen + +## [0.0.38] - 2025-09-18 + +### Changed + +- Fix template path in update-agent-context.sh +- docs: fix grammar mistakes in markdown files + +## [0.0.37] - 2025-09-17 + +### Changed + +- fix: add missing Qwen support to release workflow and agent scripts + +## [0.0.36] - 2025-09-17 + +### Changed + +- feat: Add opencode ai agent +- Fix --no-git argument resolution. + +## [0.0.35] - 2025-09-17 + +### Changed + +- chore(release): bump version to 0.0.5 and update changelog +- chore: address review feedback - remove comment and fix numbering +- feat: add Qwen Code support to Spec Kit + +## [0.0.34] - 2025-09-15 + +### Changed + +- Update template. + +## [0.0.33] - 2025-09-15 -## [0.0.20] - 2025-10-14 +### Changed -### Added +- Update scripts -- **Intelligent Branch Naming**: `create-new-feature` scripts now support `--short-name` parameter for custom branch names - - When `--short-name` provided: Uses the custom name directly (cleaned and formatted) - - When omitted: Automatically generates meaningful names using stop word filtering and length-based filtering - - Filters out common stop words (I, want, to, the, for, etc.) - - Removes words shorter than 3 characters (unless they're uppercase acronyms) - - Takes 3-4 most meaningful words from the description - - **Enforces GitHub's 244-byte branch name limit** with automatic truncation and warnings - - Examples: - - "I want to create user authentication" → `001-create-user-authentication` - - "Implement OAuth2 integration for API" → `001-implement-oauth2-integration-api` - - "Fix payment processing bug" → `001-fix-payment-processing` - - Very long descriptions are automatically truncated at word boundaries to stay within limits - - Designed for AI agents to provide semantic short names while maintaining standalone usability +## [0.0.32] - 2025-09-15 ### Changed -- Enhanced help documentation for `create-new-feature.sh` and `create-new-feature.ps1` scripts with examples -- Branch names now validated against GitHub's 244-byte limit with automatic truncation if needed +- Update template paths + +## [0.0.31] - 2025-09-15 -## [0.0.19] - 2025-10-10 +### Changed -### Added +- Update for Cursor rules & script path +- Update Specify definition +- Update README.md +- Update with video header +- fix(docs): remove redundant white space -- Support for CodeBuddy (thank you to [@lispking](https://github.com/lispking) for the contribution). -- You can now see Git-sourced errors in the Specify CLI. +## [0.0.30] - 2025-09-12 ### Changed -- Fixed the path to the constitution in `plan.md` (thank you to [@lyzno1](https://github.com/lyzno1) for spotting). -- Fixed backslash escapes in generated TOML files for Gemini (thank you to [@hsin19](https://github.com/hsin19) for the contribution). -- Implementation command now ensures that the correct ignore files are added (thank you to [@sigent-amazon](https://github.com/sigent-amazon) for the contribution). +- Update update-agent-context.ps1 + +## [0.0.29] - 2025-09-12 -## [0.0.18] - 2025-10-06 +### Changed -### Added +- Update create-release-packages.sh +- Update with check changes -- Support for using `.` as a shorthand for current directory in `specify init .` command, equivalent to `--here` flag but more intuitive for users. -- Use the `/speckit.` command prefix to easily discover Spec Kit-related commands. -- Refactor the prompts and templates to simplify their capabilities and how they are tracked. No more polluting things with tests when they are not needed. -- Ensure that tasks are created per user story (simplifies testing and validation). -- Add support for Visual Studio Code prompt shortcuts and automatic script execution. +## [0.0.28] - 2025-09-12 ### Changed -- All command files now prefixed with `speckit.` (e.g., `speckit.specify.md`, `speckit.plan.md`) for better discoverability and differentiation in IDE/CLI command palettes and file explorers +- Update wording +- Update release.yml -## [0.0.17] - 2025-09-22 +## [0.0.27] - 2025-09-12 -### Added +### Changed -- New `/clarify` command template to surface up to 5 targeted clarification questions for an existing spec and persist answers into a Clarifications section in the spec. -- New `/analyze` command template providing a non-destructive cross-artifact discrepancy and alignment report (spec, clarifications, plan, tasks, constitution) inserted after `/tasks` and before `/implement`. - - Note: Constitution rules are explicitly treated as non-negotiable; any conflict is a CRITICAL finding requiring artifact remediation, not weakening of principles. +- Support Cursor -## [0.0.16] - 2025-09-22 +## [0.0.26] - 2025-09-12 -### Added +### Changed -- `--force` flag for `init` command to bypass confirmation when using `--here` in a non-empty directory and proceed with merging/overwriting files. +- Saner approach to scripts -## [0.0.15] - 2025-09-21 +## [0.0.25] - 2025-09-12 -### Added +### Changed -- Support for Roo Code. +- Update packaging -## [0.0.14] - 2025-09-21 +## [0.0.24] - 2025-09-12 ### Changed -- Error messages are now shown consistently. +- Fix package logic -## [0.0.13] - 2025-09-21 +## [0.0.23] - 2025-09-12 -### Added +### Changed + +- Update config +- Update __init__.py +- Refactor with platform-specific constraints +- Update README.md +- Update CLI reference +- Update __init__.py +- refactor: extract Claude local path to constant for maintainability +- fix: support Claude CLI installed via migrate-installer -- Support for Kilo Code. Thank you [@shahrukhkhan489](https://github.com/shahrukhkhan489) with [#394](https://github.com/github/spec-kit/pull/394). -- Support for Auggie CLI. Thank you [@hungthai1401](https://github.com/hungthai1401) with [#137](https://github.com/github/spec-kit/pull/137). -- Agent folder security notice displayed after project provisioning completion, warning users that some agents may store credentials or auth tokens in their agent folders and recommending adding relevant folders to `.gitignore` to prevent accidental credential leakage. +## [0.0.22] - 2025-09-11 ### Changed -- Warning displayed to ensure that folks are aware that they might need to add their agent folder to `.gitignore`. -- Cleaned up the `check` command output. +- Update release.yml +- Update create-release-packages.sh +- Update create-release-packages.sh +- Update release file -## [0.0.12] - 2025-09-21 +## [0.0.21] - 2025-09-11 ### Changed -- Added additional context for OpenAI Codex users - they need to set an additional environment variable, as described in [#417](https://github.com/github/spec-kit/issues/417). +- Consolidate script creation +- Update how Copilot prompts are created +- Update local-development.md +- Local dev guide and script updates +- Update CONTRIBUTING.md +- Enhance HTTP client initialization with optional SSL verification and bump version to 0.0.3 +- Complete Gemini CLI command instructions +- Refactor HTTP client usage to utilize truststore for SSL context +- docs: Update Commands sections renaming to match implementation +- docs: Fix formatting issues in README.md for consistency +- Update docs and release -## [0.0.11] - 2025-09-20 +## [0.0.20] - 2025-09-08 -### Added +### Changed -- Codex CLI support (thank you [@honjo-hiroaki-gtt](https://github.com/honjo-hiroaki-gtt) for the contribution in [#14](https://github.com/github/spec-kit/pull/14)) -- Codex-aware context update tooling (Bash and PowerShell) so feature plans refresh `AGENTS.md` alongside existing assistants without manual edits. +- Update docs/quickstart.md +- Docs setup -## [0.0.10] - 2025-09-20 +## [0.0.19] - 2025-09-08 -### Fixed +### Changed -- Addressed [#378](https://github.com/github/spec-kit/issues/378) where a GitHub token may be attached to the request when it was empty. +- Update README.md -## [0.0.9] - 2025-09-19 +## [0.0.18] - 2025-09-08 ### Changed -- Improved agent selector UI with cyan highlighting for agent keys and gray parentheses for full names +- Update README.md -## [0.0.8] - 2025-09-19 +## [0.0.17] - 2025-09-08 -### Added +### Changed + +- Remove trailing whitespace from tasks.md template -- Windsurf IDE support as additional AI assistant option (thank you [@raedkit](https://github.com/raedkit) for the work in [#151](https://github.com/github/spec-kit/pull/151)) -- GitHub token support for API requests to handle corporate environments and rate limiting (contributed by [@zryfish](https://github.com/@zryfish) in [#243](https://github.com/github/spec-kit/pull/243)) +## [0.0.16] - 2025-09-07 ### Changed -- Updated README with Windsurf examples and GitHub token usage -- Enhanced release workflow to include Windsurf templates +- Fix release workflow to work with repository rules -## [0.0.7] - 2025-09-18 +## [0.0.15] - 2025-09-07 ### Changed -- Updated command instructions in the CLI. -- Cleaned up the code to not render agent-specific information when it's generic. +- Use `/usr/bin/env bash` instead of `/bin/bash` for shebang -## [0.0.6] - 2025-09-17 +## [0.0.14] - 2025-09-04 -### Added +### Changed -- opencode support as additional AI assistant option +- fix: correct typos in spec-driven.md -## [0.0.5] - 2025-09-17 +## [0.0.13] - 2025-09-04 -### Added +### Changed + +- Fix formatting in usage instructions + +## [0.0.12] - 2025-09-04 + +### Changed + +- Fix template path in plan command documentation + +## [0.0.11] - 2025-09-04 -- Qwen Code support as additional AI assistant option +### Changed -## [0.0.4] - 2025-09-14 +- fix: incorrect tree structure in examples -### Added +## [0.0.10] - 2025-09-04 -- SOCKS proxy support for corporate environments via `httpx[socks]` dependency +### Changed -### Fixed +- fix minor typo in Article I -N/A +## [0.0.9] - 2025-09-03 ### Changed -N/A +- Update CLI commands from '/spec' to '/specify' + +## [0.0.8] - 2025-09-02 + +### Changed + +- adding executable permission to the scripts so they execute when the coding agent launches them + +## [0.0.7] - 2025-09-02 + +### Changed + +- doco(spec-driven): Fix small typo in document + +## [0.0.6] - 2025-08-25 + +### Changed + +- Update README.md + +## [0.0.5] - 2025-08-25 + +### Changed + +- Update .github/workflows/release.yml +- Fix release workflow to work with repository rules + +## [0.0.4] - 2025-08-25 + +### Changed + +- Add John Lam as contributor and release badge + +## [0.0.3] - 2025-08-22 + +### Changed + +- Update requirements + +## [0.0.2] - 2025-08-22 + +### Changed + +- Update README.md + +## [0.0.1] - 2025-08-22 + +### Changed + +- Update release.yml + diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000000..926017a490 --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,31 @@ +cff-version: 1.2.0 +message: >- + If you use Spec Kit in your research or reference it in a paper, + please cite it using the metadata below. +type: software +title: "Spec Kit" +abstract: >- + Spec Kit is an open source toolkit for Spec-Driven Development (SDD) — + a methodology that helps software teams build high-quality software faster + by focusing on product scenarios and predictable outcomes. It provides the + Specify CLI, slash-command templates, extensions, presets, workflows, and + integrations for popular AI coding agents. +authors: + - given-names: Den + family-names: Delimarsky + alias: localden + - given-names: Manfred + family-names: Riem + alias: mnriem +repository-code: "https://github.com/github/spec-kit" +url: "https://github.github.io/spec-kit/" +license: MIT +version: "0.7.3" +date-released: "2026-04-17" +keywords: + - spec-driven development + - ai coding agents + - software engineering + - cli + - copilot + - specification diff --git a/CLAUDE.md b/CLAUDE.md index 49810965d6..1791a1e1ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,12 +1,12 @@ # spec-kit — Fellowship Fork -This is a fork of [github/spec-kit](https://github.com/github/spec-kit), intentionally diverged. We maintain our own flavor: tighter line limits, no prose, bash scripts included. +This is a fork of [github/spec-kit](https://github.com/github/spec-kit). We maintain our own flavor: tighter line limits, no prose, bash scripts included. ## Fork Status - **Upstream**: `github/spec-kit` -- **Commits behind upstream**: 252+ (intentional — we diverged significantly) -- **Do NOT auto-merge upstream** — changes must be cherry-picked manually and reviewed +- **Synced to**: v0.8.1 (2026-04-24) — 0 commits behind upstream +- **Upstream syncs**: bulk syncs are permitted under CTO review; cherry-picks for smaller updates ## Consumer Repos @@ -27,17 +27,25 @@ Consumer repos copy commands into `.claude/commands/` (e.g., `speckit.specify.md - If a consumer repo is drifted, the sync task opens a PR to update it - To update a consumer repo manually: copy updated command files into `.claude/commands/` -## Upstream Cherry-Picks +## Upstream Syncs -- The tooling crew also checks `github/spec-kit` weekly for potentially relevant commits -- A GitHub issue is created on this repo listing recommendations -- Cherry-picks are **manual only** — review carefully before applying -- Never auto-merge from upstream +- The tooling crew checks `github/spec-kit` weekly for relevant commits +- Small fixes/features: cherry-pick manually and review +- Major version bumps: bulk sync PR under CTO review is acceptable +- Consumer repos need a re-sync PR after any template changes here ## What Changed vs Upstream -- Commands cut ~80%: original ~1,400 lines → this fork ~250 lines -- Strict line limits: spec ≤50, plan ≤50, tasks ≤40 -- No prose: tasks are checkboxes, specs are bullets, plans are tables -- Bash scripts included for branch creation and plan setup -- Generic constitution template included +Templates are currently at upstream v0.8.1 line counts (re-trimming tracked in [fellowship-dev/pylot#239](https://github.com/fellowship-dev/pylot/issues/239)): + +| Template | Current | Terse target | +|----------|---------|--------------| +| `specify.md` | 327 lines | ≤50 lines | +| `plan.md` | 152 lines | ≤50 lines | +| `tasks.md` | 203 lines | ≤40 lines | +| `implement.md` | 201 lines | ≤40 lines | +| `analyze.md` | 252 lines | ≤40 lines | + +Fork additions preserved from pre-v0.8.1: +- Bash scripts for branch creation and plan setup +- Generic constitution template diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2b42e8fd61..c44063b16f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,7 +11,7 @@ These are one time installations required to be able to test your changes locall 1. Install [Python 3.11+](https://www.python.org/downloads/) 1. Install [uv](https://docs.astral.sh/uv/) for package management 1. Install [Git](https://git-scm.com/downloads) -1. Have an [AI coding agent available](README.md#-supported-ai-agents) +1. Have an [AI coding agent available](README.md#-supported-ai-coding-agent-integrations)
šŸ’” Hint if you are using VSCode or GitHub Codespaces as your IDE @@ -36,7 +36,7 @@ On [GitHub Codespaces](https://github.com/features/codespaces) it's even simpler > 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. 1. Fork and clone the repository -1. Configure and install the dependencies: `uv sync` +1. Configure and install the dependencies: `uv sync --extra test` 1. Make sure the CLI works on your machine: `uv run specify --help` 1. Create a new branch: `git checkout -b my-branch-name` 1. Make your change, add tests, and make sure everything still works @@ -44,6 +44,8 @@ On [GitHub Codespaces](https://github.com/features/codespaces) it's even simpler 1. Push to your fork and submit a pull request 1. Wait for your pull request to be reviewed and merged. +Activate the project virtual environment (see [Testing setup](#testing-setup) below), then install the CLI from your working tree (`uv pip install -e .` after `uv sync --extra test`) or otherwise ensure the shell uses the local `specify` binary before running the manual slash-command tests described below. + Here are a few things you can do that will increase the likelihood of your pull request being accepted: - Follow the project's coding conventions. @@ -62,28 +64,103 @@ When working on spec-kit: 3. Test script functionality in the `scripts/` directory 4. Ensure memory files (`memory/constitution.md`) are updated if major process changes are made -### Testing template and command changes locally +### Recommended validation flow + +For the smoothest review experience, validate changes in this order: + +1. **Run focused automated checks first** — use the quick verification commands [below](#automated-checks) to catch scaffolding and configuration regressions early. +2. **Run manual workflow tests second** — if your change affects slash commands or the developer workflow, follow the [manual testing](#manual-testing) section to choose the right commands, run them in an agent, and capture results for your PR. + +### Automated checks + +#### Agent configuration and wiring consistency + +```bash +uv run python -m pytest tests/test_agent_config_consistency.py -q +``` + +Run this when you change agent metadata, context update scripts, or integration wiring. + +### Manual testing + +#### Testing setup + +```bash +# Install the project and test dependencies from your local branch +cd +uv sync --extra test +source .venv/bin/activate # On Windows (CMD): .venv\Scripts\activate | (PowerShell): .venv\Scripts\Activate.ps1 +uv pip install -e . +# Ensure the `specify` binary in this environment points at your working tree so the agent runs the branch you're testing. + +# Initialize a test project using your local changes +uv run specify init /speckit-test --ai --offline +cd /speckit-test + +# Open in your agent +``` + +#### Manual testing process + +Any change that affects a slash command's behavior requires manually testing that command through an AI agent and submitting results with the PR. + +1. **Identify affected commands** — use the [prompt below](#determining-which-tests-to-run) to have your agent analyze your changed files and determine which commands need testing. +2. **Set up a test project** — scaffold from your local branch (see [Testing setup](#testing-setup)). +3. **Run each affected command** — invoke it in your agent, verify it completes successfully, and confirm it produces the expected output (files created, scripts executed, artifacts populated). +4. **Run prerequisites first** — commands that depend on earlier commands (e.g., `/speckit.tasks` requires `/speckit.plan` which requires `/speckit.specify`) must be run in order. +5. **Report results** — paste the [reporting template](#reporting-results) into your PR with pass/fail for each command tested. + +#### Reporting results + +Paste this into your PR: + +~~~markdown +## Manual test results + +**Agent**: [e.g., GitHub Copilot in VS Code] | **OS/Shell**: [e.g., macOS/zsh] + +| Command tested | Notes | +|----------------|-------| +| `/speckit.command` | | +~~~ + +#### Determining which tests to run + +Copy this prompt into your agent. Include the agent's response (selected tests plus a brief explanation of the mapping) in your PR. + +~~~text +Read CONTRIBUTING.md, then run `git diff --name-only main` to get my changed files. +For each changed file, determine which slash commands it affects by reading +the command templates in templates/commands/ to understand what each command +invokes. Use these mapping rules: -Running `uv run specify init` pulls released packages, which won’t include your local changes. -To test your templates, commands, and other changes locally, follow these steps: +- templates/commands/X.md → the command it defines +- scripts/bash/Y.sh or scripts/powershell/Y.ps1 → every command that invokes that script (grep templates/commands/ for the script name). Also check transitive dependencies: if the changed script is sourced by other scripts (e.g., common.sh is sourced by create-new-feature.sh, check-prerequisites.sh, setup-plan.sh, update-agent-context.sh), then every command invoking those downstream scripts is also affected +- templates/Z-template.md → every command that consumes that template during execution +- src/specify_cli/*.py → CLI commands (`specify init`, `specify check`, `specify extension *`, `specify preset *`); test the affected CLI command and, for init/scaffolding changes, at minimum test /speckit.specify +- extensions/X/commands/* → the extension command it defines +- extensions/X/scripts/* → every extension command that invokes that script +- extensions/X/extension.yml or config-template.yml → every command in that extension. Also check if the manifest defines hooks (look for `hooks:` entries like `before_specify`, `after_implement`, etc.) — if so, the core commands those hooks attach to are also affected +- presets/*/* → test preset scaffolding via `specify init` with the preset +- pyproject.toml → packaging/bundling; test `specify init` and verify bundled assets -1. **Create release packages** +Include prerequisite tests (e.g., T5 requires T3 requires T1). - Run the following command to generate the local packages: +Output in this format: - ```bash - ./.github/workflows/scripts/create-release-packages.sh v1.0.0 - ``` +### Test selection reasoning -2. **Copy the relevant package to your test project** +| Changed file | Affects | Test | Why | +|---|---|---|---| +| (path) | (command) | T# | (reason) | - ```bash - cp -r .genreleases/sdd-copilot-package-sh/. / - ``` +### Required tests -3. **Open and test the agent** +Number each test sequentially (T1, T2, ...). List prerequisite tests first. - Navigate to your test project folder and open the agent to verify your implementation. +- T1: /speckit.command — (reason) +- T2: /speckit.command — (reason) +~~~ ## AI contributions in Spec Kit diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000000..946e071e31 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,24 @@ +# Development Notes + +Spec Kit is a toolkit for spec-driven development. At its core, it is a coordinated set of prompts, templates, scripts, and CLI/integration assets that define and deliver a spec-driven workflow for AI coding agents. This document is a starting point for people modifying Spec Kit itself, with a compact orientation to the key project documents and repository organization. + +**Essential project documents:** + +| Document | Role | +| ---------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| [README.md](README.md) | Primary user-facing overview of Spec Kit and its workflow. | +| [DEVELOPMENT.md](DEVELOPMENT.md) | This document. | +| [spec-driven.md](spec-driven.md) | End-to-end explanation of the Spec-Driven Development workflow supported by Spec Kit. | +| [RELEASE-PROCESS.md](.github/workflows/RELEASE-PROCESS.md) | Release workflow, versioning rules, and changelog generation process. | +| [docs/index.md](docs/index.md) | Entry point to the `docs/` documentation set. | +| [CONTRIBUTING.md](CONTRIBUTING.md) | Contribution process, review expectations, testing, and required development practices. | + +**Main repository components:** + +| Directory | Role | +| ------------------ | ------------------------------------------------------------------------------------------- | +| `templates/` | Prompt assets and templates that define the core workflow behavior and generated artifacts. | +| `scripts/` | Supporting scripts used by the workflow, setup, and repository tooling. | +| `src/specify_cli/` | Python source for the `specify` CLI, including agent-specific assets. | +| `extensions/` | Extension-related docs, catalogs, and supporting assets. | +| `presets/` | Preset-related docs, catalogs, and supporting assets. | diff --git a/README.md b/README.md index 4a1ae9c9bb..b877d3744a 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,764 @@ -# Spec Kit (Fellowship Fork) +
+ Spec Kit Logo +

🌱 Spec Kit

+

Build high-quality software faster.

+
-Terse, opinionated fork of [github/spec-kit](https://github.com/github/spec-kit). +

+ An open source toolkit that allows you to focus on product scenarios and predictable outcomes instead of vibe coding every piece from scratch. +

-## What Changed +

+ Latest Release + GitHub stars + License + Documentation +

-- **Commands cut ~80%**: Original was ~1,400 lines across 9 commands. This fork: ~250 lines. -- **Strict line limits**: spec ≤50, plan ≤50, tasks ≤40 -- **No prose**: Tasks are checkboxes. Specs are bullets. Plans are tables. -- **Bash scripts included**: Branch creation, prerequisite checks, plan setup — all project-agnostic. -- **Generic constitution template**: Fill in your project's principles. +--- -## Installation +## Table of Contents -Copy into your project: +- [šŸ¤” What is Spec-Driven Development?](#-what-is-spec-driven-development) +- [⚔ Get Started](#-get-started) +- [šŸ“½ļø Video Overview](#ļø-video-overview) +- [🧩 Community Extensions](#-community-extensions) +- [šŸŽØ Community Presets](#-community-presets) +- [🚶 Community Walkthroughs](#-community-walkthroughs) +- [šŸ› ļø Community Friends](#ļø-community-friends) +- [šŸ¤– Supported AI Coding Agent Integrations](#-supported-ai-coding-agent-integrations) +- [šŸ”§ Specify CLI Reference](#-specify-cli-reference) +- [🧩 Making Spec Kit Your Own: Extensions & Presets](#-making-spec-kit-your-own-extensions--presets) +- [šŸ“š Core Philosophy](#-core-philosophy) +- [🌟 Development Phases](#-development-phases) +- [šŸŽÆ Experimental Goals](#-experimental-goals) +- [šŸ”§ Prerequisites](#-prerequisites) +- [šŸ“– Learn More](#-learn-more) +- [šŸ“‹ Detailed Process](#-detailed-process) +- [šŸ” Troubleshooting](#-troubleshooting) +- [šŸ’¬ Support](#-support) +- [šŸ™ Acknowledgements](#-acknowledgements) +- [šŸ“„ License](#-license) + +## šŸ¤” What is Spec-Driven Development? + +Spec-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. + +## ⚔ Get Started + +### 1. Install Specify CLI + +Choose your preferred installation method: + +> **Important:** The only official, maintained packages for Spec Kit are published from this GitHub repository. Any packages with the same name on PyPI are **not** affiliated with this project and are not maintained by the Spec Kit maintainers. Always install directly from GitHub as shown below. + +#### Option 1: Persistent Installation (Recommended) + +Install once and use everywhere. Pin a specific release tag for stability (check [Releases](https://github.com/github/spec-kit/releases) for the latest): + +```bash +# Install a specific stable release (recommended — replace vX.Y.Z with the latest tag) +uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX.Y.Z + +# Or install latest from main (may include unreleased changes) +uv tool install specify-cli --from git+https://github.com/github/spec-kit.git + +# Alternative: using pipx (also works) +pipx install git+https://github.com/github/spec-kit.git@vX.Y.Z +pipx install git+https://github.com/github/spec-kit.git +``` + +Then verify the correct version is installed: + +```bash +specify version +``` + +And use the tool directly: + +```bash +# Create new project +specify init + +# Or initialize in existing project +specify init . --ai copilot +# or +specify init --here --ai copilot + +# Check installed tools +specify check +``` + +To upgrade Specify, see the [Upgrade Guide](./docs/upgrade.md) for detailed instructions. Quick upgrade: + +```bash +uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z +# pipx users: pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z +``` + +#### Option 2: One-time Usage + +Run directly without installing: + +```bash +# Create new project (pinned to a stable release — replace vX.Y.Z with the latest tag) +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init + +# Or initialize in existing project +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init . --ai copilot +# or +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --ai copilot +``` + +**Benefits of persistent installation:** + +- Tool stays installed and available in PATH +- No need to create shell aliases +- Better tool management with `uv tool list`, `uv tool upgrade`, `uv tool uninstall` +- Cleaner shell configuration + +#### Option 3: Enterprise / Air-Gapped Installation + +If your environment blocks access to PyPI or GitHub, see the [Enterprise / Air-Gapped Installation](./docs/installation.md#enterprise--air-gapped-installation) guide for step-by-step instructions on using `pip download` to create portable, OS-specific wheel bundles on a connected machine. + +### 2. Establish project principles + +Launch your AI assistant in the project directory. Most agents expose spec-kit as `/speckit.*` slash commands; Codex CLI in skills mode uses `$speckit-*` instead. + +Use the **`/speckit.constitution`** command to create your project's governing principles and development guidelines that will guide all subsequent development. + +```bash +/speckit.constitution Create principles focused on code quality, testing standards, user experience consistency, and performance requirements +``` + +### 3. Create the spec + +Use the **`/speckit.specify`** command to describe what you want to build. Focus on the **what** and **why**, not the tech stack. + +```bash +/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. +``` + +### 4. Create a technical implementation plan + +Use the **`/speckit.plan`** command to provide your tech stack and architecture choices. + +```bash +/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. +``` + +### 5. Break down into tasks + +Use **`/speckit.tasks`** to create an actionable task list from your implementation plan. + +```bash +/speckit.tasks +``` + +### 6. Execute implementation + +Use **`/speckit.implement`** to execute all tasks and build your feature according to the plan. + +```bash +/speckit.implement +``` + +For detailed step-by-step instructions, see our [comprehensive guide](./spec-driven.md). + +## šŸ“½ļø Video Overview + +Want to see Spec Kit in action? Watch our [video overview](https://www.youtube.com/watch?v=a9eR1xsfvHg&pp=0gcJCckJAYcqIYzv)! + +[![Spec Kit video header](/media/spec-kit-video-header.jpg)](https://www.youtube.com/watch?v=a9eR1xsfvHg&pp=0gcJCckJAYcqIYzv) + +## 🧩 Community Extensions + +> [!NOTE] +> Community extensions are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting, catalog structure, or policy compliance, but they do **not review, audit, endorse, or support the extension code itself**. The Community Extensions website is also a third-party resource. Review extension source code before installation and use at your own discretion. + +šŸ” **Browse and search community extensions on the [Community Extensions website](https://speckit-community.github.io/extensions/).** + +The following community-contributed extensions are available in [`catalog.community.json`](extensions/catalog.community.json): + +**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 + +**Effect:** + +- `Read-only` — produces reports without modifying files +- `Read+Write` — modifies files, creates artifacts, or updates specs + +| Extension | Purpose | Category | Effect | URL | +|-----------|---------|----------|--------|-----| +| Agent Assign | Assign specialized Claude Code agents to spec-kit tasks for targeted execution | `process` | Read+Write | [spec-kit-agent-assign](https://github.com/xymelon/spec-kit-agent-assign) | +| AI-Driven Engineering (AIDE) | A structured 7-step workflow for building new projects from scratch with AI assistants — from vision through implementation | `process` | Read+Write | [aide](https://github.com/mnriem/spec-kit-extensions/tree/main/aide) | +| Architect Impact Previewer | Predicts architectural impact, complexity, and risks of proposed changes before implementation. | `visibility` | Read-only | [spec-kit-architect-preview](https://github.com/UmmeHabiba1312/spec-kit-architect-preview) | +| Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) | +| 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) | +| Blueprint | Stay code-literate in AI-driven development: review a complete code blueprint for every task from spec artifacts before /speckit.implement runs | `docs` | Read+Write | [spec-kit-blueprint](https://github.com/chordpli/spec-kit-blueprint) | +| Branch Convention | Configurable branch and folder naming conventions for /specify with presets and custom patterns | `process` | Read+Write | [spec-kit-branch-convention](https://github.com/Quratulain-bilal/spec-kit-branch-convention) | +| Brownfield Bootstrap | Bootstrap spec-kit for existing codebases — auto-discover architecture and adopt SDD incrementally | `process` | Read+Write | [spec-kit-brownfield](https://github.com/Quratulain-bilal/spec-kit-brownfield) | +| Bugfix Workflow | Structured bugfix workflow — capture bugs, trace to spec artifacts, and patch specs surgically | `process` | Read+Write | [spec-kit-bugfix](https://github.com/Quratulain-bilal/spec-kit-bugfix) | +| Canon | Adds canon-driven (baseline-driven) workflows: spec-first, code-first, spec-drift. Requires Canon Core preset installation. | `process` | Read+Write | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon/tree/master/extension) | +| Catalog CI | Automated validation for spec-kit community catalog entries — structure, URLs, diffs, and linting | `process` | Read-only | [spec-kit-catalog-ci](https://github.com/Quratulain-bilal/spec-kit-catalog-ci) | +| CI Guard | Spec compliance gates for CI/CD — verify specs exist, check drift, and block merges on gaps | `process` | Read-only | [spec-kit-ci-guard](https://github.com/Quratulain-bilal/spec-kit-ci-guard) | +| Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) | +| 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) | +| 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) | +| Confluence Extension | Create a doc in Confluence summarizing the specifications and planning files | `integration` | Read+Write | [spec-kit-confluence](https://github.com/aaronrsun/spec-kit-confluence) | +| 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) | +| Extensify | Create and validate extensions and extension catalogs | `process` | Read+Write | [extensify](https://github.com/mnriem/spec-kit-extensions/tree/main/extensify) | +| Fix Findings | Automated analyze-fix-reanalyze loop that resolves spec findings until clean | `code` | Read+Write | [spec-kit-fix-findings](https://github.com/Quratulain-bilal/spec-kit-fix-findings) | +| FixIt Extension | Spec-aware bug fixing — maps bugs to spec artifacts, proposes a plan, applies minimal changes | `code` | Read+Write | [spec-kit-fixit](https://github.com/speckit-community/spec-kit-fixit) | +| 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) | +| GitHub Issues Integration 1 | Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability | `integration` | Read+Write | [spec-kit-github-issues](https://github.com/Fatima367/spec-kit-github-issues) | +| GitHub Issues Integration 2 | Creates and syncs local specs from an existing GitHub issue | `integration` | Read+Write | [spec-kit-issue](https://github.com/aaronrsun/spec-kit-issue) | +| 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) | +| 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) | +| 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) | +| MAQA — Multi-Agent & Quality Assurance | Coordinator → feature → QA agent workflow with parallel worktree-based implementation. Language-agnostic. Auto-detects installed board plugins. Optional CI gate. | `process` | Read+Write | [spec-kit-maqa-ext](https://github.com/GenieRobot/spec-kit-maqa-ext) | +| MAQA Azure DevOps Integration | Azure DevOps Boards integration for MAQA — syncs User Stories and Task children as features progress | `integration` | Read+Write | [spec-kit-maqa-azure-devops](https://github.com/GenieRobot/spec-kit-maqa-azure-devops) | +| MAQA CI/CD Gate | Auto-detects GitHub Actions, CircleCI, GitLab CI, and Bitbucket Pipelines. Blocks QA handoff until pipeline is green. | `process` | Read+Write | [spec-kit-maqa-ci](https://github.com/GenieRobot/spec-kit-maqa-ci) | +| MAQA GitHub Projects Integration | GitHub Projects v2 integration for MAQA — syncs draft issues and Status columns as features progress | `integration` | Read+Write | [spec-kit-maqa-github-projects](https://github.com/GenieRobot/spec-kit-maqa-github-projects) | +| MAQA Jira Integration | Jira integration for MAQA — syncs Stories and Subtasks as features progress through the board | `integration` | Read+Write | [spec-kit-maqa-jira](https://github.com/GenieRobot/spec-kit-maqa-jira) | +| MAQA Linear Integration | Linear integration for MAQA — syncs issues and sub-issues across workflow states as features progress | `integration` | Read+Write | [spec-kit-maqa-linear](https://github.com/GenieRobot/spec-kit-maqa-linear) | +| MAQA Trello Integration | Trello board integration for MAQA — populates board from specs, moves cards, real-time checklist ticking | `integration` | Read+Write | [spec-kit-maqa-trello](https://github.com/GenieRobot/spec-kit-maqa-trello) | +| Memory Loader | Loads .specify/memory/ files before lifecycle commands so LLM agents have project governance context | `docs` | Read-only | [spec-kit-memory-loader](https://github.com/KevinBrown5280/spec-kit-memory-loader) | +| Memory MD | Repository-native durable memory for Spec Kit projects | `docs` | Read+Write | [spec-kit-memory-hub](https://github.com/DyanGalih/spec-kit-memory-hub) | +| MemoryLint | Agent memory governance tool: Automatically audits and fixes boundary conflicts between AGENTS.md and the constitution. | `process` | Read+Write | [memorylint](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint) | +| Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) | +| Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) | +| Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) | +| PR Bridge | Auto-generate pull request descriptions, checklists, and summaries from spec artifacts | `process` | Read-only | [spec-kit-pr-bridge-](https://github.com/Quratulain-bilal/spec-kit-pr-bridge-) | +| Presetify | Create and validate presets and preset catalogs | `process` | Read+Write | [presetify](https://github.com/mnriem/spec-kit-extensions/tree/main/presetify) | +| Product Forge | Full product lifecycle from research to release — portfolio, lite mode, monorepo, optional V-Model | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) | +| 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) | +| 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) | +| QA Testing Extension | Systematic QA testing with browser-driven or CLI-based validation of acceptance criteria from spec | `code` | Read-only | [spec-kit-qa](https://github.com/arunt14/spec-kit-qa) | +| Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss/spec-kit-ralph) | +| Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | `docs` | Read+Write | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) | +| Red Team | Adversarial review of specs before /speckit.plan — parallel lens agents surface risks that clarify/analyze structurally can't (prompt injection, integrity gaps, cross-spec drift, silent failures). Produces a structured findings report; no auto-edits to specs. | `docs` | Read+Write | [spec-kit-red-team](https://github.com/ashbrener/spec-kit-red-team) | +| Repository Index | Generate index for existing repo for overview, architecture and module level. | `docs` | Read-only | [spec-kit-repoindex](https://github.com/liuyiyu/spec-kit-repoindex) | +| Retro Extension | Sprint retrospective analysis with metrics, spec accuracy assessment, and improvement suggestions | `process` | Read+Write | [spec-kit-retro](https://github.com/arunt14/spec-kit-retro) | +| 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) | +| 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) | +| Ripple | Detect side effects that tests can't catch after implementation — delta-anchored analysis across 9 domain-agnostic categories | `code` | Read+Write | [spec-kit-ripple](https://github.com/chordpli/spec-kit-ripple) | +| 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) | +| Security Review | Comprehensive security audit of codebases using AI-powered DevSecOps analysis | `code` | Read-only | [spec-kit-security-review](https://github.com/DyanGalih/spec-kit-security-review) | +| SFSpeckit | Enterprise Salesforce SDLC with 18 commands for the full SDD lifecycle. | `process` | Read+Write | [spec-kit-sf](https://github.com/ysumanth06/spec-kit-sf) | +| Ship Release Extension | Automates release pipeline: pre-flight checks, branch sync, changelog generation, CI verification, and PR creation | `process` | Read+Write | [spec-kit-ship](https://github.com/arunt14/spec-kit-ship) | +| Spec Reference Loader | Reads the ## References section from the feature spec and loads only the listed docs into context | `docs` | Read-only | [spec-kit-spec-reference-loader](https://github.com/KevinBrown5280/spec-kit-spec-reference-loader) | +| Spec Critique Extension | Dual-lens critical review of spec and plan from product strategy and engineering risk perspectives | `docs` | Read-only | [spec-kit-critique](https://github.com/arunt14/spec-kit-critique) | +| Spec Diagram | Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies | `visibility` | Read-only | [spec-kit-diagram-](https://github.com/Quratulain-bilal/spec-kit-diagram-) | +| Spec Refine | Update specs in-place, propagate changes to plan and tasks, and diff impact across artifacts | `process` | Read+Write | [spec-kit-refine](https://github.com/Quratulain-bilal/spec-kit-refine) | +| Spec Scope | Effort estimation and scope tracking — estimate work, detect creep, and budget time per phase | `process` | Read-only | [spec-kit-scope-](https://github.com/Quratulain-bilal/spec-kit-scope-) | +| 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) | +| Spec Validate | Comprehension validation, review gating, and approval state for spec-kit artifacts — staged quizzes, peer review SLA, and a hard gate before /speckit.implement | `process` | Read+Write | [spec-kit-spec-validate](https://github.com/aeltayeb/spec-kit-spec-validate) | +| SpecTest | Auto-generate test scaffolds from spec criteria, map coverage, and find untested requirements | `code` | Read+Write | [spec-kit-spectest](https://github.com/Quratulain-bilal/spec-kit-spectest) | +| Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) | +| Status Report | Project status, feature progress, and next-action recommendations for spec-driven workflows | `visibility` | Read-only | [Open-Agent-Tools/spec-kit-status](https://github.com/Open-Agent-Tools/spec-kit-status) | +| Superpowers Bridge | Orchestrates obra/superpowers skills within the spec-kit SDD workflow across the full lifecycle (clarification, TDD, review, verification, critique, debugging, branch completion) | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) | +| Superpowers Bridge (WangX0111) | Bridges spec-kit with obra/superpowers (brainstorming, TDD, subagent, code-review) into a unified, resumable workflow with graceful degradation and session progress tracking | `process` | Read+Write | [superspec](https://github.com/WangX0111/superspec) | +| TinySpec | Lightweight single-file workflow for small tasks — skip the heavy multi-step SDD process | `process` | Read+Write | [spec-kit-tinyspec](https://github.com/Quratulain-bilal/spec-kit-tinyspec) | +| 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) | +| 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) | +| 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) | +| Version Guard | Verify tech stack versions against live npm registries before planning and implementation | `process` | Read-only | [spec-kit-version-guard](https://github.com/KevinBrown5280/spec-kit-version-guard) | +| What-if Analysis | Preview the downstream impact (complexity, effort, tasks, risks) of requirement changes before committing to them | `visibility` | Read-only | [spec-kit-whatif](https://github.com/DevAbdullah90/spec-kit-whatif) | +| Wireframe Visual Feedback Loop | SVG wireframe generation, review, and sign-off for spec-driven development. Approved wireframes become spec constraints honored by /speckit.plan, /speckit.tasks, and /speckit.implement | `visibility` | Read+Write | [spec-kit-extension-wireframe](https://github.com/TortoiseWolfe/spec-kit-extension-wireframe) | +| Worktree Isolation | Spawn isolated git worktrees for parallel feature development without checkout switching | `process` | Read+Write | [spec-kit-worktree](https://github.com/Quratulain-bilal/spec-kit-worktree) | +| Worktrees | Default-on worktree isolation for parallel agents — sibling or nested layout | `process` | Read+Write | [spec-kit-worktree-parallel](https://github.com/dango85/spec-kit-worktree-parallel) | + +To submit your own extension, see the [Extension Publishing Guide](extensions/EXTENSION-PUBLISHING-GUIDE.md). + +## šŸŽØ Community Presets + +Community-contributed presets customize how Spec Kit behaves — overriding templates, commands, and terminology without changing any tooling. See the full list on the [Community Presets](https://github.github.io/spec-kit/community/presets.html) page. + +> [!NOTE] +> Community presets are third-party contributions and are not maintained by the Spec Kit team. Review them carefully before use, and see the docs page above for the full disclaimer. + +To submit your own preset, see the [Presets Publishing Guide](presets/PUBLISHING.md). + +## 🚶 Community Walkthroughs + +See Spec-Driven Development in action across different scenarios with community-contributed walkthroughs; find the full list on the [Community Walkthroughs](https://github.github.io/spec-kit/community/walkthroughs.html) page. + +## šŸ› ļø Community Friends + +Community projects that extend, visualize, or build on Spec Kit. See the full list on the [Community Friends](https://github.github.io/spec-kit/community/friends.html) page. + +## šŸ¤– Supported AI Coding Agent Integrations + +Spec Kit works with 30+ AI coding agents — both CLI tools and IDE-based assistants. See the full list with notes and usage details in the [Supported AI Coding Agent Integrations](https://github.github.io/spec-kit/reference/integrations.html) guide. + +Run `specify integration list` to see all available integrations in your installed version. + +## Available Slash Commands + +After running `specify init`, your AI coding agent will have access to these slash commands for structured development. If you pass `--ai --ai-skills`, Spec Kit installs agent skills instead of slash-command prompt files; `--ai-skills` requires `--ai`. + +#### Core Commands + +Essential commands for the Spec-Driven Development workflow: + +| Command | Agent Skill | Description | +| ------------------------ | ---------------------- | -------------------------------------------------------------------------- | +| `/speckit.constitution` | `speckit-constitution` | Create or update project governing principles and development guidelines | +| `/speckit.specify` | `speckit-specify` | Define what you want to build (requirements and user stories) | +| `/speckit.plan` | `speckit-plan` | Create technical implementation plans with your chosen tech stack | +| `/speckit.tasks` | `speckit-tasks` | Generate actionable task lists for implementation | +| `/speckit.taskstoissues` | `speckit-taskstoissues`| Convert generated task lists into GitHub issues for tracking and execution | +| `/speckit.implement` | `speckit-implement` | Execute all tasks to build the feature according to the plan | + +#### Optional Commands + +Additional commands for enhanced quality and validation: + +| Command | Agent Skill | Description | +| -------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `/speckit.clarify` | `speckit-clarify` | Clarify underspecified areas (recommended before `/speckit.plan`; formerly `/quizme`) | +| `/speckit.analyze` | `speckit-analyze` | Cross-artifact consistency & coverage analysis (run after `/speckit.tasks`, before `/speckit.implement`) | +| `/speckit.checklist` | `speckit-checklist` | Generate custom quality checklists that validate requirements completeness, clarity, and consistency (like "unit tests for English") | + +## šŸ”§ Specify CLI Reference + +For full command details, options, and examples, see the [CLI Reference](https://github.github.io/spec-kit/reference/overview.html). + +## 🧩 Making Spec Kit Your Own: Extensions & Presets + +Spec Kit can be tailored to your needs through two complementary systems — **extensions** and **presets** — plus project-local overrides for one-off adjustments: + +| Priority | Component Type | Location | +| -------: | ------------------------------------------------- | -------------------------------- | +| ⬆ 1 | Project-Local Overrides | `.specify/templates/overrides/` | +| 2 | Presets — Customize core & extensions | `.specify/presets/templates/` | +| 3 | Extensions — Add new capabilities | `.specify/extensions/templates/` | +| ⬇ 4 | Spec Kit Core — Built-in SDD commands & templates | `.specify/templates/` | + +- **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. +- **Extension/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. + +### Extensions — Add New Capabilities + +Use **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*. + +```bash +# Search available extensions +specify extension search + +# Install an extension +specify extension add +``` + +For example, extensions could add Jira integration, post-implementation code review, V-Model test traceability, or project health diagnostics. + +See the [Extensions reference](https://github.github.io/spec-kit/reference/extensions.html) for the full command guide. Browse the [community extensions](#-community-extensions) above for what's available. + +### Presets — Customize Existing Workflows + +Use **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. + +```bash +# Search available presets +specify preset search + +# Install a preset +specify preset add +``` + +For 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. + +See the [Presets reference](https://github.github.io/spec-kit/reference/presets.html) for the full command guide, including resolution order and priority stacking. + +### When to Use Which + +| Goal | Use | +| --- | --- | +| Add a brand-new command or workflow | Extension | +| Customize the format of specs, plans, or tasks | Preset | +| Integrate an external tool or service | Extension | +| Enforce organizational or regulatory standards | Preset | +| Ship reusable domain-specific templates | Either — presets for template overrides, extensions for templates bundled with new commands | + +## šŸ“š Core Philosophy + +Spec-Driven Development is a structured process that emphasizes: + +- **Intent-driven development** where specifications define the "*what*" before the "*how*" +- **Rich specification creation** using guardrails and organizational principles +- **Multi-step refinement** rather than one-shot code generation from prompts +- **Heavy reliance** on advanced AI model capabilities for specification interpretation + +## 🌟 Development Phases + +| Phase | Focus | Key Activities | +| ---------------------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **0-to-1 Development** ("Greenfield") | Generate from scratch |
  • Start with high-level requirements
  • Generate specifications
  • Plan implementation steps
  • Build production-ready applications
| +| **Creative Exploration** | Parallel implementations |
  • Explore diverse solutions
  • Support multiple technology stacks & architectures
  • Experiment with UX patterns
| +| **Iterative Enhancement** ("Brownfield") | Brownfield modernization |
  • Add features iteratively
  • Modernize legacy systems
  • Adapt processes
| + +## šŸŽÆ Experimental Goals + +Our research and experimentation focus on: + +### Technology independence + +- Create applications using diverse technology stacks +- Validate the hypothesis that Spec-Driven Development is a process not tied to specific technologies, programming languages, or frameworks + +### Enterprise constraints + +- Demonstrate mission-critical application development +- Incorporate organizational constraints (cloud providers, tech stacks, engineering practices) +- Support enterprise design systems and compliance requirements + +### User-centric development + +- Build applications for different user cohorts and preferences +- Support various development approaches (from vibe-coding to AI-native development) + +### Creative & iterative processes + +- Validate the concept of parallel implementation exploration +- Provide robust iterative feature development workflows +- Extend processes to handle upgrades and modernization tasks + +## šŸ”§ Prerequisites + +- **Linux/macOS/Windows** +- [Supported](#-supported-ai-coding-agent-integrations) AI coding agent. +- [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pypa.github.io/pipx/) for persistent installation +- [Python 3.11+](https://www.python.org/downloads/) +- [Git](https://git-scm.com/downloads) + +If you encounter issues with an agent, please open an issue so we can refine the integration. + +## šŸ“– Learn More + +- **[Complete Spec-Driven Development Methodology](./spec-driven.md)** - Deep dive into the full process +- **[Detailed Walkthrough](#-detailed-process)** - Step-by-step implementation guide + +--- + +## šŸ“‹ Detailed Process + +
+Click to expand the detailed step-by-step walkthrough + +You can use the Specify CLI to bootstrap your project, which will bring in the required artifacts in your environment. Run: + +```bash +specify init +``` + +Or initialize in the current directory: + +```bash +specify init . +# or use the --here flag +specify init --here +# Skip confirmation when the directory already has files +specify init . --force +# or +specify init --here --force +``` + +![Specify CLI bootstrapping a new project in the terminal](./media/specify_cli.gif) + +You will be prompted to select the AI agent you are using. You can also proactively specify it directly in the terminal: ```bash -# Commands (Claude Code slash commands) -cp -r templates/commands/ .claude/commands/ -# Rename: speckit.specify.md, speckit.plan.md, etc. -for f in .claude/commands/*.md; do - mv "$f" ".claude/commands/speckit.$(basename "$f")" -done +specify init --ai copilot +specify init --ai gemini +specify init --ai copilot + +# Or in current directory: +specify init . --ai copilot +specify init . --ai codex --ai-skills + +# or use --here flag +specify init --here --ai copilot +specify init --here --ai codex --ai-skills + +# Force merge into a non-empty current directory +specify init . --force --ai copilot + +# or +specify init --here --force --ai copilot +``` + +The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi, Forge, Goose, 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: + +```bash +specify init --ai copilot --ignore-agent-tools +``` + +### **STEP 1:** Establish project principles + +Go to the project folder and run your AI agent. In our example, we're using `claude`. -# Scripts + templates + constitution -mkdir -p .specify/{scripts/bash,templates,memory} -cp scripts/bash/* .specify/scripts/bash/ -cp templates/spec-template.md templates/plan-template.md templates/tasks-template.md templates/checklist-template.md .specify/templates/ -cp templates/constitution-template.md .specify/memory/constitution.md -# Edit constitution.md for your project +![Bootstrapping Claude Code environment](./media/bootstrap-claude-code.gif) + +You will know that things are configured correctly if you see the `/speckit.constitution`, `/speckit.specify`, `/speckit.plan`, `/speckit.tasks`, and `/speckit.implement` commands available. + +The 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: + +```text +/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. ``` -## Workflow +This 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. + +### **STEP 2:** Create project specifications +With 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. + +> [!IMPORTANT] +> Be as explicit as possible about *what* you are trying to build and *why*. **Do not focus on the tech stack at this point**. + +An example prompt: + +```text +Develop Taskify, a team productivity platform. It should allow users to create projects, add team members, +assign tasks, comment and move tasks between boards in Kanban style. In this initial phase for this feature, +let's call it "Create Taskify," let's have multiple users but the users will be declared ahead of time, predefined. +I want five users in two different categories, one product manager and four engineers. Let's create three +different sample projects. Let's have the standard Kanban columns for the status of each task, such as "To Do," +"In Progress," "In Review," and "Done." There will be no login for this application as this is just the very +first testing thing to ensure that our basic features are set up. 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. 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. ``` -/speckit.specify 42 → spec.md (≤50 lines) -/speckit.plan → plan.md (≤50 lines) -/speckit.tasks → tasks.md (≤40 lines) -/speckit.implement → code + commits -/speckit.analyze → consistency check -/speckit.checklist → quality validation + +After 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. + +Once 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. + +The produced specification should contain a set of user stories and functional requirements, as defined in the template. + +At this stage, your project folder contents should resemble the following: + +```text +└── .specify + ā”œā”€ā”€ memory + │ └── constitution.md + ā”œā”€ā”€ scripts + │ ā”œā”€ā”€ check-prerequisites.sh + │ ā”œā”€ā”€ common.sh + │ ā”œā”€ā”€ create-new-feature.sh + │ ā”œā”€ā”€ setup-plan.sh + │ └── update-claude-md.sh + ā”œā”€ā”€ specs + │ └── 001-create-taskify + │ └── spec.md + └── templates + ā”œā”€ā”€ plan-template.md + ā”œā”€ā”€ spec-template.md + └── tasks-template.md ``` -## Original +### **STEP 3:** Functional specification clarification (required before planning) + +With the baseline specification created, you can go ahead and clarify any of the requirements that were not captured properly within the first shot attempt. + +You should run the structured clarification workflow **before** creating a technical plan to reduce rework downstream. + +Preferred order: + +1. Use `/speckit.clarify` (structured) – sequential, coverage-based questioning that records answers in a Clarifications section. +2. Optionally follow up with ad-hoc free-form refinement if something still feels vague. + +If you intentionally want to skip clarification (e.g., spike or exploratory prototype), explicitly state that so the agent doesn't block on missing clarifications. + +Example free-form refinement prompt (after `/speckit.clarify` if still needed): + +```text +For each sample project or project that you create there should be a variable number of tasks between 5 and 15 +tasks for each one randomly distributed into different states of completion. Make sure that there's at least +one task in each stage of completion. +``` + +You 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: + +```text +Read 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. +``` + +It'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**. + +### **STEP 4:** Generate a plan + +You 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: + +```text +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. +``` + +The output of this step will include a number of implementation detail documents, with your directory tree resembling this: + +```text +. +ā”œā”€ā”€ CLAUDE.md +ā”œā”€ā”€ memory +│ └── constitution.md +ā”œā”€ā”€ scripts +│ ā”œā”€ā”€ check-prerequisites.sh +│ ā”œā”€ā”€ common.sh +│ ā”œā”€ā”€ create-new-feature.sh +│ ā”œā”€ā”€ setup-plan.sh +│ └── update-claude-md.sh +ā”œā”€ā”€ specs +│ └── 001-create-taskify +│ ā”œā”€ā”€ contracts +│ │ ā”œā”€ā”€ api-spec.json +│ │ └── signalr-spec.md +│ ā”œā”€ā”€ data-model.md +│ ā”œā”€ā”€ plan.md +│ ā”œā”€ā”€ quickstart.md +│ ā”œā”€ā”€ research.md +│ └── spec.md +└── templates + ā”œā”€ā”€ CLAUDE-template.md + ā”œā”€ā”€ plan-template.md + ā”œā”€ā”€ spec-template.md + └── tasks-template.md +``` + +Check 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). + +Additionally, 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: + +```text +I want you to go through the implementation plan and implementation details, looking for areas that could +benefit from additional research as .NET Aspire is a rapidly changing library. For those areas that you identify that +require further research, I want you to update the research document with additional details about the specific +versions that we are going to be using in this Taskify application and spawn parallel research tasks to clarify +any details using research from the web. +``` + +During 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: + +```text +I think we need to break this down into a series of steps. First, identify a list of tasks +that you would need to do during implementation that you're not sure of or would benefit +from further research. Write down a list of those tasks. And then for each one of these tasks, +I want you to spin up a separate research task so that the net results is we are researching +all of those very specific tasks in parallel. What I saw you doing was it looks like you were +researching .NET Aspire in general and I don't think that's gonna do much for us in this case. +That's way too untargeted research. The research needs to help you solve a specific targeted question. +``` + +> [!NOTE] +> 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. + +### **STEP 5:** Have Claude Code validate the plan + +With 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: + +```text +Now I want you to go and audit the implementation plan and the implementation detail files. +Read through it with an eye on determining whether or not there is a sequence of tasks that you need +to be doing that are obvious from reading this. Because I don't know if there's enough here. For example, +when I look at the core implementation, it would be useful to reference the appropriate places in the implementation +details where it can find the information as it walks through each step in the core implementation or in the refinement. +``` + +This 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. + +You 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. + +> [!NOTE] +> 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. + +### **STEP 6:** Generate task breakdown with /speckit.tasks + +With 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: + +```text +/speckit.tasks +``` + +This step creates a `tasks.md` file in your feature specification directory that contains: + +- **Task breakdown organized by user story** - Each user story becomes a separate implementation phase with its own set of tasks +- **Dependency management** - Tasks are ordered to respect dependencies between components (e.g., models before services, services before endpoints) +- **Parallel execution markers** - Tasks that can run in parallel are marked with `[P]` to optimize development workflow +- **File path specifications** - Each task includes the exact file paths where implementation should occur +- **Test-driven development structure** - If tests are requested, test tasks are included and ordered to be written before implementation +- **Checkpoint validation** - Each user story phase includes checkpoints to validate independent functionality + +The 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. + +### **STEP 7:** Implementation + +Once ready, use the `/speckit.implement` command to execute your implementation plan: + +```text +/speckit.implement +``` + +The `/speckit.implement` command will: + +- Validate that all prerequisites are in place (constitution, spec, plan, and tasks) +- Parse the task breakdown from `tasks.md` +- Execute tasks in the correct order, respecting dependencies and parallel execution markers +- Follow the TDD approach defined in your task plan +- Provide progress updates and handle errors appropriately + +> [!IMPORTANT] +> The AI agent will execute local CLI commands (such as `dotnet`, `npm`, etc.) - make sure you have the required tools installed on your machine. + +Once 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. + +
+ +--- + +## šŸ” Troubleshooting + +### Git Credential Manager on Linux + +If you're having issues with Git authentication on Linux, you can install Git Credential Manager: + +```bash +#!/usr/bin/env bash +set -e +echo "Downloading Git Credential Manager v2.6.1..." +wget https://github.com/git-ecosystem/git-credential-manager/releases/download/v2.6.1/gcm-linux_amd64.2.6.1.deb +echo "Installing Git Credential Manager..." +sudo dpkg -i gcm-linux_amd64.2.6.1.deb +echo "Configuring Git to use GCM..." +git config --global credential.helper manager +echo "Cleaning up..." +rm gcm-linux_amd64.2.6.1.deb +``` + +## šŸ’¬ Support + +For 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. + +## šŸ™ Acknowledgements + +This project is heavily influenced by and based on the work and research of [John Lam](https://github.com/jflam). + +## šŸ“„ License -See [github/spec-kit](https://github.com/github/spec-kit) for the full verbose version. +This project is licensed under the terms of the MIT open source license. Please refer to the [LICENSE](./LICENSE) file for the full terms. diff --git a/SUPPORT.md b/SUPPORT.md index c6acf76e05..308abae92b 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -1,18 +1,17 @@ # Support -## How to file issues and get help +## How to get help -This project uses GitHub issues to track bugs and feature requests. Please search the existing issues before filing new issues to avoid duplicates. For new issues, file your bug or feature request as a new issue. +Please 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. -For help or questions about using this project, please: - -- Open a [GitHub issue](https://github.com/github/spec-kit/issues/new) for bug reports, feature requests, or questions about the Spec-Driven Development methodology -- Check the [comprehensive guide](./spec-driven.md) for detailed documentation on the Spec-Driven Development process - Review the [README](./README.md) for getting started instructions and troubleshooting tips +- Check the [comprehensive guide](./spec-driven.md) for detailed documentation on the Spec-Driven Development process +- Ask in [GitHub Discussions](https://github.com/github/spec-kit/discussions) for questions about using Spec Kit or the Spec-Driven Development methodology +- Open a [GitHub issue](https://github.com/github/spec-kit/issues/new) for bug reports and feature requests ## Project Status -**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 in a timely manner. +**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. ## GitHub Support Policy diff --git a/docs/community/friends.md b/docs/community/friends.md new file mode 100644 index 0000000000..31c6318699 --- /dev/null +++ b/docs/community/friends.md @@ -0,0 +1,14 @@ +# Community Friends + +> [!NOTE] +> Community projects listed here are independently created and maintained by their respective authors. They are **not reviewed, nor endorsed, nor supported by GitHub**. Review their source code before installation and use at your own discretion. + +Community projects that extend, visualize, or build on Spec Kit: + +- **[cc-spex](https://github.com/rhuss/cc-spex)** — A Claude Code plugin that adds composable traits on top of Spec Kit with [Superpowers](https://github.com/obra/superpowers)-based quality gates, spec/code review, git worktree isolation, and parallel implementation via agent teams. + +- **[Spec Kit Assistant](https://marketplace.visualstudio.com/items?itemName=rfsales.speckit-assistant)** — A VS Code extension that provides a visual orchestrator for the full SDD workflow (constitution → specification → planning → tasks → implementation) with phase status visualization, an interactive task checklist, DAG visualization, and support for Claude, Gemini, GitHub Copilot, and OpenAI backends. Requires the `specify` CLI in your PATH. + +- **[SpecKit Companion](https://marketplace.visualstudio.com/items?itemName=alfredoperez.speckit-companion)** — A VS Code extension that brings a visual GUI to Spec Kit. Browse specs in a rich markdown viewer with clickable file references, create specifications with image attachments, comment and refine each step inline (GitHub-style review), track your progress through the SDD workflow with a visual phase stepper, and manage steering documents like constitutions and templates. + +- **[cc-spec-kit](https://github.com/speckit-community/cc-spec-kit)** — Community-maintained plugin for Claude Code and GitHub Copilot CLI that installs Spec Kit skills via the plugin marketplace. diff --git a/docs/community/presets.md b/docs/community/presets.md new file mode 100644 index 0000000000..03ac777b80 --- /dev/null +++ b/docs/community/presets.md @@ -0,0 +1,22 @@ +# Community Presets + +> [!NOTE] +> Community presets are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting, catalog structure, or policy compliance, but they do **not review, audit, endorse, or support the preset code itself**. Review preset source code before installation and use at your own discretion. + +The following community-contributed presets customize how Spec Kit behaves — overriding templates, commands, and terminology without changing any tooling. Presets are available in [`catalog.community.json`](https://github.com/github/spec-kit/blob/main/presets/catalog.community.json): + +| Preset | Purpose | Provides | Requires | URL | +|--------|---------|----------|----------|-----| +| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | +| Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) | +| Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) | +| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) | +| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose. Supports interactive elements like brainstorming, interview, roleplay and extras like statistics, cover builder and bio command. Export with templates for KDP, D2D etc. | 22 templates, 27 commands, 1 script | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) | +| Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) | +| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) | +| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | +| Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands, 1 script | — | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) | +| Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) | +| VS Code Ask Questions | Enhances the clarify command to use `vscode/askQuestions` for batched interactive questioning. | 1 command | — | [spec-kit-presets](https://github.com/fdcastel/spec-kit-presets) | + +To build and publish your own preset, see the [Presets Publishing Guide](https://github.com/github/spec-kit/blob/main/presets/PUBLISHING.md). diff --git a/docs/community/walkthroughs.md b/docs/community/walkthroughs.md new file mode 100644 index 0000000000..b32c025803 --- /dev/null +++ b/docs/community/walkthroughs.md @@ -0,0 +1,20 @@ +# Community Walkthroughs + +> [!NOTE] +> Community walkthroughs are independently created and maintained by their respective authors. They are **not reviewed, nor endorsed, nor supported by GitHub**. Review their content before following along and use at your own discretion. + +See Spec-Driven Development in action across different scenarios with these community-contributed walkthroughs: + +- **[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. + +- **[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. + +- **[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. + +- **[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. + +- **[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. + +- **[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. + +- **[Greenfield Spring Boot + React with a custom extension](https://github.com/mnriem/spec-kit-aide-extension-demo)** — Walks through the **AIDE extension**, a community extension that adds an alternative spec-driven workflow to spec-kit with high-level specs (vision) and low-level specs (work items) organized in a 7-step iterative lifecycle: vision → roadmap → progress tracking → work queue → work items → execution → feedback loops. Uses a family trading platform (Spring Boot 4, React 19, PostgreSQL, Docker Compose) as the scenario to illustrate how the extension mechanism lets you plug in a different style of spec-driven development without changing any core tooling — truly utilizing the "Kit" in Spec Kit. diff --git a/docs/docfx.json b/docs/docfx.json index dca3f0f578..3fb9c32ebb 100644 --- a/docs/docfx.json +++ b/docs/docfx.json @@ -4,7 +4,9 @@ { "files": [ "*.md", - "toc.yml" + "toc.yml", + "community/*.md", + "reference/*.md" ] }, { diff --git a/docs/installation.md b/docs/installation.md index 6daff24315..c99810f706 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -3,27 +3,40 @@ ## Prerequisites - **Linux/macOS** (or Windows; PowerShell scripts now supported without WSL) -- AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli) or [Gemini CLI](https://github.com/google-gemini/gemini-cli) -- [uv](https://docs.astral.sh/uv/) for package management +- 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) +- [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pypa.github.io/pipx/) for persistent installation - [Python 3.11+](https://www.python.org/downloads/) - [Git](https://git-scm.com/downloads) ## Installation +> **Important:** The only official, maintained packages for Spec Kit come from the [github/spec-kit](https://github.com/github/spec-kit) GitHub repository. Any packages with the same name available on PyPI (e.g. `specify-cli` on pypi.org) are **not** affiliated with this project and are not maintained by the Spec Kit maintainers. For normal installs, use the GitHub-based commands shown below. For offline or air-gapped environments, locally built wheels created from this repository are also valid. + ### Initialize a New Project -The easiest way to get started is to initialize a new project: +The easiest way to get started is to initialize a new project. Pin a specific release tag for stability (check [Releases](https://github.com/github/spec-kit/releases) for the latest): ```bash +# Install from a specific stable release (recommended — replace vX.Y.Z with the latest tag) +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init + +# Or install latest from main (may include unreleased changes) uvx --from git+https://github.com/github/spec-kit.git specify init ``` +> [!NOTE] +> For a persistent installation, `pipx` works equally well: +> ```bash +> pipx install git+https://github.com/github/spec-kit.git@vX.Y.Z +> ``` +> The project uses a standard `hatchling` build backend and has no uv-specific dependencies. + Or initialize in the current directory: ```bash -uvx --from git+https://github.com/github/spec-kit.git specify init . +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init . # or use the --here flag -uvx --from git+https://github.com/github/spec-kit.git specify init --here +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here ``` ### Specify AI Agent @@ -31,10 +44,11 @@ uvx --from git+https://github.com/github/spec-kit.git specify init --here You can proactively specify your AI agent during initialization: ```bash -uvx --from git+https://github.com/github/spec-kit.git specify init --ai claude -uvx --from git+https://github.com/github/spec-kit.git specify init --ai gemini -uvx --from git+https://github.com/github/spec-kit.git specify init --ai copilot -uvx --from git+https://github.com/github/spec-kit.git specify init --ai codebuddy +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --ai claude +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --ai gemini +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --ai copilot +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --ai codebuddy +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --ai pi ``` ### Specify Script Type (Shell vs PowerShell) @@ -50,8 +64,8 @@ Auto behavior: Force a specific script type: ```bash -uvx --from git+https://github.com/github/spec-kit.git specify init --script sh -uvx --from git+https://github.com/github/spec-kit.git specify init --script ps +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --script sh +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --script ps ``` ### Ignore Agent Tools Check @@ -59,11 +73,19 @@ uvx --from git+https://github.com/github/spec-kit.git specify init --ai claude --ignore-agent-tools +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --ai claude --ignore-agent-tools ``` ## Verification +After installation, run the following command to confirm the correct version is installed: + +```bash +specify version +``` + +This helps verify you are running the official Spec Kit build from GitHub, not an unrelated package with the same name. + After initialization, you should see the following commands available in your AI agent: - `/speckit.specify` - Create specifications @@ -74,6 +96,52 @@ The `.specify/scripts` directory will contain both `.sh` and `.ps1` scripts. ## Troubleshooting +### Enterprise / Air-Gapped Installation + +If your environment blocks access to PyPI (you see 403 errors when running `uv tool install` or `pip install`), you can create a portable wheel bundle on a connected machine and transfer it to the air-gapped target. + +**Step 1: Build the wheel on a connected machine (same OS and Python version as the target)** + +```bash +# Clone the repository +git clone https://github.com/github/spec-kit.git +cd spec-kit + +# Build the wheel +pip install build +python -m build --wheel --outdir dist/ + +# Download the wheel and all its runtime dependencies +pip download -d dist/ dist/specify_cli-*.whl +``` + +> **Important:** `pip download` resolves platform-specific wheels (e.g., PyYAML includes native extensions). You must run this step on a machine with the **same OS and Python version** as the air-gapped target. If you need to support multiple platforms, repeat this step on each target OS (Linux, macOS, Windows) and Python version. + +**Step 2: Transfer the `dist/` directory to the air-gapped machine** + +Copy the entire `dist/` directory (which contains the `specify-cli` wheel and all dependency wheels) to the target machine via USB, network share, or other approved transfer method. + +**Step 3: Install on the air-gapped machine** + +```bash +pip install --no-index --find-links=./dist specify-cli +``` + +**Step 4: Initialize a project (no network required)** + +```bash +# Initialize a project — no GitHub access needed +specify init my-project --ai claude --offline +``` + +The `--offline` flag tells the CLI to use the templates, commands, and scripts bundled inside the wheel instead of downloading from GitHub. + +> **Deprecation notice:** Starting with v0.6.0, `specify init` will use bundled assets by default and the `--offline` flag will be removed. The GitHub download path will be retired because bundled assets eliminate the need for network access, avoid proxy/firewall issues, and guarantee that templates always match the installed CLI version. No action will be needed — `specify init` will simply work without network access out of the box. + +> **Note:** Python 3.11+ is required. + +> **Windows note:** Offline scaffolding requires PowerShell 7+ (`pwsh`), not Windows PowerShell 5.x (`powershell.exe`). Install from https://aka.ms/powershell. + ### Git Credential Manager on Linux If you're having issues with Git authentication on Linux, you can install Git Credential Manager: diff --git a/docs/local-development.md b/docs/local-development.md index 7fac06adf4..a23ea1d88f 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -128,16 +128,14 @@ python -m src.specify_cli init --here --ai claude --ignore-agent-tools --script Or copy only the modified CLI portion if you want a lighter sandbox. -## 9. Debug Network / TLS Skips +## 9. Debug Network / TLS Issues -If you need to bypass TLS validation while experimenting: - -```bash -specify check --skip-tls -specify init demo --skip-tls --ai gemini --ignore-agent-tools --script ps -``` - -(Use only for local experimentation.) +> **Deprecated:** The `--skip-tls` flag is a no-op and has no effect. +> It was previously used to bypass TLS validation during local testing. +> If you encounter TLS errors (e.g., on a corporate network), configure your +> environment's certificate store or proxy instead. +> +> For example, set `SSL_CERT_FILE` or configure `HTTPS_PROXY` / `HTTP_PROXY`. ## 10. Rapid Edit Loop Summary @@ -166,7 +164,7 @@ rm -rf .venv dist build *.egg-info | Scripts not executable (Linux) | Re-run init or `chmod +x scripts/*.sh` | | Git step skipped | You passed `--no-git` or Git not installed | | Wrong script type downloaded | Pass `--script sh` or `--script ps` explicitly | -| TLS errors on corporate network | Try `--skip-tls` (not for production) | +| TLS errors on corporate network | Configure your environment's certificate store or proxy. The `--skip-tls` flag is deprecated and has no effect. | ## 13. Next Steps diff --git a/docs/quickstart.md b/docs/quickstart.md index 4d3b863b35..5c0f009306 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -22,6 +22,17 @@ uvx --from git+https://github.com/github/spec-kit.git specify init [!NOTE] +> You can also install the CLI persistently with `pipx`: +> ```bash +> pipx install git+https://github.com/github/spec-kit.git +> ``` +> After installing with `pipx`, run `specify` directly instead of `uvx --from ... specify`, for example: +> ```bash +> specify init +> specify init . +> ``` + Pick script type explicitly (optional): ```bash @@ -81,6 +92,9 @@ Then, use the `/speckit.implement` slash command to execute the plan. /speckit.implement ``` +> [!TIP] +> **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. + ## Detailed Example: Building Taskify Here's a complete example of building a team productivity platform: @@ -135,7 +149,15 @@ Be specific about your tech stack and technical requirements: /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. ``` -### Step 6: Validate and Implement +### Step 6: Define Tasks + +Generate an actionable task list using the `/speckit.tasks` command: + +```bash +/speckit.tasks +``` + +### Step 7: Validate and Implement Have your AI agent audit the implementation plan using `/speckit.analyze`: @@ -149,6 +171,9 @@ Finally, implement the solution: /speckit.implement ``` +> [!TIP] +> **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. + ## Key Principles - **Be explicit** about what you're building and why @@ -159,6 +184,6 @@ Finally, implement the solution: ## Next Steps -- Read the [complete methodology](../spec-driven.md) for in-depth guidance -- Check out [more examples](../templates) in the repository +- Read the [complete methodology](https://github.com/github/spec-kit/blob/main/spec-driven.md) for in-depth guidance +- Check out [more examples](https://github.com/github/spec-kit/tree/main/templates) in the repository - Explore the [source code on GitHub](https://github.com/github/spec-kit) diff --git a/docs/reference/core.md b/docs/reference/core.md new file mode 100644 index 0000000000..fdab05a02b --- /dev/null +++ b/docs/reference/core.md @@ -0,0 +1,79 @@ +# Core Commands + +The core `specify` commands handle project initialization, system checks, and version information. + +## Initialize a Project + +```bash +specify init [] +``` + +| Option | Description | +| ------------------------ | ------------------------------------------------------------------------ | +| `--integration ` | AI coding agent integration to use (e.g. `copilot`, `claude`, `gemini`). See the [Integrations reference](integrations.md) for all available keys | +| `--integration-options` | Options for the integration (e.g. `--integration-options="--commands-dir .myagent/cmds"`) | +| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) | +| `--here` | Initialize in the current directory instead of creating a new one | +| `--force` | Force merge/overwrite when initializing in an existing directory | +| `--no-git` | Skip git repository initialization | +| `--ignore-agent-tools` | Skip checks for AI coding agent CLI tools | +| `--preset ` | Install a preset during initialization | +| `--branch-numbering` | Branch numbering strategy: `sequential` (default) or `timestamp` | + +Creates a new Spec Kit project with the necessary directory structure, templates, scripts, and AI coding agent integration files. + +Use `` to create a new directory, or `--here` (or `.`) to initialize in the current directory. If the directory already has files, use `--force` to merge without confirmation. + +### Examples + +```bash +# Create a new project with an integration +specify init my-project --integration copilot + +# Initialize in the current directory +specify init --here --integration copilot + +# Force merge into a non-empty directory +specify init --here --force --integration copilot + +# Use PowerShell scripts (Windows/cross-platform) +specify init my-project --integration copilot --script ps + +# Skip git initialization +specify init my-project --integration copilot --no-git + +# Install a preset during initialization +specify init my-project --integration copilot --preset compliance + +# Use timestamp-based branch numbering (useful for distributed teams) +specify init my-project --integration copilot --branch-numbering timestamp +``` + +### Environment Variables + +| Variable | Description | +| ----------------- | ------------------------------------------------------------------------ | +| `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. Must be set in the context of the agent prior to using `/speckit.plan` or follow-up commands. | + +## Check Installed Tools + +```bash +specify check +``` + +Checks that required tools are available on your system: `git` and any CLI-based AI coding agents. IDE-based agents are skipped since they don't require a CLI tool. + +## Version Information + +```bash +specify version +``` + +Displays the Spec Kit CLI version, Python version, platform, and architecture. + +A quick version check is also available via: + +```bash +specify --version +specify -V +``` diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md new file mode 100644 index 0000000000..923d0b9b82 --- /dev/null +++ b/docs/reference/extensions.md @@ -0,0 +1,201 @@ +# Extensions + +Extensions add new capabilities to Spec Kit — domain-specific commands, external tool integrations, quality gates, and more. They introduce new commands and templates that go beyond the built-in Spec-Driven Development workflow. + +## Search Available Extensions + +```bash +specify extension search [query] +``` + +| Option | Description | +| ------------ | ------------------------------------ | +| `--tag` | Filter by tag | +| `--author` | Filter by author | +| `--verified` | Show only verified extensions | + +Searches all active catalogs for extensions matching the query. Without a query, lists all available extensions. + +## Install an Extension + +```bash +specify extension add +``` + +| Option | Description | +| --------------- | -------------------------------------------------------- | +| `--dev` | Install from a local directory (for development) | +| `--from ` | Install from a custom URL instead of the catalog | +| `--priority `| Resolution priority (default: 10; lower = higher precedence) | + +Installs an extension from the catalog, a URL, or a local directory. Extension commands are automatically registered with the currently installed AI coding agent integration. + +> **Note:** All extension commands require a project already initialized with `specify init`. + +## Remove an Extension + +```bash +specify extension remove +``` + +| Option | Description | +| --------------- | ---------------------------------------------- | +| `--keep-config` | Preserve configuration files during removal | +| `--force` | Skip confirmation prompt | + +Removes an installed extension. Configuration files are backed up by default; use `--keep-config` to leave them in place or `--force` to skip the confirmation. + +## List Installed Extensions + +```bash +specify extension list +``` + +| Option | Description | +| ------------- | -------------------------------------------------- | +| `--available` | Show available (uninstalled) extensions | +| `--all` | Show both installed and available extensions | + +Lists installed extensions with their status, version, and command counts. + +## Extension Info + +```bash +specify extension info +``` + +Shows detailed information about an installed or available extension, including its description, version, commands, and configuration. + +## Update Extensions + +```bash +specify extension update [] +``` + +Updates a specific extension, or all installed extensions if no name is given. + +## Enable / Disable an Extension + +```bash +specify extension enable +specify extension disable +``` + +Disable an extension without removing it. Disabled extensions are not loaded and their commands are not available. Re-enable with `enable`. + +## Set Extension Priority + +```bash +specify extension set-priority +``` + +Changes the resolution priority of an extension. When multiple extensions provide a command with the same name, the extension with the lowest priority number takes precedence. + +## Catalog Management + +Extension catalogs control where `search` and `add` look for extensions. Catalogs are checked in priority order (lower number = higher precedence). + +### List Catalogs + +```bash +specify extension catalog list +``` + +Shows all active catalogs in the stack with their priorities and install permissions. + +### Add a Catalog + +```bash +specify extension catalog add +``` + +| Option | Description | +| ------------------------------------ | -------------------------------------------------- | +| `--name ` | Required. Unique name for the catalog | +| `--priority ` | Priority (default: 10; lower = higher precedence) | +| `--install-allowed / --no-install-allowed` | Whether extensions can be installed from this catalog | +| `--description ` | Optional description | + +Adds a catalog to the project's `.specify/extension-catalogs.yml`. + +### Remove a Catalog + +```bash +specify extension catalog remove +``` + +Removes a catalog from the project configuration. + +### Catalog Resolution Order + +Catalogs are resolved in this order (first match wins): + +1. **Environment variable** — `SPECKIT_CATALOG_URL` overrides all catalogs +2. **Project config** — `.specify/extension-catalogs.yml` +3. **User config** — `~/.specify/extension-catalogs.yml` +4. **Built-in defaults** — official catalog + community catalog + +Example `.specify/extension-catalogs.yml`: + +```yaml +catalogs: + - name: "my-org-catalog" + url: "https://example.com/catalog.json" + priority: 5 + install_allowed: true + description: "Our approved extensions" +``` + +## Extension Configuration + +Most extensions include configuration files in their install directory: + +```text +.specify/extensions// +ā”œā”€ā”€ -config.yml # Project config (version controlled) +ā”œā”€ā”€ -config.local.yml # Local overrides (gitignored) +└── -config.template.yml # Template reference +``` + +Configuration is merged in this order (highest priority last): + +1. **Extension defaults** (from `extension.yml`) +2. **Project config** (`-config.yml`) +3. **Local overrides** (`-config.local.yml`) +4. **Environment variables** (`SPECKIT__*`) + +To set up configuration for a newly installed extension, copy the template: + +```bash +cp .specify/extensions//-config.template.yml \ + .specify/extensions//-config.yml +``` + +## FAQ + +### Why can't I find an extension with `search`? + +Check the spelling of the extension name. The extension may not be published yet, or it may be in a catalog you haven't added. Use `specify extension catalog list` to see which catalogs are active. + +### Why doesn't the extension command appear in my AI coding agent? + +Verify the extension is installed and enabled with `specify extension list`. If it shows as installed, restart your AI coding agent — it may need to reload for it to take effect. + +### How do I set up extension configuration? + +Copy the config template that ships with the extension: + +```bash +cp .specify/extensions//-config.template.yml \ + .specify/extensions//-config.yml +``` + +See [Extension Configuration](#extension-configuration) for details on config layers and overrides. + +### How do I resolve an incompatible version error? + +Update Spec Kit to the version required by the extension. + +### Who maintains extensions? + +Most extensions are independently created and maintained by their respective authors. The Spec Kit maintainers do not review, audit, endorse, or support extension code. Review an extension's source code before installing and use at your own discretion. For issues with a specific extension, contact its author or file an issue on the extension's repository. diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md new file mode 100644 index 0000000000..dcb9a2b354 --- /dev/null +++ b/docs/reference/integrations.md @@ -0,0 +1,140 @@ +# Supported AI Coding Agent Integrations + +The Specify CLI supports a wide range of AI coding agents. When you run `specify init`, the CLI sets up the appropriate command files, context rules, and directory structures for your chosen AI coding agent — so you can start using Spec-Driven Development immediately, regardless of which tool you prefer. + +## Supported AI Coding Agents + +| Agent | Key | Notes | +| ------------------------------------------------------------------------------------ | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| [Amp](https://ampcode.com/) | `amp` | | +| [Antigravity (agy)](https://antigravity.google/) | `agy` | Skills-based integration; skills are installed automatically | +| [Auggie CLI](https://docs.augmentcode.com/cli/overview) | `auggie` | | +| [Claude Code](https://www.anthropic.com/claude-code) | `claude` | Skills-based integration; installs skills in `.claude/skills` | +| [CodeBuddy CLI](https://www.codebuddy.ai/cli) | `codebuddy` | | +| [Codex CLI](https://github.com/openai/codex) | `codex` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `$speckit-` | +| [Cursor](https://cursor.sh/) | `cursor-agent` | | +| [Forge](https://forgecode.dev/) | `forge` | | +| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | | +| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | | +| [Goose](https://block.github.io/goose/) | `goose` | Uses YAML recipe format in `.goose/recipes/` | +| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | IDE-based agent | +| [iFlow CLI](https://docs.iflow.cn/en/cli/quickstart) | `iflow` | | +| [Junie](https://junie.jetbrains.com/) | `junie` | | +| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | | +| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; supports `--migrate-legacy` for dotted→hyphenated directory migration | +| [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Alias: `--integration kiro` | +| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | | +| [opencode](https://opencode.ai/) | `opencode` | | +| [Pi Coding Agent](https://pi.dev) | `pi` | 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) | +| [Qoder CLI](https://qoder.com/cli) | `qodercli` | | +| [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | | +| [Roo Code](https://roocode.com/) | `roo` | | +| [SHAI (OVHcloud)](https://github.com/ovh/shai) | `shai` | | +| [Tabnine CLI](https://docs.tabnine.com/main/getting-started/tabnine-cli) | `tabnine` | | +| [Trae](https://www.trae.ai/) | `trae` | Skills-based integration; skills are installed automatically | +| [Windsurf](https://windsurf.com/) | `windsurf` | | +| Generic | `generic` | Bring your own agent — use `--integration generic --integration-options="--commands-dir "` for AI coding agents not listed above | + +## List Available Integrations + +```bash +specify integration list +``` + +Shows all available integrations, which one is currently installed, and whether each requires a CLI tool or is IDE-based. + +## Install an Integration + +```bash +specify integration install +``` + +| Option | Description | +| ------------------------ | ------------------------------------------------------------------------ | +| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) | +| `--integration-options` | Integration-specific options (e.g. `--integration-options="--commands-dir .myagent/cmds"`) | + +Installs the specified integration into the current project. Fails if another integration is already installed — use `switch` instead. If the installation fails partway through, it automatically rolls back to a clean state. + +> **Note:** All integration management commands require a project already initialized with `specify init`. To start a new project with a specific agent, use `specify init --integration ` instead. + +## Uninstall an Integration + +```bash +specify integration uninstall [] +``` + +| Option | Description | +| --------- | --------------------------------------------------- | +| `--force` | Remove files even if they have been modified | + +Uninstalls the current integration (or the specified one). Spec Kit tracks every file created during install along with a SHA-256 hash of the original content: + +- **Unmodified files** are removed automatically. +- **Modified files** (where you've made manual edits) are preserved so your customizations are not lost. +- Use `--force` to remove all integration files regardless of modifications. + +## Switch to a Different Integration + +```bash +specify integration switch +``` + +| Option | Description | +| ------------------------ | ------------------------------------------------------------------------ | +| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) | +| `--force` | Force removal of modified files during uninstall | +| `--integration-options` | Options for the target integration | + +Equivalent to running `uninstall` followed by `install` in a single step. + +## Upgrade an Integration + +```bash +specify integration upgrade [] +``` + +| Option | Description | +| ------------------------ | ------------------------------------------------------------------------ | +| `--force` | Overwrite files even if they have been modified | +| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) | +| `--integration-options` | Options for the integration | + +Reinstalls the current integration with updated templates and commands (e.g., after upgrading Spec Kit). Defaults to the currently installed integration; if a key is provided, it must match the installed one — otherwise the command fails and suggests using `switch` instead. Detects locally modified files and blocks the upgrade unless `--force` is used. Stale files from the previous install that are no longer needed are removed automatically. + +## Integration-Specific Options + +Some integrations accept additional options via `--integration-options`: + +| Integration | Option | Description | +| ----------- | ------------------- | -------------------------------------------------------------- | +| `generic` | `--commands-dir` | Required. Directory for command files | +| `kimi` | `--migrate-legacy` | Migrate legacy dotted skill directories to hyphenated format | + +Example: + +```bash +specify integration install generic --integration-options="--commands-dir .myagent/cmds" +``` + +## FAQ + +### Can I use multiple integrations at the same time? + +No. Only one AI coding agent integration can be installed per project. Use `specify integration switch ` to change to a different AI coding agent. + +### What happens to my changes when I uninstall or switch? + +Files you've modified are preserved automatically. Only unmodified files (matching their original SHA-256 hash) are removed. Use `--force` to override this. + +### How do I know which key to use? + +Run `specify integration list` to see all available integrations with their keys, or check the [Supported AI Coding Agents](#supported-ai-coding-agents) table above. + +### Do I need the AI coding agent installed to use an integration? + +CLI-based integrations (like Claude Code, Gemini CLI) require the tool to be installed. IDE-based integrations (like Windsurf, Cursor) work through the IDE itself. Some agents like GitHub Copilot support both IDE and CLI usage. `specify integration list` shows which type each integration is. + +### When should I use `upgrade` vs `switch`? + +Use `upgrade` when you've upgraded Spec Kit and want to refresh the same integration's templates. Use `switch` when you want to change to a different AI coding agent. diff --git a/docs/reference/overview.md b/docs/reference/overview.md new file mode 100644 index 0000000000..10fcdc3bca --- /dev/null +++ b/docs/reference/overview.md @@ -0,0 +1,33 @@ +# CLI Reference + +The Specify CLI (`specify`) manages the full lifecycle of Spec-Driven Development — from project initialization to workflow automation. + +## Core Commands + +The foundational commands for creating and managing Spec Kit projects. Initialize a new project with the necessary directory structure, templates, and scripts. Verify that your system has the required tools installed. Check version and system information. + +[Core Commands reference →](core.md) + +## Integrations + +Integrations connect Spec Kit to your AI coding agent. Each integration sets up the appropriate command files, context rules, and directory structures for a specific agent. Only one integration is active per project at a time, and you can switch between them at any point. + +[Integrations reference →](integrations.md) + +## Extensions + +Extensions add new capabilities to Spec Kit — domain-specific commands, external tool integrations, quality gates, and more. They are discovered through catalogs and can be installed, updated, enabled, disabled, or removed independently. Multiple extensions can coexist in a single project. + +[Extensions reference →](extensions.md) + +## Presets + +Presets customize how Spec Kit works — overriding command files, template files, and script files without changing any tooling. They let you enforce organizational standards, adapt the workflow to your methodology, or localize the entire experience. Multiple presets can be stacked with priority ordering to layer customizations. + +[Presets reference →](presets.md) + +## Workflows + +Workflows automate multi-step Spec-Driven Development processes into repeatable sequences. They chain commands, prompts, shell steps, and human checkpoints together, with support for conditional logic, loops, fan-out/fan-in, and the ability to pause and resume from the exact point of interruption. + +[Workflows reference →](workflows.md) diff --git a/docs/reference/presets.md b/docs/reference/presets.md new file mode 100644 index 0000000000..4a613ffc00 --- /dev/null +++ b/docs/reference/presets.md @@ -0,0 +1,224 @@ +# Presets + +Presets customize how Spec Kit works — overriding templates, commands, and terminology without changing any tooling. They let you enforce organizational standards, adapt the workflow to your methodology, or localize the entire experience. Multiple presets can be stacked with priority ordering. + +## Search Available Presets + +```bash +specify preset search [query] +``` + +| Option | Description | +| ---------- | -------------------- | +| `--tag` | Filter by tag | +| `--author` | Filter by author | + +Searches all active catalogs for presets matching the query. Without a query, lists all available presets. + +## Install a Preset + +```bash +specify preset add [] +``` + +| Option | Description | +| ---------------- | -------------------------------------------------------- | +| `--dev ` | Install from a local directory (for development) | +| `--from ` | Install from a custom URL instead of the catalog | +| `--priority ` | Resolution priority (default: 10; lower = higher precedence) | + +Installs a preset from the catalog, a URL, or a local directory. Preset commands are automatically registered with the currently installed AI coding agent integration. + +> **Note:** All preset commands require a project already initialized with `specify init`. + +## Remove a Preset + +```bash +specify preset remove +``` + +Removes an installed preset and cleans up its registered commands. + +## List Installed Presets + +```bash +specify preset list +``` + +Lists installed presets with their versions, descriptions, template counts, and current status. + +## Preset Info + +```bash +specify preset info +``` + +Shows detailed information about an installed or available preset, including its templates, metadata, and tags. + +## Resolve a File + +```bash +specify preset resolve +``` + +Shows which file will be used for a given name by tracing the full resolution stack. Useful for debugging when multiple presets provide the same file. + +## Enable / Disable a Preset + +```bash +specify preset enable +specify preset disable +``` + +Disable a preset without removing it. Disabled presets are skipped during file resolution but their commands remain registered. Re-enable with `enable`. + +## Set Preset Priority + +```bash +specify preset set-priority +``` + +Changes the resolution priority of an installed preset. Lower numbers take precedence. When multiple presets provide the same file, the one with the lowest priority number wins. + +## Catalog Management + +Preset catalogs control where `search` and `add` look for presets. Catalogs are checked in priority order (lower number = higher precedence). + +### List Catalogs + +```bash +specify preset catalog list +``` + +Shows all active catalogs with their priorities and install permissions. + +### Add a Catalog + +```bash +specify preset catalog add +``` + +| Option | Description | +| -------------------------------------------- | -------------------------------------------------- | +| `--name ` | Required. Unique name for the catalog | +| `--priority ` | Priority (default: 10; lower = higher precedence) | +| `--install-allowed / --no-install-allowed` | Whether presets can be installed from this catalog (default: discovery only) | +| `--description ` | Optional description | + +Adds a catalog to the project's `.specify/preset-catalogs.yml`. + +### Remove a Catalog + +```bash +specify preset catalog remove +``` + +Removes a catalog from the project configuration. + +### Catalog Resolution Order + +Catalogs are resolved in this order (first match wins): + +1. **Environment variable** — `SPECKIT_PRESET_CATALOG_URL` overrides all catalogs +2. **Project config** — `.specify/preset-catalogs.yml` +3. **User config** — `~/.specify/preset-catalogs.yml` +4. **Built-in defaults** — official catalog + community catalog + +Example `.specify/preset-catalogs.yml`: + +```yaml +catalogs: + - name: "my-org-presets" + url: "https://example.com/preset-catalog.json" + priority: 5 + install_allowed: true + description: "Our approved presets" +``` + +## File Resolution + +Presets can provide command files, template files (like `plan-template.md`), and script files. These are resolved at runtime using a **replace** strategy — the first match in the priority stack wins and is used entirely. Each file is looked up independently, so different files can come from different layers. + +> **Note:** Additional composition strategies (`append`, `prepend`, `wrap`) are planned for a future release. + +The resolution stack, from highest to lowest precedence: + +1. **Project-local overrides** — `.specify/templates/overrides/` +2. **Installed presets** — sorted by priority (lower = checked first) +3. **Installed extensions** — sorted by priority +4. **Spec Kit core** — `.specify/templates/` + +Commands are registered at install time (not resolved through the stack at runtime). + +### Resolution Stack + +```mermaid +flowchart TB + subgraph stack [" "] + direction TB + A["⬆ Highest precedence

1. Project-local overrides
.specify/templates/overrides/"] + B["2. Presets — by priority
.specify/presets/‹id›/"] + C["3. Extensions — by priority
.specify/extensions/‹id›/"] + D["4. Spec Kit core
.specify/templates/

⬇ Lowest precedence"] + end + + A --> B --> C --> D + + style A fill:#4a9,color:#fff + style B fill:#49a,color:#fff + style C fill:#a94,color:#fff + style D fill:#999,color:#fff +``` + +Within each layer, files are organized by type: + +| Type | Subdirectory | Override path | +| --------- | -------------- | ------------------------------------------ | +| Templates | `templates/` | `.specify/templates/overrides/` | +| Commands | `commands/` | `.specify/templates/overrides/` | +| Scripts | `scripts/` | `.specify/templates/overrides/scripts/` | + +### Resolution in Action + +```mermaid +flowchart TB + A["File requested:
plan-template.md"] --> B{"Project-local override?"} + B -- Found --> Z["āœ“ Use this file"] + B -- Not found --> C{"Preset: compliance
(priority 5)"} + C -- Found --> Z + C -- Not found --> D{"Preset: team-workflow
(priority 10)"} + D -- Found --> Z + D -- Not found --> E{"Extension files?"} + E -- Found --> Z + E -- Not found --> F["Spec Kit core"] + F --> Z +``` + +### Example + +```bash +specify preset add compliance --priority 5 +specify preset add team-workflow --priority 10 +``` + +For any file that both provide, `compliance` wins (priority 5 < 10). For files only one provides, that one is used. For files neither provides, the core default is used. + +## FAQ + +### Can I use multiple presets at the same time? + +Yes. Presets stack by priority — each file is resolved independently from the highest-priority source that provides it. Use `specify preset set-priority` to control the order. + +### How do I see which file is actually being used? + +Run `specify preset resolve ` to trace the resolution stack and see which file wins. + +### What's the difference between disabling and removing a preset? + +**Disabling** (`specify preset disable`) keeps the preset installed but excludes its files from the resolution stack. Commands the preset registered remain available in your AI coding agent. This is useful for temporarily testing behavior without a preset, or comparing output with and without it. Re-enable anytime with `specify preset enable`. + +**Removing** (`specify preset remove`) fully uninstalls the preset — deletes its files, unregisters its commands from your AI coding agent, and removes it from the registry. + +### Who maintains presets? + +Most presets are independently created and maintained by their respective authors. The Spec Kit maintainers do not review, audit, endorse, or support preset code. Review a preset's source code before installing and use at your own discretion. For issues with a specific preset, contact its author or file an issue on the preset's repository. diff --git a/docs/reference/workflows.md b/docs/reference/workflows.md new file mode 100644 index 0000000000..e7e921e1e9 --- /dev/null +++ b/docs/reference/workflows.md @@ -0,0 +1,289 @@ +# Workflows + +Workflows automate multi-step Spec-Driven Development processes — chaining commands, prompts, shell steps, and human checkpoints into repeatable sequences. They support conditional logic, loops, fan-out/fan-in, and can be paused and resumed from the exact point of interruption. + +## Run a Workflow + +```bash +specify workflow run +``` + +| Option | Description | +| ------------------- | -------------------------------------------------------- | +| `-i` / `--input` | Pass input values as `key=value` (repeatable) | + +Runs a workflow from a catalog ID, URL, or local file path. Inputs declared by the workflow can be provided via `--input` or will be prompted interactively. + +Example: + +```bash +specify workflow run speckit -i spec="Build a kanban board with drag-and-drop task management" -i scope=full +``` + +> **Note:** All workflow commands require a project already initialized with `specify init`. + +## Resume a Workflow + +```bash +specify workflow resume +``` + +Resumes a paused or failed workflow run from the exact step where it stopped. Useful after responding to a gate step or fixing an issue that caused a failure. + +## Workflow Status + +```bash +specify workflow status [] +``` + +Shows the status of a specific run, or lists all runs if no ID is given. Run states: `created`, `running`, `completed`, `paused`, `failed`, `aborted`. + +## List Installed Workflows + +```bash +specify workflow list +``` + +Lists workflows installed in the current project. + +## Install a Workflow + +```bash +specify workflow add +``` + +Installs a workflow from the catalog, a URL (HTTPS required), or a local file path. + +## Remove a Workflow + +```bash +specify workflow remove +``` + +Removes an installed workflow from the project. + +## Search Available Workflows + +```bash +specify workflow search [query] +``` + +| Option | Description | +| ------- | --------------- | +| `--tag` | Filter by tag | + +Searches all active catalogs for workflows matching the query. + +## Workflow Info + +```bash +specify workflow info +``` + +Shows detailed information about a workflow, including its steps, inputs, and requirements. + +## Catalog Management + +Workflow catalogs control where `search` and `add` look for workflows. Catalogs are checked in priority order. + +### List Catalogs + +```bash +specify workflow catalog list +``` + +Shows all active catalog sources. + +### Add a Catalog + +```bash +specify workflow catalog add +``` + +| Option | Description | +| --------------- | -------------------------------- | +| `--name ` | Optional name for the catalog | + +Adds a custom catalog URL to the project's `.specify/workflow-catalogs.yml`. + +### Remove a Catalog + +```bash +specify workflow catalog remove +``` + +Removes a catalog by its index in the catalog list. + +### Catalog Resolution Order + +Catalogs are resolved in this order (first match wins): + +1. **Environment variable** — `SPECKIT_WORKFLOW_CATALOG_URL` overrides all catalogs +2. **Project config** — `.specify/workflow-catalogs.yml` +3. **User config** — `~/.specify/workflow-catalogs.yml` +4. **Built-in defaults** — official catalog + community catalog + +## Workflow Definition + +Workflows are defined in YAML files. Here is the built-in **Full SDD Cycle** workflow that ships with Spec Kit: + +```yaml +schema_version: "1.0" +workflow: + id: "speckit" + name: "Full SDD Cycle" + version: "1.0.0" + author: "GitHub" + description: "Runs specify → plan → tasks → implement with review gates" + +requires: + speckit_version: ">=0.7.2" + integrations: + any: ["copilot", "claude", "gemini"] + +inputs: + spec: + type: string + required: true + prompt: "Describe what you want to build" + integration: + type: string + default: "copilot" + prompt: "Integration to use (e.g. claude, copilot, gemini)" + scope: + type: string + default: "full" + enum: ["full", "backend-only", "frontend-only"] + +steps: + - id: specify + command: speckit.specify + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}" + + - id: review-spec + type: gate + message: "Review the generated spec before planning." + options: [approve, reject] + on_reject: abort + + - id: plan + command: speckit.plan + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}" + + - id: review-plan + type: gate + message: "Review the plan before generating tasks." + options: [approve, reject] + on_reject: abort + + - id: tasks + command: speckit.tasks + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}" + + - id: implement + command: speckit.implement + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}" +``` + +This produces the following execution flow: + +```mermaid +flowchart TB + A["specify
(command)"] --> B{"review-spec
(gate)"} + B -- approve --> C["plan
(command)"] + B -- reject --> X1["ā¹ Abort"] + C --> D{"review-plan
(gate)"} + D -- approve --> E["tasks
(command)"] + D -- reject --> X2["ā¹ Abort"] + E --> F["implement
(command)"] + + style A fill:#49a,color:#fff + style B fill:#a94,color:#fff + style C fill:#49a,color:#fff + style D fill:#a94,color:#fff + style E fill:#49a,color:#fff + style F fill:#49a,color:#fff + style X1 fill:#999,color:#fff + style X2 fill:#999,color:#fff +``` + +Run it with: + +```bash +specify workflow run speckit -i spec="Build a kanban board with drag-and-drop task management" +``` + +## Step Types + +| Type | Purpose | +| ------------ | ------------------------------------------------ | +| `command` | Invoke a Spec Kit command (e.g., `speckit.plan`) | +| `prompt` | Send an arbitrary prompt to the AI coding agent | +| `shell` | Execute a shell command and capture output | +| `gate` | Pause for human approval before continuing | +| `if` | Conditional branching (then/else) | +| `switch` | Multi-branch dispatch on an expression | +| `while` | Loop while a condition is true | +| `do-while` | Execute at least once, then loop on condition | +| `fan-out` | Dispatch a step for each item in a list | +| `fan-in` | Aggregate results from a fan-out step | + +## Expressions + +Steps can reference inputs and previous step outputs using `{{ expression }}` syntax: + +| Namespace | Description | +| ------------------------------ | ------------------------------------ | +| `inputs.spec` | Workflow input values | +| `steps.specify.output.file` | Output from a previous step | +| `item` | Current item in a fan-out iteration | + +Available filters: `default`, `join`, `contains`, `map`. + +Example: + +```yaml +condition: "{{ steps.test.output.exit_code == 0 }}" +args: "{{ inputs.spec }}" +message: "{{ status | default('pending') }}" +``` + +## Input Types + +| Type | Coercion | +| --------- | ------------------------------------------------- | +| `string` | Pass-through | +| `number` | `"42"` → `42`, `"3.14"` → `3.14` | +| `boolean` | `"true"` / `"1"` / `"yes"` → `True` | + +## State and Resume + +Each workflow run persists its state at `.specify/workflows/runs//`: + +- `state.json` — current run state and step progress +- `inputs.json` — resolved input values +- `log.jsonl` — step-by-step execution log + +This enables `specify workflow resume` to continue from the exact step where a run was paused (e.g., at a gate) or failed. + +## FAQ + +### What happens when a workflow hits a gate step? + +The workflow pauses and waits for human input. Run `specify workflow resume ` after reviewing to continue. + +### Can I run the same workflow multiple times? + +Yes. Each run gets a unique ID and its own state directory. Use `specify workflow status` to see all runs. + +### Who maintains workflows? + +Most workflows are independently created and maintained by their respective authors. The Spec Kit maintainers do not review, audit, endorse, or support workflow code. Review a workflow's source before installing and use at your own discretion. diff --git a/docs/toc.yml b/docs/toc.yml index 18650cb571..636a8f03a1 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -12,8 +12,34 @@ - name: Upgrade href: upgrade.md +# Reference +- name: Reference + items: + - name: Overview + href: reference/overview.md + - name: Core Commands + href: reference/core.md + - name: Integrations + href: reference/integrations.md + - name: Extensions + href: reference/extensions.md + - name: Presets + href: reference/presets.md + - name: Workflows + href: reference/workflows.md + # Development workflows - name: Development items: - name: Local Development href: local-development.md + +# Community +- name: Community + items: + - name: Presets + href: community/presets.md + - name: Walkthroughs + href: community/walkthroughs.md + - name: Friends + href: community/friends.md diff --git a/docs/upgrade.md b/docs/upgrade.md index 676e5131f0..934be675e2 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -8,7 +8,8 @@ | What to Upgrade | Command | When to Use | |----------------|---------|-------------| -| **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 | +| **CLI Tool Only** | `uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z` | Get latest CLI features without touching project files | +| **CLI Tool Only (pipx)** | `pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z` | Reinstall/upgrade a pipx-installed CLI to a specific release | | **Project Files** | `specify init --here --force --ai ` | Update slash commands, templates, and scripts in your project | | **Both** | Run CLI upgrade, then project update | Recommended for major version updates | @@ -20,16 +21,26 @@ The CLI tool (`specify`) is separate from your project files. Upgrade it to get ### If you installed with `uv tool install` +Upgrade to a specific release (check [Releases](https://github.com/github/spec-kit/releases) for the latest tag): + ```bash -uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git +uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z ``` ### If you use one-shot `uvx` commands -No upgrade needed—`uvx` always fetches the latest version. Just run your commands as normal: +Specify the desired release tag: ```bash -uvx --from git+https://github.com/github/spec-kit.git specify init --here --ai copilot +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --ai copilot +``` + +### If you installed with `pipx` + +Upgrade to a specific release: + +```bash +pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z ``` ### Verify the upgrade @@ -51,8 +62,8 @@ When Spec Kit releases new features (like new slash commands or updated template Running `specify init --here --force` will update: - āœ… **Slash command files** (`.claude/commands/`, `.github/prompts/`, etc.) -- āœ… **Script files** (`.specify/scripts/`) -- āœ… **Template files** (`.specify/templates/`) +- āœ… **Script files** (`.specify/scripts/`) — **only with `--force`**; without it, only missing files are added +- āœ… **Template files** (`.specify/templates/`) — **only with `--force`**; without it, only missing files are added - āœ… **Shared memory files** (`.specify/memory/`) - **āš ļø See warnings below** ### What stays safe? @@ -74,7 +85,7 @@ Run this inside your project directory: specify init --here --force --ai ``` -Replace `` with your AI assistant. Refer to this list of [Supported AI Agents](../README.md#-supported-ai-agents) +Replace `` with your AI coding agent. Refer to this list of [Supported AI Coding Agent Integrations](reference/integrations.md) **Example:** @@ -92,7 +103,9 @@ Template files will be merged with existing content and may overwrite existing f Proceed? [y/N] ``` -With `--force`, it skips the confirmation and proceeds immediately. +With `--force`, it skips the confirmation and proceeds immediately. It also **overwrites shared infrastructure files** (`.specify/scripts/` and `.specify/templates/`) with the latest versions from the installed Spec Kit release. + +Without `--force`, shared infrastructure files that already exist are skipped — the CLI will print a warning listing the skipped files so you know which ones were not updated. **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. @@ -124,13 +137,14 @@ Or use git to restore it: git restore .specify/memory/constitution.md ``` -### 2. Custom template modifications +### 2. Custom script or template modifications -If you customized any templates in `.specify/templates/`, the upgrade will overwrite them. Back them up first: +If you customized files in `.specify/scripts/` or `.specify/templates/`, the `--force` flag will overwrite them. Back them up first: ```bash -# Back up custom templates +# Back up custom templates and scripts cp -r .specify/templates .specify/templates-backup +cp -r .specify/scripts .specify/scripts-backup # After upgrade, merge your changes back manually ``` @@ -289,8 +303,9 @@ This tells Spec Kit which feature directory to use when creating specs, plans, a ```bash ls -la .claude/commands/ # Claude Code - ls -la .gemini/commands/ # Gemini - ls -la .cursor/commands/ # Cursor + ls -la .gemini/commands/ # Gemini + ls -la .cursor/skills/ # Cursor + ls -la .pi/prompts/ # Pi Coding Agent ``` 3. **Check agent-specific setup:** @@ -398,7 +413,7 @@ The `specify` CLI tool is used for: - **Upgrades:** `specify init --here --force` to update templates and commands - **Diagnostics:** `specify check` to verify tool installation -Once 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/`, etc.). Your AI assistant reads these command files directly—no need to run `specify` again. +Once 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 coding agent reads these command files directly—no need to run `specify` again. **If your agent isn't recognizing slash commands:** @@ -410,6 +425,9 @@ Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/s # For Claude ls -la .claude/commands/ + + # For Pi + ls -la .pi/prompts/ ``` 2. **Restart your IDE/editor completely** (not just reload window) diff --git a/extensions/EXTENSION-API-REFERENCE.md b/extensions/EXTENSION-API-REFERENCE.md index 9764ca8315..ce0ff1775c 100644 --- a/extensions/EXTENSION-API-REFERENCE.md +++ b/extensions/EXTENSION-API-REFERENCE.md @@ -44,7 +44,7 @@ provides: - name: string # Required, pattern: ^speckit\.[a-z0-9-]+\.[a-z0-9-]+$ file: string # Required, relative path to command file description: string # Required - aliases: [string] # Optional, array of alternate names + aliases: [string] # Optional, same pattern as name; namespace must match extension.id and must not shadow core or installed extension commands config: # Optional, array of config files - name: string # Config file name @@ -53,7 +53,7 @@ provides: required: boolean # Default: false hooks: # Optional, event hooks - event_name: # e.g., "after_tasks", "after_implement" + event_name: # e.g., "after_specify", "after_plan", "after_tasks", "after_implement" command: string # Command to execute optional: boolean # Default: true prompt: string # Prompt text for optional hooks @@ -108,7 +108,7 @@ defaults: # Optional, default configuration values #### `hooks` - **Type**: object -- **Keys**: Event names (e.g., `after_tasks`, `after_implement`, `before_commit`) +- **Keys**: Event names (e.g., `after_specify`, `after_plan`, `after_tasks`, `after_implement`, `before_analyze`) - **Description**: Hooks that execute at lifecycle events - **Events**: Defined by core spec-kit commands @@ -243,6 +243,34 @@ manager.check_compatibility( ) # Raises: CompatibilityError if incompatible ``` +### CatalogEntry + +**Module**: `specify_cli.extensions` + +Represents a single catalog in the active catalog stack. + +```python +from specify_cli.extensions import CatalogEntry + +entry = CatalogEntry( + url="https://example.com/catalog.json", + name="default", + priority=1, + install_allowed=True, + description="Built-in catalog of installable extensions", +) +``` + +**Fields**: + +| Field | Type | Description | +|-------|------|-------------| +| `url` | `str` | Catalog URL (must use HTTPS, or HTTP for localhost) | +| `name` | `str` | Human-readable catalog name | +| `priority` | `int` | Sort order (lower = higher priority, wins on conflicts) | +| `install_allowed` | `bool` | Whether extensions from this catalog can be installed | +| `description` | `str` | Optional human-readable description of the catalog (default: empty) | + ### ExtensionCatalog **Module**: `specify_cli.extensions` @@ -253,30 +281,67 @@ from specify_cli.extensions import ExtensionCatalog catalog = ExtensionCatalog(project_root) ``` +**Class attributes**: + +```python +ExtensionCatalog.DEFAULT_CATALOG_URL # default catalog URL +ExtensionCatalog.COMMUNITY_CATALOG_URL # community catalog URL +``` + **Methods**: ```python -# Fetch catalog +# Get the ordered list of active catalogs +entries = catalog.get_active_catalogs() # List[CatalogEntry] + +# Fetch catalog (primary catalog, backward compat) catalog_data = catalog.fetch_catalog(force_refresh: bool = False) # Dict -# Search extensions +# Search extensions across all active catalogs +# Each result includes _catalog_name and _install_allowed results = catalog.search( query: Optional[str] = None, tag: Optional[str] = None, author: Optional[str] = None, verified_only: bool = False -) # Returns: List[Dict] +) # Returns: List[Dict] — each dict includes _catalog_name, _install_allowed -# Get extension info +# Get extension info (searches all active catalogs) +# Returns None if not found; includes _catalog_name and _install_allowed ext_info = catalog.get_extension_info(extension_id: str) # Optional[Dict] -# Check cache validity +# Check cache validity (primary catalog) is_valid = catalog.is_cache_valid() # bool -# Clear cache +# Clear all catalog caches catalog.clear_cache() ``` +**Result annotation fields**: + +Each extension dict returned by `search()` and `get_extension_info()` includes: + +| Field | Type | Description | +|-------|------|-------------| +| `_catalog_name` | `str` | Name of the source catalog | +| `_install_allowed` | `bool` | Whether installation is allowed from this catalog | + +**Catalog config file** (`.specify/extension-catalogs.yml`): + +```yaml +catalogs: + - name: "default" + url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json" + priority: 1 + install_allowed: true + description: "Built-in catalog of installable extensions" + - name: "community" + url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json" + priority: 2 + install_allowed: false + description: "Community-contributed extensions (discovery only)" +``` + ### HookExecutor **Module**: `specify_cli.extensions` @@ -486,10 +551,24 @@ hooks: Standard events (defined by core): +- `before_specify` - Before specification generation +- `after_specify` - After specification generation +- `before_plan` - Before implementation planning +- `after_plan` - After implementation planning +- `before_tasks` - Before task generation - `after_tasks` - After task generation +- `before_implement` - Before implementation - `after_implement` - After implementation -- `before_commit` - Before git commit -- `after_commit` - After git commit +- `before_analyze` - Before cross-artifact analysis +- `after_analyze` - After cross-artifact analysis +- `before_checklist` - Before checklist generation +- `after_checklist` - After checklist generation +- `before_clarify` - Before spec clarification +- `after_clarify` - After spec clarification +- `before_constitution` - Before constitution update +- `after_constitution` - After constitution update +- `before_taskstoissues` - Before tasks-to-issues conversion +- `after_taskstoissues` - After tasks-to-issues conversion ### Hook Configuration @@ -543,6 +622,39 @@ EXECUTE_COMMAND: {command} **Output**: List of installed extensions with metadata +### extension catalog list + +**Usage**: `specify extension catalog list` + +Lists all active catalogs in the current catalog stack, showing name, description, URL, priority, and `install_allowed` status. + +### extension catalog add + +**Usage**: `specify extension catalog add URL [OPTIONS]` + +**Options**: + +- `--name NAME` - Catalog name (required) +- `--priority INT` - Priority (lower = higher priority, default: 10) +- `--install-allowed / --no-install-allowed` - Allow installs from this catalog (default: false) +- `--description TEXT` - Optional description of the catalog + +**Arguments**: + +- `URL` - Catalog URL (must use HTTPS) + +Adds a catalog entry to `.specify/extension-catalogs.yml`. + +### extension catalog remove + +**Usage**: `specify extension catalog remove NAME` + +**Arguments**: + +- `NAME` - Catalog name to remove + +Removes a catalog entry from `.specify/extension-catalogs.yml`. + ### extension add **Usage**: `specify extension add EXTENSION [OPTIONS]` @@ -551,13 +663,13 @@ EXECUTE_COMMAND: {command} - `--from URL` - Install from custom URL - `--dev PATH` - Install from local directory -- `--version VERSION` - Install specific version -- `--no-register` - Skip command registration **Arguments**: - `EXTENSION` - Extension name or URL +**Note**: Extensions from catalogs with `install_allowed: false` cannot be installed via this command. + ### extension remove **Usage**: `specify extension remove EXTENSION [OPTIONS]` @@ -575,6 +687,8 @@ EXECUTE_COMMAND: {command} **Usage**: `specify extension search [QUERY] [OPTIONS]` +Searches all active catalogs simultaneously. Results include source catalog name and install_allowed status. + **Options**: - `--tag TAG` - Filter by tag @@ -589,6 +703,8 @@ EXECUTE_COMMAND: {command} **Usage**: `specify extension info EXTENSION` +Shows source catalog and install_allowed status. + **Arguments**: - `EXTENSION` - Extension ID diff --git a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md index ff7a3aabe5..dfc1125228 100644 --- a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md +++ b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md @@ -41,7 +41,7 @@ provides: - name: "speckit.my-ext.hello" # Must follow pattern: speckit.{ext-id}.{cmd} file: "commands/hello.md" description: "Say hello" - aliases: ["speckit.hello"] # Optional aliases + aliases: ["speckit.my-ext.hi"] # Optional aliases, same pattern config: # Optional: Config files - name: "my-ext-config.yml" @@ -177,16 +177,16 @@ Compatibility requirements. What the extension provides. -**Required sub-fields**: +**Optional sub-fields**: -- `commands`: Array of command objects (must have at least one) +- `commands`: Array of command objects (at least one command or hook is required) **Command object**: - `name`: Command name (must match `speckit.{ext-id}.{command}`) - `file`: Path to command file (relative to extension root) - `description`: Command description (optional) -- `aliases`: Alternative command names (optional, array) +- `aliases`: Alternative command names (optional, array; each must match `speckit.{ext-id}.{command}`) ### Optional Fields @@ -196,12 +196,19 @@ Integration hooks for automatic execution. Available hook points: -- `after_tasks`: After `/speckit.tasks` completes -- `after_implement`: After `/speckit.implement` completes (future) +- `before_specify` / `after_specify`: Before/after specification generation +- `before_plan` / `after_plan`: Before/after implementation planning +- `before_tasks` / `after_tasks`: Before/after task generation +- `before_implement` / `after_implement`: Before/after implementation +- `before_analyze` / `after_analyze`: Before/after cross-artifact analysis +- `before_checklist` / `after_checklist`: Before/after checklist generation +- `before_clarify` / `after_clarify`: Before/after spec clarification +- `before_constitution` / `after_constitution`: Before/after constitution update +- `before_taskstoissues` / `after_taskstoissues`: Before/after tasks-to-issues conversion Hook object: -- `command`: Command to execute (must be in `provides.commands`) +- `command`: Command to execute (typically from `provides.commands`, but can reference any registered command) - `optional`: If true, prompt user before executing - `prompt`: Prompt text for optional hooks - `description`: Hook description @@ -332,6 +339,67 @@ echo "$config" --- +## Excluding Files with `.extensionignore` + +Extension 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. + +### Format + +The file uses `.gitignore`-compatible patterns (one per line), powered by the [`pathspec`](https://pypi.org/project/pathspec/) library: + +- Blank lines are ignored +- Lines starting with `#` are comments +- `*` matches anything **except** `/` (does not cross directory boundaries) +- `**` matches zero or more directories (e.g., `docs/**/*.draft.md`) +- `?` matches any single character except `/` +- A trailing `/` restricts a pattern to directories only +- Patterns containing `/` (other than a trailing slash) are anchored to the extension root +- Patterns without `/` match at any depth in the tree +- `!` negates a previously excluded pattern (re-includes a file) +- Backslashes in patterns are normalised to forward slashes for cross-platform compatibility +- The `.extensionignore` file itself is always excluded automatically + +### Example + +```gitignore +# .extensionignore + +# Development files +tests/ +.github/ +.gitignore + +# Build artifacts +__pycache__/ +*.pyc +dist/ + +# Documentation source (keep only the built README) +docs/ +CONTRIBUTING.md +``` + +### Pattern Matching + +| Pattern | Matches | Does NOT match | +|---------|---------|----------------| +| `*.pyc` | Any `.pyc` file in any directory | — | +| `tests/` | The `tests` directory (and all its contents) | A file named `tests` | +| `docs/*.draft.md` | `docs/api.draft.md` (directly inside `docs/`) | `docs/sub/api.draft.md` (nested) | +| `.env` | The `.env` file at any level | — | +| `!README.md` | Re-includes `README.md` even if matched by an earlier pattern | — | +| `docs/**/*.draft.md` | `docs/api.draft.md`, `docs/sub/api.draft.md` | — | + +### Unsupported Features + +The following `.gitignore` features are **not applicable** in this context: + +- **Multiple `.extensionignore` files**: Only a single file at the extension root is supported (`.gitignore` supports files in subdirectories) +- **`$GIT_DIR/info/exclude` and `core.excludesFile`**: These are Git-specific and have no equivalent here +- **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/`). + +--- + ## Validation Rules ### Extension ID @@ -453,21 +521,23 @@ zip -r spec-kit-my-ext-1.0.0.zip extension.yml commands/ scripts/ docs/ Users install with: ```bash -specify extension add --from https://github.com/.../spec-kit-my-ext-1.0.0.zip +specify extension add --from https://github.com/.../spec-kit-my-ext-1.0.0.zip ``` -### Option 3: Extension Catalog (Future) +### Option 3: Community Reference Catalog -Submit to official catalog: +Submit to the community catalog for public discovery: 1. **Fork** spec-kit repository -2. **Add entry** to `extensions/catalog.json` -3. **Create PR** -4. **After merge**, users can install with: - - ```bash - specify extension add my-ext # No URL needed! - ``` +2. **Add entry** to `extensions/catalog.community.json` +3. **Update** the Community Extensions table in `README.md` with your extension +4. **Create PR** following the [Extension Publishing Guide](EXTENSION-PUBLISHING-GUIDE.md) +5. **After merge**, your extension becomes available: + - Users can browse `catalog.community.json` to discover your extension + - Users copy the entry to their own `catalog.json` + - Users install with: `specify extension add my-ext` (from their catalog) + +See the [Extension Publishing Guide](EXTENSION-PUBLISHING-GUIDE.md) for detailed submission instructions. --- diff --git a/extensions/EXTENSION-PUBLISHING-GUIDE.md b/extensions/EXTENSION-PUBLISHING-GUIDE.md index 10eacbf909..1433738743 100644 --- a/extensions/EXTENSION-PUBLISHING-GUIDE.md +++ b/extensions/EXTENSION-PUBLISHING-GUIDE.md @@ -122,33 +122,39 @@ Test that users can install from your release: specify extension add --dev /path/to/your-extension # Test from GitHub archive -specify extension add --from https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip +specify extension add --from https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip ``` --- ## Submit to Catalog +### Understanding the Catalogs + +Spec Kit uses a dual-catalog system. For details about how catalogs work, see the main [Extensions README](README.md#extension-catalogs). + +**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`. + ### 1. Fork the spec-kit Repository ```bash # Fork on GitHub -# https://github.com/statsperform/spec-kit/fork +# https://github.com/github/spec-kit/fork # Clone your fork git clone https://github.com/YOUR-USERNAME/spec-kit.git cd spec-kit ``` -### 2. Add Extension to Catalog +### 2. Add Extension to Community Catalog -Edit `extensions/catalog.json` and add your extension: +Edit `extensions/catalog.community.json` and add your extension: ```json { "schema_version": "1.0", "updated_at": "2026-01-28T15:54:00Z", - "catalog_url": "https://raw.githubusercontent.com/statsperform/spec-kit/main/extensions/catalog.json", + "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "your-extension": { "name": "Your Extension Name", @@ -198,15 +204,38 @@ Edit `extensions/catalog.json` and add your extension: - Use current timestamp for `created_at` and `updated_at` - Update the top-level `updated_at` to current time -### 3. Submit Pull Request +### 3. Update Community Extensions Table + +Add your extension to the Community Extensions table in the project root `README.md`: + +```markdown +| Your Extension Name | Brief description of what it does | `` | | [repo-name](https://github.com/your-org/spec-kit-your-extension) | +``` + +**(Table) Category** — pick the one that best fits your extension: + +- `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 + +**Effect** — choose one: + +- Read-only — produces reports without modifying files +- Read+Write — modifies files, creates artifacts, or updates specs + +Insert your extension in alphabetical order in the table. + +### 4. Submit Pull Request ```bash # Create a branch git checkout -b add-your-extension # Commit your changes -git add extensions/catalog.json -git commit -m "Add your-extension to catalog +git add extensions/catalog.community.json README.md +git commit -m "Add your-extension to community catalog - Extension ID: your-extension - Version: 1.0.0 @@ -218,7 +247,7 @@ git commit -m "Add your-extension to catalog git push origin add-your-extension # Create Pull Request on GitHub -# https://github.com/statsperform/spec-kit/compare +# https://github.com/github/spec-kit/compare ``` **Pull Request Template**: @@ -243,6 +272,8 @@ Brief description of what your extension does. - [x] Extension tested on real project - [x] All commands working - [x] No security vulnerabilities +- [x] Added to extensions/catalog.community.json +- [x] Added to Community Extensions table in README.md ### Testing Tested on: diff --git a/extensions/EXTENSION-USER-GUIDE.md b/extensions/EXTENSION-USER-GUIDE.md index 46e87cf6cc..595985d955 100644 --- a/extensions/EXTENSION-USER-GUIDE.md +++ b/extensions/EXTENSION-USER-GUIDE.md @@ -46,7 +46,7 @@ Extensions are modular packages that add new commands and functionality to Spec ### Check Your Version ```bash -specify --version +specify version # Should show 0.1.0 or higher ``` @@ -76,13 +76,15 @@ vim .specify/extensions/jira/jira-config.yml ## Finding Extensions +`specify extension search` searches **all active catalogs** simultaneously, including the community catalog by default. Results are annotated with their source catalog and install status. + ### Browse All Extensions ```bash specify extension search ``` -Shows all available extensions in the catalog. +Shows all extensions across all active catalogs (default and community by default). ### Search by Keyword @@ -158,7 +160,7 @@ This will: ```bash # From GitHub release -specify extension add --from https://github.com/org/spec-kit-ext/archive/refs/tags/v1.0.0.zip +specify extension add --from https://github.com/org/spec-kit-ext/archive/refs/tags/v1.0.0.zip ``` ### Install from Local Directory (Development) @@ -185,6 +187,21 @@ Provided commands: Check: .specify/extensions/jira/ ``` +### Automatic Agent Skill Registration + +If your project was initialized with `--ai-skills`, extension commands are **automatically registered as agent skills** during installation. This ensures that extensions are discoverable by agents that use the [agentskills.io](https://agentskills.io) skill specification. + +```text +āœ“ Extension installed successfully! + +Jira Integration (v1.0.0) + ... + +āœ“ 3 agent skill(s) auto-registered +``` + +When an extension is removed, its corresponding skills are also cleaned up automatically. Pre-existing skills that were manually customized are never overwritten. + --- ## Using Extensions @@ -197,8 +214,8 @@ Extensions add commands that appear in your AI agent (Claude Code): # In Claude Code > /speckit.jira.specstoissues -# Or use short alias (if provided) -> /speckit.specstoissues +# Or use a namespaced alias (if provided) +> /speckit.jira.sync ``` ### Extension Configuration @@ -385,6 +402,11 @@ settings: auto_execute_hooks: true # Hook configuration +# Available events: before_specify, after_specify, before_plan, after_plan, +# before_tasks, after_tasks, before_implement, after_implement, +# before_analyze, after_analyze, before_checklist, after_checklist, +# before_clarify, after_clarify, before_constitution, after_constitution, +# before_taskstoissues, after_taskstoissues hooks: after_tasks: - extension: jira @@ -400,13 +422,13 @@ In addition to extension-specific environment variables (`SPECKIT_{EXT_ID}_*`), | Variable | Description | Default | |----------|-------------|---------| -| `SPECKIT_CATALOG_URL` | Override the extension catalog URL | GitHub-hosted catalog | +| `SPECKIT_CATALOG_URL` | Override the full catalog stack with a single URL (backward compat) | Built-in default stack | | `GH_TOKEN` / `GITHUB_TOKEN` | GitHub API token for downloads | None | #### Example: Using a custom catalog for testing ```bash -# Point to a local or alternative catalog +# Point to a local or alternative catalog (replaces the full stack) export SPECKIT_CATALOG_URL="http://localhost:8000/catalog.json" # Or use a staging catalog @@ -415,11 +437,98 @@ export SPECKIT_CATALOG_URL="https://example.com/staging/catalog.json" --- +## Extension Catalogs + +Spec Kit uses a **catalog stack** — an ordered list of catalogs searched simultaneously. By default, two catalogs are active: + +| Priority | Catalog | Install Allowed | Purpose | +|----------|---------|-----------------|---------| +| 1 | `catalog.json` (default) | āœ… Yes | Curated extensions available for installation | +| 2 | `catalog.community.json` (community) | āŒ No (discovery only) | Browse community extensions | + +### Listing Active Catalogs + +```bash +specify extension catalog list +``` + +### Managing Catalogs via CLI + +You can view the main catalog management commands using `--help`: + +```text +specify extension catalog --help + + Usage: specify extension catalog [OPTIONS] COMMAND [ARGS]... + + Manage extension catalogs +╭─ Options ────────────────────────────────────────────────────────────────────────╮ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────╯ +╭─ Commands ───────────────────────────────────────────────────────────────────────╮ +│ list List all active extension catalogs. │ +│ add Add a catalog to .specify/extension-catalogs.yml. │ +│ remove Remove a catalog from .specify/extension-catalogs.yml. │ +╰──────────────────────────────────────────────────────────────────────────────────╯ +``` + +### Adding a Catalog (Project-scoped) + +```bash +# Add an internal catalog that allows installs +specify extension catalog add \ + --name "internal" \ + --priority 2 \ + --install-allowed \ + https://internal.company.com/spec-kit/catalog.json + +# Add a discovery-only catalog +specify extension catalog add \ + --name "partner" \ + --priority 5 \ + https://partner.example.com/spec-kit/catalog.json +``` + +This creates or updates `.specify/extension-catalogs.yml`. + +### Removing a Catalog + +```bash +specify extension catalog remove internal +``` + +### Manual Config File + +You can also edit `.specify/extension-catalogs.yml` directly: + +```yaml +catalogs: + - name: "default" + url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json" + priority: 1 + install_allowed: true + description: "Built-in catalog of installable extensions" + + - name: "internal" + url: "https://internal.company.com/spec-kit/catalog.json" + priority: 2 + install_allowed: true + description: "Internal company extensions" + + - name: "community" + url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json" + priority: 3 + install_allowed: false + description: "Community-contributed extensions (discovery only)" +``` + +A 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. + ## Organization Catalog Customization -### Why the Default Catalog is Empty +### Why Customize Your Catalog -The default spec-kit catalog ships empty by design. This allows organizations to: +Organizations customize their catalogs to: - **Control available extensions** - Curate which extensions your team can install - **Host private extensions** - Internal tools that shouldn't be public @@ -497,24 +606,40 @@ Options for hosting your catalog: #### 3. Configure Your Environment -##### Option A: Environment variable (recommended for CI/CD) +##### Option A: Catalog stack config file (recommended) -```bash -# In ~/.bashrc, ~/.zshrc, or CI pipeline -export SPECKIT_CATALOG_URL="https://your-org.com/spec-kit/catalog.json" +Add to `.specify/extension-catalogs.yml` in your project: + +```yaml +catalogs: + - name: "my-org" + url: "https://your-org.com/spec-kit/catalog.json" + priority: 1 + install_allowed: true ``` -##### Option B: Per-project configuration +Or use the CLI: -Create `.env` or set in your shell before running spec-kit commands: +```bash +specify extension catalog add \ + --name "my-org" \ + --install-allowed \ + https://your-org.com/spec-kit/catalog.json +``` + +##### Option B: Environment variable (recommended for CI/CD, single-catalog) ```bash -SPECKIT_CATALOG_URL="https://your-org.com/spec-kit/catalog.json" specify extension search +# In ~/.bashrc, ~/.zshrc, or CI pipeline +export SPECKIT_CATALOG_URL="https://your-org.com/spec-kit/catalog.json" ``` #### 4. Verify Configuration ```bash +# List active catalogs +specify extension catalog list + # Search should now show your catalog's extensions specify extension search @@ -614,7 +739,7 @@ You can still install extensions not in your catalog using `--from`: specify extension add jira # Direct URL (bypasses catalog) -specify extension add --from https://github.com/someone/spec-kit-ext/archive/v1.0.0.zip +specify extension add --from https://github.com/someone/spec-kit-ext/archive/v1.0.0.zip # Local development specify extension add --dev /path/to/extension @@ -684,7 +809,7 @@ specify extension add --dev /path/to/extension 2. Install older version of extension: ```bash - specify extension add --from https://github.com/org/ext/archive/v1.0.0.zip + specify extension add --from https://github.com/org/ext/archive/v1.0.0.zip ``` ### MCP Tool Not Available diff --git a/extensions/README.md b/extensions/README.md new file mode 100644 index 0000000000..f535ba539a --- /dev/null +++ b/extensions/README.md @@ -0,0 +1,125 @@ +# Spec Kit Extensions + +Extension system for [Spec Kit](https://github.com/github/spec-kit) - add new functionality without bloating the core framework. + +## Extension Catalogs + +Spec Kit provides two catalog files with different purposes: + +### Your Catalog (`catalog.json`) + +- **Purpose**: Default upstream catalog of extensions used by the Spec Kit CLI +- **Default State**: Empty by design in the upstream project - you or your organization populate a fork/copy with extensions you trust +- **Location (upstream)**: `extensions/catalog.json` in the GitHub-hosted spec-kit repo +- **CLI Default**: The `specify extension` commands use the upstream catalog URL by default, unless overridden +- **Org Catalog**: Point `SPECKIT_CATALOG_URL` at your organization's fork or hosted catalog JSON to use it instead of the upstream default +- **Customization**: Copy entries from the community catalog into your org catalog, or add your own extensions directly + +**Example override:** +```bash +# Override the default upstream catalog with your organization's catalog +export SPECKIT_CATALOG_URL="https://your-org.com/spec-kit/catalog.json" +specify extension search # Now uses your organization's catalog instead of the upstream default +``` + +### Community Reference Catalog (`catalog.community.json`) + +> [!NOTE] +> Community extensions are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting, catalog structure, or policy compliance, but they do **not review, audit, endorse, or support the extension code itself**. Review extension source code before installation and use at your own discretion. + +- **Purpose**: Browse available community-contributed extensions +- **Status**: Active - contains extensions submitted by the community +- **Location**: `extensions/catalog.community.json` +- **Usage**: Reference catalog for discovering available extensions +- **Submission**: Open to community contributions via Pull Request + +**How It Works:** + +## Making Extensions Available + +You control which extensions your team can discover and install: + +### Option 1: Curated Catalog (Recommended for Organizations) + +Populate your `catalog.json` with approved extensions: + +1. **Discover** extensions from various sources: + - Browse `catalog.community.json` for community extensions + - Find private/internal extensions in your organization's repos + - Discover extensions from trusted third parties +2. **Review** extensions and choose which ones you want to make available +3. **Add** those extension entries to your own `catalog.json` +4. **Team members** can now discover and install them: + - `specify extension search` shows your curated catalog + - `specify extension add ` installs from your catalog + +**Benefits**: Full control over available extensions, team consistency, organizational approval workflow + +**Example**: Copy an entry from `catalog.community.json` to your `catalog.json`, then your team can discover and install it by name. + +### Option 2: Direct URLs (For Ad-hoc Use) + +Skip catalog curation - team members install directly using URLs: + +```bash +specify extension add --from https://github.com/org/spec-kit-ext/archive/refs/tags/v1.0.0.zip +``` + +**Benefits**: Quick for one-off testing or private extensions + +**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`. + +## Available Community Extensions + +> [!NOTE] +> Community extensions are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting, catalog structure, or policy compliance, but they do **not review, audit, endorse, or support the extension code itself**. The Community Extensions website is also a third-party resource. Review extension source code before installation and use at your own discretion. + +šŸ” **Browse and search community extensions on the [Community Extensions website](https://speckit-community.github.io/extensions/).** + +See the [Community Extensions](../README.md#-community-extensions) section in the main README for the full list of available community-contributed extensions. + +For the raw catalog data, see [`catalog.community.json`](catalog.community.json). + + +## Adding Your Extension + +### Submission Process + +To add your extension to the community catalog: + +1. **Prepare your extension** following the [Extension Development Guide](EXTENSION-DEVELOPMENT-GUIDE.md) +2. **Create a GitHub release** for your extension +3. **Submit a Pull Request** that: + - Adds your extension to `extensions/catalog.community.json` + - Updates this README with your extension in the Available Extensions table +4. **Wait for review** - maintainers will review and merge if criteria are met + +See the [Extension Publishing Guide](EXTENSION-PUBLISHING-GUIDE.md) for detailed step-by-step instructions. + +### Submission Checklist + +Before submitting, ensure: + +- āœ… Valid `extension.yml` manifest +- āœ… Complete README with installation and usage instructions +- āœ… LICENSE file included +- āœ… GitHub release created with semantic version (e.g., v1.0.0) +- āœ… Extension tested on a real project +- āœ… All commands working as documented + +## Installing Extensions +Once extensions are available (either in your catalog or via direct URL), install them: + +```bash +# From your curated catalog (by name) +specify extension search # See what's in your catalog +specify extension add # Install by name + +# Direct from URL (bypasses catalog) +specify extension add --from https://github.com///archive/refs/tags/.zip + +# List installed extensions +specify extension list +``` + +For more information, see the [Extension User Guide](EXTENSION-USER-GUIDE.md). diff --git a/extensions/RFC-EXTENSION-SYSTEM.md b/extensions/RFC-EXTENSION-SYSTEM.md index 3bfa0ea060..dd4c97e8a2 100644 --- a/extensions/RFC-EXTENSION-SYSTEM.md +++ b/extensions/RFC-EXTENSION-SYSTEM.md @@ -1,9 +1,9 @@ # RFC: Spec Kit Extension System -**Status**: Draft +**Status**: Implemented **Author**: Stats Perform Engineering **Created**: 2026-01-28 -**Updated**: 2026-01-28 +**Updated**: 2026-03-11 --- @@ -24,8 +24,9 @@ 13. [Security Considerations](#security-considerations) 14. [Migration Strategy](#migration-strategy) 15. [Implementation Phases](#implementation-phases) -16. [Open Questions](#open-questions) -17. [Appendices](#appendices) +16. [Resolved Questions](#resolved-questions) +17. [Open Questions (Remaining)](#open-questions-remaining) +18. [Appendices](#appendices) --- @@ -222,7 +223,7 @@ provides: - name: "speckit.jira.specstoissues" file: "commands/specstoissues.md" description: "Create Jira hierarchy from spec and tasks" - aliases: ["speckit.specstoissues"] # Alternate names + aliases: ["speckit.jira.sync"] # Alternate names - name: "speckit.jira.discover-fields" file: "commands/discover-fields.md" @@ -358,12 +359,15 @@ specify extension add jira "installed_at": "2026-01-28T14:30:00Z", "source": "catalog", "manifest_hash": "sha256:abc123...", - "enabled": true + "enabled": true, + "priority": 10 } } } ``` +**Priority Field**: Extensions are ordered by `priority` (lower = higher precedence). Default is 10. Used for template resolution when multiple extensions provide the same template. + ### 3. Configuration ```bash @@ -858,11 +862,41 @@ def should_execute_hook(hook: dict, config: dict) -> bool: ## Extension Discovery & Catalog -### Central Catalog +### Dual Catalog System + +Spec Kit uses two catalog files with different purposes: + +#### User Catalog (`catalog.json`) **URL**: `https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json` -**Format**: +- **Purpose**: Organization's curated catalog of approved extensions +- **Default State**: Empty by design - users populate with extensions they trust +- **Usage**: Primary catalog (priority 1, `install_allowed: true`) in the default stack +- **Control**: Organizations maintain their own fork/version for their teams + +#### Community Reference Catalog (`catalog.community.json`) + +**URL**: `https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json` + +- **Purpose**: Reference catalog of available community-contributed extensions +- **Verification**: Community extensions may have `verified: false` initially +- **Status**: Active - open for community contributions +- **Submission**: Via Pull Request following the Extension Publishing Guide +- **Usage**: Secondary catalog (priority 2, `install_allowed: false`) in the default stack — discovery only + +**How It Works (default stack):** + +1. **Discover**: `specify extension search` searches both catalogs — community extensions appear automatically +2. **Review**: Evaluate community extensions for security, quality, and organizational fit +3. **Curate**: Copy approved entries from community catalog to your `catalog.json`, or add to `.specify/extension-catalogs.yml` with `install_allowed: true` +4. **Install**: Use `specify extension add ` — only allowed from `install_allowed: true` catalogs + +This approach gives organizations full control over which extensions can be installed while still providing community discoverability out of the box. + +### Catalog Format + +**Format** (same for both catalogs): ```json { @@ -931,24 +965,113 @@ specify extension info jira ### Custom Catalogs -Organizations can host private catalogs: +Spec 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. + +#### Catalog Stack Resolution + +The active catalog stack is resolved in this order (first match wins): + +1. **`SPECKIT_CATALOG_URL` environment variable** — single catalog replacing all defaults (backward compat) +2. **Project-level `.specify/extension-catalogs.yml`** — full control for the project +3. **User-level `~/.specify/extension-catalogs.yml`** — personal defaults +4. **Built-in default stack** — `catalog.json` (install_allowed: true) + `catalog.community.json` (install_allowed: false) + +#### Default Built-in Stack + +When no config file exists, the CLI uses: + +| Priority | Catalog | install_allowed | Purpose | +|----------|---------|-----------------|---------| +| 1 | `catalog.json` (default) | `true` | Curated extensions available for installation | +| 2 | `catalog.community.json` (community) | `false` | Discovery only — browse but not install | + +This 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`. + +#### `.specify/extension-catalogs.yml` Config File + +```yaml +catalogs: + - name: "default" + url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json" + priority: 1 # Highest — only approved entries can be installed + install_allowed: true + description: "Built-in catalog of installable extensions" + + - name: "internal" + url: "https://internal.company.com/spec-kit/catalog.json" + priority: 2 + install_allowed: true + description: "Internal company extensions" + + - name: "community" + url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json" + priority: 3 # Lowest — discovery only, not installable + install_allowed: false + description: "Community-contributed extensions (discovery only)" +``` + +A 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. + +#### Catalog CLI Commands ```bash -# Add custom catalog -specify extension add-catalog https://internal.company.com/spec-kit/catalog.json +# List active catalogs with name, URL, priority, and install_allowed +specify extension catalog list + +# Add a catalog (project-scoped) +specify extension catalog add --name "internal" --install-allowed \ + https://internal.company.com/spec-kit/catalog.json + +# Add a discovery-only catalog +specify extension catalog add --name "community" \ + https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json + +# Remove a catalog +specify extension catalog remove internal + +# Show which catalog an extension came from +specify extension info jira +# → Source catalog: default +``` + +#### Merge Conflict Resolution + +When 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. + +#### `install_allowed: false` Behavior + +Extensions from discovery-only catalogs are shown in `specify extension search` results but cannot be installed directly: + +``` +⚠ 'linear' is available in the 'community' catalog but installation is not allowed from that catalog. + +To enable installation, add 'linear' to an approved catalog (install_allowed: true) in .specify/extension-catalogs.yml. +``` + +#### `SPECKIT_CATALOG_URL` (Backward Compatibility) + +The `SPECKIT_CATALOG_URL` environment variable still works — it is treated as a single `install_allowed: true` catalog, **replacing both defaults** for full backward compatibility: -# Set as default -specify extension set-catalog --default https://internal.company.com/spec-kit/catalog.json +```bash +# Point to your organization's catalog +export SPECKIT_CATALOG_URL="https://internal.company.com/spec-kit/catalog.json" -# List catalogs -specify extension catalogs +# All extension commands now use your custom catalog +specify extension search # Uses custom catalog +specify extension add jira # Installs from custom catalog ``` -**Catalog priority**: +**Requirements:** +- URL must use HTTPS (HTTP only allowed for localhost testing) +- Catalog must follow the standard catalog.json schema +- Must be publicly accessible or accessible within your network -1. Project-specific catalog (`.specify/extension-catalogs.yml`) -2. User-level catalog (`~/.specify/extension-catalogs.yml`) -3. Default GitHub catalog +**Example for testing:** +```bash +# Test with localhost during development +export SPECKIT_CATALOG_URL="http://localhost:8000/catalog.json" +specify extension search +``` --- @@ -964,11 +1087,15 @@ List installed extensions in current project. $ specify extension list Installed Extensions: - āœ“ jira (v1.0.0) - Jira Integration - Commands: 3 | Hooks: 2 | Status: Enabled - - āœ“ linear (v0.9.0) - Linear Integration - Commands: 1 | Hooks: 1 | Status: Enabled + āœ“ Jira Integration (v1.0.0) + jira + Create Jira issues from spec-kit artifacts + Commands: 3 | Hooks: 2 | Priority: 10 | Status: Enabled + + āœ“ Linear Integration (v0.9.0) + linear + Create Linear issues from spec-kit artifacts + Commands: 1 | Hooks: 1 | Priority: 10 | Status: Enabled ``` **Options:** @@ -1076,10 +1203,9 @@ Next steps: **Options:** -- `--from URL`: Install from custom URL or Git repo -- `--version VERSION`: Install specific version -- `--dev PATH`: Install from local path (development mode) -- `--no-register`: Skip command registration (manual setup) +- `--from URL`: Install from a remote URL (archive). Does not accept Git repositories directly. +- `--dev`: Install from a local path in development mode (the PATH is the positional `extension` argument). +- `--priority NUMBER`: Set resolution priority (lower = higher precedence, default 10) #### `specify extension remove NAME` @@ -1160,6 +1286,29 @@ $ specify extension disable jira To re-enable: specify extension enable jira ``` +#### `specify extension set-priority NAME PRIORITY` + +Change the resolution priority of an installed extension. + +```bash +$ specify extension set-priority jira 5 + +āœ“ Extension 'Jira Integration' priority changed: 10 → 5 + +Lower priority = higher precedence in template resolution +``` + +**Priority Values:** + +- Lower numbers = higher precedence (checked first in resolution) +- Default priority is 10 +- Must be a positive integer (1 or higher) + +**Use Cases:** + +- Ensure a critical extension's templates take precedence +- Override default resolution order when multiple extensions provide similar templates + --- ## Compatibility & Versioning @@ -1368,7 +1517,7 @@ specify extension add github-projects /speckit.github.taskstoissues ``` -**Compatibility shim** (if needed): +**Migration alias** (if needed): ```yaml # extension.yml @@ -1376,212 +1525,234 @@ provides: commands: - name: "speckit.github.taskstoissues" file: "commands/taskstoissues.md" - aliases: ["speckit.taskstoissues"] # Backward compatibility + aliases: ["speckit.github.sync-taskstoissues"] # Alternate namespaced entry point ``` -AI agent registers both names, so old scripts work. +AI agents register both names, so callers can migrate to the alternate alias without relying on deprecated global shortcuts like `/speckit.taskstoissues`. --- ## Implementation Phases -### Phase 1: Core Extension System (Week 1-2) +### Phase 1: Core Extension System āœ… COMPLETED **Goal**: Basic extension infrastructure **Deliverables**: -- [ ] Extension manifest schema (`extension.yml`) -- [ ] Extension directory structure -- [ ] CLI commands: - - [ ] `specify extension list` - - [ ] `specify extension add` (from URL) - - [ ] `specify extension remove` -- [ ] Extension registry (`.specify/extensions/.registry`) -- [ ] Command registration (Claude only initially) -- [ ] Basic validation (manifest schema, compatibility) -- [ ] Documentation (extension development guide) +- [x] Extension manifest schema (`extension.yml`) +- [x] Extension directory structure +- [x] CLI commands: + - [x] `specify extension list` + - [x] `specify extension add` (from URL and local `--dev`) + - [x] `specify extension remove` +- [x] Extension registry (`.specify/extensions/.registry`) +- [x] Command registration (Claude and 15+ other agents) +- [x] Basic validation (manifest schema, compatibility) +- [x] Documentation (extension development guide) **Testing**: -- [ ] Unit tests for manifest parsing -- [ ] Integration test: Install dummy extension -- [ ] Integration test: Register commands with Claude +- [x] Unit tests for manifest parsing +- [x] Integration test: Install dummy extension +- [x] Integration test: Register commands with Claude -### Phase 2: Jira Extension (Week 3) +### Phase 2: Jira Extension āœ… COMPLETED **Goal**: First production extension **Deliverables**: -- [ ] Create `spec-kit-jira` repository -- [ ] Port Jira functionality to extension -- [ ] Create `jira-config.yml` template -- [ ] Commands: - - [ ] `specstoissues.md` - - [ ] `discover-fields.md` - - [ ] `sync-status.md` -- [ ] Helper scripts -- [ ] Documentation (README, configuration guide, examples) -- [ ] Release v1.0.0 +- [x] Create `spec-kit-jira` repository +- [x] Port Jira functionality to extension +- [x] Create `jira-config.yml` template +- [x] Commands: + - [x] `specstoissues.md` + - [x] `discover-fields.md` + - [x] `sync-status.md` +- [x] Helper scripts +- [x] Documentation (README, configuration guide, examples) +- [x] Release v3.0.0 **Testing**: -- [ ] Test on `eng-msa-ts` project -- [ ] Verify spec→Epic, phase→Story, task→Issue mapping -- [ ] Test configuration loading and validation -- [ ] Test custom field application +- [x] Test on `eng-msa-ts` project +- [x] Verify spec→Epic, phase→Story, task→Issue mapping +- [x] Test configuration loading and validation +- [x] Test custom field application -### Phase 3: Extension Catalog (Week 4) +### Phase 3: Extension Catalog āœ… COMPLETED **Goal**: Discovery and distribution **Deliverables**: -- [ ] Central catalog (`extensions/catalog.json` in spec-kit repo) -- [ ] Catalog fetch and parsing -- [ ] CLI commands: - - [ ] `specify extension search` - - [ ] `specify extension info` -- [ ] Catalog publishing process (GitHub Action) -- [ ] Documentation (how to publish extensions) +- [x] Central catalog (`extensions/catalog.json` in spec-kit repo) +- [x] Community catalog (`extensions/catalog.community.json`) +- [x] Catalog fetch and parsing with multi-catalog support +- [x] CLI commands: + - [x] `specify extension search` + - [x] `specify extension info` + - [x] `specify extension catalog list` + - [x] `specify extension catalog add` + - [x] `specify extension catalog remove` +- [x] Documentation (how to publish extensions) **Testing**: -- [ ] Test catalog fetch -- [ ] Test extension search/filtering -- [ ] Test catalog caching +- [x] Test catalog fetch +- [x] Test extension search/filtering +- [x] Test catalog caching +- [x] Test multi-catalog merge with priority -### Phase 4: Advanced Features (Week 5-6) +### Phase 4: Advanced Features āœ… COMPLETED **Goal**: Hooks, updates, multi-agent support **Deliverables**: -- [ ] Hook system (`hooks` in extension.yml) -- [ ] Hook registration and execution -- [ ] Project extensions config (`.specify/extensions.yml`) -- [ ] CLI commands: - - [ ] `specify extension update` - - [ ] `specify extension enable/disable` -- [ ] Command registration for multiple agents (Gemini, Copilot) -- [ ] Extension update notifications -- [ ] Configuration layer resolution (project, local, env) +- [x] Hook system (`hooks` in extension.yml) +- [x] Hook registration and execution +- [x] Project extensions config (`.specify/extensions.yml`) +- [x] CLI commands: + - [x] `specify extension update` (with atomic backup/restore) + - [x] `specify extension enable/disable` +- [x] Command registration for multiple agents (15+ agents including Claude, Copilot, Gemini, Cursor, etc.) +- [x] Extension update notifications (version comparison) +- [x] Configuration layer resolution (project, local, env) + +**Additional features implemented beyond original RFC**: + +- [x] **Display name resolution**: All commands accept extension display names in addition to IDs +- [x] **Ambiguous name handling**: User-friendly tables when multiple extensions match a name +- [x] **Atomic update with rollback**: Full backup of extension dir, commands, hooks, and registry with automatic rollback on failure +- [x] **Pre-install ID validation**: Validates extension ID from ZIP before installing (security) +- [x] **Enabled state preservation**: Disabled extensions stay disabled after update +- [x] **Registry update/restore methods**: Clean API for enable/disable and rollback operations +- [x] **Catalog error fallback**: `extension info` falls back to local info when catalog unavailable +- [x] **`_install_allowed` flag**: Discovery-only catalogs can't be used for installation +- [x] **Cache invalidation**: Cache invalidated when `SPECKIT_CATALOG_URL` changes **Testing**: -- [ ] Test hooks in core commands -- [ ] Test extension updates (preserve config) -- [ ] Test multi-agent registration +- [x] Test hooks in core commands +- [x] Test extension updates (preserve config) +- [x] Test multi-agent registration +- [x] Test atomic rollback on update failure +- [x] Test enabled state preservation +- [x] Test display name resolution -### Phase 5: Polish & Documentation (Week 7) +### Phase 5: Polish & Documentation āœ… COMPLETED **Goal**: Production ready **Deliverables**: -- [ ] Comprehensive documentation: - - [ ] User guide (installing/using extensions) - - [ ] Extension development guide - - [ ] Extension API reference - - [ ] Migration guide (core → extension) -- [ ] Error messages and validation improvements -- [ ] CLI help text updates -- [ ] Example extension template (cookiecutter) -- [ ] Blog post / announcement -- [ ] Video tutorial +- [x] Comprehensive documentation: + - [x] User guide (EXTENSION-USER-GUIDE.md) + - [x] Extension development guide (EXTENSION-DEV-GUIDE.md) + - [x] Extension API reference (EXTENSION-API-REFERENCE.md) +- [x] Error messages and validation improvements +- [x] CLI help text updates **Testing**: -- [ ] End-to-end testing on multiple projects -- [ ] Community beta testing -- [ ] Performance testing (large projects) +- [x] End-to-end testing on multiple projects +- [x] 163 unit tests passing --- -## Open Questions +## Resolved Questions -### 1. Extension Namespace +The following questions from the original RFC have been resolved during implementation: -**Question**: Should extension commands use namespace prefix? +### 1. Extension Namespace āœ… RESOLVED -**Options**: +**Question**: Should extension commands use namespace prefix? -- A) Prefixed: `/speckit.jira.specstoissues` (explicit, avoids conflicts) -- B) Short alias: `/jira.specstoissues` (shorter, less verbose) -- C) Both: Register both names, prefer prefixed in docs +**Decision**: **Option C** - Both prefixed and aliases are supported. Commands use `speckit.{extension}.{command}` as canonical name, with optional aliases defined in manifest. -**Recommendation**: C (both), prefixed is canonical +**Implementation**: The `aliases` field in `extension.yml` allows extensions to register additional command names. --- -### 2. Config File Location +### 2. Config File Location āœ… RESOLVED **Question**: Where should extension configs live? -**Options**: - -- A) Extension directory: `.specify/extensions/jira/jira-config.yml` (encapsulated) -- B) Root level: `.specify/jira-config.yml` (more visible) -- C) Unified: `.specify/extensions.yml` (all extension configs in one file) +**Decision**: **Option A** - Extension directory (`.specify/extensions/{ext-id}/{ext-id}-config.yml`). This keeps extensions self-contained and easier to manage. -**Recommendation**: A (extension directory), cleaner separation +**Implementation**: Each extension has its own config file within its directory, with layered resolution (defaults → project → local → env vars). --- -### 3. Command File Format +### 3. Command File Format āœ… RESOLVED **Question**: Should extensions use universal format or agent-specific? -**Options**: - -- A) Universal Markdown: Extensions write once, CLI converts per-agent -- B) Agent-specific: Extensions provide separate files for each agent -- C) Hybrid: Universal default, agent-specific overrides +**Decision**: **Option A** - Universal Markdown format. Extensions write commands once, CLI converts to agent-specific format during registration. -**Recommendation**: A (universal), reduces duplication +**Implementation**: `CommandRegistrar` class handles conversion to 15+ agent formats (Claude, Copilot, Gemini, Cursor, etc.). --- -### 4. Hook Execution Model +### 4. Hook Execution Model āœ… RESOLVED **Question**: How should hooks execute? -**Options**: - -- A) AI agent interprets: Core commands output `EXECUTE_COMMAND: name` -- B) CLI executes: Core commands call `specify extension hook after_tasks` -- C) Agent built-in: Extension system built into AI agent (Claude SDK) +**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. -**Recommendation**: A initially (simpler), move to C long-term +**Implementation**: `HookExecutor` class manages hook registration and state in `extensions.yml`. --- -### 5. Extension Distribution +### 5. Extension Distribution āœ… RESOLVED **Question**: How should extensions be packaged? +**Decision**: **Option A** - ZIP archives downloaded from GitHub releases (via catalog `download_url`). Local development uses `--dev` flag with directory path. + +**Implementation**: `ExtensionManager.install_from_zip()` handles ZIP extraction and validation. + +--- + +### 6. Multi-Version Support āœ… RESOLVED + +**Question**: Can multiple versions of same extension coexist? + +**Decision**: **Option A** - Single version only. Updates replace the existing version with atomic rollback on failure. + +**Implementation**: `extension update` performs atomic backup/restore to ensure safe updates. + +--- + +## Open Questions (Remaining) + +### 1. Sandboxing / Permissions (Future) + +**Question**: Should extensions declare required permissions? + **Options**: -- A) ZIP archives: Downloaded from GitHub releases -- B) Git repos: Cloned directly (`git clone`) -- C) Python packages: Installable via `uv tool install` +- A) No sandboxing (current): Extensions run with same privileges as AI agent +- B) Permission declarations: Extensions declare `filesystem:read`, `network:external`, etc. +- C) Opt-in sandboxing: Organizations can enable permission enforcement -**Recommendation**: A (ZIP), simpler for non-Python extensions in future +**Status**: Deferred to future version. Currently using trust-based model where users trust extension authors. --- -### 6. Multi-Version Support +### 2. Package Signatures (Future) -**Question**: Can multiple versions of same extension coexist? +**Question**: Should extensions be cryptographically signed? **Options**: -- A) Single version: Only one version installed at a time -- B) Multi-version: Side-by-side versions (`.specify/extensions/jira@1.0/`, `.specify/extensions/jira@2.0/`) -- C) Per-branch: Different branches use different versions +- A) No signatures (current): Trust based on catalog source +- B) GPG/Sigstore signatures: Verify package integrity +- C) Catalog-level verification: Catalog maintainers verify packages -**Recommendation**: A initially (simpler), consider B in future if needed +**Status**: Deferred to future version. `checksum` field is available in catalog schema but not enforced. --- diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json new file mode 100644 index 0000000000..469e3ddf9b --- /dev/null +++ b/extensions/catalog.community.json @@ -0,0 +1,2540 @@ +{ + "schema_version": "1.0", + "updated_at": "2026-04-24T00:00:00Z", + "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", + "extensions": { + "aide": { + "name": "AI-Driven Engineering (AIDE)", + "id": "aide", + "description": "A structured 7-step workflow for building new projects from scratch with AI assistants — from vision through implementation.", + "author": "mnriem", + "version": "1.0.0", + "download_url": "https://github.com/mnriem/spec-kit-extensions/releases/download/aide-v1.0.0/aide.zip", + "repository": "https://github.com/mnriem/spec-kit-extensions", + "homepage": "https://github.com/mnriem/spec-kit-extensions", + "documentation": "https://github.com/mnriem/spec-kit-extensions/blob/main/aide/README.md", + "changelog": "https://github.com/mnriem/spec-kit-extensions/blob/main/aide/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.2.0" + }, + "provides": { + "commands": 7, + "hooks": 0 + }, + "tags": [ + "workflow", + "project-management", + "ai-driven", + "new-project", + "planning", + "experimental" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-18T00:00:00Z", + "updated_at": "2026-03-18T00:00:00Z" + }, + "agent-assign": { + "name": "Agent Assign", + "id": "agent-assign", + "description": "Assign specialized Claude Code agents to spec-kit tasks for targeted execution", + "author": "xuyang", + "version": "1.0.0", + "download_url": "https://github.com/xymelon/spec-kit-agent-assign/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/xymelon/spec-kit-agent-assign", + "homepage": "https://github.com/xymelon/spec-kit-agent-assign", + "documentation": "https://github.com/xymelon/spec-kit-agent-assign/blob/main/README.md", + "changelog": "https://github.com/xymelon/spec-kit-agent-assign/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.3.0" + }, + "provides": { + "commands": 3, + "hooks": 1 + }, + "tags": [ + "agent", + "automation", + "implementation", + "multi-agent", + "task-routing" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-31T00:00:00Z", + "updated_at": "2026-03-31T00:00:00Z" + }, + "architect-preview": { + "name": "Architect Impact Previewer", + "id": "architect-preview", + "description": "Predicts architectural impact, complexity, and risks of proposed changes before implementation.", + "author": "Umme Habiba", + "version": "1.0.0", + "download_url": "https://github.com/UmmeHabiba1312/spec-kit-architect-preview/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/UmmeHabiba1312/spec-kit-architect-preview", + "homepage": "https://github.com/UmmeHabiba1312/spec-kit-architect-preview", + "documentation": "https://github.com/UmmeHabiba1312/spec-kit-architect-preview/blob/main/README.md", + "changelog": "https://github.com/UmmeHabiba1312/spec-kit-architect-preview/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 0 + }, + "tags": [ + "architecture", + "analysis", + "risk-assessment", + "planning", + "preview" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-14T00:00:00Z", + "updated_at": "2026-04-14T00:00:00Z" + }, + "archive": { + "name": "Archive Extension", + "id": "archive", + "description": "Archive merged features into main project memory, resolving gaps and conflicts.", + "author": "Stanislav Deviatov", + "version": "1.0.0", + "download_url": "https://github.com/stn1slv/spec-kit-archive/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/stn1slv/spec-kit-archive", + "homepage": "https://github.com/stn1slv/spec-kit-archive", + "documentation": "https://github.com/stn1slv/spec-kit-archive/blob/main/README.md", + "changelog": "https://github.com/stn1slv/spec-kit-archive/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 0 + }, + "tags": [ + "archive", + "memory", + "merge", + "changelog" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-14T00:00:00Z", + "updated_at": "2026-03-14T00:00:00Z" + }, + "azure-devops": { + "name": "Azure DevOps Integration", + "id": "azure-devops", + "description": "Sync user stories and tasks to Azure DevOps work items using OAuth authentication.", + "author": "pragya247", + "version": "1.0.0", + "download_url": "https://github.com/pragya247/spec-kit-azure-devops/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/pragya247/spec-kit-azure-devops", + "homepage": "https://github.com/pragya247/spec-kit-azure-devops", + "documentation": "https://github.com/pragya247/spec-kit-azure-devops/blob/main/README.md", + "changelog": "https://github.com/pragya247/spec-kit-azure-devops/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0", + "tools": [ + { + "name": "az", + "version": ">=2.0.0", + "required": true + } + ] + }, + "provides": { + "commands": 1, + "hooks": 1 + }, + "tags": [ + "azure", + "devops", + "project-management", + "work-items", + "issue-tracking" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-03T00:00:00Z", + "updated_at": "2026-03-03T00:00:00Z" + }, + "blueprint": { + "name": "Blueprint", + "id": "blueprint", + "description": "Stay code-literate in AI-driven development: review a complete code blueprint for every task from spec artifacts before /speckit.implement runs", + "author": "chordpli", + "version": "1.0.0", + "download_url": "https://github.com/chordpli/spec-kit-blueprint/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/chordpli/spec-kit-blueprint", + "homepage": "https://github.com/chordpli/spec-kit-blueprint", + "documentation": "https://github.com/chordpli/spec-kit-blueprint/blob/main/README.md", + "changelog": "https://github.com/chordpli/spec-kit-blueprint/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.2.0" + }, + "provides": { + "commands": 2, + "hooks": 1 + }, + "tags": [ + "blueprint", + "pre-implementation", + "review", + "scaffolding", + "code-literacy" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-17T00:00:00Z", + "updated_at": "2026-04-17T00:00:00Z" + }, + "branch-convention": { + "name": "Branch Convention", + "id": "branch-convention", + "description": "Configurable branch and folder naming conventions for /specify with presets and custom patterns.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-branch-convention/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-branch-convention", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-branch-convention", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-branch-convention/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-branch-convention/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 3, + "hooks": 1 + }, + "tags": [ + "branch", + "naming", + "convention", + "gitflow", + "workflow" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-08T00:00:00Z", + "updated_at": "2026-04-08T00:00:00Z" + }, + "brownfield": { + "name": "Brownfield Bootstrap", + "id": "brownfield", + "description": "Bootstrap spec-kit for existing codebases — auto-discover architecture and adopt SDD incrementally.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-brownfield/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-brownfield", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-brownfield", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-brownfield/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-brownfield/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 4, + "hooks": 1 + }, + "tags": [ + "brownfield", + "bootstrap", + "existing-project", + "migration", + "onboarding" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-10T00:00:00Z", + "updated_at": "2026-04-10T00:00:00Z" + }, + "bugfix": { + "name": "Bugfix Workflow", + "id": "bugfix", + "description": "Structured bugfix workflow — capture bugs, trace to spec artifacts, and patch specs surgically.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-bugfix/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-bugfix", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-bugfix", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-bugfix/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-bugfix/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 3, + "hooks": 1 + }, + "tags": [ + "bugfix", + "debugging", + "workflow", + "traceability", + "maintenance" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-09T00:00:00Z", + "updated_at": "2026-04-09T00:00:00Z" + }, + "canon": { + "name": "Canon", + "id": "canon", + "description": "Adds canon-driven (baseline-driven) workflows: spec-first, code-first, spec-drift. Requires Canon Core preset installation.", + "author": "Maxim Stupakov", + "version": "0.1.0", + "download_url": "https://github.com/maximiliamus/spec-kit-canon/releases/download/v0.1.0/spec-kit-canon-v0.1.0.zip", + "repository": "https://github.com/maximiliamus/spec-kit-canon", + "homepage": "https://github.com/maximiliamus/spec-kit-canon", + "documentation": "https://github.com/maximiliamus/spec-kit-canon/blob/master/README.md", + "changelog": "https://github.com/maximiliamus/spec-kit-canon/blob/master/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.3" + }, + "provides": { + "commands": 16, + "hooks": 0 + }, + "tags": [ + "process", + "baseline", + "canon", + "drift", + "spec-first", + "code-first", + "spec-drift", + "vibecoding" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-29T00:00:00Z", + "updated_at": "2026-03-29T00:00:00Z" + }, + "catalog-ci": { + "name": "Catalog CI", + "id": "catalog-ci", + "description": "Automated validation for spec-kit community catalog entries — structure, URLs, diffs, and linting.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-catalog-ci/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-catalog-ci", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-catalog-ci", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-catalog-ci/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-catalog-ci/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 4, + "hooks": 0 + }, + "tags": [ + "ci", + "validation", + "catalog", + "quality", + "automation" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-16T00:00:00Z", + "updated_at": "2026-04-16T00:00:00Z" + }, + "ci-guard": { + "name": "CI Guard", + "id": "ci-guard", + "description": "Spec compliance gates for CI/CD — verify specs exist, check drift, and block merges on gaps.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-ci-guard/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-ci-guard", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-ci-guard", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-ci-guard/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-ci-guard/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 5, + "hooks": 2 + }, + "tags": [ + "ci-cd", + "compliance", + "governance", + "quality-gate", + "drift-detection", + "automation" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-10T17:00:00Z", + "updated_at": "2026-04-10T17:00:00Z" + }, + "checkpoint": { + "name": "Checkpoint Extension", + "id": "checkpoint", + "description": "An extension to commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end.", + "author": "aaronrsun", + "version": "1.0.0", + "download_url": "https://github.com/aaronrsun/spec-kit-checkpoint/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/aaronrsun/spec-kit-checkpoint", + "homepage": "https://github.com/aaronrsun/spec-kit-checkpoint", + "documentation": "https://github.com/aaronrsun/spec-kit-checkpoint/blob/main/README.md", + "changelog": "https://github.com/aaronrsun/spec-kit-checkpoint/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 0 + }, + "tags": [ + "checkpoint", + "commit" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-22T00:00:00Z", + "updated_at": "2026-03-22T00:00:00Z" + }, + "cleanup": { + "name": "Cleanup Extension", + "id": "cleanup", + "description": "Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues.", + "author": "dsrednicki", + "version": "1.0.0", + "download_url": "https://github.com/dsrednicki/spec-kit-cleanup/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/dsrednicki/spec-kit-cleanup", + "homepage": "https://github.com/dsrednicki/spec-kit-cleanup", + "documentation": "https://github.com/dsrednicki/spec-kit-cleanup/blob/main/README.md", + "changelog": "https://github.com/dsrednicki/spec-kit-cleanup/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 1 + }, + "tags": [ + "quality", + "tech-debt", + "review", + "cleanup", + "scout-rule" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-02-22T00:00:00Z", + "updated_at": "2026-02-22T00:00:00Z" + }, + "conduct": { + "name": "Conduct Extension", + "id": "conduct", + "description": "Executes a single spec-kit phase via sub-agent delegation to reduce context pollution.", + "author": "twbrandon7", + "version": "1.0.1", + "download_url": "https://github.com/twbrandon7/spec-kit-conduct-ext/archive/refs/tags/v1.0.1.zip", + "repository": "https://github.com/twbrandon7/spec-kit-conduct-ext", + "homepage": "https://github.com/twbrandon7/spec-kit-conduct-ext", + "documentation": "https://github.com/twbrandon7/spec-kit-conduct-ext/blob/main/README.md", + "changelog": "https://github.com/twbrandon7/spec-kit-conduct-ext/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.3.1" + }, + "provides": { + "commands": 1, + "hooks": 0 + }, + "tags": [ + "conduct", + "workflow", + "automation" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-19T12:08:20Z", + "updated_at": "2026-04-03T12:35:01Z" + }, + "critique": { + "name": "Spec Critique Extension", + "id": "critique", + "description": "Dual-lens critical review of spec and plan from product strategy and engineering risk perspectives.", + "author": "arunt14", + "version": "1.0.0", + "download_url": "https://github.com/arunt14/spec-kit-critique/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/arunt14/spec-kit-critique", + "homepage": "https://github.com/arunt14/spec-kit-critique", + "documentation": "https://github.com/arunt14/spec-kit-critique/blob/main/README.md", + "changelog": "https://github.com/arunt14/spec-kit-critique/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 1 + }, + "tags": [ + "docs", + "review", + "planning" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-01T00:00:00Z", + "updated_at": "2026-04-01T00:00:00Z" + }, + "confluence": { + "name": "Confluence Extension", + "id": "confluence", + "description": "Create, read, and update Confluence docs for your project", + "author": "aaronrsun", + "version": "1.1.1", + "download_url": "https://github.com/aaronrsun/spec-kit-confluence/archive/refs/tags/v1.1.1.zip", + "repository": "https://github.com/aaronrsun/spec-kit-confluence", + "homepage": "https://github.com/aaronrsun/spec-kit-confluence", + "documentation": "https://github.com/aaronrsun/spec-kit-confluence/blob/main/README.md", + "changelog": "https://github.com/aaronrsun/spec-kit-confluence/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 0 + }, + "tags": [ + "confluence" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-29T00:00:00Z", + "updated_at": "2026-03-29T00:00:00Z" + }, + "diagram": { + "name": "Spec Diagram", + "id": "diagram", + "description": "Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-diagram-/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-diagram-", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-diagram-", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-diagram-/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-diagram-/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 3, + "hooks": 1 + }, + "tags": [ + "diagram", + "mermaid", + "visualization", + "workflow", + "dependencies" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-08T00:00:00Z", + "updated_at": "2026-04-08T00:00:00Z" + }, + "docguard": { + "name": "DocGuard — CDD Enforcement", + "id": "docguard", + "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.", + "author": "raccioly", + "version": "0.9.11", + "download_url": "https://github.com/raccioly/docguard/releases/download/v0.9.11/spec-kit-docguard-v0.9.11.zip", + "repository": "https://github.com/raccioly/docguard", + "homepage": "https://www.npmjs.com/package/docguard-cli", + "documentation": "https://github.com/raccioly/docguard/blob/main/extensions/spec-kit-docguard/README.md", + "changelog": "https://github.com/raccioly/docguard/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0", + "tools": [ + { + "name": "node", + "version": ">=18.0.0", + "required": true + } + ] + }, + "provides": { + "commands": 6, + "hooks": 3 + }, + "tags": [ + "documentation", + "validation", + "quality", + "cdd", + "traceability", + "ai-agents", + "enforcement", + "spec-kit" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-13T00:00:00Z", + "updated_at": "2026-03-18T18:53:31Z" + }, + "doctor": { + "name": "Project Health Check", + "id": "doctor", + "description": "Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git.", + "author": "KhawarHabibKhan", + "version": "1.0.0", + "download_url": "https://github.com/KhawarHabibKhan/spec-kit-doctor/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/KhawarHabibKhan/spec-kit-doctor", + "homepage": "https://github.com/KhawarHabibKhan/spec-kit-doctor", + "documentation": "https://github.com/KhawarHabibKhan/spec-kit-doctor/blob/main/README.md", + "changelog": "https://github.com/KhawarHabibKhan/spec-kit-doctor/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 0 + }, + "tags": [ + "diagnostics", + "health-check", + "validation", + "project-structure" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-13T00:00:00Z", + "updated_at": "2026-03-13T00:00:00Z" + }, + "extensify": { + "name": "Extensify", + "id": "extensify", + "description": "Create and validate extensions and extension catalogs.", + "author": "mnriem", + "version": "1.0.0", + "download_url": "https://github.com/mnriem/spec-kit-extensions/releases/download/extensify-v1.0.0/extensify.zip", + "repository": "https://github.com/mnriem/spec-kit-extensions", + "homepage": "https://github.com/mnriem/spec-kit-extensions", + "documentation": "https://github.com/mnriem/spec-kit-extensions/blob/main/extensify/README.md", + "changelog": "https://github.com/mnriem/spec-kit-extensions/blob/main/extensify/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.2.0" + }, + "provides": { + "commands": 4, + "hooks": 0 + }, + "tags": [ + "extensions", + "workflow", + "validation", + "experimental" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-18T00:00:00Z", + "updated_at": "2026-03-18T00:00:00Z" + }, + "fix-findings": { + "name": "Fix Findings", + "id": "fix-findings", + "description": "Automated analyze-fix-reanalyze loop that resolves spec findings until clean.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-fix-findings/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-fix-findings", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-fix-findings", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-fix-findings/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-fix-findings/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 1 + }, + "tags": [ + "code", + "analysis", + "quality", + "automation", + "findings" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-01T00:00:00Z", + "updated_at": "2026-04-01T00:00:00Z" + }, + "fixit": { + "name": "FixIt Extension", + "id": "fixit", + "description": "Spec-aware bug fixing: maps bugs to spec artifacts, proposes a plan, applies minimal changes.", + "author": "ismaelJimenez", + "version": "1.0.0", + "download_url": "https://github.com/speckit-community/spec-kit-fixit/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/speckit-community/spec-kit-fixit", + "homepage": "https://github.com/speckit-community/spec-kit-fixit", + "documentation": "https://github.com/speckit-community/spec-kit-fixit/blob/main/README.md", + "changelog": "https://github.com/speckit-community/spec-kit-fixit/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 0 + }, + "tags": [ + "debugging", + "fixit", + "spec-alignment", + "post-implementation" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-30T00:00:00Z", + "updated_at": "2026-03-30T00:00:00Z" + }, + "fleet": { + "name": "Fleet Orchestrator", + "id": "fleet", + "description": "Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases.", + "author": "sharathsatish", + "version": "1.1.0", + "download_url": "https://github.com/sharathsatish/spec-kit-fleet/archive/refs/tags/v1.1.0.zip", + "repository": "https://github.com/sharathsatish/spec-kit-fleet", + "homepage": "https://github.com/sharathsatish/spec-kit-fleet", + "documentation": "https://github.com/sharathsatish/spec-kit-fleet/blob/main/README.md", + "changelog": "https://github.com/sharathsatish/spec-kit-fleet/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 2, + "hooks": 1 + }, + "tags": [ + "orchestration", + "workflow", + "human-in-the-loop", + "parallel" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-06T00:00:00Z", + "updated_at": "2026-03-31T00:00:00Z" + }, + "github-issues": { + "name": "GitHub Issues Integration 1", + "id": "github-issues", + "description": "Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability", + "author": "Fatima367", + "version": "1.0.0", + "download_url": "https://github.com/Fatima367/spec-kit-github-issues/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Fatima367/spec-kit-github-issues", + "homepage": "https://github.com/Fatima367/spec-kit-github-issues", + "documentation": "https://github.com/Fatima367/spec-kit-github-issues/blob/main/README.md", + "changelog": "https://github.com/Fatima367/spec-kit-github-issues/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0", + "tools": [ + { + "name": "gh", + "version": ">=2.0.0", + "required": true + } + ] + }, + "provides": { + "commands": 3, + "hooks": 0 + }, + "tags": [ + "integration", + "github", + "issues", + "import", + "sync", + "traceability" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-12T15:30:00Z", + "updated_at": "2026-04-13T14:39:00Z" + }, + "issue": { + "name": "GitHub Issues Integration 2", + "id": "issue", + "description": "Creates and syncs local specs based on an existing issue in GitHub", + "author": "aaronrsun", + "version": "1.0.0", + "download_url": "https://github.com/aaronrsun/spec-kit-issue/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/aaronrsun/spec-kit-issue", + "homepage": "https://github.com/aaronrsun/spec-kit-issue", + "documentation": "https://github.com/aaronrsun/spec-kit-issue/blob/main/README.md", + "changelog": "https://github.com/aaronrsun/spec-kit-issue/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 3, + "hooks": 0 + }, + "tags": [ + "issue", + "integration", + "github", + "issues", + "sync" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-04T00:00:00Z", + "updated_at": "2026-04-04T00:00:00Z" + }, + "iterate": { + "name": "Iterate", + "id": "iterate", + "description": "Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building", + "author": "Vianca Martinez", + "version": "2.0.0", + "download_url": "https://github.com/imviancagrace/spec-kit-iterate/archive/refs/tags/v2.0.0.zip", + "repository": "https://github.com/imviancagrace/spec-kit-iterate", + "homepage": "https://github.com/imviancagrace/spec-kit-iterate", + "documentation": "https://github.com/imviancagrace/spec-kit-iterate/blob/main/README.md", + "changelog": "https://github.com/imviancagrace/spec-kit-iterate/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 2, + "hooks": 0 + }, + "tags": [ + "iteration", + "change-management", + "spec-maintenance" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-17T00:00:00Z", + "updated_at": "2026-03-17T00:00:00Z" + }, + "jira": { + "name": "Jira Integration", + "id": "jira", + "description": "Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support.", + "author": "mbachorik", + "version": "2.1.0", + "download_url": "https://github.com/mbachorik/spec-kit-jira/archive/refs/tags/v2.1.0.zip", + "repository": "https://github.com/mbachorik/spec-kit-jira", + "homepage": "https://github.com/mbachorik/spec-kit-jira", + "documentation": "https://github.com/mbachorik/spec-kit-jira/blob/main/README.md", + "changelog": "https://github.com/mbachorik/spec-kit-jira/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 3, + "hooks": 1 + }, + "tags": [ + "issue-tracking", + "jira", + "atlassian", + "project-management" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-05T00:00:00Z", + "updated_at": "2026-03-05T00:00:00Z" + }, + "learn": { + "name": "Learning Extension", + "id": "learn", + "description": "Generate educational guides from implementations and enhance clarifications with mentoring context.", + "author": "Vianca Martinez", + "version": "1.0.0", + "download_url": "https://github.com/imviancagrace/spec-kit-learn/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/imviancagrace/spec-kit-learn", + "homepage": "https://github.com/imviancagrace/spec-kit-learn", + "documentation": "https://github.com/imviancagrace/spec-kit-learn/blob/main/README.md", + "changelog": "https://github.com/imviancagrace/spec-kit-learn/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 2, + "hooks": 1 + }, + "tags": [ + "learning", + "education", + "mentoring", + "knowledge-transfer" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-17T00:00:00Z", + "updated_at": "2026-03-17T00:00:00Z" + }, + "maqa": { + "name": "MAQA — Multi-Agent & Quality Assurance", + "id": "maqa", + "description": "Coordinator → feature → QA agent workflow with parallel worktree-based implementation. Language-agnostic. Auto-detects installed board plugins (Trello, Linear, GitHub Projects, Jira, Azure DevOps). Optional CI gate.", + "author": "GenieRobot", + "version": "0.1.3", + "download_url": "https://github.com/GenieRobot/spec-kit-maqa-ext/releases/download/maqa-v0.1.3/maqa.zip", + "repository": "https://github.com/GenieRobot/spec-kit-maqa-ext", + "homepage": "https://github.com/GenieRobot/spec-kit-maqa-ext", + "documentation": "https://github.com/GenieRobot/spec-kit-maqa-ext/blob/main/README.md", + "changelog": "https://github.com/GenieRobot/spec-kit-maqa-ext/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.3.0" + }, + "provides": { + "commands": 4, + "hooks": 1 + }, + "tags": [ + "multi-agent", + "orchestration", + "quality-assurance", + "workflow", + "parallel", + "tdd" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-26T00:00:00Z", + "updated_at": "2026-03-27T00:00:00Z" + }, + "maqa-azure-devops": { + "name": "MAQA Azure DevOps Integration", + "id": "maqa-azure-devops", + "description": "Azure DevOps Boards integration for the MAQA extension. Populates work items from specs, moves User Stories across columns as features progress, real-time Task child ticking.", + "author": "GenieRobot", + "version": "0.1.0", + "download_url": "https://github.com/GenieRobot/spec-kit-maqa-azure-devops/releases/download/maqa-azure-devops-v0.1.0/maqa-azure-devops.zip", + "repository": "https://github.com/GenieRobot/spec-kit-maqa-azure-devops", + "homepage": "https://github.com/GenieRobot/spec-kit-maqa-azure-devops", + "documentation": "https://github.com/GenieRobot/spec-kit-maqa-azure-devops/blob/main/README.md", + "changelog": "https://github.com/GenieRobot/spec-kit-maqa-azure-devops/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.3.0" + }, + "provides": { + "commands": 2, + "hooks": 0 + }, + "tags": [ + "azure-devops", + "project-management", + "multi-agent", + "maqa", + "kanban" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-27T00:00:00Z", + "updated_at": "2026-03-27T00:00:00Z" + }, + "maqa-ci": { + "name": "MAQA CI/CD Gate", + "id": "maqa-ci", + "description": "CI/CD pipeline gate for the MAQA extension. Auto-detects GitHub Actions, CircleCI, GitLab CI, and Bitbucket Pipelines. Blocks QA handoff until pipeline is green.", + "author": "GenieRobot", + "version": "0.1.0", + "download_url": "https://github.com/GenieRobot/spec-kit-maqa-ci/releases/download/maqa-ci-v0.1.0/maqa-ci.zip", + "repository": "https://github.com/GenieRobot/spec-kit-maqa-ci", + "homepage": "https://github.com/GenieRobot/spec-kit-maqa-ci", + "documentation": "https://github.com/GenieRobot/spec-kit-maqa-ci/blob/main/README.md", + "changelog": "https://github.com/GenieRobot/spec-kit-maqa-ci/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.3.0" + }, + "provides": { + "commands": 2, + "hooks": 0 + }, + "tags": [ + "ci-cd", + "github-actions", + "circleci", + "gitlab-ci", + "quality-gate", + "maqa" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-27T00:00:00Z", + "updated_at": "2026-03-27T00:00:00Z" + }, + "maqa-github-projects": { + "name": "MAQA GitHub Projects Integration", + "id": "maqa-github-projects", + "description": "GitHub Projects v2 integration for the MAQA extension. Populates draft issues from specs, moves items across Status columns as features progress, real-time task list ticking.", + "author": "GenieRobot", + "version": "0.1.0", + "download_url": "https://github.com/GenieRobot/spec-kit-maqa-github-projects/releases/download/maqa-github-projects-v0.1.0/maqa-github-projects.zip", + "repository": "https://github.com/GenieRobot/spec-kit-maqa-github-projects", + "homepage": "https://github.com/GenieRobot/spec-kit-maqa-github-projects", + "documentation": "https://github.com/GenieRobot/spec-kit-maqa-github-projects/blob/main/README.md", + "changelog": "https://github.com/GenieRobot/spec-kit-maqa-github-projects/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.3.0" + }, + "provides": { + "commands": 2, + "hooks": 0 + }, + "tags": [ + "github-projects", + "project-management", + "multi-agent", + "maqa", + "kanban" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-27T00:00:00Z", + "updated_at": "2026-03-27T00:00:00Z" + }, + "maqa-jira": { + "name": "MAQA Jira Integration", + "id": "maqa-jira", + "description": "Jira integration for the MAQA extension. Populates Stories from specs, moves issues across board columns as features progress, real-time Subtask ticking.", + "author": "GenieRobot", + "version": "0.1.0", + "download_url": "https://github.com/GenieRobot/spec-kit-maqa-jira/releases/download/maqa-jira-v0.1.0/maqa-jira.zip", + "repository": "https://github.com/GenieRobot/spec-kit-maqa-jira", + "homepage": "https://github.com/GenieRobot/spec-kit-maqa-jira", + "documentation": "https://github.com/GenieRobot/spec-kit-maqa-jira/blob/main/README.md", + "changelog": "https://github.com/GenieRobot/spec-kit-maqa-jira/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.3.0" + }, + "provides": { + "commands": 2, + "hooks": 0 + }, + "tags": [ + "jira", + "project-management", + "multi-agent", + "maqa", + "kanban" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-27T00:00:00Z", + "updated_at": "2026-03-27T00:00:00Z" + }, + "maqa-linear": { + "name": "MAQA Linear Integration", + "id": "maqa-linear", + "description": "Linear integration for the MAQA extension. Populates issues from specs, moves items across workflow states as features progress, real-time sub-issue ticking.", + "author": "GenieRobot", + "version": "0.1.0", + "download_url": "https://github.com/GenieRobot/spec-kit-maqa-linear/releases/download/maqa-linear-v0.1.0/maqa-linear.zip", + "repository": "https://github.com/GenieRobot/spec-kit-maqa-linear", + "homepage": "https://github.com/GenieRobot/spec-kit-maqa-linear", + "documentation": "https://github.com/GenieRobot/spec-kit-maqa-linear/blob/main/README.md", + "changelog": "https://github.com/GenieRobot/spec-kit-maqa-linear/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.3.0" + }, + "provides": { + "commands": 2, + "hooks": 0 + }, + "tags": [ + "linear", + "project-management", + "multi-agent", + "maqa", + "kanban" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-27T00:00:00Z", + "updated_at": "2026-03-27T00:00:00Z" + }, + "maqa-trello": { + "name": "MAQA Trello Integration", + "id": "maqa-trello", + "description": "Trello board integration for the MAQA extension. Populates board from specs, moves cards between lists as features progress, real-time checklist ticking.", + "author": "GenieRobot", + "version": "0.1.1", + "download_url": "https://github.com/GenieRobot/spec-kit-maqa-trello/releases/download/maqa-trello-v0.1.1/maqa-trello.zip", + "repository": "https://github.com/GenieRobot/spec-kit-maqa-trello", + "homepage": "https://github.com/GenieRobot/spec-kit-maqa-trello", + "documentation": "https://github.com/GenieRobot/spec-kit-maqa-trello/blob/main/README.md", + "changelog": "https://github.com/GenieRobot/spec-kit-maqa-trello/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.3.0" + }, + "provides": { + "commands": 2, + "hooks": 0 + }, + "tags": [ + "trello", + "project-management", + "multi-agent", + "maqa", + "kanban" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-26T00:00:00Z", + "updated_at": "2026-03-26T00:00:00Z" + }, + "memory-loader": { + "name": "Memory Loader", + "id": "memory-loader", + "description": "Loads .specify/memory/ files before spec-kit lifecycle commands so LLM agents have project governance context", + "author": "KevinBrown5280", + "version": "1.0.0", + "download_url": "https://github.com/KevinBrown5280/spec-kit-memory-loader/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/KevinBrown5280/spec-kit-memory-loader", + "homepage": "https://github.com/KevinBrown5280/spec-kit-memory-loader", + "documentation": "https://github.com/KevinBrown5280/spec-kit-memory-loader/blob/main/README.md", + "changelog": "https://github.com/KevinBrown5280/spec-kit-memory-loader/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.6.0" + }, + "provides": { + "commands": 1, + "hooks": 7 + }, + "tags": [ + "context", + "memory", + "governance", + "hooks" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-20T00:00:00Z", + "updated_at": "2026-04-20T00:00:00Z" + }, + "memory-md": { + "name": "Memory MD", + "id": "memory-md", + "description": "Repository-native durable memory for Spec Kit projects", + "author": "DyanGalih", + "version": "0.6.2", + "download_url": "https://github.com/DyanGalih/spec-kit-memory-hub/archive/refs/tags/v0.6.2.zip", + "repository": "https://github.com/DyanGalih/spec-kit-memory-hub", + "homepage": "https://github.com/DyanGalih/spec-kit-memory-hub", + "documentation": "https://github.com/DyanGalih/spec-kit-memory-hub/blob/main/README.md", + "changelog": "https://github.com/DyanGalih/spec-kit-memory-hub/blob/main/docs/memory-workflow-v0.6.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.6.0" + }, + "provides": { + "commands": 5, + "hooks": 0 + }, + "tags": [ + "memory", + "workflow", + "docs", + "copilot", + "markdown" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-23T00:00:00Z", + "updated_at": "2026-04-23T00:00:00Z" + }, + "memorylint": { + "name": "MemoryLint", + "id": "memorylint", + "description": "Agent memory governance tool: Automatically audits and fixes boundary conflicts between AGENTS.md and the constitution.", + "author": "RbBtSn0w", + "version": "1.3.0", + "download_url": "https://github.com/RbBtSn0w/spec-kit-extensions/releases/download/memorylint-v1.3.0/memorylint.zip", + "repository": "https://github.com/RbBtSn0w/spec-kit-extensions", + "homepage": "https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint", + "documentation": "https://github.com/RbBtSn0w/spec-kit-extensions/blob/main/memorylint/README.md", + "changelog": "https://github.com/RbBtSn0w/spec-kit-extensions/blob/main/memorylint/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.5.1" + }, + "provides": { + "commands": 1, + "hooks": 1 + }, + "tags": [ + "memory", + "governance", + "constitution", + "agents-md", + "process" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-09T00:00:00Z", + "updated_at": "2026-04-16T13:10:26Z" + }, + "onboard": { + "name": "Onboard", + "id": "onboard", + "description": "Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step.", + "author": "Rafael Sales", + "version": "2.1.0", + "download_url": "https://github.com/dmux/spec-kit-onboard/archive/refs/tags/v2.1.0.zip", + "repository": "https://github.com/dmux/spec-kit-onboard", + "homepage": "https://github.com/dmux/spec-kit-onboard", + "documentation": "https://github.com/dmux/spec-kit-onboard/blob/main/README.md", + "changelog": "https://github.com/dmux/spec-kit-onboard/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 7, + "hooks": 3 + }, + "tags": [ + "onboarding", + "learning", + "mentoring", + "developer-experience", + "gamification", + "knowledge-transfer" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-26T00:00:00Z", + "updated_at": "2026-03-26T00:00:00Z" + }, + "optimize": { + "name": "Optimize Extension", + "id": "optimize", + "description": "Audits and optimizes AI governance for context efficiency", + "author": "sakitA", + "version": "1.0.0", + "download_url": "https://github.com/sakitA/spec-kit-optimize/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/sakitA/spec-kit-optimize", + "homepage": "https://github.com/sakitA/spec-kit-optimize", + "documentation": "https://github.com/sakitA/spec-kit-optimize/blob/main/README.md", + "changelog": "https://github.com/sakitA/spec-kit-optimize/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 3, + "hooks": 0 + }, + "tags": [ + "constitution", + "optimization", + "token-budget", + "governance", + "audit" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-03T00:00:00Z", + "updated_at": "2026-04-03T00:00:00Z" + }, + "plan-review-gate": { + "name": "Plan Review Gate", + "id": "plan-review-gate", + "description": "Require spec.md and plan.md to be merged via MR/PR before allowing task generation", + "author": "luno", + "version": "1.0.0", + "download_url": "https://github.com/luno/spec-kit-plan-review-gate/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/luno/spec-kit-plan-review-gate", + "homepage": "https://github.com/luno/spec-kit-plan-review-gate", + "documentation": "https://github.com/luno/spec-kit-plan-review-gate/blob/main/README.md", + "changelog": "https://github.com/luno/spec-kit-plan-review-gate/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 1 + }, + "tags": [ + "review", + "quality", + "workflow", + "gate" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-27T08:22:30Z", + "updated_at": "2026-03-27T08:22:30Z" + }, + "pr-bridge": { + "name": "PR Bridge", + "id": "pr-bridge", + "description": "Auto-generate pull request descriptions, checklists, and summaries from spec artifacts.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-pr-bridge-/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-pr-bridge-", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-pr-bridge-", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-pr-bridge-/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-pr-bridge-/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 3, + "hooks": 1 + }, + "tags": [ + "pull-request", + "automation", + "traceability", + "workflow", + "review" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-10T00:00:00Z", + "updated_at": "2026-04-10T00:00:00Z" + }, + "presetify": { + "name": "Presetify", + "id": "presetify", + "description": "Create and validate presets and preset catalogs.", + "author": "mnriem", + "version": "1.0.0", + "download_url": "https://github.com/mnriem/spec-kit-extensions/releases/download/presetify-v1.0.0/presetify.zip", + "repository": "https://github.com/mnriem/spec-kit-extensions", + "homepage": "https://github.com/mnriem/spec-kit-extensions", + "documentation": "https://github.com/mnriem/spec-kit-extensions/blob/main/presetify/README.md", + "changelog": "https://github.com/mnriem/spec-kit-extensions/blob/main/presetify/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.2.0" + }, + "provides": { + "commands": 4, + "hooks": 0 + }, + "tags": [ + "presets", + "workflow", + "templates", + "experimental" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-18T00:00:00Z", + "updated_at": "2026-03-18T00:00:00Z" + }, + "product-forge": { + "name": "Product Forge", + "id": "product-forge", + "description": "Full product lifecycle from research to release — portfolio, lite mode, monorepo, optional V-Model", + "author": "VaiYav", + "version": "1.5.1", + "download_url": "https://github.com/VaiYav/speckit-product-forge/archive/refs/tags/v1.5.1.zip", + "repository": "https://github.com/VaiYav/speckit-product-forge", + "homepage": "https://github.com/VaiYav/speckit-product-forge", + "documentation": "https://github.com/VaiYav/speckit-product-forge/blob/main/README.md", + "changelog": "https://github.com/VaiYav/speckit-product-forge/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 29, + "hooks": 0 + }, + "tags": [ + "process", + "lifecycle", + "monorepo", + "v-model", + "portfolio" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-28T00:00:00Z", + "updated_at": "2026-04-24T15:52:00Z" + }, + "qa": { + "name": "QA Testing Extension", + "id": "qa", + "description": "Systematic QA testing with browser-driven or CLI-based validation of acceptance criteria from spec.", + "author": "arunt14", + "version": "1.0.0", + "download_url": "https://github.com/arunt14/spec-kit-qa/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/arunt14/spec-kit-qa", + "homepage": "https://github.com/arunt14/spec-kit-qa", + "documentation": "https://github.com/arunt14/spec-kit-qa/blob/main/README.md", + "changelog": "https://github.com/arunt14/spec-kit-qa/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 1 + }, + "tags": [ + "code", + "testing", + "qa" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-01T00:00:00Z", + "updated_at": "2026-04-01T00:00:00Z" + }, + "ralph": { + "name": "Ralph Loop", + "id": "ralph", + "description": "Autonomous implementation loop using AI agent CLI.", + "author": "Rubiss", + "version": "1.0.1", + "download_url": "https://github.com/Rubiss/spec-kit-ralph/archive/refs/tags/v1.0.1.zip", + "repository": "https://github.com/Rubiss/spec-kit-ralph", + "homepage": "https://github.com/Rubiss/spec-kit-ralph", + "documentation": "https://github.com/Rubiss/spec-kit-ralph/blob/main/README.md", + "changelog": "https://github.com/Rubiss/spec-kit-ralph/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0", + "tools": [ + { + "name": "copilot", + "required": true + }, + { + "name": "git", + "required": true + } + ] + }, + "provides": { + "commands": 2, + "hooks": 1 + }, + "tags": [ + "implementation", + "automation", + "loop", + "copilot" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-09T00:00:00Z", + "updated_at": "2026-04-12T19:00:00Z" + }, + "reconcile": { + "name": "Reconcile Extension", + "id": "reconcile", + "description": "Reconcile implementation drift by surgically updating the feature's own spec, plan, and tasks.", + "author": "Stanislav Deviatov", + "version": "1.0.0", + "download_url": "https://github.com/stn1slv/spec-kit-reconcile/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/stn1slv/spec-kit-reconcile", + "homepage": "https://github.com/stn1slv/spec-kit-reconcile", + "documentation": "https://github.com/stn1slv/spec-kit-reconcile/blob/main/README.md", + "changelog": "https://github.com/stn1slv/spec-kit-reconcile/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 0 + }, + "tags": [ + "reconcile", + "drift", + "tasks", + "remediation" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-14T00:00:00Z", + "updated_at": "2026-03-14T00:00:00Z" + }, + "red-team": { + "name": "Red Team", + "id": "red-team", + "description": "Adversarial review of functional specs before /speckit.plan. Parallel adversarial lens agents catch hostile actors, silent failures, and regulatory blind spots that clarify/analyze cannot.", + "author": "Ash Brener", + "version": "1.0.2", + "download_url": "https://github.com/ashbrener/spec-kit-red-team/releases/download/v1.0.2/red-team-v1.0.2.zip", + "repository": "https://github.com/ashbrener/spec-kit-red-team", + "homepage": "https://github.com/ashbrener/spec-kit-red-team", + "documentation": "https://github.com/ashbrener/spec-kit-red-team/blob/main/README.md", + "changelog": "https://github.com/ashbrener/spec-kit-red-team/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 2, + "hooks": 1 + }, + "tags": [ + "adversarial-review", + "quality-gate", + "spec-hardening", + "pre-plan", + "audit" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-22T00:00:00Z", + "updated_at": "2026-04-22T00:00:00Z" + }, + "refine": { + "name": "Spec Refine", + "id": "refine", + "description": "Update specs in-place, propagate changes to plan and tasks, and diff impact across artifacts.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-refine/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-refine", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-refine", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-refine/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-refine/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 4, + "hooks": 2 + }, + "tags": [ + "refine", + "iterate", + "propagation", + "workflow", + "specifications" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-08T00:00:00Z", + "updated_at": "2026-04-08T00:00:00Z" + }, + "repoindex": { + "name": "Repository Index", + "id": "repoindex", + "description": "Generate index of your repo for overview, architecture and module", + "author": "Yiyu Liu", + "version": "1.0.0", + "download_url": "https://github.com/liuyiyu/spec-kit-repoindex/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/liuyiyu/spec-kit-repoindex", + "homepage": "https://github.com/liuyiyu/spec-kit-repoindex", + "documentation": "https://github.com/liuyiyu/spec-kit-repoindex/tree/main/docs", + "changelog": "https://github.com/liuyiyu/spec-kit-repoindex/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0", + "tools": [ + { + "name": "no need", + "version": ">=1.0.0", + "required": false + } + ] + }, + "provides": { + "commands": 3, + "hooks": 0 + }, + "tags": [ + "utility", + "brownfield", + "analysis" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-23T13:30:00Z", + "updated_at": "2026-03-23T13:30:00Z" + }, + "retro": { + "name": "Retro Extension", + "id": "retro", + "description": "Sprint retrospective analysis with metrics, spec accuracy assessment, and improvement suggestions.", + "author": "arunt14", + "version": "1.0.0", + "download_url": "https://github.com/arunt14/spec-kit-retro/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/arunt14/spec-kit-retro", + "homepage": "https://github.com/arunt14/spec-kit-retro", + "documentation": "https://github.com/arunt14/spec-kit-retro/blob/main/README.md", + "changelog": "https://github.com/arunt14/spec-kit-retro/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 0 + }, + "tags": [ + "process", + "retrospective", + "metrics" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-01T00:00:00Z", + "updated_at": "2026-04-01T00:00:00Z" + }, + "retrospective": { + "name": "Retrospective Extension", + "id": "retrospective", + "description": "Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates.", + "author": "emi-dm", + "version": "1.0.0", + "download_url": "https://github.com/emi-dm/spec-kit-retrospective/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/emi-dm/spec-kit-retrospective", + "homepage": "https://github.com/emi-dm/spec-kit-retrospective", + "documentation": "https://github.com/emi-dm/spec-kit-retrospective/blob/main/README.md", + "changelog": "https://github.com/emi-dm/spec-kit-retrospective/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 1 + }, + "tags": [ + "retrospective", + "spec-drift", + "quality", + "analysis", + "governance" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-02-24T00:00:00Z", + "updated_at": "2026-02-24T00:00:00Z" + }, + "review": { + "name": "Review Extension", + "id": "review", + "description": "Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification.", + "author": "ismaelJimenez", + "version": "1.0.1", + "download_url": "https://github.com/ismaelJimenez/spec-kit-review/archive/refs/tags/v1.0.1.zip", + "repository": "https://github.com/ismaelJimenez/spec-kit-review", + "homepage": "https://github.com/ismaelJimenez/spec-kit-review", + "documentation": "https://github.com/ismaelJimenez/spec-kit-review/blob/main/README.md", + "changelog": "https://github.com/ismaelJimenez/spec-kit-review/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 7, + "hooks": 1 + }, + "tags": [ + "code-review", + "quality", + "review", + "testing", + "error-handling", + "type-design", + "simplification" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-06T00:00:00Z", + "updated_at": "2026-04-09T00:00:00Z" + }, + "ripple": { + "name": "Ripple", + "id": "ripple", + "description": "Detect side effects that tests can't catch after implementation — delta-anchored analysis across 9 domain-agnostic categories with fix-induced side effect detection", + "author": "chordpli", + "version": "1.0.0", + "download_url": "https://github.com/chordpli/spec-kit-ripple/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/chordpli/spec-kit-ripple", + "homepage": "https://github.com/chordpli/spec-kit-ripple", + "documentation": "https://github.com/chordpli/spec-kit-ripple/blob/main/README.md", + "changelog": "https://github.com/chordpli/spec-kit-ripple/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.2.0" + }, + "provides": { + "commands": 3, + "hooks": 1 + }, + "tags": [ + "side-effects", + "post-implementation", + "analysis", + "quality", + "risk-detection" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-20T00:00:00Z", + "updated_at": "2026-04-20T00:00:00Z" + }, + "scope": { + "name": "Spec Scope", + "id": "scope", + "description": "Effort estimation and scope tracking — estimate work, detect creep, and budget time per phase.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-scope-/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-scope-", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-scope-", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-scope-/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-scope-/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 4, + "hooks": 1 + }, + "tags": [ + "estimation", + "scope", + "effort", + "planning", + "project-management", + "tracking" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-17T02:00:00Z", + "updated_at": "2026-04-17T02:00:00Z" + }, + "security-review": { + "name": "Security Review", + "id": "security-review", + "description": "Comprehensive security audit of codebases using AI-powered DevSecOps analysis", + "author": "DyanGalih", + "version": "1.1.1", + "download_url": "https://github.com/DyanGalih/spec-kit-security-review/archive/refs/tags/v1.1.1.zip", + "repository": "https://github.com/DyanGalih/spec-kit-security-review", + "homepage": "https://github.com/DyanGalih/spec-kit-security-review", + "documentation": "https://github.com/DyanGalih/spec-kit-security-review/blob/main/README.md", + "changelog": "https://github.com/DyanGalih/spec-kit-security-review/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 3, + "hooks": 0 + }, + "tags": [ + "security", + "devsecops", + "audit", + "owasp", + "compliance" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-03T03:24:03Z", + "updated_at": "2026-04-03T04:15:00Z" + }, + "sf": { + "name": "SFSpeckit — Salesforce Spec-Driven Development", + "id": "sf", + "description": "Enterprise-Grade Spec-Driven Development (SDD) Framework for Salesforce.", + "author": "Sumanth Yanamala", + "version": "1.0.0", + "download_url": "https://github.com/ysumanth06/spec-kit-sf/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/ysumanth06/spec-kit-sf", + "homepage": "https://ysumanth06.github.io/spec-kit-sf/", + "documentation": "https://ysumanth06.github.io/spec-kit-sf/introduction.html", + "changelog": "https://github.com/ysumanth06/spec-kit-sf/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0", + "tools": [ + { + "name": "sf", + "version": ">=2.0.0", + "required": true + }, + { + "name": "gh", + "version": ">=2.0.0", + "required": false + } + ] + }, + "provides": { + "commands": 18, + "hooks": 2 + }, + "tags": [ + "salesforce", + "enterprise", + "sdlc", + "apex", + "devops" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-13T22:11:30Z", + "updated_at": "2026-04-13T22:11:30Z" + }, + "ship": { + "name": "Ship Release Extension", + "id": "ship", + "description": "Automates release pipeline: pre-flight checks, branch sync, changelog generation, CI verification, and PR creation.", + "author": "arunt14", + "version": "1.0.0", + "download_url": "https://github.com/arunt14/spec-kit-ship/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/arunt14/spec-kit-ship", + "homepage": "https://github.com/arunt14/spec-kit-ship", + "documentation": "https://github.com/arunt14/spec-kit-ship/blob/main/README.md", + "changelog": "https://github.com/arunt14/spec-kit-ship/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 1 + }, + "tags": [ + "process", + "release", + "automation" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-01T00:00:00Z", + "updated_at": "2026-04-01T00:00:00Z" + }, + "spec-reference-loader": { + "name": "Spec Reference Loader", + "id": "spec-reference-loader", + "description": "Reads the ## References section from the current feature spec and loads the listed files into context", + "author": "KevinBrown5280", + "version": "1.0.0", + "download_url": "https://github.com/KevinBrown5280/spec-kit-spec-reference-loader/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/KevinBrown5280/spec-kit-spec-reference-loader", + "homepage": "https://github.com/KevinBrown5280/spec-kit-spec-reference-loader", + "documentation": "https://github.com/KevinBrown5280/spec-kit-spec-reference-loader/blob/main/README.md", + "changelog": "https://github.com/KevinBrown5280/spec-kit-spec-reference-loader/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.6.0" + }, + "provides": { + "commands": 1, + "hooks": 6 + }, + "tags": [ + "context", + "references", + "docs", + "hooks" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-20T00:00:00Z", + "updated_at": "2026-04-20T00:00:00Z" + }, + "spec-validate": { + "name": "Spec Validate", + "id": "spec-validate", + "description": "Comprehension validation, review gating, and approval state for spec-kit artifacts — staged-reveal quizzes, peer review SLA, and a hard gate before /speckit.implement.", + "author": "Ahmed Eltayeb", + "version": "1.0.1", + "download_url": "https://github.com/aeltayeb/spec-kit-spec-validate/archive/refs/tags/v1.0.1.zip", + "repository": "https://github.com/aeltayeb/spec-kit-spec-validate", + "homepage": "https://github.com/aeltayeb/spec-kit-spec-validate", + "documentation": "https://github.com/aeltayeb/spec-kit-spec-validate/blob/main/README.md", + "changelog": "https://github.com/aeltayeb/spec-kit-spec-validate/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.5.0" + }, + "provides": { + "commands": 6, + "hooks": 3 + }, + "tags": [ + "validation", + "review", + "quality", + "workflow", + "process" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-20T00:00:00Z", + "updated_at": "2026-04-21T00:00:00Z" + }, + "speckit-utils": { + "name": "SDD Utilities", + "id": "speckit-utils", + "description": "Resume interrupted workflows, validate project health, and verify spec-to-task traceability.", + "author": "mvanhorn", + "version": "1.0.0", + "download_url": "https://github.com/mvanhorn/speckit-utils/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/mvanhorn/speckit-utils", + "homepage": "https://github.com/mvanhorn/speckit-utils", + "documentation": "https://github.com/mvanhorn/speckit-utils/blob/main/README.md", + "changelog": "https://github.com/mvanhorn/speckit-utils/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 3, + "hooks": 2 + }, + "tags": [ + "resume", + "doctor", + "validate", + "workflow", + "health-check" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-18T00:00:00Z", + "updated_at": "2026-03-18T00:00:00Z" + }, + "spectest": { + "name": "SpecTest", + "id": "spectest", + "description": "Auto-generate test scaffolds from spec criteria, map coverage, and find untested requirements.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-spectest/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-spectest", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-spectest", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-spectest/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-spectest/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 4, + "hooks": 1 + }, + "tags": [ + "testing", + "test-generation", + "coverage", + "quality", + "automation", + "traceability" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-10T16:00:00Z", + "updated_at": "2026-04-10T16:00:00Z" + }, + "staff-review": { + "name": "Staff Review Extension", + "id": "staff-review", + "description": "Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage.", + "author": "arunt14", + "version": "1.0.0", + "download_url": "https://github.com/arunt14/spec-kit-staff-review/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/arunt14/spec-kit-staff-review", + "homepage": "https://github.com/arunt14/spec-kit-staff-review", + "documentation": "https://github.com/arunt14/spec-kit-staff-review/blob/main/README.md", + "changelog": "https://github.com/arunt14/spec-kit-staff-review/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 1 + }, + "tags": [ + "code", + "review", + "quality" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-01T00:00:00Z", + "updated_at": "2026-04-01T00:00:00Z" + }, + "status": { + "name": "Project Status", + "id": "status", + "description": "Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary.", + "author": "KhawarHabibKhan", + "version": "1.0.0", + "download_url": "https://github.com/KhawarHabibKhan/spec-kit-status/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/KhawarHabibKhan/spec-kit-status", + "homepage": "https://github.com/KhawarHabibKhan/spec-kit-status", + "documentation": "https://github.com/KhawarHabibKhan/spec-kit-status/blob/main/README.md", + "changelog": "https://github.com/KhawarHabibKhan/spec-kit-status/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 0 + }, + "tags": [ + "status", + "workflow", + "progress", + "feature-tracking", + "task-progress" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-16T00:00:00Z", + "updated_at": "2026-03-16T00:00:00Z" + }, + "status-report": { + "name": "Status Report", + "id": "status-report", + "description": "Project status, feature progress, and next-action recommendations for spec-driven workflows.", + "author": "Open-Agent-Tools", + "version": "1.2.5", + "download_url": "https://github.com/Open-Agent-Tools/spec-kit-status/archive/refs/tags/v1.2.5.zip", + "repository": "https://github.com/Open-Agent-Tools/spec-kit-status", + "homepage": "https://github.com/Open-Agent-Tools/spec-kit-status", + "documentation": "https://github.com/Open-Agent-Tools/spec-kit-status/blob/main/README.md", + "changelog": "https://github.com/Open-Agent-Tools/spec-kit-status/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 0 + }, + "tags": [ + "workflow", + "project-management", + "status" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-08T15:05:14Z", + "updated_at": "2026-04-08T15:05:14Z" + }, + "superb": { + "name": "Superpowers Bridge", + "id": "superb", + "description": "Orchestrates obra/superpowers skills within the spec-kit SDD workflow. Thin bridge commands delegate to superpowers' authoritative SKILL.md files at runtime (with graceful fallback), while bridge-original commands provide spec-kit-native value. Eight commands cover the full lifecycle: intent clarification, TDD enforcement, task review, verification, critique, systematic debugging, branch completion, and review response. Hook-bound commands fire automatically; standalone commands are invoked when needed.", + "author": "rbbtsn0w", + "version": "1.3.0", + "download_url": "https://github.com/RbBtSn0w/spec-kit-extensions/releases/download/superpowers-bridge-v1.3.0/superpowers-bridge.zip", + "repository": "https://github.com/RbBtSn0w/spec-kit-extensions", + "homepage": "https://github.com/RbBtSn0w/spec-kit-extensions", + "documentation": "https://github.com/RbBtSn0w/spec-kit-extensions/blob/main/superpowers-bridge/README.md", + "changelog": "https://github.com/RbBtSn0w/spec-kit-extensions/blob/main/superpowers-bridge/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.3", + "tools": [ + { + "name": "superpowers", + "version": ">=5.0.0", + "required": false + } + ] + }, + "provides": { + "commands": 8, + "hooks": 4 + }, + "tags": [ + "methodology", + "tdd", + "code-review", + "workflow", + "superpowers", + "brainstorming", + "verification", + "debugging", + "branch-management" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-30T00:00:00Z", + "updated_at": "2026-04-16T14:08:23Z" + }, + "superpowers-bridge": { + "name": "Superpowers Bridge", + "id": "superpowers-bridge", + "description": "Bridges spec-kit workflows with obra/superpowers capabilities for brainstorming, TDD, code review, and resumable execution.", + "author": "WangX0111", + "version": "1.0.0", + "download_url": "https://github.com/WangX0111/superspec/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/WangX0111/superspec", + "homepage": "https://github.com/WangX0111/superspec", + "documentation": "https://github.com/WangX0111/superspec/blob/main/README.md", + "changelog": "https://github.com/WangX0111/superspec/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 5, + "hooks": 3 + }, + "tags": [ + "superpowers", + "brainstorming", + "tdd", + "code-review", + "subagent", + "workflow" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-22T00:00:00Z", + "updated_at": "2026-04-22T00:00:00Z" + }, + "sync": { + "name": "Spec Sync", + "id": "sync", + "description": "Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval.", + "author": "bgervin", + "version": "0.1.0", + "download_url": "https://github.com/bgervin/spec-kit-sync/archive/refs/tags/v0.1.0.zip", + "repository": "https://github.com/bgervin/spec-kit-sync", + "homepage": "https://github.com/bgervin/spec-kit-sync", + "documentation": "https://github.com/bgervin/spec-kit-sync/blob/main/README.md", + "changelog": "https://github.com/bgervin/spec-kit-sync/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 5, + "hooks": 1 + }, + "tags": [ + "sync", + "drift", + "validation", + "bidirectional", + "backfill" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-02T00:00:00Z", + "updated_at": "2026-03-02T00:00:00Z" + }, + "tinyspec": { + "name": "TinySpec", + "id": "tinyspec", + "description": "Lightweight single-file workflow for small tasks — skip the heavy multi-step SDD process.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-tinyspec/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-tinyspec", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-tinyspec", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-tinyspec/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-tinyspec/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 3, + "hooks": 1 + }, + "tags": [ + "lightweight", + "small-tasks", + "workflow", + "productivity", + "efficiency" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-10T00:00:00Z", + "updated_at": "2026-04-10T00:00:00Z" + }, + "v-model": { + "name": "V-Model Extension Pack", + "id": "v-model", + "description": "Enforces V-Model paired generation of development specs and test specs with full traceability.", + "author": "leocamello", + "version": "0.5.0", + "download_url": "https://github.com/leocamello/spec-kit-v-model/archive/refs/tags/v0.5.0.zip", + "repository": "https://github.com/leocamello/spec-kit-v-model", + "homepage": "https://github.com/leocamello/spec-kit-v-model", + "documentation": "https://github.com/leocamello/spec-kit-v-model/blob/main/README.md", + "changelog": "https://github.com/leocamello/spec-kit-v-model/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 14, + "hooks": 1 + }, + "tags": [ + "v-model", + "traceability", + "testing", + "compliance", + "safety-critical" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-02-20T00:00:00Z", + "updated_at": "2026-04-06T00:00:00Z" + }, + "verify": { + "name": "Verify Extension", + "id": "verify", + "description": "Post-implementation quality gate that validates implemented code against specification artifacts.", + "author": "ismaelJimenez", + "version": "1.0.3", + "download_url": "https://github.com/ismaelJimenez/spec-kit-verify/archive/refs/tags/v1.0.3.zip", + "repository": "https://github.com/ismaelJimenez/spec-kit-verify", + "homepage": "https://github.com/ismaelJimenez/spec-kit-verify", + "documentation": "https://github.com/ismaelJimenez/spec-kit-verify/blob/main/README.md", + "changelog": "https://github.com/ismaelJimenez/spec-kit-verify/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 1 + }, + "tags": [ + "verification", + "quality-gate", + "implementation", + "spec-adherence", + "compliance" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-03T00:00:00Z", + "updated_at": "2026-04-09T00:00:00Z" + }, + "verify-tasks": { + "name": "Verify Tasks Extension", + "id": "verify-tasks", + "description": "Detect phantom completions: tasks marked [X] in tasks.md with no real implementation.", + "author": "Dave Sharpe", + "version": "1.0.0", + "download_url": "https://github.com/datastone-inc/spec-kit-verify-tasks/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/datastone-inc/spec-kit-verify-tasks", + "homepage": "https://github.com/datastone-inc/spec-kit-verify-tasks", + "documentation": "https://github.com/datastone-inc/spec-kit-verify-tasks/blob/main/README.md", + "changelog": "https://github.com/datastone-inc/spec-kit-verify-tasks/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 1 + }, + "tags": [ + "verification", + "quality", + "phantom-completion", + "tasks" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-16T00:00:00Z", + "updated_at": "2026-03-16T00:00:00Z" + }, + "version-guard": { + "name": "Version Guard", + "id": "version-guard", + "description": "Verify tech stack versions against live registries before planning and implementation", + "author": "KevinBrown5280", + "version": "1.2.0", + "download_url": "https://github.com/KevinBrown5280/spec-kit-version-guard/archive/refs/tags/v1.2.0.zip", + "repository": "https://github.com/KevinBrown5280/spec-kit-version-guard", + "homepage": "https://github.com/KevinBrown5280/spec-kit-version-guard", + "documentation": "https://github.com/KevinBrown5280/spec-kit-version-guard/blob/main/README.md", + "changelog": "https://github.com/KevinBrown5280/spec-kit-version-guard/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.2.0" + }, + "provides": { + "commands": 3, + "hooks": 4 + }, + "tags": [ + "versioning", + "npm", + "validation", + "hooks" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-20T00:00:00Z", + "updated_at": "2026-04-22T21:10:00Z" + }, + "whatif": { + "name": "What-if Analysis", + "id": "whatif", + "description": "Preview the downstream impact (complexity, effort, tasks, risks) of requirement changes before committing to them.", + "author": "DevAbdullah90", + "version": "1.0.0", + "repository": "https://github.com/DevAbdullah90/spec-kit-whatif", + "homepage": "https://github.com/DevAbdullah90/spec-kit-whatif", + "documentation": "https://github.com/DevAbdullah90/spec-kit-whatif/blob/main/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.6.0" + }, + "provides": { + "commands": 1, + "hooks": 0 + }, + "tags": [ + "analysis", + "planning", + "simulation" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-13T00:00:00Z", + "updated_at": "2026-04-13T00:00:00Z" + }, + "wireframe": { + "name": "Wireframe Visual Feedback Loop", + "id": "wireframe", + "description": "SVG wireframe generation, review, and sign-off for spec-driven development. Approved wireframes become spec constraints honored by /speckit.plan, /speckit.tasks, and /speckit.implement.", + "author": "TortoiseWolfe", + "version": "0.1.1", + "download_url": "https://github.com/TortoiseWolfe/spec-kit-extension-wireframe/archive/refs/tags/v0.1.1.zip", + "repository": "https://github.com/TortoiseWolfe/spec-kit-extension-wireframe", + "homepage": "https://github.com/TortoiseWolfe/spec-kit-extension-wireframe", + "documentation": "https://github.com/TortoiseWolfe/spec-kit-extension-wireframe/blob/main/README.md", + "changelog": "https://github.com/TortoiseWolfe/spec-kit-extension-wireframe/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.6.0" + }, + "provides": { + "commands": 6, + "hooks": 3 + }, + "tags": [ + "wireframe", + "visual", + "design", + "ui", + "mockup", + "svg", + "feedback-loop", + "sign-off" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-22T00:00:00Z", + "updated_at": "2026-04-22T00:00:00Z" + }, + "worktree": { + "name": "Worktree Isolation", + "id": "worktree", + "description": "Spawn isolated git worktrees for parallel feature development without checkout switching.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-worktree/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-worktree", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-worktree", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-worktree/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-worktree/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 3, + "hooks": 1 + }, + "tags": [ + "worktree", + "git", + "parallel", + "isolation", + "workflow" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-09T00:00:00Z", + "updated_at": "2026-04-09T00:00:00Z" + }, + "worktrees": { + "name": "Worktrees", + "id": "worktrees", + "description": "Default-on worktree isolation for parallel agents — sibling or nested layout", + "author": "dango85", + "version": "1.0.0", + "download_url": "https://github.com/dango85/spec-kit-worktree-parallel/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/dango85/spec-kit-worktree-parallel", + "homepage": "https://github.com/dango85/spec-kit-worktree-parallel", + "documentation": "https://github.com/dango85/spec-kit-worktree-parallel/blob/main/README.md", + "changelog": "https://github.com/dango85/spec-kit-worktree-parallel/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 3, + "hooks": 1 + }, + "tags": [ + "worktree", + "git", + "parallel", + "isolation", + "agents" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-13T00:00:00Z", + "updated_at": "2026-04-13T00:00:00Z" + } + } +} diff --git a/extensions/catalog.example.json b/extensions/catalog.example.json deleted file mode 100644 index afbcc0b566..0000000000 --- a/extensions/catalog.example.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "schema_version": "1.0", - "updated_at": "2026-02-03T00:00:00Z", - "catalog_url": "https://your-org.example.com/speckit/catalog.json", - "extensions": { - "jira": { - "name": "Jira Integration", - "id": "jira", - "description": "Create Jira Epics, Stories, and Issues from spec-kit artifacts", - "author": "Your Organization", - "version": "2.1.0", - "download_url": "https://github.com/your-org/spec-kit-jira/archive/refs/tags/v2.1.0.zip", - "repository": "https://github.com/your-org/spec-kit-jira", - "homepage": "https://github.com/your-org/spec-kit-jira", - "documentation": "https://github.com/your-org/spec-kit-jira/blob/main/README.md", - "changelog": "https://github.com/your-org/spec-kit-jira/blob/main/CHANGELOG.md", - "license": "MIT", - "requires": { - "speckit_version": ">=0.1.0", - "tools": [ - { - "name": "atlassian", - "version": ">=1.0.0", - "required": true - } - ] - }, - "provides": { - "commands": 3, - "hooks": 1 - }, - "tags": ["jira", "atlassian", "issue-tracking"], - "verified": true, - "downloads": 0, - "stars": 0, - "created_at": "2026-01-28T00:00:00Z", - "updated_at": "2026-02-03T00:00:00Z" - }, - "linear": { - "name": "Linear Integration", - "id": "linear", - "description": "Sync specs and tasks with Linear issues", - "author": "Your Organization", - "version": "1.0.0", - "download_url": "https://github.com/your-org/spec-kit-linear/archive/refs/tags/v1.0.0.zip", - "repository": "https://github.com/your-org/spec-kit-linear", - "license": "MIT", - "requires": { - "speckit_version": ">=0.1.0" - }, - "provides": { - "commands": 2 - }, - "tags": ["linear", "issue-tracking"], - "verified": false, - "created_at": "2026-01-30T00:00:00Z", - "updated_at": "2026-01-30T00:00:00Z" - } - } -} diff --git a/extensions/catalog.json b/extensions/catalog.json index bdebd83dd4..de9372e2bc 100644 --- a/extensions/catalog.json +++ b/extensions/catalog.json @@ -1,6 +1,22 @@ { "schema_version": "1.0", - "updated_at": "2026-02-03T00:00:00Z", + "updated_at": "2026-04-10T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json", - "extensions": {} -} + "extensions": { + "git": { + "name": "Git Branching Workflow", + "id": "git", + "version": "1.0.0", + "description": "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "bundled": true, + "tags": [ + "git", + "branching", + "workflow", + "core" + ] + } + } +} \ No newline at end of file diff --git a/extensions/git/README.md b/extensions/git/README.md new file mode 100644 index 0000000000..31ba75c30f --- /dev/null +++ b/extensions/git/README.md @@ -0,0 +1,100 @@ +# Git Branching Workflow Extension + +Git repository initialization, feature branch creation, numbering (sequential/timestamp), validation, remote detection, and auto-commit for Spec Kit. + +## Overview + +This extension provides Git operations as an optional, self-contained module. It manages: + +- **Repository initialization** with configurable commit messages +- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering +- **Branch validation** to ensure branches follow naming conventions +- **Git remote detection** for GitHub integration (e.g., issue creation) +- **Auto-commit** after core commands (configurable per-command with custom messages) + +## Commands + +| Command | Description | +|---------|-------------| +| `speckit.git.initialize` | Initialize a Git repository with a configurable commit message | +| `speckit.git.feature` | Create a feature branch with sequential or timestamp numbering | +| `speckit.git.validate` | Validate current branch follows feature branch naming conventions | +| `speckit.git.remote` | Detect Git remote URL for GitHub integration | +| `speckit.git.commit` | Auto-commit changes (configurable per-command enable/disable and messages) | + +## Hooks + +| Event | Command | Optional | Description | +|-------|---------|----------|-------------| +| `before_constitution` | `speckit.git.initialize` | No | Init git repo before constitution | +| `before_specify` | `speckit.git.feature` | No | Create feature branch before specification | +| `before_clarify` | `speckit.git.commit` | Yes | Commit outstanding changes before clarification | +| `before_plan` | `speckit.git.commit` | Yes | Commit outstanding changes before planning | +| `before_tasks` | `speckit.git.commit` | Yes | Commit outstanding changes before task generation | +| `before_implement` | `speckit.git.commit` | Yes | Commit outstanding changes before implementation | +| `before_checklist` | `speckit.git.commit` | Yes | Commit outstanding changes before checklist | +| `before_analyze` | `speckit.git.commit` | Yes | Commit outstanding changes before analysis | +| `before_taskstoissues` | `speckit.git.commit` | Yes | Commit outstanding changes before issue sync | +| `after_constitution` | `speckit.git.commit` | Yes | Auto-commit after constitution update | +| `after_specify` | `speckit.git.commit` | Yes | Auto-commit after specification | +| `after_clarify` | `speckit.git.commit` | Yes | Auto-commit after clarification | +| `after_plan` | `speckit.git.commit` | Yes | Auto-commit after planning | +| `after_tasks` | `speckit.git.commit` | Yes | Auto-commit after task generation | +| `after_implement` | `speckit.git.commit` | Yes | Auto-commit after implementation | +| `after_checklist` | `speckit.git.commit` | Yes | Auto-commit after checklist | +| `after_analyze` | `speckit.git.commit` | Yes | Auto-commit after analysis | +| `after_taskstoissues` | `speckit.git.commit` | Yes | Auto-commit after issue sync | + +## Configuration + +Configuration is stored in `.specify/extensions/git/git-config.yml`: + +```yaml +# Branch numbering strategy: "sequential" or "timestamp" +branch_numbering: sequential + +# Custom commit message for git init +init_commit_message: "[Spec Kit] Initial commit" + +# Auto-commit per command (all disabled by default) +# Example: enable auto-commit after specify +auto_commit: + default: false + after_specify: + enabled: true + message: "[Spec Kit] Add specification" +``` + +## Installation + +```bash +# Install the bundled git extension (no network required) +specify extension add git +``` + +## Disabling + +```bash +# Disable the git extension (spec creation continues without branching) +specify extension disable git + +# Re-enable it +specify extension enable git +``` + +## Graceful Degradation + +When Git is not installed or the directory is not a Git repository: +- Spec directories are still created under `specs/` +- Branch creation is skipped with a warning +- Branch validation is skipped with a warning +- Remote detection returns empty results + +## Scripts + +The extension bundles cross-platform scripts: + +- `scripts/bash/create-new-feature.sh` — Bash implementation +- `scripts/bash/git-common.sh` — Shared Git utilities (Bash) +- `scripts/powershell/create-new-feature.ps1` — PowerShell implementation +- `scripts/powershell/git-common.ps1` — Shared Git utilities (PowerShell) diff --git a/extensions/git/commands/speckit.git.commit.md b/extensions/git/commands/speckit.git.commit.md new file mode 100644 index 0000000000..e606f911df --- /dev/null +++ b/extensions/git/commands/speckit.git.commit.md @@ -0,0 +1,48 @@ +--- +description: "Auto-commit changes after a Spec Kit command completes" +--- + +# Auto-Commit Changes + +Automatically stage and commit all changes after a Spec Kit command completes. + +## Behavior + +This command is invoked as a hook after (or before) core commands. It: + +1. Determines the event name from the hook context (e.g., if invoked as an `after_specify` hook, the event is `after_specify`; if `before_plan`, the event is `before_plan`) +2. Checks `.specify/extensions/git/git-config.yml` for the `auto_commit` section +3. Looks up the specific event key to see if auto-commit is enabled +4. Falls back to `auto_commit.default` if no event-specific key exists +5. Uses the per-command `message` if configured, otherwise a default message +6. If enabled and there are uncommitted changes, runs `git add .` + `git commit` + +## Execution + +Determine the event name from the hook that triggered this command, then run the script: + +- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh ` +- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 ` + +Replace `` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`). + +## Configuration + +In `.specify/extensions/git/git-config.yml`: + +```yaml +auto_commit: + default: false # Global toggle — set true to enable for all commands + after_specify: + enabled: true # Override per-command + message: "[Spec Kit] Add specification" + after_plan: + enabled: false + message: "[Spec Kit] Add implementation plan" +``` + +## Graceful Degradation + +- If Git is not available or the current directory is not a repository: skips with a warning +- If no config file exists: skips (disabled by default) +- If no changes to commit: skips with a message diff --git a/extensions/git/commands/speckit.git.feature.md b/extensions/git/commands/speckit.git.feature.md new file mode 100644 index 0000000000..1a9c5e35da --- /dev/null +++ b/extensions/git/commands/speckit.git.feature.md @@ -0,0 +1,67 @@ +--- +description: "Create a feature branch with sequential or timestamp numbering" +--- + +# Create Feature Branch + +Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `/speckit.specify` workflow. + +## User Input + +```text +$ARGUMENTS +``` + +You **MUST** consider the user input before proceeding (if not empty). + +## Environment Variable Override + +If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set: +- The script uses the exact value as the branch name, bypassing all prefix/suffix generation +- `--short-name`, `--number`, and `--timestamp` flags are ignored +- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name + +## Prerequisites + +- Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null` +- If Git is not available, warn the user and skip branch creation + +## Branch Numbering Mode + +Determine the branch numbering strategy by checking configuration in this order: + +1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value +2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility) +3. Default to `sequential` if neither exists + +## Execution + +Generate a concise short name (2-4 words) for the branch: +- Analyze the feature description and extract the most meaningful keywords +- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug") +- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.) + +Run the appropriate script based on your platform: + +- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "" ""` +- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "" ""` +- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "" ""` +- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "" ""` + +**IMPORTANT**: +- Do NOT pass `--number` — the script determines the correct next number automatically +- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably +- You must only ever run this script once per feature +- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM` + +## Graceful Degradation + +If Git is not installed or the current directory is not a Git repository: +- Branch creation is skipped with a warning: `[specify] Warning: Git repository not detected; skipped branch creation` +- The script still outputs `BRANCH_NAME` and `FEATURE_NUM` so the caller can reference them + +## Output + +The script outputs JSON with: +- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`) +- `FEATURE_NUM`: The numeric or timestamp prefix used diff --git a/extensions/git/commands/speckit.git.initialize.md b/extensions/git/commands/speckit.git.initialize.md new file mode 100644 index 0000000000..4451ee6b77 --- /dev/null +++ b/extensions/git/commands/speckit.git.initialize.md @@ -0,0 +1,49 @@ +--- +description: "Initialize a Git repository with an initial commit" +--- + +# Initialize Git Repository + +Initialize a Git repository in the current project directory if one does not already exist. + +## Execution + +Run the appropriate script from the project root: + +- **Bash**: `.specify/extensions/git/scripts/bash/initialize-repo.sh` +- **PowerShell**: `.specify/extensions/git/scripts/powershell/initialize-repo.ps1` + +If the extension scripts are not found, fall back to: +- **Bash**: `git init && git add . && git commit -m "Initial commit from Specify template"` +- **PowerShell**: `git init; git add .; git commit -m "Initial commit from Specify template"` + +The script handles all checks internally: +- Skips if Git is not available +- Skips if already inside a Git repository +- Runs `git init`, `git add .`, and `git commit` with an initial commit message + +## Customization + +Replace the script to add project-specific Git initialization steps: +- Custom `.gitignore` templates +- Default branch naming (`git config init.defaultBranch`) +- Git LFS setup +- Git hooks installation +- Commit signing configuration +- Git Flow initialization + +## Output + +On success: +- `āœ“ Git repository initialized` + +## Graceful Degradation + +If Git is not installed: +- Warn the user +- Skip repository initialization +- The project continues to function without Git (specs can still be created under `specs/`) + +If Git is installed but `git init`, `git add .`, or `git commit` fails: +- Surface the error to the user +- Stop this command rather than continuing with a partially initialized repository diff --git a/extensions/git/commands/speckit.git.remote.md b/extensions/git/commands/speckit.git.remote.md new file mode 100644 index 0000000000..712a3e8b8c --- /dev/null +++ b/extensions/git/commands/speckit.git.remote.md @@ -0,0 +1,45 @@ +--- +description: "Detect Git remote URL for GitHub integration" +--- + +# Detect Git Remote URL + +Detect the Git remote URL for integration with GitHub services (e.g., issue creation). + +## Prerequisites + +- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null` +- If Git is not available, output a warning and return empty: + ``` + [specify] Warning: Git repository not detected; cannot determine remote URL + ``` + +## Execution + +Run the following command to get the remote URL: + +```bash +git config --get remote.origin.url +``` + +## Output + +Parse the remote URL and determine: + +1. **Repository owner**: Extract from the URL (e.g., `github` from `https://github.com/github/spec-kit.git`) +2. **Repository name**: Extract from the URL (e.g., `spec-kit` from `https://github.com/github/spec-kit.git`) +3. **Is GitHub**: Whether the remote points to a GitHub repository + +Supported URL formats: +- HTTPS: `https://github.com//.git` +- SSH: `git@github.com:/.git` + +> [!CAUTION] +> ONLY report a GitHub repository if the remote URL actually points to github.com. +> Do NOT assume the remote is GitHub if the URL format doesn't match. + +## Graceful Degradation + +If Git is not installed, the directory is not a Git repository, or no remote is configured: +- Return an empty result +- Do NOT error — other workflows should continue without Git remote information diff --git a/extensions/git/commands/speckit.git.validate.md b/extensions/git/commands/speckit.git.validate.md new file mode 100644 index 0000000000..dd84618cb8 --- /dev/null +++ b/extensions/git/commands/speckit.git.validate.md @@ -0,0 +1,49 @@ +--- +description: "Validate current branch follows feature branch naming conventions" +--- + +# Validate Feature Branch + +Validate that the current Git branch follows the expected feature branch naming conventions. + +## Prerequisites + +- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null` +- If Git is not available, output a warning and skip validation: + ``` + [specify] Warning: Git repository not detected; skipped branch validation + ``` + +## Validation Rules + +Get the current branch name: + +```bash +git rev-parse --abbrev-ref HEAD +``` + +The branch name must match one of these patterns: + +1. **Sequential**: `^[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`) +2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`) + +## Execution + +If on a feature branch (matches either pattern): +- Output: `āœ“ On feature branch: ` +- Check if the corresponding spec directory exists under `specs/`: + - For sequential branches, look for `specs/-*` where prefix matches the numeric portion + - For timestamp branches, look for `specs/-*` where prefix matches the `YYYYMMDD-HHMMSS` portion +- If spec directory exists: `āœ“ Spec directory found: ` +- If spec directory missing: `⚠ No spec directory found for prefix ` + +If NOT on a feature branch: +- Output: `āœ— Not on a feature branch. Current branch: ` +- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name` + +## Graceful Degradation + +If Git is not installed or the directory is not a Git repository: +- Check the `SPECIFY_FEATURE` environment variable as a fallback +- If set, validate that value against the naming patterns +- If not set, skip validation with a warning diff --git a/extensions/git/config-template.yml b/extensions/git/config-template.yml new file mode 100644 index 0000000000..8c414babe6 --- /dev/null +++ b/extensions/git/config-template.yml @@ -0,0 +1,62 @@ +# Git Branching Workflow Extension Configuration +# Copied to .specify/extensions/git/git-config.yml on install + +# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS) +branch_numbering: sequential + +# Commit message used by `git commit` during repository initialization +init_commit_message: "[Spec Kit] Initial commit" + +# Auto-commit before/after core commands. +# Set "default" to enable for all commands, then override per-command. +# Each key can be true/false. Message is customizable per-command. +auto_commit: + default: false + before_clarify: + enabled: false + message: "[Spec Kit] Save progress before clarification" + before_plan: + enabled: false + message: "[Spec Kit] Save progress before planning" + before_tasks: + enabled: false + message: "[Spec Kit] Save progress before task generation" + before_implement: + enabled: false + message: "[Spec Kit] Save progress before implementation" + before_checklist: + enabled: false + message: "[Spec Kit] Save progress before checklist" + before_analyze: + enabled: false + message: "[Spec Kit] Save progress before analysis" + before_taskstoissues: + enabled: false + message: "[Spec Kit] Save progress before issue sync" + after_constitution: + enabled: false + message: "[Spec Kit] Add project constitution" + after_specify: + enabled: false + message: "[Spec Kit] Add specification" + after_clarify: + enabled: false + message: "[Spec Kit] Clarify specification" + after_plan: + enabled: false + message: "[Spec Kit] Add implementation plan" + after_tasks: + enabled: false + message: "[Spec Kit] Add tasks" + after_implement: + enabled: false + message: "[Spec Kit] Implementation progress" + after_checklist: + enabled: false + message: "[Spec Kit] Add checklist" + after_analyze: + enabled: false + message: "[Spec Kit] Add analysis report" + after_taskstoissues: + enabled: false + message: "[Spec Kit] Sync tasks to issues" diff --git a/extensions/git/extension.yml b/extensions/git/extension.yml new file mode 100644 index 0000000000..13c1977ea1 --- /dev/null +++ b/extensions/git/extension.yml @@ -0,0 +1,140 @@ +schema_version: "1.0" + +extension: + id: git + name: "Git Branching Workflow" + version: "1.0.0" + description: "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection" + author: spec-kit-core + repository: https://github.com/github/spec-kit + license: MIT + +requires: + speckit_version: ">=0.2.0" + tools: + - name: git + required: false + +provides: + commands: + - name: speckit.git.feature + file: commands/speckit.git.feature.md + description: "Create a feature branch with sequential or timestamp numbering" + - name: speckit.git.validate + file: commands/speckit.git.validate.md + description: "Validate current branch follows feature branch naming conventions" + - name: speckit.git.remote + file: commands/speckit.git.remote.md + description: "Detect Git remote URL for GitHub integration" + - name: speckit.git.initialize + file: commands/speckit.git.initialize.md + description: "Initialize a Git repository with an initial commit" + - name: speckit.git.commit + file: commands/speckit.git.commit.md + description: "Auto-commit changes after a Spec Kit command completes" + + config: + - name: "git-config.yml" + template: "config-template.yml" + description: "Git branching configuration" + required: false + +hooks: + before_constitution: + command: speckit.git.initialize + optional: false + description: "Initialize Git repository before constitution setup" + before_specify: + command: speckit.git.feature + optional: false + description: "Create feature branch before specification" + before_clarify: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before clarification?" + description: "Auto-commit before spec clarification" + before_plan: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before planning?" + description: "Auto-commit before implementation planning" + before_tasks: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before task generation?" + description: "Auto-commit before task generation" + before_implement: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before implementation?" + description: "Auto-commit before implementation" + before_checklist: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before checklist?" + description: "Auto-commit before checklist generation" + before_analyze: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before analysis?" + description: "Auto-commit before analysis" + before_taskstoissues: + command: speckit.git.commit + optional: true + prompt: "Commit outstanding changes before issue sync?" + description: "Auto-commit before tasks-to-issues conversion" + after_constitution: + command: speckit.git.commit + optional: true + prompt: "Commit constitution changes?" + description: "Auto-commit after constitution update" + after_specify: + command: speckit.git.commit + optional: true + prompt: "Commit specification changes?" + description: "Auto-commit after specification" + after_clarify: + command: speckit.git.commit + optional: true + prompt: "Commit clarification changes?" + description: "Auto-commit after spec clarification" + after_plan: + command: speckit.git.commit + optional: true + prompt: "Commit plan changes?" + description: "Auto-commit after implementation planning" + after_tasks: + command: speckit.git.commit + optional: true + prompt: "Commit task changes?" + description: "Auto-commit after task generation" + after_implement: + command: speckit.git.commit + optional: true + prompt: "Commit implementation changes?" + description: "Auto-commit after implementation" + after_checklist: + command: speckit.git.commit + optional: true + prompt: "Commit checklist changes?" + description: "Auto-commit after checklist generation" + after_analyze: + command: speckit.git.commit + optional: true + prompt: "Commit analysis results?" + description: "Auto-commit after analysis" + after_taskstoissues: + command: speckit.git.commit + optional: true + prompt: "Commit after syncing issues?" + description: "Auto-commit after tasks-to-issues conversion" + +tags: + - "git" + - "branching" + - "workflow" + +config: + defaults: + branch_numbering: sequential + init_commit_message: "[Spec Kit] Initial commit" diff --git a/extensions/git/git-config.yml b/extensions/git/git-config.yml new file mode 100644 index 0000000000..8c414babe6 --- /dev/null +++ b/extensions/git/git-config.yml @@ -0,0 +1,62 @@ +# Git Branching Workflow Extension Configuration +# Copied to .specify/extensions/git/git-config.yml on install + +# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS) +branch_numbering: sequential + +# Commit message used by `git commit` during repository initialization +init_commit_message: "[Spec Kit] Initial commit" + +# Auto-commit before/after core commands. +# Set "default" to enable for all commands, then override per-command. +# Each key can be true/false. Message is customizable per-command. +auto_commit: + default: false + before_clarify: + enabled: false + message: "[Spec Kit] Save progress before clarification" + before_plan: + enabled: false + message: "[Spec Kit] Save progress before planning" + before_tasks: + enabled: false + message: "[Spec Kit] Save progress before task generation" + before_implement: + enabled: false + message: "[Spec Kit] Save progress before implementation" + before_checklist: + enabled: false + message: "[Spec Kit] Save progress before checklist" + before_analyze: + enabled: false + message: "[Spec Kit] Save progress before analysis" + before_taskstoissues: + enabled: false + message: "[Spec Kit] Save progress before issue sync" + after_constitution: + enabled: false + message: "[Spec Kit] Add project constitution" + after_specify: + enabled: false + message: "[Spec Kit] Add specification" + after_clarify: + enabled: false + message: "[Spec Kit] Clarify specification" + after_plan: + enabled: false + message: "[Spec Kit] Add implementation plan" + after_tasks: + enabled: false + message: "[Spec Kit] Add tasks" + after_implement: + enabled: false + message: "[Spec Kit] Implementation progress" + after_checklist: + enabled: false + message: "[Spec Kit] Add checklist" + after_analyze: + enabled: false + message: "[Spec Kit] Add analysis report" + after_taskstoissues: + enabled: false + message: "[Spec Kit] Sync tasks to issues" diff --git a/extensions/git/scripts/bash/auto-commit.sh b/extensions/git/scripts/bash/auto-commit.sh new file mode 100755 index 0000000000..f0b423187b --- /dev/null +++ b/extensions/git/scripts/bash/auto-commit.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# Git extension: auto-commit.sh +# Automatically commit changes after a Spec Kit command completes. +# Checks per-command config keys in git-config.yml before committing. +# +# Usage: auto-commit.sh +# e.g.: auto-commit.sh after_specify + +set -e + +EVENT_NAME="${1:-}" +if [ -z "$EVENT_NAME" ]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +_find_project_root() { + local dir="$1" + while [ "$dir" != "/" ]; do + if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then + echo "$dir" + return 0 + fi + dir="$(dirname "$dir")" + done + return 1 +} + +REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)" +cd "$REPO_ROOT" + +# Check if git is available +if ! command -v git >/dev/null 2>&1; then + echo "[specify] Warning: Git not found; skipped auto-commit" >&2 + exit 0 +fi + +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "[specify] Warning: Not a Git repository; skipped auto-commit" >&2 + exit 0 +fi + +# Read per-command config from git-config.yml +_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml" +_enabled=false +_commit_msg="" + +if [ -f "$_config_file" ]; then + # Parse the auto_commit section for this event. + # Look for auto_commit..enabled and .message + # Also check auto_commit.default as fallback. + _in_auto_commit=false + _in_event=false + _default_enabled=false + + while IFS= read -r _line; do + # Detect auto_commit: section + if echo "$_line" | grep -q '^auto_commit:'; then + _in_auto_commit=true + _in_event=false + continue + fi + + # Exit auto_commit section on next top-level key + if $_in_auto_commit && echo "$_line" | grep -Eq '^[a-z]'; then + break + fi + + if $_in_auto_commit; then + # Check default key + if echo "$_line" | grep -Eq "^[[:space:]]+default:[[:space:]]"; then + _val=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]') + [ "$_val" = "true" ] && _default_enabled=true + fi + + # Detect our event subsection + if echo "$_line" | grep -Eq "^[[:space:]]+${EVENT_NAME}:"; then + _in_event=true + continue + fi + + # Inside our event subsection + if $_in_event; then + # Exit on next sibling key (same indent level as event name) + if echo "$_line" | grep -Eq '^[[:space:]]{2}[a-z]' && ! echo "$_line" | grep -Eq '^[[:space:]]{4}'; then + _in_event=false + continue + fi + if echo "$_line" | grep -Eq '[[:space:]]+enabled:'; then + _val=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]') + [ "$_val" = "true" ] && _enabled=true + [ "$_val" = "false" ] && _enabled=false + fi + if echo "$_line" | grep -Eq '[[:space:]]+message:'; then + _commit_msg=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//') + fi + fi + fi + done < "$_config_file" + + # If event-specific key not found, use default + if [ "$_enabled" = "false" ] && [ "$_default_enabled" = "true" ]; then + # Only use default if the event wasn't explicitly set to false + # Check if event section existed at all + if ! grep -q "^[[:space:]]*${EVENT_NAME}:" "$_config_file" 2>/dev/null; then + _enabled=true + fi + fi +else + # No config file — auto-commit disabled by default + exit 0 +fi + +if [ "$_enabled" != "true" ]; then + exit 0 +fi + +# Check if there are changes to commit +if git diff --quiet HEAD 2>/dev/null && git diff --cached --quiet 2>/dev/null && [ -z "$(git ls-files --others --exclude-standard 2>/dev/null)" ]; then + echo "[specify] No changes to commit after $EVENT_NAME" >&2 + exit 0 +fi + +# Derive a human-readable command name from the event +# e.g., after_specify -> specify, before_plan -> plan +_command_name=$(echo "$EVENT_NAME" | sed 's/^after_//' | sed 's/^before_//') +_phase=$(echo "$EVENT_NAME" | grep -q '^before_' && echo 'before' || echo 'after') + +# Use custom message if configured, otherwise default +if [ -z "$_commit_msg" ]; then + _commit_msg="[Spec Kit] Auto-commit ${_phase} ${_command_name}" +fi + +# Stage and commit +_git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; } +_git_out=$(git commit -q -m "$_commit_msg" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; } + +echo "[OK] Changes committed ${_phase} ${_command_name}" >&2 diff --git a/extensions/git/scripts/bash/create-new-feature.sh b/extensions/git/scripts/bash/create-new-feature.sh new file mode 100755 index 0000000000..f7aa31610e --- /dev/null +++ b/extensions/git/scripts/bash/create-new-feature.sh @@ -0,0 +1,453 @@ +#!/usr/bin/env bash +# Git extension: create-new-feature.sh +# Adapted from core scripts/bash/create-new-feature.sh for extension layout. +# Sources common.sh from the project's installed scripts, falling back to +# git-common.sh for minimal git helpers. + +set -e + +JSON_MODE=false +DRY_RUN=false +ALLOW_EXISTING=false +SHORT_NAME="" +BRANCH_NUMBER="" +USE_TIMESTAMP=false +ARGS=() +i=1 +while [ $i -le $# ]; do + arg="${!i}" + case "$arg" in + --json) + JSON_MODE=true + ;; + --dry-run) + DRY_RUN=true + ;; + --allow-existing-branch) + ALLOW_EXISTING=true + ;; + --short-name) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --short-name requires a value' >&2 + exit 1 + fi + i=$((i + 1)) + next_arg="${!i}" + if [[ "$next_arg" == --* ]]; then + echo 'Error: --short-name requires a value' >&2 + exit 1 + fi + SHORT_NAME="$next_arg" + ;; + --number) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --number requires a value' >&2 + exit 1 + fi + i=$((i + 1)) + next_arg="${!i}" + if [[ "$next_arg" == --* ]]; then + echo 'Error: --number requires a value' >&2 + exit 1 + fi + BRANCH_NUMBER="$next_arg" + if [[ ! "$BRANCH_NUMBER" =~ ^[0-9]+$ ]]; then + echo 'Error: --number must be a non-negative integer' >&2 + exit 1 + fi + ;; + --timestamp) + USE_TIMESTAMP=true + ;; + --help|-h) + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " + echo "" + echo "Options:" + echo " --json Output in JSON format" + echo " --dry-run Compute branch name without creating the branch" + echo " --allow-existing-branch Switch to branch if it already exists instead of failing" + echo " --short-name Provide a custom short name (2-4 words) for the branch" + echo " --number N Specify branch number manually (overrides auto-detection)" + echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" + echo " --help, -h Show this help message" + echo "" + echo "Environment variables:" + echo " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation" + echo "" + echo "Examples:" + echo " $0 'Add user authentication system' --short-name 'user-auth'" + echo " $0 'Implement OAuth2 integration for API' --number 5" + echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'" + echo " GIT_BRANCH_NAME=my-branch $0 'feature description'" + exit 0 + ;; + *) + ARGS+=("$arg") + ;; + esac + i=$((i + 1)) +done + +FEATURE_DESCRIPTION="${ARGS[*]}" +if [ -z "$FEATURE_DESCRIPTION" ]; then + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " >&2 + exit 1 +fi + +# Trim whitespace and validate description is not empty +FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g') +if [ -z "$FEATURE_DESCRIPTION" ]; then + echo "Error: Feature description cannot be empty or contain only whitespace" >&2 + exit 1 +fi + +# Function to get highest number from specs directory +get_highest_from_specs() { + local specs_dir="$1" + local highest=0 + + if [ -d "$specs_dir" ]; then + for dir in "$specs_dir"/*; do + [ -d "$dir" ] || continue + dirname=$(basename "$dir") + # Match sequential prefixes (>=3 digits), but skip timestamp dirs. + if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then + number=$(echo "$dirname" | grep -Eo '^[0-9]+') + number=$((10#$number)) + if [ "$number" -gt "$highest" ]; then + highest=$number + fi + fi + done + fi + + echo "$highest" +} + +# Function to get highest number from git branches +get_highest_from_branches() { + git branch -a 2>/dev/null | sed 's/^[* ]*//; s|^remotes/[^/]*/||' | _extract_highest_number +} + +# Extract the highest sequential feature number from a list of ref names (one per line). +_extract_highest_number() { + local highest=0 + while IFS= read -r name; do + [ -z "$name" ] && continue + if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then + number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0") + number=$((10#$number)) + if [ "$number" -gt "$highest" ]; then + highest=$number + fi + fi + done + echo "$highest" +} + +# Function to get highest number from remote branches without fetching (side-effect-free) +get_highest_from_remote_refs() { + local highest=0 + + for remote in $(git remote 2>/dev/null); do + local remote_highest + remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number) + if [ "$remote_highest" -gt "$highest" ]; then + highest=$remote_highest + fi + done + + echo "$highest" +} + +# Function to check existing branches and return next available number. +check_existing_branches() { + local specs_dir="$1" + local skip_fetch="${2:-false}" + + if [ "$skip_fetch" = true ]; then + local highest_remote=$(get_highest_from_remote_refs) + local highest_branch=$(get_highest_from_branches) + if [ "$highest_remote" -gt "$highest_branch" ]; then + highest_branch=$highest_remote + fi + else + git fetch --all --prune >/dev/null 2>&1 || true + local highest_branch=$(get_highest_from_branches) + fi + + local highest_spec=$(get_highest_from_specs "$specs_dir") + + local max_num=$highest_branch + if [ "$highest_spec" -gt "$max_num" ]; then + max_num=$highest_spec + fi + + echo $((max_num + 1)) +} + +# Function to clean and format a branch name +clean_branch_name() { + local name="$1" + echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//' +} + +# --------------------------------------------------------------------------- +# Source common.sh for resolve_template, json_escape, get_repo_root, has_git. +# +# Search locations in priority order: +# 1. .specify/scripts/bash/common.sh under the project root (installed project) +# 2. scripts/bash/common.sh under the project root (source checkout fallback) +# 3. git-common.sh next to this script (minimal fallback — lacks resolve_template) +# --------------------------------------------------------------------------- +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Find project root by walking up from the script location +_find_project_root() { + local dir="$1" + while [ "$dir" != "/" ]; do + if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then + echo "$dir" + return 0 + fi + dir="$(dirname "$dir")" + done + return 1 +} + +_common_loaded=false +_PROJECT_ROOT=$(_find_project_root "$SCRIPT_DIR") || true + +if [ -n "$_PROJECT_ROOT" ] && [ -f "$_PROJECT_ROOT/.specify/scripts/bash/common.sh" ]; then + source "$_PROJECT_ROOT/.specify/scripts/bash/common.sh" + _common_loaded=true +elif [ -n "$_PROJECT_ROOT" ] && [ -f "$_PROJECT_ROOT/scripts/bash/common.sh" ]; then + source "$_PROJECT_ROOT/scripts/bash/common.sh" + _common_loaded=true +elif [ -f "$SCRIPT_DIR/git-common.sh" ]; then + source "$SCRIPT_DIR/git-common.sh" + _common_loaded=true +fi + +if [ "$_common_loaded" != "true" ]; then + echo "Error: Could not locate common.sh or git-common.sh. Please ensure the Specify core scripts are installed." >&2 + exit 1 +fi + +# Resolve repository root +if type get_repo_root >/dev/null 2>&1; then + REPO_ROOT=$(get_repo_root) +elif git rev-parse --show-toplevel >/dev/null 2>&1; then + REPO_ROOT=$(git rev-parse --show-toplevel) +elif [ -n "$_PROJECT_ROOT" ]; then + REPO_ROOT="$_PROJECT_ROOT" +else + echo "Error: Could not determine repository root." >&2 + exit 1 +fi + +# Check if git is available at this repo root +if type has_git >/dev/null 2>&1; then + if has_git "$REPO_ROOT"; then + HAS_GIT=true + else + HAS_GIT=false + fi +elif git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + HAS_GIT=true +else + HAS_GIT=false +fi + +cd "$REPO_ROOT" + +SPECS_DIR="$REPO_ROOT/specs" + +# Function to generate branch name with stop word filtering +generate_branch_name() { + local description="$1" + + 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)$" + + local clean_name=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g') + + local meaningful_words=() + for word in $clean_name; do + [ -z "$word" ] && continue + if ! echo "$word" | grep -qiE "$stop_words"; then + if [ ${#word} -ge 3 ]; then + meaningful_words+=("$word") + elif echo "$description" | grep -qw -- "${word^^}"; then + meaningful_words+=("$word") + fi + fi + done + + if [ ${#meaningful_words[@]} -gt 0 ]; then + local max_words=3 + if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi + + local result="" + local count=0 + for word in "${meaningful_words[@]}"; do + if [ $count -ge $max_words ]; then break; fi + if [ -n "$result" ]; then result="$result-"; fi + result="$result$word" + count=$((count + 1)) + done + echo "$result" + else + local cleaned=$(clean_branch_name "$description") + echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//' + fi +} + +# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix) +if [ -n "${GIT_BRANCH_NAME:-}" ]; then + BRANCH_NAME="$GIT_BRANCH_NAME" + # Extract FEATURE_NUM from the branch name if it starts with a numeric prefix + # Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^[0-9]+ pattern + if echo "$BRANCH_NAME" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then + FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]{8}-[0-9]{6}') + BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}" + elif echo "$BRANCH_NAME" | grep -Eq '^[0-9]+-'; then + FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]+') + BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}" + else + FEATURE_NUM="$BRANCH_NAME" + BRANCH_SUFFIX="$BRANCH_NAME" + fi +else + # Generate branch name + if [ -n "$SHORT_NAME" ]; then + BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME") + else + BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION") + fi + + # Warn if --number and --timestamp are both specified + if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then + >&2 echo "[specify] Warning: --number is ignored when --timestamp is used" + BRANCH_NUMBER="" + fi + + # Determine branch prefix + if [ "$USE_TIMESTAMP" = true ]; then + FEATURE_NUM=$(date +%Y%m%d-%H%M%S) + BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" + else + if [ -z "$BRANCH_NUMBER" ]; then + if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then + BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true) + elif [ "$DRY_RUN" = true ]; then + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") + BRANCH_NUMBER=$((HIGHEST + 1)) + elif [ "$HAS_GIT" = true ]; then + BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR") + else + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") + BRANCH_NUMBER=$((HIGHEST + 1)) + fi + fi + + FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))") + BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" + fi +fi + +# GitHub enforces a 244-byte limit on branch names +MAX_BRANCH_LENGTH=244 +_byte_length() { printf '%s' "$1" | LC_ALL=C wc -c | tr -d ' '; } +BRANCH_BYTE_LEN=$(_byte_length "$BRANCH_NAME") +if [ -n "${GIT_BRANCH_NAME:-}" ] && [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then + >&2 echo "Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is ${BRANCH_BYTE_LEN} bytes." + exit 1 +elif [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then + PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 )) + MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH)) + + TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH) + TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//') + + ORIGINAL_BRANCH_NAME="$BRANCH_NAME" + BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}" + + >&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit" + >&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)" + >&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)" +fi + +if [ "$DRY_RUN" != true ]; then + if [ "$HAS_GIT" = true ]; then + branch_create_error="" + if ! branch_create_error=$(git checkout -q -b "$BRANCH_NAME" 2>&1); then + current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + if git branch --list "$BRANCH_NAME" | grep -q .; then + if [ "$ALLOW_EXISTING" = true ]; then + if [ "$current_branch" = "$BRANCH_NAME" ]; then + : + elif ! switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1); then + >&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again." + if [ -n "$switch_branch_error" ]; then + >&2 printf '%s\n' "$switch_branch_error" + fi + exit 1 + fi + elif [ "$USE_TIMESTAMP" = true ]; then + >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name." + exit 1 + else + >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number." + exit 1 + fi + else + >&2 echo "Error: Failed to create git branch '$BRANCH_NAME'." + if [ -n "$branch_create_error" ]; then + >&2 printf '%s\n' "$branch_create_error" + else + >&2 echo "Please check your git configuration and try again." + fi + exit 1 + fi + fi + else + >&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME" + fi + + printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2 +fi + +if $JSON_MODE; then + if command -v jq >/dev/null 2>&1; then + if [ "$DRY_RUN" = true ]; then + jq -cn \ + --arg branch_name "$BRANCH_NAME" \ + --arg feature_num "$FEATURE_NUM" \ + '{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num,DRY_RUN:true}' + else + jq -cn \ + --arg branch_name "$BRANCH_NAME" \ + --arg feature_num "$FEATURE_NUM" \ + '{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num}' + fi + else + if type json_escape >/dev/null 2>&1; then + _je_branch=$(json_escape "$BRANCH_NAME") + _je_num=$(json_escape "$FEATURE_NUM") + else + _je_branch="$BRANCH_NAME" + _je_num="$FEATURE_NUM" + fi + if [ "$DRY_RUN" = true ]; then + printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$_je_branch" "$_je_num" + else + printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s"}\n' "$_je_branch" "$_je_num" + fi + fi +else + echo "BRANCH_NAME: $BRANCH_NAME" + echo "FEATURE_NUM: $FEATURE_NUM" + if [ "$DRY_RUN" != true ]; then + printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" + fi +fi diff --git a/extensions/git/scripts/bash/git-common.sh b/extensions/git/scripts/bash/git-common.sh new file mode 100755 index 0000000000..b78356d1c6 --- /dev/null +++ b/extensions/git/scripts/bash/git-common.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Git-specific common functions for the git extension. +# Extracted from scripts/bash/common.sh — contains only git-specific +# branch validation and detection logic. + +# Check if we have git available at the repo root +has_git() { + local repo_root="${1:-$(pwd)}" + { [ -d "$repo_root/.git" ] || [ -f "$repo_root/.git" ]; } && \ + command -v git >/dev/null 2>&1 && \ + git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1 +} + +# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name"). +# Only when the full name is exactly two slash-free segments; otherwise returns the raw name. +spec_kit_effective_branch_name() { + local raw="$1" + if [[ "$raw" =~ ^([^/]+)/([^/]+)$ ]]; then + printf '%s\n' "${BASH_REMATCH[2]}" + else + printf '%s\n' "$raw" + fi +} + +# Validate that a branch name matches the expected feature branch pattern. +# Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats. +# Logic aligned with scripts/bash/common.sh check_feature_branch after effective-name normalization. +check_feature_branch() { + local raw="$1" + local has_git_repo="$2" + + # For non-git repos, we can't enforce branch naming but still provide output + if [[ "$has_git_repo" != "true" ]]; then + echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2 + return 0 + fi + + local branch + branch=$(spec_kit_effective_branch_name "$raw") + + # Accept sequential prefix (3+ digits) but exclude malformed timestamps + # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022") + local is_sequential=false + if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then + is_sequential=true + fi + if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then + echo "ERROR: Not on a feature branch. Current branch: $raw" >&2 + echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2 + return 1 + fi + + return 0 +} diff --git a/extensions/git/scripts/bash/initialize-repo.sh b/extensions/git/scripts/bash/initialize-repo.sh new file mode 100755 index 0000000000..296e363b94 --- /dev/null +++ b/extensions/git/scripts/bash/initialize-repo.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Git extension: initialize-repo.sh +# Initialize a Git repository with an initial commit. +# Customizable — replace this script to add .gitignore templates, +# default branch config, git-flow, LFS, signing, etc. + +set -e + +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Find project root +_find_project_root() { + local dir="$1" + while [ "$dir" != "/" ]; do + if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then + echo "$dir" + return 0 + fi + dir="$(dirname "$dir")" + done + return 1 +} + +REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)" +cd "$REPO_ROOT" + +# Read commit message from extension config, fall back to default +COMMIT_MSG="[Spec Kit] Initial commit" +_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml" +if [ -f "$_config_file" ]; then + _msg=$(grep '^init_commit_message:' "$_config_file" 2>/dev/null | sed 's/^init_commit_message:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//') + if [ -n "$_msg" ]; then + COMMIT_MSG="$_msg" + fi +fi + +# Check if git is available +if ! command -v git >/dev/null 2>&1; then + echo "[specify] Warning: Git not found; skipped repository initialization" >&2 + exit 0 +fi + +# Check if already a git repo +if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "[specify] Git repository already initialized; skipping" >&2 + exit 0 +fi + +# Initialize +_git_out=$(git init -q 2>&1) || { echo "[specify] Error: git init failed: $_git_out" >&2; exit 1; } +_git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; } +_git_out=$(git commit --allow-empty -q -m "$COMMIT_MSG" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; } + +echo "āœ“ Git repository initialized" >&2 diff --git a/extensions/git/scripts/powershell/auto-commit.ps1 b/extensions/git/scripts/powershell/auto-commit.ps1 new file mode 100644 index 0000000000..4a8b0e00cd --- /dev/null +++ b/extensions/git/scripts/powershell/auto-commit.ps1 @@ -0,0 +1,169 @@ +#!/usr/bin/env pwsh +# Git extension: auto-commit.ps1 +# Automatically commit changes after a Spec Kit command completes. +# Checks per-command config keys in git-config.yml before committing. +# +# Usage: auto-commit.ps1 +# e.g.: auto-commit.ps1 after_specify +param( + [Parameter(Position = 0, Mandatory = $true)] + [string]$EventName +) +$ErrorActionPreference = 'Stop' + +function Find-ProjectRoot { + param([string]$StartDir) + $current = Resolve-Path $StartDir + while ($true) { + foreach ($marker in @('.specify', '.git')) { + if (Test-Path (Join-Path $current $marker)) { + return $current + } + } + $parent = Split-Path $current -Parent + if ($parent -eq $current) { return $null } + $current = $parent + } +} + +$repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot +if (-not $repoRoot) { $repoRoot = Get-Location } +Set-Location $repoRoot + +# Check if git is available +if (-not (Get-Command git -ErrorAction SilentlyContinue)) { + Write-Warning "[specify] Warning: Git not found; skipped auto-commit" + exit 0 +} + +# Temporarily relax ErrorActionPreference so git stderr warnings +# (e.g. CRLF notices on Windows) do not become terminating errors. +$savedEAP = $ErrorActionPreference +$ErrorActionPreference = 'Continue' +try { + git rev-parse --is-inside-work-tree 2>$null | Out-Null + $isRepo = $LASTEXITCODE -eq 0 +} finally { + $ErrorActionPreference = $savedEAP +} +if (-not $isRepo) { + Write-Warning "[specify] Warning: Not a Git repository; skipped auto-commit" + exit 0 +} + +# Read per-command config from git-config.yml +$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml" +$enabled = $false +$commitMsg = "" + +if (Test-Path $configFile) { + # Parse YAML to find auto_commit section + $inAutoCommit = $false + $inEvent = $false + $defaultEnabled = $false + + foreach ($line in Get-Content $configFile) { + # Detect auto_commit: section + if ($line -match '^auto_commit:') { + $inAutoCommit = $true + $inEvent = $false + continue + } + + # Exit auto_commit section on next top-level key + if ($inAutoCommit -and $line -match '^[a-z]') { + break + } + + if ($inAutoCommit) { + # Check default key + if ($line -match '^\s+default:\s*(.+)$') { + $val = $matches[1].Trim().ToLower() + if ($val -eq 'true') { $defaultEnabled = $true } + } + + # Detect our event subsection + if ($line -match "^\s+${EventName}:") { + $inEvent = $true + continue + } + + # Inside our event subsection + if ($inEvent) { + # Exit on next sibling key (2-space indent, not 4+) + if ($line -match '^\s{2}[a-z]' -and $line -notmatch '^\s{4}') { + $inEvent = $false + continue + } + if ($line -match '\s+enabled:\s*(.+)$') { + $val = $matches[1].Trim().ToLower() + if ($val -eq 'true') { $enabled = $true } + if ($val -eq 'false') { $enabled = $false } + } + if ($line -match '\s+message:\s*(.+)$') { + $commitMsg = $matches[1].Trim() -replace '^["'']' -replace '["'']$' + } + } + } + } + + # If event-specific key not found, use default + if (-not $enabled -and $defaultEnabled) { + $hasEventKey = Select-String -Path $configFile -Pattern "^\s*${EventName}:" -Quiet + if (-not $hasEventKey) { + $enabled = $true + } + } +} else { + # No config file — auto-commit disabled by default + exit 0 +} + +if (-not $enabled) { + exit 0 +} + +# Check if there are changes to commit +# Relax ErrorActionPreference so CRLF warnings on stderr do not terminate. +$savedEAP = $ErrorActionPreference +$ErrorActionPreference = 'Continue' +try { + git diff --quiet HEAD 2>$null; $d1 = $LASTEXITCODE + git diff --cached --quiet 2>$null; $d2 = $LASTEXITCODE + $untracked = git ls-files --others --exclude-standard 2>$null +} finally { + $ErrorActionPreference = $savedEAP +} + +if ($d1 -eq 0 -and $d2 -eq 0 -and -not $untracked) { + Write-Host "[specify] No changes to commit after $EventName" -ForegroundColor DarkGray + exit 0 +} + +# Derive a human-readable command name from the event +$commandName = $EventName -replace '^after_', '' -replace '^before_', '' +$phase = if ($EventName -match '^before_') { 'before' } else { 'after' } + +# Use custom message if configured, otherwise default +if (-not $commitMsg) { + $commitMsg = "[Spec Kit] Auto-commit $phase $commandName" +} + +# Stage and commit +# Relax ErrorActionPreference so CRLF warnings on stderr do not terminate, +# while still allowing redirected error output to be captured for diagnostics. +$savedEAP = $ErrorActionPreference +$ErrorActionPreference = 'Continue' +try { + $out = git add . 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "git add failed: $out" } + $out = git commit -q -m $commitMsg 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "git commit failed: $out" } +} catch { + Write-Warning "[specify] Error: $_" + exit 1 +} finally { + $ErrorActionPreference = $savedEAP +} + +Write-Host "[OK] Changes committed $phase $commandName" diff --git a/extensions/git/scripts/powershell/create-new-feature.ps1 b/extensions/git/scripts/powershell/create-new-feature.ps1 new file mode 100644 index 0000000000..b579f05160 --- /dev/null +++ b/extensions/git/scripts/powershell/create-new-feature.ps1 @@ -0,0 +1,403 @@ +#!/usr/bin/env pwsh +# Git extension: create-new-feature.ps1 +# Adapted from core scripts/powershell/create-new-feature.ps1 for extension layout. +# Sources common.ps1 from the project's installed scripts, falling back to +# git-common.ps1 for minimal git helpers. +[CmdletBinding()] +param( + [switch]$Json, + [switch]$AllowExistingBranch, + [switch]$DryRun, + [string]$ShortName, + [Parameter()] + [long]$Number = 0, + [switch]$Timestamp, + [switch]$Help, + [Parameter(Position = 0, ValueFromRemainingArguments = $true)] + [string[]]$FeatureDescription +) +$ErrorActionPreference = 'Stop' + +if ($Help) { + Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " + Write-Host "" + Write-Host "Options:" + Write-Host " -Json Output in JSON format" + Write-Host " -DryRun Compute branch name without creating the branch" + Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing" + Write-Host " -ShortName Provide a custom short name (2-4 words) for the branch" + Write-Host " -Number N Specify branch number manually (overrides auto-detection)" + Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" + Write-Host " -Help Show this help message" + Write-Host "" + Write-Host "Environment variables:" + Write-Host " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation" + Write-Host "" + exit 0 +} + +if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) { + Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " + exit 1 +} + +$featureDesc = ($FeatureDescription -join ' ').Trim() + +if ([string]::IsNullOrWhiteSpace($featureDesc)) { + Write-Error "Error: Feature description cannot be empty or contain only whitespace" + exit 1 +} + +function Get-HighestNumberFromSpecs { + param([string]$SpecsDir) + + [long]$highest = 0 + if (Test-Path $SpecsDir) { + Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object { + if ($_.Name -match '^(\d{3,})-' -and $_.Name -notmatch '^\d{8}-\d{6}-') { + [long]$num = 0 + if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) { + $highest = $num + } + } + } + } + return $highest +} + +function Get-HighestNumberFromNames { + param([string[]]$Names) + + [long]$highest = 0 + foreach ($name in $Names) { + if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') { + [long]$num = 0 + if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) { + $highest = $num + } + } + } + return $highest +} + +function Get-HighestNumberFromBranches { + param() + + try { + $branches = git branch -a 2>$null + if ($LASTEXITCODE -eq 0 -and $branches) { + $cleanNames = $branches | ForEach-Object { + $_.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', '' + } + return Get-HighestNumberFromNames -Names $cleanNames + } + } catch { + Write-Verbose "Could not check Git branches: $_" + } + return 0 +} + +function Get-HighestNumberFromRemoteRefs { + [long]$highest = 0 + try { + $remotes = git remote 2>$null + if ($remotes) { + foreach ($remote in $remotes) { + $env:GIT_TERMINAL_PROMPT = '0' + $refs = git ls-remote --heads $remote 2>$null + $env:GIT_TERMINAL_PROMPT = $null + if ($LASTEXITCODE -eq 0 -and $refs) { + $refNames = $refs | ForEach-Object { + if ($_ -match 'refs/heads/(.+)$') { $matches[1] } + } | Where-Object { $_ } + $remoteHighest = Get-HighestNumberFromNames -Names $refNames + if ($remoteHighest -gt $highest) { $highest = $remoteHighest } + } + } + } + } catch { + Write-Verbose "Could not query remote refs: $_" + } + return $highest +} + +function Get-NextBranchNumber { + param( + [string]$SpecsDir, + [switch]$SkipFetch + ) + + if ($SkipFetch) { + $highestBranch = Get-HighestNumberFromBranches + $highestRemote = Get-HighestNumberFromRemoteRefs + $highestBranch = [Math]::Max($highestBranch, $highestRemote) + } else { + try { + git fetch --all --prune 2>$null | Out-Null + } catch { } + $highestBranch = Get-HighestNumberFromBranches + } + + $highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir + $maxNum = [Math]::Max($highestBranch, $highestSpec) + return $maxNum + 1 +} + +function ConvertTo-CleanBranchName { + param([string]$Name) + return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', '' +} + +# --------------------------------------------------------------------------- +# Source common.ps1 from the project's installed scripts. +# Search locations in priority order: +# 1. .specify/scripts/powershell/common.ps1 under the project root +# 2. scripts/powershell/common.ps1 under the project root (source checkout) +# 3. git-common.ps1 next to this script (minimal fallback) +# --------------------------------------------------------------------------- +function Find-ProjectRoot { + param([string]$StartDir) + $current = Resolve-Path $StartDir + while ($true) { + foreach ($marker in @('.specify', '.git')) { + if (Test-Path (Join-Path $current $marker)) { + return $current + } + } + $parent = Split-Path $current -Parent + if ($parent -eq $current) { return $null } + $current = $parent + } +} + +$projectRoot = Find-ProjectRoot -StartDir $PSScriptRoot +$commonLoaded = $false + +if ($projectRoot) { + $candidates = @( + (Join-Path $projectRoot ".specify/scripts/powershell/common.ps1"), + (Join-Path $projectRoot "scripts/powershell/common.ps1") + ) + foreach ($candidate in $candidates) { + if (Test-Path $candidate) { + . $candidate + $commonLoaded = $true + break + } + } +} + +if (-not $commonLoaded -and (Test-Path "$PSScriptRoot/git-common.ps1")) { + . "$PSScriptRoot/git-common.ps1" + $commonLoaded = $true +} + +if (-not $commonLoaded) { + throw "Unable to locate common script file. Please ensure the Specify core scripts are installed." +} + +# Resolve repository root +if (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue) { + $repoRoot = Get-RepoRoot +} elseif ($projectRoot) { + $repoRoot = $projectRoot +} else { + throw "Could not determine repository root." +} + +# Check if git is available +if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) { + # Call without parameters for compatibility with core common.ps1 (no -RepoRoot param) + # and git-common.ps1 (has -RepoRoot param with default). + $hasGit = Test-HasGit +} else { + try { + git -C $repoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null + $hasGit = ($LASTEXITCODE -eq 0) + } catch { + $hasGit = $false + } +} + +Set-Location $repoRoot + +$specsDir = Join-Path $repoRoot 'specs' + +function Get-BranchName { + param([string]$Description) + + $stopWords = @( + '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' + ) + + $cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' ' + $words = $cleanName -split '\s+' | Where-Object { $_ } + + $meaningfulWords = @() + foreach ($word in $words) { + if ($stopWords -contains $word) { continue } + if ($word.Length -ge 3) { + $meaningfulWords += $word + } elseif ($Description -match "\b$($word.ToUpper())\b") { + $meaningfulWords += $word + } + } + + if ($meaningfulWords.Count -gt 0) { + $maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 } + $result = ($meaningfulWords | Select-Object -First $maxWords) -join '-' + return $result + } else { + $result = ConvertTo-CleanBranchName -Name $Description + $fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3 + return [string]::Join('-', $fallbackWords) + } +} + +# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix) +if ($env:GIT_BRANCH_NAME) { + $branchName = $env:GIT_BRANCH_NAME + # Check 244-byte limit (UTF-8) for override names + $branchNameUtf8ByteCount = [System.Text.Encoding]::UTF8.GetByteCount($branchName) + if ($branchNameUtf8ByteCount -gt 244) { + throw "GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is $branchNameUtf8ByteCount bytes; please supply a shorter override branch name." + } + # Extract FEATURE_NUM from the branch name if it starts with a numeric prefix + # Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^\d+ pattern + if ($branchName -match '^(\d{8}-\d{6})-') { + $featureNum = $matches[1] + } elseif ($branchName -match '^(\d+)-') { + $featureNum = $matches[1] + } else { + $featureNum = $branchName + } +} else { + if ($ShortName) { + $branchSuffix = ConvertTo-CleanBranchName -Name $ShortName + } else { + $branchSuffix = Get-BranchName -Description $featureDesc + } + + if ($Timestamp -and $Number -ne 0) { + Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used" + $Number = 0 + } + + if ($Timestamp) { + $featureNum = Get-Date -Format 'yyyyMMdd-HHmmss' + $branchName = "$featureNum-$branchSuffix" + } else { + if ($Number -eq 0) { + if ($DryRun -and $hasGit) { + $Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch + } elseif ($DryRun) { + $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 + } elseif ($hasGit) { + $Number = Get-NextBranchNumber -SpecsDir $specsDir + } else { + $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 + } + } + + $featureNum = ('{0:000}' -f $Number) + $branchName = "$featureNum-$branchSuffix" + } +} + +$maxBranchLength = 244 +if ($branchName.Length -gt $maxBranchLength) { + $prefixLength = $featureNum.Length + 1 + $maxSuffixLength = $maxBranchLength - $prefixLength + + $truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength)) + $truncatedSuffix = $truncatedSuffix -replace '-$', '' + + $originalBranchName = $branchName + $branchName = "$featureNum-$truncatedSuffix" + + Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit" + Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)" + Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)" +} + +if (-not $DryRun) { + if ($hasGit) { + $branchCreated = $false + $branchCreateError = '' + try { + $branchCreateError = git checkout -q -b $branchName 2>&1 | Out-String + if ($LASTEXITCODE -eq 0) { + $branchCreated = $true + } + } catch { + $branchCreateError = $_.Exception.Message + } + + if (-not $branchCreated) { + $currentBranch = '' + try { $currentBranch = (git rev-parse --abbrev-ref HEAD 2>$null).Trim() } catch {} + $existingBranch = git branch --list $branchName 2>$null + if ($existingBranch) { + if ($AllowExistingBranch) { + if ($currentBranch -eq $branchName) { + # Already on the target branch + } else { + $switchBranchError = git checkout -q $branchName 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { + if ($switchBranchError) { + Write-Error "Error: Branch '$branchName' exists but could not be checked out.`n$($switchBranchError.Trim())" + } else { + Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again." + } + exit 1 + } + } + } elseif ($Timestamp) { + Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName." + exit 1 + } else { + Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number." + exit 1 + } + } else { + if ($branchCreateError) { + Write-Error "Error: Failed to create git branch '$branchName'.`n$($branchCreateError.Trim())" + } else { + Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again." + } + exit 1 + } + } + } else { + if ($Json) { + [Console]::Error.WriteLine("[specify] Warning: Git repository not detected; skipped branch creation for $branchName") + } else { + Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName" + } + } + + $env:SPECIFY_FEATURE = $branchName +} + +if ($Json) { + $obj = [PSCustomObject]@{ + BRANCH_NAME = $branchName + FEATURE_NUM = $featureNum + HAS_GIT = $hasGit + } + if ($DryRun) { + $obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true + } + $obj | ConvertTo-Json -Compress +} else { + Write-Output "BRANCH_NAME: $branchName" + Write-Output "FEATURE_NUM: $featureNum" + Write-Output "HAS_GIT: $hasGit" + if (-not $DryRun) { + Write-Output "SPECIFY_FEATURE environment variable set to: $branchName" + } +} diff --git a/extensions/git/scripts/powershell/git-common.ps1 b/extensions/git/scripts/powershell/git-common.ps1 new file mode 100644 index 0000000000..82210000b6 --- /dev/null +++ b/extensions/git/scripts/powershell/git-common.ps1 @@ -0,0 +1,51 @@ +#!/usr/bin/env pwsh +# Git-specific common functions for the git extension. +# Extracted from scripts/powershell/common.ps1 — contains only git-specific +# branch validation and detection logic. + +function Test-HasGit { + param([string]$RepoRoot = (Get-Location)) + try { + if (-not (Test-Path (Join-Path $RepoRoot '.git'))) { return $false } + if (-not (Get-Command git -ErrorAction SilentlyContinue)) { return $false } + git -C $RepoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null + return ($LASTEXITCODE -eq 0) + } catch { + return $false + } +} + +function Get-SpecKitEffectiveBranchName { + param([string]$Branch) + if ($Branch -match '^([^/]+)/([^/]+)$') { + return $Matches[2] + } + return $Branch +} + +function Test-FeatureBranch { + param( + [string]$Branch, + [bool]$HasGit = $true + ) + + # For non-git repos, we can't enforce branch naming but still provide output + if (-not $HasGit) { + Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation" + return $true + } + + $raw = $Branch + $Branch = Get-SpecKitEffectiveBranchName $raw + + # Accept sequential prefix (3+ digits) but exclude malformed timestamps + # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022") + $hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$') + $isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp) + if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') { + [Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw") + [Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name") + return $false + } + return $true +} diff --git a/extensions/git/scripts/powershell/initialize-repo.ps1 b/extensions/git/scripts/powershell/initialize-repo.ps1 new file mode 100644 index 0000000000..324240a3e7 --- /dev/null +++ b/extensions/git/scripts/powershell/initialize-repo.ps1 @@ -0,0 +1,69 @@ +#!/usr/bin/env pwsh +# Git extension: initialize-repo.ps1 +# Initialize a Git repository with an initial commit. +# Customizable — replace this script to add .gitignore templates, +# default branch config, git-flow, LFS, signing, etc. +$ErrorActionPreference = 'Stop' + +# Find project root +function Find-ProjectRoot { + param([string]$StartDir) + $current = Resolve-Path $StartDir + while ($true) { + foreach ($marker in @('.specify', '.git')) { + if (Test-Path (Join-Path $current $marker)) { + return $current + } + } + $parent = Split-Path $current -Parent + if ($parent -eq $current) { return $null } + $current = $parent + } +} + +$repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot +if (-not $repoRoot) { $repoRoot = Get-Location } +Set-Location $repoRoot + +# Read commit message from extension config, fall back to default +$commitMsg = "[Spec Kit] Initial commit" +$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml" +if (Test-Path $configFile) { + foreach ($line in Get-Content $configFile) { + if ($line -match '^init_commit_message:\s*(.+)$') { + $val = $matches[1].Trim() -replace '^["'']' -replace '["'']$' + if ($val) { $commitMsg = $val } + break + } + } +} + +# Check if git is available +if (-not (Get-Command git -ErrorAction SilentlyContinue)) { + Write-Warning "[specify] Warning: Git not found; skipped repository initialization" + exit 0 +} + +# Check if already a git repo +try { + git rev-parse --is-inside-work-tree 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { + Write-Warning "[specify] Git repository already initialized; skipping" + exit 0 + } +} catch { } + +# Initialize +try { + $out = git init -q 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "git init failed: $out" } + $out = git add . 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "git add failed: $out" } + $out = git commit --allow-empty -q -m $commitMsg 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "git commit failed: $out" } +} catch { + Write-Warning "[specify] Error: $_" + exit 1 +} + +Write-Host "āœ“ Git repository initialized" diff --git a/extensions/selftest/commands/selftest.md b/extensions/selftest/commands/selftest.md new file mode 100644 index 0000000000..6f5655edd5 --- /dev/null +++ b/extensions/selftest/commands/selftest.md @@ -0,0 +1,69 @@ +--- +description: "Validate the lifecycle of an extension from the catalog." +--- + +# Extension Self-Test: `$ARGUMENTS` + +This command drives a self-test simulating the developer experience with the `$ARGUMENTS` extension. + +## Goal + +Validate the end-to-end lifecycle (discovery, installation, registration) for the extension: `$ARGUMENTS`. +If `$ARGUMENTS` is empty, you must tell the user to provide an extension name, for example: `/speckit.selftest.extension linear`. + +## Steps + +### Step 1: Catalog Discovery Validation + +Check if the extension exists in the Spec Kit catalog. +Execute 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. + +```bash +specify extension info "$ARGUMENTS" +``` + +### Step 2: Simulate Installation + +First, 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. + +```bash +specify extension add "$ARGUMENTS" +``` + +Then, simulate adding the extension by installing it from its catalog download URL, which should bypass the restriction. +Obtain the extension's `download_url` from the catalog metadata (for example, via a catalog info command or UI), then run: + +```bash +specify extension add "$ARGUMENTS" --from "" +``` + +### Step 3: Registration Verification + +Once the `add` command completes, verify the installation by checking the project configuration. +Use terminal tools (like `cat`) to verify that the following file contains a record for `$ARGUMENTS`. + +```bash +cat .specify/extensions/.registry/$ARGUMENTS.json +``` + +### Step 4: Verification Report + +Analyze the standard output of the three steps. +Generate a terminal-style test output format detailing the results of discovery, installation, and registration. Return this directly to the user. + +Example output format: +```text +============================= test session starts ============================== +collected 3 items + +test_selftest_discovery.py::test_catalog_search [PASS/FAIL] + Details: [Provide execution result of specify extension search] + +test_selftest_installation.py::test_extension_add [PASS/FAIL] + Details: [Provide execution result of specify extension add] + +test_selftest_registration.py::test_config_verification [PASS/FAIL] + Details: [Provide execution result of registry record verification] + +============================== [X] passed in ... ============================== +``` diff --git a/extensions/selftest/extension.yml b/extensions/selftest/extension.yml new file mode 100644 index 0000000000..2d47fdf2cf --- /dev/null +++ b/extensions/selftest/extension.yml @@ -0,0 +1,16 @@ +schema_version: "1.0" +extension: + id: selftest + name: Spec Kit Self-Test Utility + version: 1.0.0 + description: Verifies catalog extensions by programmatically walking through the discovery, installation, and registration lifecycle. + author: spec-kit-core + repository: https://github.com/github/spec-kit + license: MIT +requires: + speckit_version: ">=0.2.0" +provides: + commands: + - name: speckit.selftest.extension + file: commands/selftest.md + description: Validate the lifecycle of an extension from the catalog. diff --git a/extensions/template/extension.yml b/extensions/template/extension.yml index 2f51ae7fd5..abf7e45afc 100644 --- a/extensions/template/extension.yml +++ b/extensions/template/extension.yml @@ -47,8 +47,8 @@ provides: - name: "speckit.my-extension.example" file: "commands/example.md" description: "Example command that demonstrates functionality" - # Optional: Add aliases for shorter command names - aliases: ["speckit.example"] + # Optional: Add aliases in the same namespaced format + aliases: ["speckit.my-extension.example-short"] # ADD MORE COMMANDS: Copy this block for each command # - name: "speckit.my-extension.another-command" diff --git a/integrations/CONTRIBUTING.md b/integrations/CONTRIBUTING.md new file mode 100644 index 0000000000..77a50d4d98 --- /dev/null +++ b/integrations/CONTRIBUTING.md @@ -0,0 +1,142 @@ +# Contributing to the Integration Catalog + +This guide covers adding integrations to both the **built-in** and **community** catalogs. + +## Adding a Built-In Integration + +Built-in integrations are maintained by the Spec Kit core team and ship with the CLI. + +### Checklist + +1. **Create the integration subpackage** under `src/specify_cli/integrations//` + — `` matches the integration key when it contains no hyphens (e.g., `gemini`), or replaces hyphens with underscores when it does (e.g., key `cursor-agent` → directory `cursor_agent/`, key `kiro-cli` → directory `kiro_cli/`). Python package names cannot use hyphens. +2. **Implement the integration class** extending `MarkdownIntegration`, `TomlIntegration`, or `SkillsIntegration` +3. **Register the integration** in `src/specify_cli/integrations/__init__.py` +4. **Add tests** under `tests/integrations/test_integration_.py` +5. **Add a catalog entry** in `integrations/catalog.json` +6. **Update documentation** in `AGENTS.md` and `README.md` + +### Catalog Entry Format + +Add your integration under the top-level `integrations` key in `integrations/catalog.json`: + +```json +{ + "schema_version": "1.0", + "integrations": { + "my-agent": { + "id": "my-agent", + "name": "My Agent", + "version": "1.0.0", + "description": "Integration for My Agent", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + } + } +} +``` + +## Adding a Community Integration + +Community integrations are contributed by external developers and listed in `integrations/catalog.community.json` for discovery. + +### Prerequisites + +1. **Working integration** — tested with `specify integration install` +2. **Public repository** — hosted on GitHub or similar +3. **`integration.yml` descriptor** — valid descriptor file (see below) +4. **Documentation** — README with usage instructions +5. **License** — open source license file + +### `integration.yml` Descriptor + +Every community integration must include an `integration.yml`: + +```yaml +schema_version: "1.0" +integration: + id: "my-agent" + name: "My Agent" + version: "1.0.0" + description: "Integration for My Agent" + author: "your-name" + repository: "https://github.com/your-name/speckit-my-agent" + license: "MIT" +requires: + speckit_version: ">=0.6.0" + tools: + - name: "my-agent" + version: ">=1.0.0" + required: true +provides: + commands: + - name: "speckit.specify" + file: "templates/speckit.specify.md" + scripts: + - update-context.sh +``` + +### Descriptor Validation Rules + +| Field | Rule | +|-------|------| +| `schema_version` | Must be `"1.0"` | +| `integration.id` | Lowercase alphanumeric + hyphens (`^[a-z0-9-]+$`) | +| `integration.version` | Valid PEP 440 version (parsed with `packaging.version.Version()`) | +| `requires.speckit_version` | Required field; specify a version constraint such as `>=0.6.0` (current validation checks presence only) | +| `provides` | Must include at least one command or script | +| `provides.commands[].name` | String identifier | +| `provides.commands[].file` | Relative path to template file | + +### Submitting to the Community Catalog + +1. **Fork** the [spec-kit repository](https://github.com/github/spec-kit) +2. **Add your entry** under the `integrations` key in `integrations/catalog.community.json`: + +```json +{ + "schema_version": "1.0", + "integrations": { + "my-agent": { + "id": "my-agent", + "name": "My Agent", + "version": "1.0.0", + "description": "Integration for My Agent", + "author": "your-name", + "repository": "https://github.com/your-name/speckit-my-agent", + "tags": ["cli"] + } + } +} +``` + +3. **Open a pull request** with: + - Your catalog entry + - Link to your integration repository + - Confirmation that `integration.yml` is valid + +### Version Updates + +To update your integration version in the catalog: + +1. Release a new version of your integration +2. Open a PR updating the `version` field in `catalog.community.json` +3. Ensure backward compatibility or document breaking changes + +## Upgrade Workflow + +The `specify integration upgrade` command supports diff-aware upgrades: + +1. **Hash comparison** — the manifest records SHA-256 hashes of all installed files +2. **Modified file detection** — files changed since installation are flagged +3. **Safe default** — the upgrade blocks if any installed files were modified since installation +4. **Forced reinstall** — passing `--force` overwrites modified files with the latest version + +```bash +# Upgrade current integration (blocks if files are modified) +specify integration upgrade + +# Force upgrade (overwrites modified files) +specify integration upgrade --force +``` diff --git a/integrations/README.md b/integrations/README.md new file mode 100644 index 0000000000..b755e0416d --- /dev/null +++ b/integrations/README.md @@ -0,0 +1,129 @@ +# Spec Kit Integration Catalog + +The integration catalog enables discovery, versioning, and distribution of AI agent integrations for Spec Kit. + +## Catalog Files + +### Built-In Catalog (`catalog.json`) + +Contains integrations that ship with Spec Kit. These are maintained by the core team and always installable. + +### Community Catalog (`catalog.community.json`) + +Community-contributed integrations. Listed for discovery only — users install from the source repositories. + +## Catalog Configuration + +The catalog stack is resolved in this order (first match wins): + +1. **Environment variable** — `SPECKIT_INTEGRATION_CATALOG_URL` overrides all catalogs with a single URL +2. **Project config** — `.specify/integration-catalogs.yml` in the project root +3. **User config** — `~/.specify/integration-catalogs.yml` in the user home directory +4. **Built-in defaults** — `catalog.json` + `catalog.community.json` + +Example `integration-catalogs.yml`: + +```yaml +catalogs: + - url: "https://example.com/my-catalog.json" + name: "my-catalog" + priority: 1 + install_allowed: true +``` + +## CLI Commands + +```bash +# List built-in integrations (default) +specify integration list + +# Browse full catalog (built-in + community) +specify integration list --catalog + +# Install an integration +specify integration install copilot + +# Upgrade the current integration (diff-aware) +specify integration upgrade + +# Upgrade with force (overwrite modified files) +specify integration upgrade --force +``` + +## Integration Descriptor (`integration.yml`) + +Each integration can include an `integration.yml` descriptor that documents its metadata, requirements, and provided commands/scripts: + +```yaml +schema_version: "1.0" +integration: + id: "my-agent" + name: "My Agent" + version: "1.0.0" + description: "Integration for My Agent" + author: "my-org" + repository: "https://github.com/my-org/speckit-my-agent" + license: "MIT" +requires: + speckit_version: ">=0.6.0" + tools: + - name: "my-agent" + version: ">=1.0.0" + required: true +provides: + commands: + - name: "speckit.specify" + file: "templates/speckit.specify.md" + - name: "speckit.plan" + file: "templates/speckit.plan.md" + scripts: + - update-context.sh + - update-context.ps1 +``` + +## Catalog Schema + +Both catalog files follow the same JSON schema: + +```json +{ + "schema_version": "1.0", + "updated_at": "2026-04-08T00:00:00Z", + "catalog_url": "https://...", + "integrations": { + "my-agent": { + "id": "my-agent", + "name": "My Agent", + "version": "1.0.0", + "description": "Integration for My Agent", + "author": "my-org", + "repository": "https://github.com/my-org/speckit-my-agent", + "tags": ["cli"] + } + } +} +``` + +### Required Fields + +| Field | Type | Description | +|-------|------|-------------| +| `schema_version` | string | Must be `"1.0"` | +| `updated_at` | string | ISO 8601 timestamp | +| `integrations` | object | Map of integration ID → metadata | + +### Integration Entry Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `id` | string | Yes | Unique ID (lowercase alphanumeric + hyphens) | +| `name` | string | Yes | Human-readable display name | +| `version` | string | Yes | PEP 440 version (e.g., `1.0.0`, `1.0.0a1`) | +| `description` | string | Yes | One-line description | +| `author` | string | No | Author name or organization | +| `repository` | string | No | Source repository URL | +| `tags` | array | No | Searchable tags (e.g., `["cli", "ide"]`) | + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for how to add integrations to the community catalog. diff --git a/integrations/catalog.community.json b/integrations/catalog.community.json new file mode 100644 index 0000000000..47eb6d550d --- /dev/null +++ b/integrations/catalog.community.json @@ -0,0 +1,6 @@ +{ + "schema_version": "1.0", + "updated_at": "2026-04-08T00:00:00Z", + "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.community.json", + "integrations": {} +} diff --git a/integrations/catalog.json b/integrations/catalog.json new file mode 100644 index 0000000000..3df96b8789 --- /dev/null +++ b/integrations/catalog.json @@ -0,0 +1,259 @@ +{ + "schema_version": "1.0", + "updated_at": "2026-04-08T00:00:00Z", + "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json", + "integrations": { + "claude": { + "id": "claude", + "name": "Claude Code", + "version": "1.0.0", + "description": "Anthropic Claude Code CLI integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli", "anthropic"] + }, + "copilot": { + "id": "copilot", + "name": "GitHub Copilot", + "version": "1.0.0", + "description": "GitHub Copilot IDE integration with agent commands and prompt files", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["ide", "github"] + }, + "gemini": { + "id": "gemini", + "name": "Gemini CLI", + "version": "1.0.0", + "description": "Google Gemini CLI integration with TOML command format", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli", "google"] + }, + "cursor-agent": { + "id": "cursor-agent", + "name": "Cursor", + "version": "1.0.0", + "description": "Cursor IDE integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["ide"] + }, + "windsurf": { + "id": "windsurf", + "name": "Windsurf", + "version": "1.0.0", + "description": "Windsurf IDE workflow integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["ide"] + }, + "amp": { + "id": "amp", + "name": "Amp", + "version": "1.0.0", + "description": "Amp CLI integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + }, + "codex": { + "id": "codex", + "name": "Codex CLI", + "version": "1.0.0", + "description": "Codex CLI skills-based integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli", "skills"] + }, + "qwen": { + "id": "qwen", + "name": "Qwen Code", + "version": "1.0.0", + "description": "Alibaba Qwen Code CLI integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli", "alibaba"] + }, + "opencode": { + "id": "opencode", + "name": "opencode", + "version": "1.0.0", + "description": "opencode CLI integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + }, + "forge": { + "id": "forge", + "name": "Forge", + "version": "1.0.0", + "description": "Forge CLI integration with parameter-based commands", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + }, + "kiro-cli": { + "id": "kiro-cli", + "name": "Kiro CLI", + "version": "1.0.0", + "description": "Kiro CLI prompt-based integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + }, + "junie": { + "id": "junie", + "name": "Junie", + "version": "1.0.0", + "description": "Junie by JetBrains CLI integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli", "jetbrains"] + }, + "auggie": { + "id": "auggie", + "name": "Auggie CLI", + "version": "1.0.0", + "description": "Auggie CLI integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + }, + "shai": { + "id": "shai", + "name": "SHAI", + "version": "1.0.0", + "description": "SHAI CLI integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + }, + "tabnine": { + "id": "tabnine", + "name": "Tabnine CLI", + "version": "1.0.0", + "description": "Tabnine CLI integration with TOML command format", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + }, + "kilocode": { + "id": "kilocode", + "name": "Kilo Code", + "version": "1.0.0", + "description": "Kilo Code IDE workflow integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["ide"] + }, + "roo": { + "id": "roo", + "name": "Roo Code", + "version": "1.0.0", + "description": "Roo Code IDE integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["ide"] + }, + "bob": { + "id": "bob", + "name": "IBM Bob", + "version": "1.0.0", + "description": "IBM Bob IDE integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["ide", "ibm"] + }, + "trae": { + "id": "trae", + "name": "Trae", + "version": "1.0.0", + "description": "Trae IDE rules-based integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["ide"] + }, + "codebuddy": { + "id": "codebuddy", + "name": "CodeBuddy", + "version": "1.0.0", + "description": "CodeBuddy CLI integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + }, + "qodercli": { + "id": "qodercli", + "name": "Qoder CLI", + "version": "1.0.0", + "description": "Qoder CLI integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + }, + "kimi": { + "id": "kimi", + "name": "Kimi Code", + "version": "1.0.0", + "description": "Kimi Code CLI skills-based integration by Moonshot AI", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli", "skills"] + }, + "pi": { + "id": "pi", + "name": "Pi Coding Agent", + "version": "1.0.0", + "description": "Pi terminal coding agent prompt-based integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + }, + "iflow": { + "id": "iflow", + "name": "iFlow CLI", + "version": "1.0.0", + "description": "iFlow CLI integration by iflow-ai", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + }, + "vibe": { + "id": "vibe", + "name": "Mistral Vibe", + "version": "1.0.0", + "description": "Mistral Vibe CLI prompt-based integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli", "mistral"] + }, + "agy": { + "id": "agy", + "name": "Antigravity", + "version": "1.0.0", + "description": "Antigravity IDE skills-based integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["ide", "skills"] + }, + "generic": { + "id": "generic", + "name": "Generic (bring your own agent)", + "version": "1.0.0", + "description": "Generic integration for any agent via --ai-commands-dir", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["generic"] + }, + "goose": { + "id": "goose", + "name": "Goose", + "version": "1.0.0", + "description": "Goose CLI integration with YAML recipe format", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + } + } +} diff --git a/newsletters/2026-February.md b/newsletters/2026-February.md new file mode 100644 index 0000000000..18a112a7f5 --- /dev/null +++ b/newsletters/2026-February.md @@ -0,0 +1,54 @@ +# Spec Kit - February 2026 Newsletter + +This 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. + +| **Spec Kit Core (Feb 2026)** | **Community & Content** | **Roadmap & Next** | +| --- | --- | --- | +| 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). | + +*** + +## Spec Kit Project Updates + +Spec 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) + +The 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) + +By 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) + +On 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) + +## Community & Content + +**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) + +**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) + +Several 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) + +On **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) + +On 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) + +## SDD Ecosystem + +Other spec-driven development tools also saw activity in February. + +AWS **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/) + +**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. + +**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) + +An **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) + +## Roadmap + +**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) + +Areas under discussion or in progress for future development: + +- **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) +- **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) +- **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) +- **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) diff --git a/newsletters/2026-March.md b/newsletters/2026-March.md new file mode 100644 index 0000000000..d97ca3960f --- /dev/null +++ b/newsletters/2026-March.md @@ -0,0 +1,80 @@ +# Spec Kit - March 2026 Newsletter + +This edition covers Spec Kit activity in March 2026. Nine releases shipped (v0.2.0 through v0.4.3), introducing a pluggable preset system, air-gapped deployment, automatic skill registration, and seven new AI agent integrations. The community extension catalog grew past 20 entries, independent walkthroughs and blog posts proliferated, and industry coverage debated whether "vibe coding" is dead. A summary is in the table below, followed by details. + +| **Spec Kit Core (Mar 2026)** | **Community & Content** | **SDD Ecosystem & Next** | +| --- | --- | --- | +| Nine releases shipped with major features: multi-catalog extensions, pluggable presets, air-gapped deployment, and auto-registration of extension skills. Seven new agents added. The repo grew from ~71k to **82,616 stars**. [\[github.com\]](https://github.com/github/spec-kit/releases) | Walkthroughs by Tiago Valverde, Alfredo Perez, and Sergey Golubev. Over 20 community extensions. The Spec Kit Assistant VS Code extension was recognized as a Community Friend. A Microsoft Learn training module became available. | ByteIota reported AWS pushing SDD as the new standard. Augment Code published a Spec Kit vs. Intent comparison. Competitors differentiate on orchestration depth and living specs; Spec Kit leads in agent breadth and portability. | + +*** + +## Spec Kit Project Updates + +### Releases Overview + +**v0.2.0** (March 10) opened the month with **simultaneous multi-catalog support**, enabling both core and community extension catalogs at the same time. It added **Tabnine CLI** and **Kimi Code CLI** agents, four community extensions (Understanding, Ralph, Review, Fleet Orchestrator), and `.extensionignore` support. Patch **v0.2.1** fixed broken quickstart links and added catalog CLI help. [\[github.com\]](https://github.com/github/spec-kit/releases) + +**v0.3.0** (mid-March) delivered the **pluggable preset system** with catalog, resolver, and skills propagation. Presets let teams override default templates with their own conventions, using priority-based stacking. The release also added a **/selftest.extension** for testing extensions, **Mistral Vibe CLI**, migrated **Qwen Code CLI** from TOML to Markdown, and hardened bash scripts against shell injection. New community extensions included DocGuard CDD, Archive & Reconcile, specify-status, and specify-doctor. [\[github.com\]](https://github.com/github/spec-kit/releases) + +**v0.3.1** added before/after hook events, JSONC deep-merge for `settings.json`, and the **Trae IDE** agent. **v0.3.2** added **Junie**, **iFlow CLI**, and **Pi Coding Agent**, plus a preset submission template and an Extension Comparison Guide. Community extensions continued arriving: verify-tasks, conduct, cognitive-squad, speckit-utils, spec-kit-iterate, and spec-kit-learn. [\[github.com\]](https://github.com/github/spec-kit/releases) + +**v0.4.0** (late March) introduced **auto-registration of extension skills** — installed extensions' commands are now automatically exposed as agent skills. It also delivered **air-gapped/offline deployment** by embedding core templates in the CLI wheel and added timestamp-based branch naming. [\[github.com\]](https://github.com/github/spec-kit/releases) + +Three patches closed the month. **v0.4.1** fixed a missing Assumptions section in the spec template and improved repo root detection. **v0.4.2** added AIDE, Extensify, and Presetify to the community catalog, moved the community extensions table into the main README, and recognized the **Spec Kit Assistant VS Code extension** as a Community Friend. **v0.4.3** unified skill naming conventions and restored **PowerShell 5.1 compatibility**. [\[github.com\]](https://github.com/github/spec-kit/releases) + +### Bug Fixes and Security Hardening + +The most significant fix was **shell injection hardening** of bash scripts, addressing potential vulnerabilities from unsanitized git branch names and environment variables. Other fixes included switching to **global branch numbering** for consistent sequencing, suppressing git checkout exceptions and fetch stdout leaks, properly encoding JSON control characters, and adding explicit PowerShell positional binding. [\[github.com\]](https://github.com/github/spec-kit/releases) + +### The Extension Ecosystem + +By late March, over **20 community extensions** had been built for Spec Kit. Thulasi Rajasekaran's LinkedIn article *"The Feature That Turns Spec Kit Into a Platform"* highlighted standouts: **Conduct** (orchestrates SDD phases via sub-agents to avoid context pollution), **Verify Tasks** (catches "phantom completions" — tasks marked done with no real code), **Understanding** (31 quality metrics against specs based on IEEE/ISO standards), and the **Jira and Azure DevOps integrations**. [\[linkedin.com\]](https://www.linkedin.com/pulse/feature-turns-spec-kit-platform-extensions-presets-rajasekaran-3ejgc) + +Rajasekaran argued the real significance of presets is what they enable: the same machinery that turned "User Stories" into pirate-speak "Crew Tales" could enforce compliance requirements, add mandatory threat-model sections, or require test tasks before implementation tasks. Organizations can curate available extensions by hosting custom catalog URLs. [\[linkedin.com\]](https://www.linkedin.com/pulse/feature-turns-spec-kit-platform-extensions-presets-rajasekaran-3ejgc) + +## Community & Content + +### Developer Walkthroughs and Blog Posts + +March produced a wave of independent content as developers explored SDD in practice. + +**Tiago Valverde** published *"Spec-Driven Development in Practice: A Walkthrough with Spec Kit"* on March 14. He documents building an Instagram-style photo mural feature using the full Spec Kit workflow, contrasting it with previous ad-hoc prompting: while directly prompting Claude worked for small changes, complex work led to scope creep, ambiguous requirements discovered too late, and no artifacts left behind. Valverde recommends being specific in the initial prompt, reviewing `spec.md` immediately, and highlights the clarify step as particularly valuable. A shorter companion piece, *"The Shift from Vibe Coding to Spec-Driven Development,"* appeared on March 8. [\[tiagovalverde.com\]](https://www.tiagovalverde.com/posts/spec-driven-development-in-practice-a-walkthrough-with-spec-kit) + +**Alfredo Perez** published *"Build Your Own SDD Workflow"* on March 21, taking a deliberately contrarian approach. He praises SDD in principle but argues the full seven-step workflow carries too much ceremony for smaller tasks. His solution is a lean **4-step custom workflow** — `specify → plan → tasks → implement` — dropping constitution, clarify, and review, wired into the **SpecKit Companion** VS Code extension. The article highlights an important tradeoff: full rigor vs. lightweight adoption. Perez also presented this workflow at an **Angular Community Meetup** on March 25. [\[alfredo-perez.dev\]](https://www.alfredo-perez.dev/blog/2026-03-21-build-your-own-sdd-workflow) + +**Sergey Golubev** of prodfeat.ai published *"20+ SDD Frameworks: A Catalog for AI Development"* on March 17. The catalog organizes **20+ frameworks in 6 categories**, highlighting **BMAD-METHOD** (~41k stars, simulates an agile team from AI roles), **QuintCode + FPF** (preserves decision rationale via a 5-phase ADI Cycle), and **cc-sdd** (~2.9k stars, enforced SDD workflow for 8 tools). Golubev presents a three-level maturity model: *Spec-First* (spec per task, discarded after), *Spec-Anchored* (living document), and *Spec-as-Source* (spec is the only artifact). His conclusion: "SDD is not a fad… AI agents generate good code when the task is well-defined. Without a spec — you're rolling the dice." [\[prodfeat.ai\]](https://www.prodfeat.ai/en/blog/2026-03-17-sdd-frameworks-catalog) + +### Community Tools and Documentation + +The **Spec Kit Assistant VS Code extension** was formally recognized as a Community Friend and added to the README. The README was reorganized: community extensions table moved into the main page for discoverability, a community presets section was added, and the publishing guide gained Category and Effect columns. New walkthroughs included Java brownfield, Go/React brownfield dashboard, and the Spring Boot pirate-speak preset demo. [\[github.com\]](https://github.com/github/spec-kit/releases) + +A notable community project appeared: **speckit-pipeline** by iandeherdt — a pipeline atop Spec Kit with a **design loop** (designer + critic agents iterating in a browser) and a **build loop** (developer + evaluator agents verifying against acceptance criteria). An open issue (#1966) requests a built-in pipeline command, suggesting this pattern may eventually reach core. + +A public **Microsoft Learn** training module, *"Implement Spec-Driven Development using the GitHub Spec Kit"* (3 hours, 13 units), provided an onboarding path for enterprise developers. + +## SDD Ecosystem & Industry Trends + +### The "Vibe Coding Is Dead" Narrative + +*ByteIota* published *"Spec-Driven Development Kills 'Vibe Coding'"* on March 20, reporting AWS pushing SDD as the new standard. Key claims: over 100,000 developers adopting SDD approaches in early tool previews, AWS demonstrating a two-week feature completed in two days using Kiro IDE, and WEF research indicating 65% of developers expect their role to shift toward spec-first workflows in 2026. [\[byteiota.com\]](https://byteiota.com/spec-driven-development-kills-vibe-coding-march-2026/) + +Critics got equal space. *Marmelab* called SDD "the exact mistakes Agile was designed to solve." An *Isoform* controlled test found SDD took 33 minutes for 689 lines vs. 8 minutes with iterative prompting, with no measured quality improvement. The emerging consensus favored hybrids — a Red Hat developer captured it: "Use the vibes to explore. Use specifications to build." Other independent articles appeared from Shimon Ifrah, Raul Proenza (Cox Automotive), CGI, and Vishal Mysore. ByteIota also raised an underappreciated concern: if specs replace coding, how do juniors build the judgment to write good specs or review AI-generated code? [\[byteiota.com\]](https://byteiota.com/spec-driven-development-kills-vibe-coding-march-2026/) + +### Competitive Landscape + +**Augment Code** published *"Intent vs GitHub Spec Kit (2026): Platform or Framework?"* on March 31. The core tradeoff: Spec Kit's strength is **portability** across 22+ agents; Intent offers **living specs** with automated drift detection. The comparison surfaced spec drift as a key architectural concern — Spec Kit's specs can become stale post-implementation, and while community extensions address this, native real-time drift detection is not yet in core. [\[augmentcode.com\]](https://www.augmentcode.com/tools/intent-vs-github) + +The broader landscape continued evolving. OpenSpec held ~29.3k stars, BMAD-METHOD grew to ~41k, and Tessl continued in private beta. While Spec Kit leads in GitHub popularity and agent breadth, alternatives differentiate on orchestration depth (Intent, BMAD), enforced discipline (cc-sdd), decision trails (QuintCode), and spec-as-source vision (Tessl). [\[prodfeat.ai\]](https://www.prodfeat.ai/en/blog/2026-03-17-sdd-frameworks-catalog) + +## Roadmap + +Areas under discussion or in progress for future development: + +- **Spec lifecycle management** -- supporting longer-lived specifications that evolve across multiple iterations. The Augment Code comparison and community commentary highlighted "spec drift" as a key concern. The Archive & Reconcile extension (#1844) is a community step; a core solution is expected to be a focus area. [\[augmentcode.com\]](https://www.augmentcode.com/tools/intent-vs-github) [\[github.com\]](https://github.com/github/spec-kit/releases) +- **CI/CD integration** -- incorporating Spec Kit verification into pull request workflows and failing builds when specs are out of alignment. The Jira and Azure DevOps extensions (#1764, #1734) are a first step. [\[github.com\]](https://github.com/github/spec-kit/releases) +- **End-to-end workflow automation** -- an open issue (#1966) proposes a built-in pipeline command. The community-built **speckit-pipeline** by iandeherdt already demonstrates multi-agent loops with browser verification. [\[github.com\]](https://github.com/iandeherdt/speckit-pipeline) +- **Continued agent expansion** -- seven new agents were added in March alone. The agent-agnostic design means support for emerging tools can be added by anyone. [\[byteiota.com\]](https://byteiota.com/spec-driven-development-kills-vibe-coding-march-2026/) +- **Experience simplification** -- the preset system, custom workflows, and growing walkthrough library lower the learning curve, but extension discoverability will need a more robust solution as the catalog grows. [\[github.com\]](https://github.com/github/spec-kit/releases) +- **Toward a stable release** -- nine releases in one month reflects pre-1.0 momentum. Reaching 1.0 will require stabilizing the extension and preset APIs and ensuring backward compatibility across the agent and extension surface area. [\[github.com\]](https://github.com/github/spec-kit/blob/main/newsletters/2026-February.md) + + diff --git a/presets/ARCHITECTURE.md b/presets/ARCHITECTURE.md new file mode 100644 index 0000000000..3a119cbd5f --- /dev/null +++ b/presets/ARCHITECTURE.md @@ -0,0 +1,175 @@ +# Preset System Architecture + +This document describes the internal architecture of the preset system — how template resolution, command registration, and catalog management work under the hood. + +For usage instructions, see [README.md](README.md). + +## Template Resolution + +When Spec Kit needs a template (e.g. `spec-template`), the `PresetResolver` walks a priority stack and returns the first match: + +```mermaid +flowchart TD + A["resolve_template('spec-template')"] --> B{Override exists?} + B -- Yes --> C[".specify/templates/overrides/spec-template.md"] + B -- No --> D{Preset provides it?} + D -- Yes --> E[".specify/presets/‹preset-id›/templates/spec-template.md"] + D -- No --> F{Extension provides it?} + F -- Yes --> G[".specify/extensions/‹ext-id›/templates/spec-template.md"] + F -- No --> H[".specify/templates/spec-template.md"] + + E -- "multiple presets?" --> I["lowest priority number wins"] + I --> E + + style C fill:#4caf50,color:#fff + style E fill:#2196f3,color:#fff + style G fill:#ff9800,color:#fff + style H fill:#9e9e9e,color:#fff +``` + +| Priority | Source | Path | Use case | +|----------|--------|------|----------| +| 1 (highest) | Override | `.specify/templates/overrides/` | One-off project-local tweaks | +| 2 | Preset | `.specify/presets//templates/` | Shareable, stackable customizations | +| 3 | Extension | `.specify/extensions//templates/` | Extension-provided templates | +| 4 (lowest) | Core | `.specify/templates/` | Shipped defaults | + +When multiple presets are installed, they're sorted by their `priority` field (lower number = higher precedence). This is set via `--priority` on `specify preset add`. + +The resolution is implemented three times to ensure consistency: +- **Python**: `PresetResolver` in `src/specify_cli/presets.py` +- **Bash**: `resolve_template()` in `scripts/bash/common.sh` +- **PowerShell**: `Resolve-Template` in `scripts/powershell/common.ps1` + +### Composition Strategies + +Templates, commands, and scripts support a `strategy` field that controls how a preset's content is combined with lower-priority content instead of fully replacing it: + +| Strategy | Description | Templates | Commands | Scripts | +|----------|-------------|-----------|----------|---------| +| `replace` (default) | Fully replaces lower-priority content | āœ“ | āœ“ | āœ“ | +| `prepend` | Places content before lower-priority content (separated by a blank line) | āœ“ | āœ“ | — | +| `append` | Places content after lower-priority content (separated by a blank line) | āœ“ | āœ“ | — | +| `wrap` | Content contains `{CORE_TEMPLATE}` (templates/commands) or `$CORE_SCRIPT` (scripts) placeholder replaced with lower-priority content | āœ“ | āœ“ | āœ“ | + +Composition is recursive — multiple composing presets chain. The `PresetResolver.resolve_content()` method walks the full priority stack bottom-up and applies each layer's strategy. + +Content resolution functions for composition: +- **Python**: `PresetResolver.resolve_content()` in `src/specify_cli/presets.py` (templates, commands, and scripts) +- **Bash**: `resolve_template_content()` in `scripts/bash/common.sh` (templates only; command/script composition is handled by the Python resolver) +- **PowerShell**: `Resolve-TemplateContent` in `scripts/powershell/common.ps1` (templates only; command/script composition is handled by the Python resolver) + +## Command Registration + +When 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`. + +```mermaid +flowchart TD + A["specify preset add my-preset"] --> B{Preset has type: command?} + B -- No --> Z["done (templates only)"] + B -- Yes --> C{Extension command?} + C -- "speckit.myext.cmd\n(3+ dot segments)" --> D{Extension installed?} + D -- No --> E["skip (extension not active)"] + D -- Yes --> F["register command"] + C -- "speckit.specify\n(core command)" --> F + F --> G["detect agent directories"] + G --> H[".claude/commands/"] + G --> I[".gemini/commands/"] + G --> J[".github/agents/"] + G --> K["... (17+ agents)"] + H --> L["write .md (Markdown format)"] + I --> M["write .toml (TOML format)"] + J --> N["write .agent.md + .prompt.md"] + + style E fill:#ff5722,color:#fff + style L fill:#4caf50,color:#fff + style M fill:#4caf50,color:#fff + style N fill:#4caf50,color:#fff +``` + +### Extension safety check + +Command names follow the pattern `speckit..`. When a command has 3+ dot segments, the system extracts the extension ID and checks if `.specify/extensions//` exists. If the extension isn't installed, the command is skipped — preventing orphan files referencing non-existent extensions. + +Core commands (e.g. `speckit.specify`, with only 2 segments) are always registered. + +### Agent format rendering + +The `CommandRegistrar` renders commands differently per agent: + +| Agent | Format | Extension | Arg placeholder | +|-------|--------|-----------|-----------------| +| Claude, Cursor, opencode, Windsurf, etc. | Markdown | `.md` | `$ARGUMENTS` | +| Copilot | Markdown | `.agent.md` + `.prompt.md` | `$ARGUMENTS` | +| Gemini, Qwen, Tabnine | TOML | `.toml` | `{{args}}` | + +### Cleanup on removal + +When `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. + +## Catalog System + +```mermaid +flowchart TD + A["specify preset search"] --> B["PresetCatalog.get_active_catalogs()"] + B --> C{SPECKIT_PRESET_CATALOG_URL set?} + C -- Yes --> D["single custom catalog"] + C -- No --> E{.specify/preset-catalogs.yml exists?} + E -- Yes --> F["project-level catalog stack"] + E -- No --> G{"~/.specify/preset-catalogs.yml exists?"} + G -- Yes --> H["user-level catalog stack"] + G -- No --> I["built-in defaults"] + I --> J["default (install allowed)"] + I --> K["community (discovery only)"] + + style D fill:#ff9800,color:#fff + style F fill:#2196f3,color:#fff + style H fill:#2196f3,color:#fff + style J fill:#4caf50,color:#fff + style K fill:#9e9e9e,color:#fff +``` + +Catalogs 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. + +## Repository Layout + +``` +presets/ +ā”œā”€ā”€ ARCHITECTURE.md # This file +ā”œā”€ā”€ PUBLISHING.md # Guide for submitting presets to the catalog +ā”œā”€ā”€ README.md # User guide +ā”œā”€ā”€ catalog.json # Official preset catalog +ā”œā”€ā”€ catalog.community.json # Community preset catalog +ā”œā”€ā”€ scaffold/ # Scaffold for creating new presets +│ ā”œā”€ā”€ preset.yml # Example manifest +│ ā”œā”€ā”€ README.md # Guide for customizing the scaffold +│ ā”œā”€ā”€ commands/ +│ │ ā”œā”€ā”€ speckit.specify.md # Core command override example +│ │ └── speckit.myext.myextcmd.md # Extension command override example +│ └── templates/ +│ ā”œā”€ā”€ spec-template.md # Core template override example +│ └── myext-template.md # Extension template override example +└── self-test/ # Self-test preset (overrides all core templates) + ā”œā”€ā”€ preset.yml + ā”œā”€ā”€ commands/ + │ └── speckit.specify.md + └── templates/ + ā”œā”€ā”€ spec-template.md + ā”œā”€ā”€ plan-template.md + ā”œā”€ā”€ tasks-template.md + ā”œā”€ā”€ checklist-template.md + ā”œā”€ā”€ constitution-template.md + └── agent-file-template.md +``` + +## Module Structure + +``` +src/specify_cli/ +ā”œā”€ā”€ agents.py # CommandRegistrar — shared infrastructure for writing +│ # command files to agent directories +ā”œā”€ā”€ presets.py # PresetManifest, PresetRegistry, PresetManager, +│ # PresetCatalog, PresetCatalogEntry, PresetResolver +└── __init__.py # CLI commands: specify preset list/add/remove/search/ + # resolve/info, specify preset catalog list/add/remove +``` diff --git a/presets/PUBLISHING.md b/presets/PUBLISHING.md new file mode 100644 index 0000000000..661614e5c0 --- /dev/null +++ b/presets/PUBLISHING.md @@ -0,0 +1,306 @@ +# Preset Publishing Guide + +This guide explains how to publish your preset to the Spec Kit preset catalog, making it discoverable by `specify preset search`. + +## Table of Contents + +1. [Prerequisites](#prerequisites) +2. [Prepare Your Preset](#prepare-your-preset) +3. [Submit to Catalog](#submit-to-catalog) +4. [Verification Process](#verification-process) +5. [Release Workflow](#release-workflow) +6. [Best Practices](#best-practices) + +--- + +## Prerequisites + +Before publishing a preset, ensure you have: + +1. **Valid Preset**: A working preset with a valid `preset.yml` manifest +2. **Git Repository**: Preset hosted on GitHub (or other public git hosting) +3. **Documentation**: README.md with description and usage instructions +4. **License**: Open source license file (MIT, Apache 2.0, etc.) +5. **Versioning**: Semantic versioning (e.g., 1.0.0) +6. **Testing**: Preset tested on real projects with `specify preset add --dev` + +--- + +## Prepare Your Preset + +### 1. Preset Structure + +Ensure your preset follows the standard structure: + +```text +your-preset/ +ā”œā”€ā”€ preset.yml # Required: Preset manifest +ā”œā”€ā”€ README.md # Required: Documentation +ā”œā”€ā”€ LICENSE # Required: License file +ā”œā”€ā”€ CHANGELOG.md # Recommended: Version history +│ +ā”œā”€ā”€ templates/ # Template overrides +│ ā”œā”€ā”€ spec-template.md +│ ā”œā”€ā”€ plan-template.md +│ └── ... +│ +└── commands/ # Command overrides (optional) + └── speckit.specify.md +``` + +Start from the [scaffold](scaffold/) if you're creating a new preset. + +### 2. preset.yml Validation + +Verify your manifest is valid: + +```yaml +schema_version: "1.0" + +preset: + id: "your-preset" # Unique lowercase-hyphenated ID + name: "Your Preset Name" # Human-readable name + version: "1.0.0" # Semantic version + description: "Brief description (one sentence)" + author: "Your Name or Organization" + repository: "https://github.com/your-org/spec-kit-preset-your-preset" + license: "MIT" + +requires: + speckit_version: ">=0.1.0" # Required spec-kit version + +provides: + templates: + - type: "template" + name: "spec-template" + file: "templates/spec-template.md" + description: "Custom spec template" + replaces: "spec-template" + +tags: # 2-5 relevant tags + - "category" + - "workflow" +``` + +**Validation Checklist**: + +- āœ… `id` is lowercase with hyphens only (no underscores, spaces, or special characters) +- āœ… `version` follows semantic versioning (X.Y.Z) +- āœ… `description` is concise (under 200 characters) +- āœ… `repository` URL is valid and public +- āœ… All template and command files exist in the preset directory +- āœ… Template names are lowercase with hyphens only +- āœ… Command names use dot notation (e.g. `speckit.specify`) +- āœ… Tags are lowercase and descriptive + +### 3. Test Locally + +```bash +# Install from local directory +specify preset add --dev /path/to/your-preset + +# Verify templates resolve from your preset +specify preset resolve spec-template + +# Verify preset info +specify preset info your-preset + +# List installed presets +specify preset list + +# Remove when done testing +specify preset remove your-preset +``` + +If your preset includes command overrides, verify they appear in the agent directories: + +```bash +# Check Claude commands (if using Claude) +ls .claude/commands/speckit.*.md + +# Check Copilot commands (if using Copilot) +ls .github/agents/speckit.*.agent.md + +# Check Gemini commands (if using Gemini) +ls .gemini/commands/speckit.*.toml +``` + +### 4. Create GitHub Release + +Create a GitHub release for your preset version: + +```bash +# Tag the release +git tag v1.0.0 +git push origin v1.0.0 +``` + +The release archive URL will be: + +```text +https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip +``` + +### 5. Test Installation from Archive + +```bash +specify preset add --from https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip +``` + +--- + +## Submit to Catalog + +### Understanding the Catalogs + +Spec Kit uses a dual-catalog system: + +- **`catalog.json`** — Official, verified presets (install allowed by default) +- **`catalog.community.json`** — Community-contributed presets (discovery only by default) + +All community presets should be submitted to `catalog.community.json`. + +### 1. Fork the spec-kit Repository + +```bash +git clone https://github.com/YOUR-USERNAME/spec-kit.git +cd spec-kit +``` + +### 2. Add Preset to Community Catalog + +Edit `presets/catalog.community.json` and add your preset. + +> **āš ļø Entries must be sorted alphabetically by preset ID.** Insert your preset in the correct position within the `"presets"` object. + +```json +{ + "schema_version": "1.0", + "updated_at": "2026-03-10T00:00:00Z", + "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json", + "presets": { + "your-preset": { + "name": "Your Preset Name", + "description": "Brief description of what your preset provides", + "author": "Your Name", + "version": "1.0.0", + "download_url": "https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/your-org/spec-kit-preset-your-preset", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "templates": 3, + "commands": 1 + }, + "tags": [ + "category", + "workflow" + ], + "created_at": "2026-03-10T00:00:00Z", + "updated_at": "2026-03-10T00:00:00Z" + } + } +} +``` + +### 3. Update Community Presets Table + +Add your preset to the Community Presets table on the docs site at `docs/community/presets.md`: + +```markdown +| Your Preset Name | Brief description of what your preset does | N templates, M commands[, P scripts] | — | [repo-name](https://github.com/your-org/spec-kit-preset-your-preset) | +``` + +Insert your row in alphabetical order by preset **name** (the first column of the table). + +### 4. Submit Pull Request + +```bash +git checkout -b add-your-preset +git add presets/catalog.community.json docs/community/presets.md +git commit -m "Add your-preset to community catalog + +- Preset ID: your-preset +- Version: 1.0.0 +- Author: Your Name +- Description: Brief description +" +git push origin add-your-preset +``` + +**Pull Request Checklist**: + +```markdown +## Preset Submission + +**Preset Name**: Your Preset Name +**Preset ID**: your-preset +**Version**: 1.0.0 +**Repository**: https://github.com/your-org/spec-kit-preset-your-preset + +### Checklist +- [ ] Valid preset.yml manifest +- [ ] README.md with description and usage +- [ ] LICENSE file included +- [ ] GitHub release created +- [ ] Preset tested with `specify preset add --dev` +- [ ] Templates resolve correctly (`specify preset resolve`) +- [ ] Commands register to agent directories (if applicable) +- [ ] Commands match template sections (command + template are coherent) +- [ ] Added to presets/catalog.community.json +- [ ] Added row to docs/community/presets.md table +``` + +--- + +## Verification Process + +After submission, maintainers will review: + +1. **Manifest validation** — valid `preset.yml`, all files exist +2. **Template quality** — templates are useful and well-structured +3. **Command coherence** — commands reference sections that exist in templates +4. **Security** — no malicious content, safe file operations +5. **Documentation** — clear README explaining what the preset does + +Once verified, `verified: true` is set and the preset appears in `specify preset search`. + +--- + +## Release Workflow + +When releasing a new version: + +1. Update `version` in `preset.yml` +2. Update CHANGELOG.md +3. Tag and push: `git tag v1.1.0 && git push origin v1.1.0` +4. Submit PR to update `version` and `download_url` in `presets/catalog.community.json` + +--- + +## Best Practices + +### Template Design + +- **Keep sections clear** — use headings and placeholder text the LLM can replace +- **Match commands to templates** — if your preset overrides a command, make sure it references the sections in your template +- **Document customization points** — use HTML comments to guide users on what to change + +### Naming + +- Preset IDs should be descriptive: `healthcare-compliance`, `enterprise-safe`, `startup-lean` +- Avoid generic names: `my-preset`, `custom`, `test` + +### Stacking + +- Design presets to work well when stacked with others +- Only override templates you need to change +- Document which templates and commands your preset modifies + +### Command Overrides + +- Only override commands when the workflow needs to change, not just the output format +- If you only need different template sections, a template override is sufficient +- Test command overrides with multiple agents (Claude, Gemini, Copilot) diff --git a/presets/README.md b/presets/README.md new file mode 100644 index 0000000000..7d7b9ae8a2 --- /dev/null +++ b/presets/README.md @@ -0,0 +1,142 @@ +# Presets + +Presets 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. + +## How It Works + +When Spec Kit needs a template (e.g. `spec-template`), it walks a resolution stack: + +1. `.specify/templates/overrides/` — project-local one-off overrides +2. `.specify/presets//templates/` — installed presets (sorted by priority) +3. `.specify/extensions//templates/` — extension-provided templates +4. `.specify/templates/` — core templates shipped with Spec Kit + +If no preset is installed, core templates are used — exactly the same behavior as before presets existed. + +Template resolution happens **at runtime** — although preset files are copied into `.specify/presets//` during installation, Spec Kit walks the resolution stack on every template lookup rather than merging templates into a single location. + +For detailed resolution and command registration flows, see [ARCHITECTURE.md](ARCHITECTURE.md). + +## Command Overrides + +Presets 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). + +Unlike 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. + +## Quick Start + +```bash +# Search available presets +specify preset search + +# Install a preset from the catalog +specify preset add healthcare-compliance + +# Install from a local directory (for development) +specify preset add --dev ./my-preset + +# Install with a specific priority (lower = higher precedence) +specify preset add healthcare-compliance --priority 5 + +# List installed presets +specify preset list + +# See which template a name resolves to +specify preset resolve spec-template + +# Get detailed info about a preset +specify preset info healthcare-compliance + +# Remove a preset +specify preset remove healthcare-compliance +``` + +## Stacking Presets + +Multiple presets can be installed simultaneously. The `--priority` flag controls which one wins when two presets provide the same template (lower number = higher precedence): + +```bash +specify preset add enterprise-safe --priority 10 # base layer +specify preset add healthcare-compliance --priority 5 # overrides enterprise-safe +specify preset add pm-workflow --priority 1 # overrides everything +``` + +Presets **override by default**, they don't merge. If two presets both provide `spec-template` with the default `replace` strategy, the one with the lowest priority number wins entirely. However, presets can use **composition strategies** to augment rather than replace content. + +### Composition Strategies + +Presets can declare a `strategy` per template to control how content is combined. The `name` field identifies which template to compose with in the priority stack, while `file` points to the actual content file (which can differ from the convention path `templates/.md`): + +```yaml +provides: + templates: + - type: "template" + name: "spec-template" + file: "templates/spec-addendum.md" + strategy: "append" # adds content after the core template +``` + +| Strategy | Description | +|----------|-------------| +| `replace` (default) | Fully replaces the lower-priority template | +| `prepend` | Places content **before** the resolved lower-priority template, separated by a blank line | +| `append` | Places content **after** the resolved lower-priority template, separated by a blank line | +| `wrap` | Content contains `{CORE_TEMPLATE}` placeholder (or `$CORE_SCRIPT` for scripts) replaced with the lower-priority content | + +**Supported combinations:** + +| Type | `replace` | `prepend` | `append` | `wrap` | +|------|-----------|-----------|----------|--------| +| **template** | āœ“ (default) | āœ“ | āœ“ | āœ“ | +| **command** | āœ“ (default) | āœ“ | āœ“ | āœ“ | +| **script** | āœ“ (default) | — | — | āœ“ | + +Multiple composing presets chain recursively. For example, a security preset with `prepend` and a compliance preset with `append` will produce: security header + core content + compliance footer. + +## Catalog Management + +Presets are discovered through catalogs. By default, Spec Kit uses the official and community catalogs: + +> [!NOTE] +> Community presets are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting, catalog structure, or policy compliance, but they do **not review, audit, endorse, or support the preset code itself**. Review preset source code before installation and use at your own discretion. + +```bash +# List active catalogs +specify preset catalog list + +# Add a custom catalog +specify preset catalog add https://example.com/catalog.json --name my-org --install-allowed + +# Remove a catalog +specify preset catalog remove my-org +``` + +## Creating a Preset + +See [scaffold/](scaffold/) for a scaffold you can copy to create your own preset. + +1. Copy `scaffold/` to a new directory +2. Edit `preset.yml` with your preset's metadata +3. Add or replace templates in `templates/` +4. Test locally with `specify preset add --dev .` +5. Verify with `specify preset resolve spec-template` + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `SPECKIT_PRESET_CATALOG_URL` | Override the catalog URL (replaces all defaults) | + +## Configuration Files + +| File | Scope | Description | +|------|-------|-------------| +| `.specify/preset-catalogs.yml` | Project | Custom catalog stack for this project | +| `~/.specify/preset-catalogs.yml` | User | Custom catalog stack for all projects | + +## Future Considerations + +The following enhancements are under consideration for future releases: + +- **Structural merge strategies** — Parsing Markdown sections for per-section granularity (e.g., "replace only ## Security"). +- **Conflict detection** — `specify preset lint` / `specify preset doctor` for detecting composition conflicts. diff --git a/presets/catalog.community.json b/presets/catalog.community.json new file mode 100644 index 0000000000..caf28e5041 --- /dev/null +++ b/presets/catalog.community.json @@ -0,0 +1,312 @@ +{ + "schema_version": "1.0", + "updated_at": "2026-04-15T00:00:00Z", + "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json", + "presets": { + "aide-in-place": { + "name": "AIDE In-Place Migration", + "id": "aide-in-place", + "version": "1.0.0", + "description": "Adapts the AIDE workflow for in-place technology migrations (X → Y pattern). Overrides vision, roadmap, progress, and work item commands with migration-specific guidance.", + "author": "mnriem", + "repository": "https://github.com/mnriem/spec-kit-presets", + "download_url": "https://github.com/mnriem/spec-kit-presets/releases/download/aide-in-place-v1.0.0/aide-in-place.zip", + "homepage": "https://github.com/mnriem/spec-kit-presets", + "documentation": "https://github.com/mnriem/spec-kit-presets/blob/main/aide-in-place/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.2.0", + "extensions": ["aide"] + }, + "provides": { + "templates": 2, + "commands": 8 + }, + "tags": [ + "migration", + "in-place", + "brownfield", + "aide" + ] + }, + "canon-core": { + "name": "Canon Core", + "id": "canon-core", + "version": "0.1.0", + "description": "Adapts original Spec Kit workflow to work together with Canon extension.", + "author": "Maxim Stupakov", + "download_url": "https://github.com/maximiliamus/spec-kit-canon/releases/download/v0.1.0/spec-kit-canon-core-v0.1.0.zip", + "repository": "https://github.com/maximiliamus/spec-kit-canon", + "homepage": "https://github.com/maximiliamus/spec-kit-canon", + "documentation": "https://github.com/maximiliamus/spec-kit-canon/blob/master/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.3" + }, + "provides": { + "templates": 2, + "commands": 8 + }, + "tags": [ + "baseline", + "canon", + "spec-first" + ] + }, + "claude-ask-questions": { + "name": "Claude AskUserQuestion", + "id": "claude-ask-questions", + "version": "1.0.0", + "description": "Upgrades /speckit.clarify and /speckit.checklist on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question.", + "author": "0xrafasec", + "repository": "https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions", + "download_url": "https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions/archive/refs/tags/v1.0.0.zip", + "homepage": "https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions", + "documentation": "https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions/blob/main/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.6.0" + }, + "provides": { + "templates": 0, + "commands": 2 + }, + "tags": [ + "claude", + "ask-user-question", + "clarify", + "checklist" + ], + "created_at": "2026-04-13T00:00:00Z", + "updated_at": "2026-04-13T00:00:00Z" + }, + "explicit-task-dependencies": { + "name": "Explicit Task Dependencies", + "id": "explicit-task-dependencies", + "version": "1.0.0", + "description": "Adds explicit (depends on T###) dependency declarations and an Execution Wave DAG to tasks.md for dependency-resolved parallel scheduling", + "author": "Quratulain-bilal", + "repository": "https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies/archive/refs/tags/v1.0.0.zip", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies/blob/main/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "templates": 1, + "commands": 1 + }, + "tags": [ + "dependencies", + "parallel", + "scheduling", + "wave-dag" + ] + }, + "fiction-book-writing": { + "name": "Fiction Book Writing", + "id": "fiction-book-writing", + "version": "1.6.0", + "description": "Spec-Driven Development for novel and long-form fiction. 27 AI commands from idea to submission: story bible governance, 9 POV modes, all major plot structure frameworks, scene-by-scene drafting with quality gates, audiobook pipeline (SSML/ElevenLabs), cover design, sensitivity review, pacing and prose statistics, and pandoc-based export to DOCX/EPUB/LaTeX. Two style modes: author voice sample extraction or humanized-AI prose with 5 craft profiles. 12 languages supported.", + "author": "Andreas Daumann", + "repository": "https://github.com/adaumann/speckit-preset-fiction-book-writing", + "download_url": "https://github.com/adaumann/speckit-preset-fiction-book-writing/archive/refs/tags/v1.6.0.zip", + "homepage": "https://github.com/adaumann/speckit-preset-fiction-book-writing", + "documentation": "https://github.com/adaumann/speckit-preset-fiction-book-writing/blob/main/fiction-book-writing/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.5.0" + }, + "provides": { + "templates": 22, + "commands": 27, + "scripts": 1 + }, + "tags": [ + "writing", + "novel", + "fiction", + "storytelling", + "creative-writing", + "kdp", + "multi-pov", + "export", + "book", + "brainstorming", + "roleplay", + "audiobook", + "language-support" + ], + "created_at": "2026-04-09T08:00:00Z", + "updated_at": "2026-04-19T08:00:00Z" + }, + "jira": { + "name": "Jira Issue Tracking", + "id": "jira", + "version": "1.0.0", + "description": "Overrides speckit.taskstoissues to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools.", + "author": "luno", + "repository": "https://github.com/luno/spec-kit-preset-jira", + "download_url": "https://github.com/luno/spec-kit-preset-jira/archive/refs/tags/v1.0.0.zip", + "homepage": "https://github.com/luno/spec-kit-preset-jira", + "documentation": "https://github.com/luno/spec-kit-preset-jira/blob/main/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "templates": 0, + "commands": 1 + }, + "tags": [ + "jira", + "atlassian", + "issue-tracking", + "preset" + ], + "created_at": "2026-04-15T00:00:00Z", + "updated_at": "2026-04-15T00:00:00Z" + }, + "multi-repo-branching": { + "name": "Multi-Repo Branching", + "id": "multi-repo-branching", + "version": "1.0.0", + "description": "Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases.", + "author": "sakitA", + "repository": "https://github.com/sakitA/spec-kit-preset-multi-repo-branching", + "download_url": "https://github.com/sakitA/spec-kit-preset-multi-repo-branching/archive/refs/tags/v1.0.0.zip", + "homepage": "https://github.com/sakitA/spec-kit-preset-multi-repo-branching", + "documentation": "https://github.com/sakitA/spec-kit-preset-multi-repo-branching/blob/master/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "templates": 0, + "commands": 2 + }, + "tags": [ + "multi-repo-branching", + "multi-module", + "submodules", + "monorepo" + ], + "created_at": "2026-04-09T00:00:00Z", + "updated_at": "2026-04-09T00:00:00Z" + }, + "pirate": { + "name": "Pirate Speak (Full)", + "id": "pirate", + "version": "1.0.0", + "description": "Arrr! Transforms all Spec Kit output into pirate speak. Specs, plans, and tasks be written fer scallywags.", + "author": "mnriem", + "repository": "https://github.com/mnriem/spec-kit-presets", + "download_url": "https://github.com/mnriem/spec-kit-presets/releases/download/pirate-v1.0.0/pirate.zip", + "homepage": "https://github.com/mnriem/spec-kit-presets", + "documentation": "https://github.com/mnriem/spec-kit-presets/blob/main/pirate/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "templates": 6, + "commands": 9 + }, + "tags": [ + "pirate", + "theme", + "fun", + "experimental" + ] + }, + "screenwriting": { + "name": "Screenwriting", + "id": "screenwriting", + "version": "1.0.0", + "description": "Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents replace prose fiction conventions. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks.", + "author": "Andreas Daumann", + "repository": "https://github.com/adaumann/speckit-preset-screenwriting", + "download_url": "https://github.com/adaumann/speckit-preset-screenwriting/archive/refs/tags/v1.0.0.zip", + "homepage": "https://github.com/adaumann/speckit-preset-screenwriting", + "documentation": "https://github.com/adaumann/speckit-preset-screenwriting/blob/main/screenwriting/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.5.0" + }, + "provides": { + "templates": 26, + "commands": 32, + "scripts": 1 + }, + "tags": [ + "writing", + "screenplay", + "scriptwriting", + "film", + "tv", + "fountain", + "fountain-format", + "beat-sheet", + "teleplay", + "drama", + "comedy", + "storytelling", + "tutorial", + "education" + ], + "created_at": "2026-04-23T08:00:00Z", + "updated_at": "2026-04-23T08:00:00Z" + }, + "toc-navigation": { + "name": "Table of Contents Navigation", + "id": "toc-navigation", + "version": "1.0.0", + "description": "Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents", + "author": "Quratulain-bilal", + "repository": "https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation/archive/refs/tags/v1.0.0.zip", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation/blob/main/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "templates": 3, + "commands": 3 + }, + "tags": [ + "navigation", + "toc", + "documentation" + ] + }, + "vscode-ask-questions": { + "name": "VS Code Ask Questions", + "id": "vscode-ask-questions", + "version": "1.0.0", + "description": "Enhances the clarify command to use vscode/askQuestions for batched interactive questioning, reducing API request costs in GitHub Copilot.", + "author": "fdcastel", + "repository": "https://github.com/fdcastel/spec-kit-presets", + "download_url": "https://github.com/fdcastel/spec-kit-presets/releases/download/vscode-ask-questions-v1.0.0/vscode-ask-questions.zip", + "homepage": "https://github.com/fdcastel/spec-kit-presets", + "documentation": "https://github.com/fdcastel/spec-kit-presets/blob/main/vscode-ask-questions/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "templates": 0, + "commands": 1 + }, + "tags": [ + "vscode", + "askquestions", + "clarify", + "interactive" + ] + } + } +} diff --git a/presets/catalog.json b/presets/catalog.json new file mode 100644 index 0000000000..f272617926 --- /dev/null +++ b/presets/catalog.json @@ -0,0 +1,30 @@ +{ + "schema_version": "1.0", + "updated_at": "2026-04-24T00:00:00Z", + "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.json", + "presets": { + "lean": { + "name": "Lean Workflow", + "id": "lean", + "version": "1.0.0", + "description": "Minimal core workflow commands - just the prompt, just the artifact", + "author": "github", + "repository": "https://github.com/github/spec-kit", + "license": "MIT", + "bundled": true, + "requires": { + "speckit_version": ">=0.6.0" + }, + "provides": { + "commands": 5, + "templates": 0 + }, + "tags": [ + "lean", + "minimal", + "workflow", + "core" + ] + } + } +} diff --git a/presets/lean/README.md b/presets/lean/README.md new file mode 100644 index 0000000000..ab17257f96 --- /dev/null +++ b/presets/lean/README.md @@ -0,0 +1,45 @@ +# Lean Workflow + +A minimal preset that strips the Spec Kit workflow down to its essentials — just the prompt, just the artifact. + +## When to Use + +Use Lean when you want the structured specify → plan → tasks → implement pipeline without the ceremony of the full templates. Each command produces a single focused Markdown file with no boilerplate sections to fill in. + +## Commands Included + +| Command | Output | Description | +|---------|--------|-------------| +| `speckit.specify` | `spec.md` | Create a specification from a feature description | +| `speckit.plan` | `plan.md` | Create an implementation plan from the spec | +| `speckit.tasks` | `tasks.md` | Create dependency-ordered tasks from spec and plan | +| `speckit.implement` | *(code)* | Execute all tasks in order, marking progress | +| `speckit.constitution` | `constitution.md` | Create or update the project constitution | + +## What It Replaces + +Lean overrides the five core workflow commands with self-contained prompts that produce each artifact directly — no separate template files involved. The result is a shorter, more direct workflow. + +## Installation + +```bash +# Lean is a bundled preset — no download needed +specify preset add lean +``` + +## Development + +```bash +# Test from local directory +specify preset add --dev ./presets/lean + +# Verify commands resolve +specify preset resolve speckit.specify + +# Remove when done +specify preset remove lean +``` + +## License + +MIT diff --git a/presets/lean/commands/speckit.constitution.md b/presets/lean/commands/speckit.constitution.md new file mode 100644 index 0000000000..920337003e --- /dev/null +++ b/presets/lean/commands/speckit.constitution.md @@ -0,0 +1,15 @@ +--- +description: Create or update the project constitution. +--- + +## User Input + +```text +$ARGUMENTS +``` + +## Outline + +1. Create or update the project constitution and store it in `.specify/memory/constitution.md`. + - Project name, guiding principles, non-negotiable rules + - Derive from user input and existing repo context (README, docs) diff --git a/presets/lean/commands/speckit.implement.md b/presets/lean/commands/speckit.implement.md new file mode 100644 index 0000000000..fc68a1f8b1 --- /dev/null +++ b/presets/lean/commands/speckit.implement.md @@ -0,0 +1,22 @@ +--- +description: Execute the implementation plan by processing all tasks in tasks.md. +--- + +## User Input + +```text +$ARGUMENTS +``` + +## Outline + +1. Read `.specify/feature.json` to get the feature directory path. + +2. **Load context**: `.specify/memory/constitution.md` and `/spec.md` and `/plan.md` and `/tasks.md`. + +3. **Execute tasks** in order: + - Complete each task before moving to the next + - Mark completed tasks by changing `- [ ]` to `- [x]` in `/tasks.md` + - Halt on failure and report the issue + +4. **Validate**: Verify all tasks are completed and the implementation matches the spec. diff --git a/presets/lean/commands/speckit.plan.md b/presets/lean/commands/speckit.plan.md new file mode 100644 index 0000000000..9fbbe4c371 --- /dev/null +++ b/presets/lean/commands/speckit.plan.md @@ -0,0 +1,19 @@ +--- +description: Create a plan and store it in plan.md. +--- + +## User Input + +```text +$ARGUMENTS +``` + +## Outline + +1. Read `.specify/feature.json` to get the feature directory path. + +2. **Load context**: `.specify/memory/constitution.md` and `/spec.md`. + +3. Create an implementation plan and store it in `/plan.md`. + - Technical context: tech stack, dependencies, project structure + - Design decisions, architecture, file structure diff --git a/presets/lean/commands/speckit.specify.md b/presets/lean/commands/speckit.specify.md new file mode 100644 index 0000000000..c15353557a --- /dev/null +++ b/presets/lean/commands/speckit.specify.md @@ -0,0 +1,23 @@ +--- +description: Create a specification and store it in spec.md. +--- + +## User Input + +```text +$ARGUMENTS +``` + +## Outline + +1. **Ask the user** for the feature directory path (e.g., `specs/my-feature`). Do not proceed until provided. + +2. Create the directory and write `.specify/feature.json`: + ```json + { "feature_directory": "" } + ``` + +3. Create a specification from the user input and store it in `/spec.md`. + - Overview, functional requirements, user scenarios, success criteria + - Every requirement must be testable + - Make informed defaults for unspecified details diff --git a/presets/lean/commands/speckit.tasks.md b/presets/lean/commands/speckit.tasks.md new file mode 100644 index 0000000000..724a7b8400 --- /dev/null +++ b/presets/lean/commands/speckit.tasks.md @@ -0,0 +1,19 @@ +--- +description: Create the tasks needed for implementation and store them in tasks.md. +--- + +## User Input + +```text +$ARGUMENTS +``` + +## Outline + +1. Read `.specify/feature.json` to get the feature directory path. + +2. **Load context**: `.specify/memory/constitution.md` and `/spec.md` and `/plan.md`. + +3. Create dependency-ordered implementation tasks and store them in `/tasks.md`. + - Every task uses checklist format: `- [ ] [TaskID] Description with file path` + - Organized by phase: setup, foundational, user stories in priority order, polish diff --git a/presets/lean/preset.yml b/presets/lean/preset.yml new file mode 100644 index 0000000000..973b3b7318 --- /dev/null +++ b/presets/lean/preset.yml @@ -0,0 +1,51 @@ +schema_version: "1.0" + +preset: + id: "lean" + name: "Lean Workflow" + version: "1.0.0" + description: "Minimal core workflow commands - just the prompt, just the artifact" + author: "github" + repository: "https://github.com/github/spec-kit" + license: "MIT" + +requires: + speckit_version: ">=0.6.0" + +provides: + templates: + - type: "command" + name: "speckit.specify" + file: "commands/speckit.specify.md" + description: "Lean specify - create spec.md from a feature description" + replaces: "speckit.specify" + + - type: "command" + name: "speckit.plan" + file: "commands/speckit.plan.md" + description: "Lean plan - create plan.md from the spec" + replaces: "speckit.plan" + + - type: "command" + name: "speckit.tasks" + file: "commands/speckit.tasks.md" + description: "Lean tasks - create tasks.md from plan and spec" + replaces: "speckit.tasks" + + - type: "command" + name: "speckit.implement" + file: "commands/speckit.implement.md" + description: "Lean implement - execute tasks from tasks.md" + replaces: "speckit.implement" + + - type: "command" + name: "speckit.constitution" + file: "commands/speckit.constitution.md" + description: "Lean constitution - create or update project constitution" + replaces: "speckit.constitution" + +tags: + - "lean" + - "minimal" + - "workflow" + - "core" diff --git a/presets/scaffold/README.md b/presets/scaffold/README.md new file mode 100644 index 0000000000..b30a1ab6ac --- /dev/null +++ b/presets/scaffold/README.md @@ -0,0 +1,46 @@ +# My Preset + +A custom preset for Spec Kit. Copy this directory and customize it to create your own. + +## Templates Included + +| Template | Type | Description | +|----------|------|-------------| +| `spec-template` | template | Custom feature specification template (overrides core and extensions) | +| `myext-template` | template | Override of the myext extension's report template | +| `speckit.specify` | command | Custom specification command (overrides core) | +| `speckit.myext.myextcmd` | command | Override of the myext extension's myextcmd command | + +## Development + +1. Copy this directory: `cp -r presets/scaffold my-preset` +2. Edit `preset.yml` — set your preset's ID, name, description, and templates +3. Add or modify templates in `templates/` +4. Test locally: `specify preset add --dev ./my-preset` +5. Verify resolution: `specify preset resolve spec-template` +6. Remove when done testing: `specify preset remove my-preset` + +## Manifest Reference (`preset.yml`) + +Required fields: +- `schema_version` — always `"1.0"` +- `preset.id` — lowercase alphanumeric with hyphens +- `preset.name` — human-readable name +- `preset.version` — semantic version (e.g. `1.0.0`) +- `preset.description` — brief description +- `requires.speckit_version` — version constraint (e.g. `>=0.1.0`) +- `provides.templates` — list of templates with `type`, `name`, and `file` + +## Template Types + +- **template** — Document scaffolds (spec-template.md, plan-template.md, tasks-template.md, etc.) +- **command** — AI agent workflow prompts (e.g. speckit.specify, speckit.plan) +- **script** — Custom scripts (reserved for future use) + +## Publishing + +See the [Preset Publishing Guide](../PUBLISHING.md) for details on submitting to the catalog. + +## License + +MIT diff --git a/presets/scaffold/commands/speckit.myext.myextcmd.md b/presets/scaffold/commands/speckit.myext.myextcmd.md new file mode 100644 index 0000000000..5adef240b6 --- /dev/null +++ b/presets/scaffold/commands/speckit.myext.myextcmd.md @@ -0,0 +1,20 @@ +--- +description: "Override of the myext extension's myextcmd command" +--- + + + +You are following a customized version of the myext extension's myextcmd command. + +When executing this command: + +1. Read the user's input from $ARGUMENTS +2. Follow the standard myextcmd workflow +3. Additionally, apply the following customizations from this preset: + - Add compliance checks before proceeding + - Include audit trail entries in the output + +> CUSTOMIZE: Replace the instructions above with your own. +> This file overrides the command that the "myext" extension provides. +> When this preset is installed, all agents (Claude, Gemini, Copilot, etc.) +> will use this version instead of the extension's original. diff --git a/presets/scaffold/commands/speckit.specify.md b/presets/scaffold/commands/speckit.specify.md new file mode 100644 index 0000000000..7926cc138c --- /dev/null +++ b/presets/scaffold/commands/speckit.specify.md @@ -0,0 +1,23 @@ +--- +description: "Create a feature specification (preset override)" +scripts: + sh: scripts/bash/create-new-feature.sh "{ARGS}" + ps: scripts/powershell/create-new-feature.ps1 "{ARGS}" +--- + +## User Input + +```text +$ARGUMENTS +``` + +Given the feature description above: + +1. **Create the feature branch** by running the script: + - Bash: `{SCRIPT} --json --short-name "" ""` + - The JSON output contains BRANCH_NAME and SPEC_FILE paths. + +2. **Read the spec-template** to see the sections you need to fill. + +3. **Write the specification** to SPEC_FILE, replacing the placeholders in each section + (Overview, Requirements, Acceptance Criteria) with details from the user's description. diff --git a/presets/scaffold/preset.yml b/presets/scaffold/preset.yml new file mode 100644 index 0000000000..65111ba9f3 --- /dev/null +++ b/presets/scaffold/preset.yml @@ -0,0 +1,120 @@ +schema_version: "1.0" + +preset: + # CUSTOMIZE: Change 'my-preset' to your preset ID (lowercase, hyphen-separated) + id: "my-preset" + + # CUSTOMIZE: Human-readable name for your preset + name: "My Preset" + + # CUSTOMIZE: Update version when releasing (semantic versioning: X.Y.Z) + version: "1.0.0" + + # CUSTOMIZE: Brief description (under 200 characters) + description: "Brief description of what your preset provides" + + # CUSTOMIZE: Your name or organization name + author: "Your Name" + + # CUSTOMIZE: GitHub repository URL (create before publishing) + repository: "https://github.com/your-org/spec-kit-preset-my-preset" + + # REVIEW: License (MIT is recommended for open source) + license: "MIT" + +# Requirements for this preset +requires: + # CUSTOMIZE: Minimum spec-kit version required + speckit_version: ">=0.1.0" + +# Templates provided by this preset +provides: + templates: + # CUSTOMIZE: Define your template overrides + # Templates are document scaffolds (spec-template.md, plan-template.md, etc.) + # + # Strategy options (optional, defaults to "replace"): + # replace - Fully replaces the lower-priority template (default) + # prepend - Places this content BEFORE the lower-priority template + # append - Places this content AFTER the lower-priority template + # wrap - Uses {CORE_TEMPLATE} placeholder (templates/commands) or + # $CORE_SCRIPT placeholder (scripts), replaced with lower-priority content + # + # Note: Scripts only support "replace" and "wrap" strategies. + - type: "template" + name: "spec-template" + file: "templates/spec-template.md" + description: "Custom feature specification template" + replaces: "spec-template" # Which core template this overrides (optional) + + # ADD MORE TEMPLATES: Copy this block for each template + # - type: "template" + # name: "plan-template" + # file: "templates/plan-template.md" + # description: "Custom plan template" + # replaces: "plan-template" + + # COMPOSITION EXAMPLES: + # The `file` field points to the content file (can differ from the + # convention path `templates/.md`). The `name` field identifies + # which template to compose with in the priority stack. + # + # Append additional sections to an existing template: + # - type: "template" + # name: "spec-template" + # file: "templates/spec-addendum.md" + # description: "Add compliance section to spec template" + # strategy: "append" + # + # Wrap a command with preamble/sign-off: + # - type: "command" + # name: "speckit.specify" + # file: "commands/specify-wrapper.md" + # description: "Wrap specify command with compliance checks" + # strategy: "wrap" + # # In the wrapper file, use {CORE_TEMPLATE} where the original content goes + + # OVERRIDE EXTENSION TEMPLATES: + # Presets sit above extensions in the resolution stack, so you can + # override templates provided by any installed extension. + # For example, if the "myext" extension provides a spec-template, + # the preset's version above will take priority automatically. + + # Override a template provided by the "myext" extension: + - type: "template" + name: "myext-template" + file: "templates/myext-template.md" + description: "Override myext's report template" + replaces: "myext-template" + + # Command overrides (AI agent workflow prompts) + # Presets can override both core and extension commands. + # Commands are automatically registered into all detected agent + # directories (.claude/commands/, .gemini/commands/, etc.) + + # Override a core command: + - type: "command" + name: "speckit.specify" + file: "commands/speckit.specify.md" + description: "Custom specification command" + replaces: "speckit.specify" + + # Override an extension command (e.g. from the "myext" extension): + - type: "command" + name: "speckit.myext.myextcmd" + file: "commands/speckit.myext.myextcmd.md" + description: "Override myext's myextcmd command with custom workflow" + replaces: "speckit.myext.myextcmd" + + # Script templates (reserved for future use) + # - type: "script" + # name: "create-new-feature" + # file: "scripts/bash/create-new-feature.sh" + # description: "Custom feature creation script" + # replaces: "create-new-feature" + +# CUSTOMIZE: Add relevant tags (2-5 recommended) +# Used for discovery in catalog +tags: + - "example" + - "preset" diff --git a/presets/scaffold/templates/myext-template.md b/presets/scaffold/templates/myext-template.md new file mode 100644 index 0000000000..2b4f5a3fe0 --- /dev/null +++ b/presets/scaffold/templates/myext-template.md @@ -0,0 +1,24 @@ +# MyExt Report + +> This template overrides the one provided by the "myext" extension. +> Customize it to match your needs. + +## Summary + +Brief summary of the report. + +## Details + +- Detail 1 +- Detail 2 + +## Actions + +- [ ] Action 1 +- [ ] Action 2 + + diff --git a/presets/scaffold/templates/spec-template.md b/presets/scaffold/templates/spec-template.md new file mode 100644 index 0000000000..432bca3ccf --- /dev/null +++ b/presets/scaffold/templates/spec-template.md @@ -0,0 +1,18 @@ +# Feature Specification: [FEATURE NAME] + +**Created**: [DATE] +**Status**: Draft + +## Overview + +[Brief description of the feature] + +## Requirements + +- [ ] Requirement 1 +- [ ] Requirement 2 + +## Acceptance Criteria + +- [ ] Criterion 1 +- [ ] Criterion 2 diff --git a/presets/self-test/commands/speckit.specify.md b/presets/self-test/commands/speckit.specify.md new file mode 100644 index 0000000000..d5e2c74889 --- /dev/null +++ b/presets/self-test/commands/speckit.specify.md @@ -0,0 +1,15 @@ +--- +description: "Self-test override of the specify command" +--- + + + +You are following the self-test preset's version of the specify command. + +When creating a specification, follow this process: + +1. Read the user's requirements from $ARGUMENTS +2. Create a specification document using the spec-template +3. Include all standard sections plus the self-test marker + +> This command is provided by the self-test preset. diff --git a/presets/self-test/commands/speckit.wrap-test.md b/presets/self-test/commands/speckit.wrap-test.md new file mode 100644 index 0000000000..78ace30ea8 --- /dev/null +++ b/presets/self-test/commands/speckit.wrap-test.md @@ -0,0 +1,14 @@ +--- +description: "Self-test wrap command — pre/post around core" +strategy: wrap +--- + +## Preset Pre-Logic + +preset:self-test wrap-pre + +{CORE_TEMPLATE} + +## Preset Post-Logic + +preset:self-test wrap-post diff --git a/presets/self-test/preset.yml b/presets/self-test/preset.yml new file mode 100644 index 0000000000..8e718430aa --- /dev/null +++ b/presets/self-test/preset.yml @@ -0,0 +1,66 @@ +schema_version: "1.0" + +preset: + id: "self-test" + name: "Self-Test Preset" + version: "1.0.0" + description: "A preset that overrides all core templates for testing purposes" + author: "github" + repository: "https://github.com/github/spec-kit" + license: "MIT" + +requires: + speckit_version: ">=0.1.0" + +provides: + templates: + - type: "template" + name: "spec-template" + file: "templates/spec-template.md" + description: "Self-test spec template" + replaces: "spec-template" + + - type: "template" + name: "plan-template" + file: "templates/plan-template.md" + description: "Self-test plan template" + replaces: "plan-template" + + - type: "template" + name: "tasks-template" + file: "templates/tasks-template.md" + description: "Self-test tasks template" + replaces: "tasks-template" + + - type: "template" + name: "checklist-template" + file: "templates/checklist-template.md" + description: "Self-test checklist template" + replaces: "checklist-template" + + - type: "template" + name: "constitution-template" + file: "templates/constitution-template.md" + description: "Self-test constitution template" + replaces: "constitution-template" + + - type: "template" + name: "agent-file-template" + file: "templates/agent-file-template.md" + description: "Self-test agent file template" + replaces: "agent-file-template" + + - type: "command" + name: "speckit.specify" + file: "commands/speckit.specify.md" + description: "Self-test override of the specify command" + replaces: "speckit.specify" + + - type: "command" + name: "speckit.wrap-test" + file: "commands/speckit.wrap-test.md" + description: "Self-test wrap strategy command" + +tags: + - "testing" + - "self-test" diff --git a/presets/self-test/templates/agent-file-template.md b/presets/self-test/templates/agent-file-template.md new file mode 100644 index 0000000000..7b9267bade --- /dev/null +++ b/presets/self-test/templates/agent-file-template.md @@ -0,0 +1,9 @@ +# Agent File (Self-Test Preset) + + + +> This template is provided by the self-test preset. + +## Agent Instructions + +Follow these guidelines when working on this project. diff --git a/presets/self-test/templates/checklist-template.md b/presets/self-test/templates/checklist-template.md new file mode 100644 index 0000000000..c761eb0298 --- /dev/null +++ b/presets/self-test/templates/checklist-template.md @@ -0,0 +1,15 @@ +# Checklist (Self-Test Preset) + + + +> This template is provided by the self-test preset. + +## Pre-Implementation + +- [ ] Spec reviewed +- [ ] Plan approved + +## Post-Implementation + +- [ ] Tests passing +- [ ] Documentation updated diff --git a/presets/self-test/templates/constitution-template.md b/presets/self-test/templates/constitution-template.md new file mode 100644 index 0000000000..0c53211fb0 --- /dev/null +++ b/presets/self-test/templates/constitution-template.md @@ -0,0 +1,15 @@ +# Constitution (Self-Test Preset) + + + +> This template is provided by the self-test preset. + +## Principles + +1. Principle 1 +2. Principle 2 + +## Guidelines + +- Guideline 1 +- Guideline 2 diff --git a/presets/self-test/templates/plan-template.md b/presets/self-test/templates/plan-template.md new file mode 100644 index 0000000000..5cdaa0a41e --- /dev/null +++ b/presets/self-test/templates/plan-template.md @@ -0,0 +1,22 @@ +# Implementation Plan (Self-Test Preset) + + + +> This template is provided by the self-test preset. + +## Approach + +Describe the implementation approach. + +## Steps + +1. Step 1 +2. Step 2 + +## Dependencies + +- Dependency 1 + +## Risks + +- Risk 1 diff --git a/presets/self-test/templates/spec-template.md b/presets/self-test/templates/spec-template.md new file mode 100644 index 0000000000..a54956f166 --- /dev/null +++ b/presets/self-test/templates/spec-template.md @@ -0,0 +1,23 @@ +# Feature Specification (Self-Test Preset) + + + +> This template is provided by the self-test preset. + +## Overview + +Brief description of the feature. + +## Requirements + +- Requirement 1 +- Requirement 2 + +## Design + +Describe the design approach. + +## Acceptance Criteria + +- [ ] Criterion 1 +- [ ] Criterion 2 diff --git a/presets/self-test/templates/tasks-template.md b/presets/self-test/templates/tasks-template.md new file mode 100644 index 0000000000..80fa4c5fab --- /dev/null +++ b/presets/self-test/templates/tasks-template.md @@ -0,0 +1,17 @@ +# Tasks (Self-Test Preset) + + + +> This template is provided by the self-test preset. + +## Task List + +- [ ] Task 1 +- [ ] Task 2 + +## Estimation + +| Task | Estimate | +|------|----------| +| Task 1 | TBD | +| Task 2 | TBD | diff --git a/pyproject.toml b/pyproject.toml index de6fe5fe9a..7dc4efac7f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,17 +1,18 @@ [project] name = "specify-cli" -version = "0.1.0" +version = "0.8.1" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ - "typer", + "typer>=0.24.0", + "click>=8.2.1", "rich", - "httpx[socks]", "platformdirs", "readchar", - "truststore>=0.10.4", "pyyaml>=6.0", "packaging>=23.0", + "pathspec>=0.12.0", + "json5>=0.13.0", ] [project.scripts] @@ -24,6 +25,26 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/specify_cli"] +[tool.hatch.build.targets.wheel.force-include] +# Bundle core assets so `specify init` works without network access (air-gapped / enterprise) +# Page templates (exclude commands/ — bundled separately below to avoid duplication) +"templates/checklist-template.md" = "specify_cli/core_pack/templates/checklist-template.md" +"templates/constitution-template.md" = "specify_cli/core_pack/templates/constitution-template.md" +"templates/plan-template.md" = "specify_cli/core_pack/templates/plan-template.md" +"templates/spec-template.md" = "specify_cli/core_pack/templates/spec-template.md" +"templates/tasks-template.md" = "specify_cli/core_pack/templates/tasks-template.md" +"templates/vscode-settings.json" = "specify_cli/core_pack/templates/vscode-settings.json" +# Command templates +"templates/commands" = "specify_cli/core_pack/commands" +"scripts/bash" = "specify_cli/core_pack/scripts/bash" +"scripts/powershell" = "specify_cli/core_pack/scripts/powershell" +# Bundled extensions (installable via `specify extension add `) +"extensions/git" = "specify_cli/core_pack/extensions/git" +# Bundled workflows (auto-installed during `specify init`) +"workflows/speckit" = "specify_cli/core_pack/workflows/speckit" +# Bundled presets (installable via `specify preset add ` or `specify init --preset `) +"presets/lean" = "specify_cli/core_pack/presets/lean" + [project.optional-dependencies] test = [ "pytest>=7.0", @@ -50,4 +71,3 @@ precision = 2 show_missing = true skip_covered = false - diff --git a/scripts/bash/check-prerequisites.sh b/scripts/bash/check-prerequisites.sh old mode 100755 new mode 100644 index 3e6dc6f505..88a5559460 --- a/scripts/bash/check-prerequisites.sh +++ b/scripts/bash/check-prerequisites.sh @@ -1,61 +1,190 @@ #!/usr/bin/env bash -# Check prerequisites for Speckit workflow phases + +# Consolidated prerequisite checking script +# +# This script provides unified prerequisite checking for Spec-Driven Development workflow. +# It replaces the functionality previously spread across multiple scripts. +# +# Usage: ./check-prerequisites.sh [OPTIONS] +# +# OPTIONS: +# --json Output in JSON format +# --require-tasks Require tasks.md to exist (for implementation phase) +# --include-tasks Include tasks.md in AVAILABLE_DOCS list +# --paths-only Only output path variables (no validation) +# --help, -h Show help message +# +# OUTPUTS: +# JSON mode: {"FEATURE_DIR":"...", "AVAILABLE_DOCS":["..."]} +# Text mode: FEATURE_DIR:... \n AVAILABLE_DOCS: \n āœ“/āœ— file.md +# Paths only: REPO_ROOT: ... \n BRANCH: ... \n FEATURE_DIR: ... etc. set -e +# Parse command line arguments JSON_MODE=false -REQUIRE_PLAN=false REQUIRE_TASKS=false +INCLUDE_TASKS=false +PATHS_ONLY=false for arg in "$@"; do case "$arg" in - --json) JSON_MODE=true ;; - --require-plan) REQUIRE_PLAN=true ;; - --require-tasks) REQUIRE_TASKS=true ;; + --json) + JSON_MODE=true + ;; + --require-tasks) + REQUIRE_TASKS=true + ;; + --include-tasks) + INCLUDE_TASKS=true + ;; + --paths-only) + PATHS_ONLY=true + ;; --help|-h) - echo "Usage: $0 [--json] [--require-plan] [--require-tasks]" - echo " --json Output JSON format" - echo " --require-plan Require plan.md exists" - echo " --require-tasks Require tasks.md exists" + cat << 'EOF' +Usage: check-prerequisites.sh [OPTIONS] + +Consolidated prerequisite checking for Spec-Driven Development workflow. + +OPTIONS: + --json Output in JSON format + --require-tasks Require tasks.md to exist (for implementation phase) + --include-tasks Include tasks.md in AVAILABLE_DOCS list + --paths-only Only output path variables (no prerequisite validation) + --help, -h Show this help message + +EXAMPLES: + # Check task prerequisites (plan.md required) + ./check-prerequisites.sh --json + + # Check implementation prerequisites (plan.md + tasks.md required) + ./check-prerequisites.sh --json --require-tasks --include-tasks + + # Get feature paths only (no validation) + ./check-prerequisites.sh --paths-only + +EOF exit 0 ;; + *) + echo "ERROR: Unknown option '$arg'. Use --help for usage information." >&2 + exit 1 + ;; esac done -# Load common functions +# Source common functions SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/common.sh" -# Get paths -eval $(get_feature_paths) +# Get feature paths and validate branch +_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } +eval "$_paths_output" +unset _paths_output check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1 -# Check feature directory +# If paths-only mode, output paths and exit (support JSON + paths-only combined) +if $PATHS_ONLY; then + if $JSON_MODE; then + # Minimal JSON paths payload (no validation performed) + if has_jq; then + jq -cn \ + --arg repo_root "$REPO_ROOT" \ + --arg branch "$CURRENT_BRANCH" \ + --arg feature_dir "$FEATURE_DIR" \ + --arg feature_spec "$FEATURE_SPEC" \ + --arg impl_plan "$IMPL_PLAN" \ + --arg tasks "$TASKS" \ + '{REPO_ROOT:$repo_root,BRANCH:$branch,FEATURE_DIR:$feature_dir,FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,TASKS:$tasks}' + else + printf '{"REPO_ROOT":"%s","BRANCH":"%s","FEATURE_DIR":"%s","FEATURE_SPEC":"%s","IMPL_PLAN":"%s","TASKS":"%s"}\n' \ + "$(json_escape "$REPO_ROOT")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$TASKS")" + fi + else + echo "REPO_ROOT: $REPO_ROOT" + echo "BRANCH: $CURRENT_BRANCH" + echo "FEATURE_DIR: $FEATURE_DIR" + echo "FEATURE_SPEC: $FEATURE_SPEC" + echo "IMPL_PLAN: $IMPL_PLAN" + echo "TASKS: $TASKS" + fi + exit 0 +fi + +# Validate required directories and files if [[ ! -d "$FEATURE_DIR" ]]; then echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2 - echo "Run /speckit.specify first." >&2 + echo "Run /speckit.specify first to create the feature structure." >&2 exit 1 fi -# Check required files -if $REQUIRE_PLAN && [[ ! -f "$IMPL_PLAN" ]]; then - echo "ERROR: plan.md not found. Run /speckit.plan first." >&2 +if [[ ! -f "$IMPL_PLAN" ]]; then + echo "ERROR: plan.md not found in $FEATURE_DIR" >&2 + echo "Run /speckit.plan first to create the implementation plan." >&2 exit 1 fi +# Check for tasks.md if required if $REQUIRE_TASKS && [[ ! -f "$TASKS" ]]; then - echo "ERROR: tasks.md not found. Run /speckit.tasks first." >&2 + echo "ERROR: tasks.md not found in $FEATURE_DIR" >&2 + echo "Run /speckit.tasks first to create the task list." >&2 exit 1 fi -# Output +# Build list of available documents +docs=() + +# Always check these optional docs +[[ -f "$RESEARCH" ]] && docs+=("research.md") +[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md") + +# Check contracts directory (only if it exists and has files) +if [[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then + docs+=("contracts/") +fi + +[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md") + +# Include tasks.md if requested and it exists +if $INCLUDE_TASKS && [[ -f "$TASKS" ]]; then + docs+=("tasks.md") +fi + +# Output results if $JSON_MODE; then - printf '{"FEATURE_DIR":"%s","SPEC":"%s","PLAN":"%s","TASKS":"%s","BRANCH":"%s"}\n' \ - "$FEATURE_DIR" "$FEATURE_SPEC" "$IMPL_PLAN" "$TASKS" "$CURRENT_BRANCH" + # Build JSON array of documents + if has_jq; then + if [[ ${#docs[@]} -eq 0 ]]; then + json_docs="[]" + else + json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .) + fi + jq -cn \ + --arg feature_dir "$FEATURE_DIR" \ + --argjson docs "$json_docs" \ + '{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs}' + else + if [[ ${#docs[@]} -eq 0 ]]; then + json_docs="[]" + else + json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done) + json_docs="[${json_docs%,}]" + fi + printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$(json_escape "$FEATURE_DIR")" "$json_docs" + fi else - echo "FEATURE_DIR: $FEATURE_DIR" - echo "BRANCH: $CURRENT_BRANCH" - check_file "$FEATURE_SPEC" "spec.md" - check_file "$IMPL_PLAN" "plan.md" - check_file "$TASKS" "tasks.md" + # Text output + echo "FEATURE_DIR:$FEATURE_DIR" + echo "AVAILABLE_DOCS:" + + # Show status of each potential document + check_file "$RESEARCH" "research.md" + check_file "$DATA_MODEL" "data-model.md" + check_dir "$CONTRACTS_DIR" "contracts/" + check_file "$QUICKSTART" "quickstart.md" + + if $INCLUDE_TASKS; then + check_file "$TASKS" "tasks.md" + fi fi diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh old mode 100755 new mode 100644 index 54230771f6..03141e4462 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -1,47 +1,92 @@ #!/usr/bin/env bash -# Common functions for Speckit scripts +# Common functions and variables for all scripts -# Get repository root +# Find repository root by searching upward for .specify directory +# This is the primary marker for spec-kit projects +find_specify_root() { + local dir="${1:-$(pwd)}" + # Normalize to absolute path to prevent infinite loop with relative paths + # Use -- to handle paths starting with - (e.g., -P, -L) + dir="$(cd -- "$dir" 2>/dev/null && pwd)" || return 1 + local prev_dir="" + while true; do + if [ -d "$dir/.specify" ]; then + echo "$dir" + return 0 + fi + # Stop if we've reached filesystem root or dirname stops changing + if [ "$dir" = "/" ] || [ "$dir" = "$prev_dir" ]; then + break + fi + prev_dir="$dir" + dir="$(dirname "$dir")" + done + return 1 +} + +# Get repository root, prioritizing .specify directory over git +# This prevents using a parent git repo when spec-kit is initialized in a subdirectory get_repo_root() { + # First, look for .specify directory (spec-kit's own marker) + local specify_root + if specify_root=$(find_specify_root); then + echo "$specify_root" + return + fi + + # Fallback to git if no .specify found if git rev-parse --show-toplevel >/dev/null 2>&1; then git rev-parse --show-toplevel - else - local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - (cd "$script_dir/../../.." && pwd) + return fi + + # Final fallback to script location for non-git repos + local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + (cd "$script_dir/../../.." && pwd) } -# Get current branch +# Get current branch, with fallback for non-git repositories get_current_branch() { - # Check SPECIFY_FEATURE env var first + # First check if SPECIFY_FEATURE environment variable is set if [[ -n "${SPECIFY_FEATURE:-}" ]]; then echo "$SPECIFY_FEATURE" return fi - # Then check git - if git rev-parse --abbrev-ref HEAD >/dev/null 2>&1; then - git rev-parse --abbrev-ref HEAD + # Then check git if available at the spec-kit root (not parent) + local repo_root=$(get_repo_root) + if has_git; then + git -C "$repo_root" rev-parse --abbrev-ref HEAD return fi - # Fallback: find latest feature directory - local repo_root=$(get_repo_root) + # For non-git repos, try to find the latest feature directory local specs_dir="$repo_root/specs" if [[ -d "$specs_dir" ]]; then local latest_feature="" local highest=0 + local latest_timestamp="" for dir in "$specs_dir"/*; do if [[ -d "$dir" ]]; then local dirname=$(basename "$dir") - # Match issue-based naming: N-name or N-M-name (any number length) - if [[ "$dirname" =~ ^([0-9]+)(-[0-9]+)?- ]]; then + if [[ "$dirname" =~ ^([0-9]{8}-[0-9]{6})- ]]; then + # Timestamp-based branch: compare lexicographically + local ts="${BASH_REMATCH[1]}" + if [[ "$ts" > "$latest_timestamp" ]]; then + latest_timestamp="$ts" + latest_feature=$dirname + fi + elif [[ "$dirname" =~ ^([0-9]{3,})- ]]; then local number=${BASH_REMATCH[1]} + number=$((10#$number)) if [[ "$number" -gt "$highest" ]]; then highest=$number - latest_feature=$dirname + # Only update if no timestamp branch found yet + if [[ -z "$latest_timestamp" ]]; then + latest_feature=$dirname + fi fi fi fi @@ -53,49 +98,135 @@ get_current_branch() { fi fi - echo "main" + echo "main" # Final fallback } +# Check if we have git available at the spec-kit root level +# Returns true only if git is installed and the repo root is inside a git work tree +# Handles both regular repos (.git directory) and worktrees/submodules (.git file) has_git() { - git rev-parse --show-toplevel >/dev/null 2>&1 + # First check if git command is available (before calling get_repo_root which may use git) + command -v git >/dev/null 2>&1 || return 1 + local repo_root=$(get_repo_root) + # Check if .git exists (directory or file for worktrees/submodules) + [ -e "$repo_root/.git" ] || return 1 + # Verify it's actually a valid git work tree + git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1 +} + +# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name"). +# Only when the full name is exactly two slash-free segments; otherwise returns the raw name. +spec_kit_effective_branch_name() { + local raw="$1" + if [[ "$raw" =~ ^([^/]+)/([^/]+)$ ]]; then + printf '%s\n' "${BASH_REMATCH[2]}" + else + printf '%s\n' "$raw" + fi } -# Check if branch follows issue-based naming: N-name or N-M-name check_feature_branch() { - local branch="$1" + local raw="$1" local has_git_repo="$2" + # For non-git repos, we can't enforce branch naming but still provide output if [[ "$has_git_repo" != "true" ]]; then - echo "[speckit] Warning: Git not detected; skipped branch validation" >&2 + echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2 return 0 fi - # Match: 1223-name or 1225-1-name (issue number, optional sub-issue, then name) - if [[ ! "$branch" =~ ^[0-9]+(-[0-9]+)?- ]]; then - echo "ERROR: Not on a feature branch. Current branch: $branch" >&2 - echo "Feature branches should be named like: 1223-feature-name or 1225-1-sub-feature" >&2 + local branch + branch=$(spec_kit_effective_branch_name "$raw") + + # Accept sequential prefix (3+ digits) but exclude malformed timestamps + # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022") + local is_sequential=false + if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then + is_sequential=true + fi + if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then + echo "ERROR: Not on a feature branch. Current branch: $raw" >&2 + echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2 return 1 fi return 0 } -# Find feature directory by issue number prefix +# Safely read .specify/feature.json's "feature_directory" value. +# Prints the raw value (possibly relative) to stdout, or empty string if the file +# is missing, unparseable, or does not contain the key. Always returns 0 so callers +# under `set -e` cannot be aborted by parser failure. +# Parser order mirrors the historical get_feature_paths behavior: jq -> python3 -> grep/sed. +read_feature_json_feature_directory() { + local repo_root="$1" + local fj="$repo_root/.specify/feature.json" + [[ -f "$fj" ]] || { printf '%s' ''; return 0; } + + local _fd='' + if command -v jq >/dev/null 2>&1; then + if ! _fd=$(jq -r '.feature_directory // empty' "$fj" 2>/dev/null); then + _fd='' + fi + elif command -v python3 >/dev/null 2>&1; then + # Use Python so pretty-printed/multi-line JSON still parses correctly. + if ! _fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); v=d.get('feature_directory'); print(v if v else '')" "$fj" 2>/dev/null); then + _fd='' + fi + else + # Last-resort single-line grep/sed fallback. The `|| true` guards against + # grep returning 1 (no match) aborting under `set -e` / `pipefail`. + _fd=$( { grep -E '"feature_directory"[[:space:]]*:' "$fj" 2>/dev/null || true; } \ + | head -n 1 \ + | sed -E 's/^[^:]*:[[:space:]]*"([^"]*)".*$/\1/' ) + fi + + printf '%s' "$_fd" + return 0 +} + +# Returns 0 when .specify/feature.json lists feature_directory that exists as a directory +# and matches the resolved active FEATURE_DIR (so /speckit.plan can skip git branch pattern checks). +# Delegates parsing to read_feature_json_feature_directory, which is safe under `set -e`. +feature_json_matches_feature_dir() { + local repo_root="$1" + local active_feature_dir="$2" + + local _fd + _fd=$(read_feature_json_feature_directory "$repo_root") + + [[ -n "$_fd" ]] || return 1 + [[ "$_fd" != /* ]] && _fd="$repo_root/$_fd" + [[ -d "$_fd" ]] || return 1 + + local norm_json norm_active + norm_json="$(cd -- "$_fd" 2>/dev/null && pwd -P)" || return 1 + norm_active="$(cd -- "$active_feature_dir" 2>/dev/null && pwd -P)" || return 1 + + [[ "$norm_json" == "$norm_active" ]] +} + +# Find feature directory by numeric prefix instead of exact branch match +# This allows multiple branches to work on the same spec (e.g., 004-fix-bug, 004-add-feature) find_feature_dir_by_prefix() { local repo_root="$1" - local branch_name="$2" + local branch_name + branch_name=$(spec_kit_effective_branch_name "$2") local specs_dir="$repo_root/specs" - # Extract issue prefix: N or N-M from branch name - if [[ ! "$branch_name" =~ ^([0-9]+(-[0-9]+)?)-(.+)$ ]]; then - # No numeric prefix, fall back to exact match + # Extract prefix from branch (e.g., "004" from "004-whatever" or "20260319-143022" from timestamp branches) + local prefix="" + if [[ "$branch_name" =~ ^([0-9]{8}-[0-9]{6})- ]]; then + prefix="${BASH_REMATCH[1]}" + elif [[ "$branch_name" =~ ^([0-9]{3,})- ]]; then + prefix="${BASH_REMATCH[1]}" + else + # If branch doesn't have a recognized prefix, fall back to exact match echo "$specs_dir/$branch_name" return fi - local prefix="${BASH_REMATCH[1]}" - - # Search for matching directories + # Search for directories in specs/ that start with this prefix local matches=() if [[ -d "$specs_dir" ]]; then for dir in "$specs_dir"/"$prefix"-*; do @@ -105,13 +236,18 @@ find_feature_dir_by_prefix() { done fi + # Handle results if [[ ${#matches[@]} -eq 0 ]]; then + # No match found - return the branch name path (will fail later with clear error) echo "$specs_dir/$branch_name" elif [[ ${#matches[@]} -eq 1 ]]; then + # Exactly one match - perfect! echo "$specs_dir/${matches[0]}" else + # Multiple matches - this shouldn't happen with proper naming convention echo "ERROR: Multiple spec directories found with prefix '$prefix': ${matches[*]}" >&2 - echo "$specs_dir/$branch_name" + echo "Please ensure only one spec directory exists per prefix." >&2 + return 1 fi } @@ -119,20 +255,391 @@ get_feature_paths() { local repo_root=$(get_repo_root) local current_branch=$(get_current_branch) local has_git_repo="false" - has_git && has_git_repo="true" - - local feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch") - - cat < python3 -> grep/sed. Returns empty on + # missing/unparseable/unset so we fall through to the branch-prefix lookup. + local _fd + _fd=$(read_feature_json_feature_directory "$repo_root") + if [[ -n "$_fd" ]]; then + feature_dir="$_fd" + # Normalize relative paths to absolute under repo root + [[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir" + elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then + echo "ERROR: Failed to resolve feature directory" >&2 + return 1 + fi + elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then + echo "ERROR: Failed to resolve feature directory" >&2 + return 1 + fi + + # Use printf '%q' to safely quote values, preventing shell injection + # via crafted branch names or paths containing special characters + printf 'REPO_ROOT=%q\n' "$repo_root" + printf 'CURRENT_BRANCH=%q\n' "$current_branch" + printf 'HAS_GIT=%q\n' "$has_git_repo" + printf 'FEATURE_DIR=%q\n' "$feature_dir" + printf 'FEATURE_SPEC=%q\n' "$feature_dir/spec.md" + printf 'IMPL_PLAN=%q\n' "$feature_dir/plan.md" + printf 'TASKS=%q\n' "$feature_dir/tasks.md" + printf 'RESEARCH=%q\n' "$feature_dir/research.md" + printf 'DATA_MODEL=%q\n' "$feature_dir/data-model.md" + printf 'QUICKSTART=%q\n' "$feature_dir/quickstart.md" + printf 'CONTRACTS_DIR=%q\n' "$feature_dir/contracts" +} + +# Check if jq is available for safe JSON construction +has_jq() { + command -v jq >/dev/null 2>&1 +} + +# Escape a string for safe embedding in a JSON value (fallback when jq is unavailable). +# Handles backslash, double-quote, and JSON-required control character escapes (RFC 8259). +json_escape() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + s="${s//$'\n'/\\n}" + s="${s//$'\t'/\\t}" + s="${s//$'\r'/\\r}" + s="${s//$'\b'/\\b}" + s="${s//$'\f'/\\f}" + # Escape any remaining U+0001-U+001F control characters as \uXXXX. + # (U+0000/NUL cannot appear in bash strings and is excluded.) + # LC_ALL=C ensures ${#s} counts bytes and ${s:$i:1} yields single bytes, + # so multi-byte UTF-8 sequences (first byte >= 0xC0) pass through intact. + local LC_ALL=C + local i char code + for (( i=0; i<${#s}; i++ )); do + char="${s:$i:1}" + printf -v code '%d' "'$char" 2>/dev/null || code=256 + if (( code >= 1 && code <= 31 )); then + printf '\\u%04x' "$code" + else + printf '%s' "$char" + fi + done } check_file() { [[ -f "$1" ]] && echo " āœ“ $2" || echo " āœ— $2"; } check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " āœ“ $2" || echo " āœ— $2"; } + +# Resolve a template name to a file path using the priority stack: +# 1. .specify/templates/overrides/ +# 2. .specify/presets//templates/ (sorted by priority from .registry) +# 3. .specify/extensions//templates/ +# 4. .specify/templates/ (core) +resolve_template() { + local template_name="$1" + local repo_root="$2" + local base="$repo_root/.specify/templates" + + # Priority 1: Project overrides + local override="$base/overrides/${template_name}.md" + [ -f "$override" ] && echo "$override" && return 0 + + # Priority 2: Installed presets (sorted by priority from .registry) + local presets_dir="$repo_root/.specify/presets" + if [ -d "$presets_dir" ]; then + local registry_file="$presets_dir/.registry" + if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then + # Read preset IDs sorted by priority (lower number = higher precedence). + # The python3 call is wrapped in an if-condition so that set -e does not + # abort the function when python3 exits non-zero (e.g. invalid JSON). + local sorted_presets="" + if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c " +import json, sys, os +try: + with open(os.environ['SPECKIT_REGISTRY']) as f: + data = json.load(f) + presets = data.get('presets', {}) + for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10): + if isinstance(meta, dict) and meta.get('enabled', True) is not False: + print(pid) +except Exception: + sys.exit(1) +" 2>/dev/null); then + if [ -n "$sorted_presets" ]; then + # python3 succeeded and returned preset IDs — search in priority order + while IFS= read -r preset_id; do + local candidate="$presets_dir/$preset_id/templates/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 + done <<< "$sorted_presets" + fi + # python3 succeeded but registry has no presets — nothing to search + else + # python3 failed (missing, or registry parse error) — fall back to unordered directory scan + for preset in "$presets_dir"/*/; do + [ -d "$preset" ] || continue + local candidate="$preset/templates/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 + done + fi + else + # Fallback: alphabetical directory order (no python3 available) + for preset in "$presets_dir"/*/; do + [ -d "$preset" ] || continue + local candidate="$preset/templates/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 + done + fi + fi + + # Priority 3: Extension-provided templates + local ext_dir="$repo_root/.specify/extensions" + if [ -d "$ext_dir" ]; then + for ext in "$ext_dir"/*/; do + [ -d "$ext" ] || continue + # Skip hidden directories (e.g. .backup, .cache) + case "$(basename "$ext")" in .*) continue;; esac + local candidate="$ext/templates/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 + done + fi + + # Priority 4: Core templates + local core="$base/${template_name}.md" + [ -f "$core" ] && echo "$core" && return 0 + + # Template not found in any location. + # Return 1 so callers can distinguish "not found" from "found". + # Callers running under set -e should use: TEMPLATE=$(resolve_template ...) || true + return 1 +} + +# Resolve a template name to composed content using composition strategies. +# Reads strategy metadata from preset manifests and composes content +# from multiple layers using prepend, append, or wrap strategies. +# +# Usage: CONTENT=$(resolve_template_content "template-name" "$REPO_ROOT") +# Returns composed content string on stdout; exit code 1 if not found. +resolve_template_content() { + local template_name="$1" + local repo_root="$2" + local base="$repo_root/.specify/templates" + + # Collect all layers (highest priority first) + local -a layer_paths=() + local -a layer_strategies=() + + # Priority 1: Project overrides (always "replace") + local override="$base/overrides/${template_name}.md" + if [ -f "$override" ]; then + layer_paths+=("$override") + layer_strategies+=("replace") + fi + + # Priority 2: Installed presets (sorted by priority from .registry) + local presets_dir="$repo_root/.specify/presets" + if [ -d "$presets_dir" ]; then + local registry_file="$presets_dir/.registry" + local sorted_presets="" + if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then + if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c " +import json, sys, os +try: + with open(os.environ['SPECKIT_REGISTRY']) as f: + data = json.load(f) + presets = data.get('presets', {}) + for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10): + if isinstance(meta, dict) and meta.get('enabled', True) is not False: + print(pid) +except Exception: + sys.exit(1) +" 2>/dev/null); then + if [ -n "$sorted_presets" ]; then + local yaml_warned=false + while IFS= read -r preset_id; do + # Read strategy and file path from preset manifest + local strategy="replace" + local manifest_file="" + local manifest="$presets_dir/$preset_id/preset.yml" + if [ -f "$manifest" ] && command -v python3 >/dev/null 2>&1; then + # Requires PyYAML; falls back to replace/convention if unavailable + local result + local py_stderr + py_stderr=$(mktemp) + result=$(SPECKIT_MANIFEST="$manifest" SPECKIT_TMPL="$template_name" python3 -c " +import sys, os +try: + import yaml +except ImportError: + print('yaml_missing', file=sys.stderr) + print('replace\t') + sys.exit(0) +try: + with open(os.environ['SPECKIT_MANIFEST']) as f: + data = yaml.safe_load(f) + for t in data.get('provides', {}).get('templates', []): + if t.get('name') == os.environ['SPECKIT_TMPL'] and t.get('type', 'template') == 'template': + print(t.get('strategy', 'replace') + '\t' + t.get('file', '')) + sys.exit(0) + print('replace\t') +except Exception: + print('replace\t') +" 2>"$py_stderr") + local parse_status=$? + if [ $parse_status -eq 0 ] && [ -n "$result" ]; then + IFS=$'\t' read -r strategy manifest_file <<< "$result" + strategy=$(printf '%s' "$strategy" | tr '[:upper:]' '[:lower:]') + fi + if [ "$yaml_warned" = false ] && grep -q 'yaml_missing' "$py_stderr" 2>/dev/null; then + echo "Warning: PyYAML not available; composition strategies may be ignored" >&2 + yaml_warned=true + fi + rm -f "$py_stderr" + fi + # Try manifest file path first, then convention path + local candidate="" + if [ -n "$manifest_file" ]; then + # Reject absolute paths and parent traversal + case "$manifest_file" in + /*|*../*|../*) manifest_file="" ;; + esac + fi + if [ -n "$manifest_file" ]; then + local mf="$presets_dir/$preset_id/$manifest_file" + [ -f "$mf" ] && candidate="$mf" + fi + if [ -z "$candidate" ]; then + local cf="$presets_dir/$preset_id/templates/${template_name}.md" + [ -f "$cf" ] && candidate="$cf" + fi + if [ -n "$candidate" ]; then + layer_paths+=("$candidate") + layer_strategies+=("$strategy") + fi + done <<< "$sorted_presets" + fi + else + # python3 failed — fall back to unordered directory scan (replace only) + for preset in "$presets_dir"/*/; do + [ -d "$preset" ] || continue + local candidate="$preset/templates/${template_name}.md" + if [ -f "$candidate" ]; then + layer_paths+=("$candidate") + layer_strategies+=("replace") + fi + done + fi + else + # No python3 or registry — fall back to unordered directory scan (replace only) + for preset in "$presets_dir"/*/; do + [ -d "$preset" ] || continue + local candidate="$preset/templates/${template_name}.md" + if [ -f "$candidate" ]; then + layer_paths+=("$candidate") + layer_strategies+=("replace") + fi + done + fi + fi + + # Priority 3: Extension-provided templates (always "replace") + local ext_dir="$repo_root/.specify/extensions" + if [ -d "$ext_dir" ]; then + for ext in "$ext_dir"/*/; do + [ -d "$ext" ] || continue + case "$(basename "$ext")" in .*) continue;; esac + local candidate="$ext/templates/${template_name}.md" + if [ -f "$candidate" ]; then + layer_paths+=("$candidate") + layer_strategies+=("replace") + fi + done + fi + + # Priority 4: Core templates (always "replace") + local core="$base/${template_name}.md" + if [ -f "$core" ]; then + layer_paths+=("$core") + layer_strategies+=("replace") + fi + + local count=${#layer_paths[@]} + [ "$count" -eq 0 ] && return 1 + + # Check if any layer uses a non-replace strategy + local has_composition=false + for s in "${layer_strategies[@]}"; do + [ "$s" != "replace" ] && has_composition=true && break + done + + # If the top (highest-priority) layer is replace, it wins entirely — + # lower layers are irrelevant regardless of their strategies. + if [ "${layer_strategies[0]}" = "replace" ]; then + cat "${layer_paths[0]}" + return 0 + fi + + if [ "$has_composition" = false ]; then + cat "${layer_paths[0]}" + return 0 + fi + + # Find the effective base: scan from highest priority (index 0) downward + # to find the nearest replace layer. Only compose layers above that base. + local base_idx=-1 + local i + for (( i=0; i=0; i-- )); do + local path="${layer_paths[$i]}" + local strat="${layer_strategies[$i]}" + local layer_content + # Preserve trailing newlines + layer_content=$(cat "$path"; printf x) + layer_content="${layer_content%x}" + + case "$strat" in + replace) content="$layer_content" ;; + prepend) content="$(printf '%s\n\n%s' "$layer_content" "$content")" ;; + append) content="$(printf '%s\n\n%s' "$content" "$layer_content")" ;; + wrap) + case "$layer_content" in + *'{CORE_TEMPLATE}'*) ;; + *) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 1 ;; + esac + while [[ "$layer_content" == *'{CORE_TEMPLATE}'* ]]; do + local before="${layer_content%%\{CORE_TEMPLATE\}*}" + local after="${layer_content#*\{CORE_TEMPLATE\}}" + layer_content="${before}${content}${after}" + done + content="$layer_content" + ;; + *) echo "Error: unknown strategy '$strat'" >&2; return 1 ;; + esac + done + + printf '%s' "$content" + return 0 +} + diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh old mode 100755 new mode 100644 index d249370143..c3537704f6 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -3,12 +3,12 @@ set -e JSON_MODE=false +DRY_RUN=false +ALLOW_EXISTING=false SHORT_NAME="" -ISSUE_NUMBER="" -SUB_ISSUE="" +BRANCH_NUMBER="" +USE_TIMESTAMP=false ARGS=() - -# Parse arguments i=1 while [ $i -le $# ]; do arg="${!i}" @@ -16,31 +16,58 @@ while [ $i -le $# ]; do --json) JSON_MODE=true ;; + --dry-run) + DRY_RUN=true + ;; + --allow-existing-branch) + ALLOW_EXISTING=true + ;; --short-name) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --short-name requires a value' >&2 + exit 1 + fi i=$((i + 1)) - SHORT_NAME="${!i}" + next_arg="${!i}" + # Check if the next argument is another option (starts with --) + if [[ "$next_arg" == --* ]]; then + echo 'Error: --short-name requires a value' >&2 + exit 1 + fi + SHORT_NAME="$next_arg" ;; - --issue) + --number) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --number requires a value' >&2 + exit 1 + fi i=$((i + 1)) - ISSUE_NUMBER="${!i}" + next_arg="${!i}" + if [[ "$next_arg" == --* ]]; then + echo 'Error: --number requires a value' >&2 + exit 1 + fi + BRANCH_NUMBER="$next_arg" ;; - --sub) - i=$((i + 1)) - SUB_ISSUE="${!i}" + --timestamp) + USE_TIMESTAMP=true ;; --help|-h) - echo "Usage: $0 [--json] --issue [--sub ] --short-name " + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " echo "" echo "Options:" echo " --json Output in JSON format" - echo " --issue GitHub issue number (required)" - echo " --sub Sub-issue number for child issues (e.g., 1225-1)" - echo " --short-name Short name for branch (required)" + echo " --dry-run Compute branch name and paths without creating branches, directories, or files" + echo " --allow-existing-branch Switch to branch if it already exists instead of failing" + echo " --short-name Provide a custom short name (2-4 words) for the branch" + echo " --number N Specify branch number manually (overrides auto-detection)" + echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" echo " --help, -h Show this help message" echo "" echo "Examples:" - echo " $0 --issue 1223 --short-name 'skylight-optimization' 'Optimize Skylight APM costs'" - echo " $0 --issue 1225 --sub 1 --short-name 'user-model' 'Create user model for auth feature'" + echo " $0 'Add user authentication system' --short-name 'user-auth'" + echo " $0 'Implement OAuth2 integration for API' --number 5" + echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'" exit 0 ;; *) @@ -51,106 +78,336 @@ while [ $i -le $# ]; do done FEATURE_DESCRIPTION="${ARGS[*]}" - -# Validate required arguments -if [ -z "$ISSUE_NUMBER" ]; then - echo "Error: --issue is required (GitHub issue number)" >&2 +if [ -z "$FEATURE_DESCRIPTION" ]; then + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " >&2 exit 1 fi -if [ -z "$SHORT_NAME" ]; then - echo "Error: --short-name is required" >&2 +# Trim whitespace and validate description is not empty (e.g., user passed only whitespace) +FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g') +if [ -z "$FEATURE_DESCRIPTION" ]; then + echo "Error: Feature description cannot be empty or contain only whitespace" >&2 exit 1 fi -# Function to find the repository root -find_repo_root() { - local dir="$1" - while [ "$dir" != "/" ]; do - if [ -d "$dir/.git" ] || [ -d "$dir/.specify" ]; then - echo "$dir" - return 0 +# Function to get highest number from specs directory +get_highest_from_specs() { + local specs_dir="$1" + local highest=0 + + if [ -d "$specs_dir" ]; then + for dir in "$specs_dir"/*; do + [ -d "$dir" ] || continue + dirname=$(basename "$dir") + # Match sequential prefixes (>=3 digits), but skip timestamp dirs. + if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then + number=$(echo "$dirname" | grep -Eo '^[0-9]+') + number=$((10#$number)) + if [ "$number" -gt "$highest" ]; then + highest=$number + fi + fi + done + fi + + echo "$highest" +} + +# Function to get highest number from git branches +get_highest_from_branches() { + git branch -a 2>/dev/null | sed 's/^[* ]*//; s|^remotes/[^/]*/||' | _extract_highest_number +} + +# Extract the highest sequential feature number from a list of ref names (one per line). +# Shared by get_highest_from_branches and get_highest_from_remote_refs. +_extract_highest_number() { + local highest=0 + while IFS= read -r name; do + [ -z "$name" ] && continue + if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then + number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0") + number=$((10#$number)) + if [ "$number" -gt "$highest" ]; then + highest=$number + fi fi - dir="$(dirname "$dir")" done - return 1 + echo "$highest" } -# Function to clean branch name +# Function to get highest number from remote branches without fetching (side-effect-free) +get_highest_from_remote_refs() { + local highest=0 + + for remote in $(git remote 2>/dev/null); do + local remote_highest + remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number) + if [ "$remote_highest" -gt "$highest" ]; then + highest=$remote_highest + fi + done + + echo "$highest" +} + +# Function to check existing branches (local and remote) and return next available number. +# When skip_fetch is true, queries remotes via ls-remote (read-only) instead of fetching. +check_existing_branches() { + local specs_dir="$1" + local skip_fetch="${2:-false}" + + if [ "$skip_fetch" = true ]; then + # Side-effect-free: query remotes via ls-remote + local highest_remote=$(get_highest_from_remote_refs) + local highest_branch=$(get_highest_from_branches) + if [ "$highest_remote" -gt "$highest_branch" ]; then + highest_branch=$highest_remote + fi + else + # Fetch all remotes to get latest branch info (suppress errors if no remotes) + git fetch --all --prune >/dev/null 2>&1 || true + local highest_branch=$(get_highest_from_branches) + fi + + # Get highest number from ALL specs (not just matching short name) + local highest_spec=$(get_highest_from_specs "$specs_dir") + + # Take the maximum of both + local max_num=$highest_branch + if [ "$highest_spec" -gt "$max_num" ]; then + max_num=$highest_spec + fi + + # Return next number + echo $((max_num + 1)) +} + +# Function to clean and format a branch name clean_branch_name() { local name="$1" echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//' } -# Resolve repository root +# Resolve repository root using common.sh functions which prioritize .specify over git SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" -if git rev-parse --show-toplevel >/dev/null 2>&1; then - REPO_ROOT=$(git rev-parse --show-toplevel) +REPO_ROOT=$(get_repo_root) + +# Check if git is available at this repo root (not a parent) +if has_git; then HAS_GIT=true else - REPO_ROOT="$(find_repo_root "$SCRIPT_DIR")" - if [ -z "$REPO_ROOT" ]; then - echo "Error: Could not determine repository root." >&2 - exit 1 - fi HAS_GIT=false fi cd "$REPO_ROOT" SPECS_DIR="$REPO_ROOT/specs" -mkdir -p "$SPECS_DIR" +if [ "$DRY_RUN" != true ]; then + mkdir -p "$SPECS_DIR" +fi -# Clean short name -BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME") +# Function to generate branch name with stop word filtering and length filtering +generate_branch_name() { + local description="$1" + + # Common stop words to filter out + 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)$" + + # Convert to lowercase and split into words + local clean_name=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g') + + # Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original) + local meaningful_words=() + for word in $clean_name; do + # Skip empty words + [ -z "$word" ] && continue + + # Keep words that are NOT stop words AND (length >= 3 OR are potential acronyms) + if ! echo "$word" | grep -qiE "$stop_words"; then + if [ ${#word} -ge 3 ]; then + meaningful_words+=("$word") + elif echo "$description" | grep -q "\b${word^^}\b"; then + # Keep short words if they appear as uppercase in original (likely acronyms) + meaningful_words+=("$word") + fi + fi + done + + # If we have meaningful words, use first 3-4 of them + if [ ${#meaningful_words[@]} -gt 0 ]; then + local max_words=3 + if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi + + local result="" + local count=0 + for word in "${meaningful_words[@]}"; do + if [ $count -ge $max_words ]; then break; fi + if [ -n "$result" ]; then result="$result-"; fi + result="$result$word" + count=$((count + 1)) + done + echo "$result" + else + # Fallback to original logic if no meaningful words found + local cleaned=$(clean_branch_name "$description") + echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//' + fi +} -# Build branch name: issue-shortname or issue-sub-shortname -if [ -n "$SUB_ISSUE" ]; then - BRANCH_NAME="${ISSUE_NUMBER}-${SUB_ISSUE}-${BRANCH_SUFFIX}" +# Generate branch name +if [ -n "$SHORT_NAME" ]; then + # Use provided short name, just clean it up + BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME") else - BRANCH_NAME="${ISSUE_NUMBER}-${BRANCH_SUFFIX}" + # Generate from description with smart filtering + BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION") +fi + +# Warn if --number and --timestamp are both specified +if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then + >&2 echo "[specify] Warning: --number is ignored when --timestamp is used" + BRANCH_NUMBER="" fi -# GitHub enforces 244-byte limit +# Determine branch prefix +if [ "$USE_TIMESTAMP" = true ]; then + FEATURE_NUM=$(date +%Y%m%d-%H%M%S) + BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" +else + # Determine branch number + if [ -z "$BRANCH_NUMBER" ]; then + if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then + # Dry-run: query remotes via ls-remote (side-effect-free, no fetch) + BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true) + elif [ "$DRY_RUN" = true ]; then + # Dry-run without git: local spec dirs only + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") + BRANCH_NUMBER=$((HIGHEST + 1)) + elif [ "$HAS_GIT" = true ]; then + # Check existing branches on remotes + BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR") + else + # Fall back to local directory check + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") + BRANCH_NUMBER=$((HIGHEST + 1)) + fi + fi + + # Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal) + FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))") + BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" +fi + +# GitHub enforces a 244-byte limit on branch names +# Validate and truncate if necessary MAX_BRANCH_LENGTH=244 if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then - >&2 echo "Warning: Branch name exceeds 244 bytes, truncating..." - BRANCH_NAME="${BRANCH_NAME:0:$MAX_BRANCH_LENGTH}" + # Calculate how much we need to trim from suffix + # Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4 + PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 )) + MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH)) + + # Truncate suffix at word boundary if possible + TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH) + # Remove trailing hyphen if truncation created one + TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//') + + ORIGINAL_BRANCH_NAME="$BRANCH_NAME" + BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}" + + >&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit" + >&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)" + >&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)" fi -# Create branch if git available -if [ "$HAS_GIT" = true ]; then - # Check if branch already exists - if git show-ref --verify --quiet "refs/heads/$BRANCH_NAME" 2>/dev/null; then - git checkout "$BRANCH_NAME" - >&2 echo "Switched to existing branch: $BRANCH_NAME" +FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME" +SPEC_FILE="$FEATURE_DIR/spec.md" + +if [ "$DRY_RUN" != true ]; then + if [ "$HAS_GIT" = true ]; then + branch_create_error="" + if ! branch_create_error=$(git checkout -q -b "$BRANCH_NAME" 2>&1); then + current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + # Check if branch already exists + if git branch --list "$BRANCH_NAME" | grep -q .; then + if [ "$ALLOW_EXISTING" = true ]; then + # If we're already on the branch, continue without another checkout. + if [ "$current_branch" = "$BRANCH_NAME" ]; then + : + # Otherwise switch to the existing branch instead of failing. + elif ! switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1); then + >&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again." + if [ -n "$switch_branch_error" ]; then + >&2 printf '%s\n' "$switch_branch_error" + fi + exit 1 + fi + elif [ "$USE_TIMESTAMP" = true ]; then + >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name." + exit 1 + else + >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number." + exit 1 + fi + else + >&2 echo "Error: Failed to create git branch '$BRANCH_NAME'." + if [ -n "$branch_create_error" ]; then + >&2 printf '%s\n' "$branch_create_error" + else + >&2 echo "Please check your git configuration and try again." + fi + exit 1 + fi + fi else - git checkout -b "$BRANCH_NAME" - >&2 echo "Created new branch: $BRANCH_NAME" + >&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME" fi -else - >&2 echo "Warning: Git not detected; skipped branch creation" -fi -# Create feature directory and spec file -FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME" -mkdir -p "$FEATURE_DIR" + mkdir -p "$FEATURE_DIR" -TEMPLATE="$REPO_ROOT/.specify/templates/spec-template.md" -SPEC_FILE="$FEATURE_DIR/spec.md" -if [ -f "$TEMPLATE" ]; then cp "$TEMPLATE" "$SPEC_FILE"; else touch "$SPEC_FILE"; fi + if [ ! -f "$SPEC_FILE" ]; then + TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true + if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then + cp "$TEMPLATE" "$SPEC_FILE" + else + echo "Warning: Spec template not found; created empty spec file" >&2 + touch "$SPEC_FILE" + fi + fi -# Export for session -export SPECIFY_FEATURE="$BRANCH_NAME" + # Inform the user how to persist the feature variable in their own shell + printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2 +fi -# Output if $JSON_MODE; then - printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","ISSUE":"%s","SUB":"%s"}\n' \ - "$BRANCH_NAME" "$SPEC_FILE" "$ISSUE_NUMBER" "$SUB_ISSUE" + if command -v jq >/dev/null 2>&1; then + if [ "$DRY_RUN" = true ]; then + jq -cn \ + --arg branch_name "$BRANCH_NAME" \ + --arg spec_file "$SPEC_FILE" \ + --arg feature_num "$FEATURE_NUM" \ + '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num,DRY_RUN:true}' + else + jq -cn \ + --arg branch_name "$BRANCH_NAME" \ + --arg spec_file "$SPEC_FILE" \ + --arg feature_num "$FEATURE_NUM" \ + '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}' + fi + else + if [ "$DRY_RUN" = true ]; then + printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")" + else + printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")" + fi + fi else echo "BRANCH_NAME: $BRANCH_NAME" echo "SPEC_FILE: $SPEC_FILE" - echo "ISSUE: $ISSUE_NUMBER" - [ -n "$SUB_ISSUE" ] && echo "SUB: $SUB_ISSUE" + echo "FEATURE_NUM: $FEATURE_NUM" + if [ "$DRY_RUN" != true ]; then + printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" + fi fi diff --git a/scripts/bash/setup-plan.sh b/scripts/bash/setup-plan.sh index d01c6d6cb5..f2d2f6e6fc 100755 --- a/scripts/bash/setup-plan.sh +++ b/scripts/bash/setup-plan.sh @@ -28,29 +28,43 @@ SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/common.sh" # Get all paths and variables from common functions -eval $(get_feature_paths) +_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } +eval "$_paths_output" +unset _paths_output -# Check if we're on a proper feature branch (only for git repos) -check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1 +# If feature.json pins an existing feature directory, branch naming is not required. +if ! feature_json_matches_feature_dir "$REPO_ROOT" "$FEATURE_DIR"; then + check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1 +fi # Ensure the feature directory exists mkdir -p "$FEATURE_DIR" # Copy plan template if it exists -TEMPLATE="$REPO_ROOT/.specify/templates/plan-template.md" -if [[ -f "$TEMPLATE" ]]; then +TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT") || true +if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then cp "$TEMPLATE" "$IMPL_PLAN" echo "Copied plan template to $IMPL_PLAN" else - echo "Warning: Plan template not found at $TEMPLATE" + echo "Warning: Plan template not found" # Create a basic plan file if template doesn't exist touch "$IMPL_PLAN" fi # Output results if $JSON_MODE; then - printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s","HAS_GIT":"%s"}\n' \ - "$FEATURE_SPEC" "$IMPL_PLAN" "$FEATURE_DIR" "$CURRENT_BRANCH" "$HAS_GIT" + if has_jq; then + jq -cn \ + --arg feature_spec "$FEATURE_SPEC" \ + --arg impl_plan "$IMPL_PLAN" \ + --arg specs_dir "$FEATURE_DIR" \ + --arg branch "$CURRENT_BRANCH" \ + --arg has_git "$HAS_GIT" \ + '{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,SPECS_DIR:$specs_dir,BRANCH:$branch,HAS_GIT:$has_git}' + else + printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s","HAS_GIT":"%s"}\n' \ + "$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$HAS_GIT")" + fi else echo "FEATURE_SPEC: $FEATURE_SPEC" echo "IMPL_PLAN: $IMPL_PLAN" diff --git a/scripts/bash/update-agent-context.sh b/scripts/bash/update-agent-context.sh deleted file mode 100755 index 5b19abf659..0000000000 --- a/scripts/bash/update-agent-context.sh +++ /dev/null @@ -1,808 +0,0 @@ -#!/usr/bin/env bash - -# Update agent context files with information from plan.md -# -# This script maintains AI agent context files by parsing feature specifications -# and updating agent-specific configuration files with project information. -# -# MAIN FUNCTIONS: -# 1. Environment Validation -# - Verifies git repository structure and branch information -# - Checks for required plan.md files and templates -# - Validates file permissions and accessibility -# -# 2. Plan Data Extraction -# - Parses plan.md files to extract project metadata -# - Identifies language/version, frameworks, databases, and project types -# - Handles missing or incomplete specification data gracefully -# -# 3. Agent File Management -# - Creates new agent context files from templates when needed -# - Updates existing agent files with new project information -# - Preserves manual additions and custom configurations -# - Supports multiple AI agent formats and directory structures -# -# 4. Content Generation -# - Generates language-specific build/test commands -# - Creates appropriate project directory structures -# - Updates technology stacks and recent changes sections -# - Maintains consistent formatting and timestamps -# -# 5. Multi-Agent Support -# - Handles agent-specific file paths and naming conventions -# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Amazon Q Developer CLI, or Antigravity -# - Can update single agents or all existing agent files -# - Creates default Claude file if no agent files exist -# -# Usage: ./update-agent-context.sh [agent_type] -# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|shai|q|agy|bob|qoder -# Leave empty to update all existing agent files - -set -e - -# Enable strict error handling -set -u -set -o pipefail - -#============================================================================== -# Configuration and Global Variables -#============================================================================== - -# Get script directory and load common functions -SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" - -# Get all paths and variables from common functions -eval $(get_feature_paths) - -NEW_PLAN="$IMPL_PLAN" # Alias for compatibility with existing code -AGENT_TYPE="${1:-}" - -# Agent-specific file paths -CLAUDE_FILE="$REPO_ROOT/CLAUDE.md" -GEMINI_FILE="$REPO_ROOT/GEMINI.md" -COPILOT_FILE="$REPO_ROOT/.github/agents/copilot-instructions.md" -CURSOR_FILE="$REPO_ROOT/.cursor/rules/specify-rules.mdc" -QWEN_FILE="$REPO_ROOT/QWEN.md" -AGENTS_FILE="$REPO_ROOT/AGENTS.md" -WINDSURF_FILE="$REPO_ROOT/.windsurf/rules/specify-rules.md" -KILOCODE_FILE="$REPO_ROOT/.kilocode/rules/specify-rules.md" -AUGGIE_FILE="$REPO_ROOT/.augment/rules/specify-rules.md" -ROO_FILE="$REPO_ROOT/.roo/rules/specify-rules.md" -CODEBUDDY_FILE="$REPO_ROOT/CODEBUDDY.md" -QODER_FILE="$REPO_ROOT/QODER.md" -AMP_FILE="$REPO_ROOT/AGENTS.md" -SHAI_FILE="$REPO_ROOT/SHAI.md" -Q_FILE="$REPO_ROOT/AGENTS.md" -AGY_FILE="$REPO_ROOT/.agent/rules/specify-rules.md" -BOB_FILE="$REPO_ROOT/AGENTS.md" - -# Template file -TEMPLATE_FILE="$REPO_ROOT/.specify/templates/agent-file-template.md" - -# Global variables for parsed plan data -NEW_LANG="" -NEW_FRAMEWORK="" -NEW_DB="" -NEW_PROJECT_TYPE="" - -#============================================================================== -# Utility Functions -#============================================================================== - -log_info() { - echo "INFO: $1" -} - -log_success() { - echo "āœ“ $1" -} - -log_error() { - echo "ERROR: $1" >&2 -} - -log_warning() { - echo "WARNING: $1" >&2 -} - -# Cleanup function for temporary files -cleanup() { - local exit_code=$? - rm -f /tmp/agent_update_*_$$ - rm -f /tmp/manual_additions_$$ - exit $exit_code -} - -# Set up cleanup trap -trap cleanup EXIT INT TERM - -#============================================================================== -# Validation Functions -#============================================================================== - -validate_environment() { - # Check if we have a current branch/feature (git or non-git) - if [[ -z "$CURRENT_BRANCH" ]]; then - log_error "Unable to determine current feature" - if [[ "$HAS_GIT" == "true" ]]; then - log_info "Make sure you're on a feature branch" - else - log_info "Set SPECIFY_FEATURE environment variable or create a feature first" - fi - exit 1 - fi - - # Check if plan.md exists - if [[ ! -f "$NEW_PLAN" ]]; then - log_error "No plan.md found at $NEW_PLAN" - log_info "Make sure you're working on a feature with a corresponding spec directory" - if [[ "$HAS_GIT" != "true" ]]; then - log_info "Use: export SPECIFY_FEATURE=your-feature-name or create a new feature first" - fi - exit 1 - fi - - # Check if template exists (needed for new files) - if [[ ! -f "$TEMPLATE_FILE" ]]; then - log_warning "Template file not found at $TEMPLATE_FILE" - log_warning "Creating new agent files will fail" - fi -} - -#============================================================================== -# Plan Parsing Functions -#============================================================================== - -extract_plan_field() { - local field_pattern="$1" - local plan_file="$2" - - grep "^\*\*${field_pattern}\*\*: " "$plan_file" 2>/dev/null | \ - head -1 | \ - sed "s|^\*\*${field_pattern}\*\*: ||" | \ - sed 's/^[ \t]*//;s/[ \t]*$//' | \ - grep -v "NEEDS CLARIFICATION" | \ - grep -v "^N/A$" || echo "" -} - -parse_plan_data() { - local plan_file="$1" - - if [[ ! -f "$plan_file" ]]; then - log_error "Plan file not found: $plan_file" - return 1 - fi - - if [[ ! -r "$plan_file" ]]; then - log_error "Plan file is not readable: $plan_file" - return 1 - fi - - log_info "Parsing plan data from $plan_file" - - NEW_LANG=$(extract_plan_field "Language/Version" "$plan_file") - NEW_FRAMEWORK=$(extract_plan_field "Primary Dependencies" "$plan_file") - NEW_DB=$(extract_plan_field "Storage" "$plan_file") - NEW_PROJECT_TYPE=$(extract_plan_field "Project Type" "$plan_file") - - # Log what we found - if [[ -n "$NEW_LANG" ]]; then - log_info "Found language: $NEW_LANG" - else - log_warning "No language information found in plan" - fi - - if [[ -n "$NEW_FRAMEWORK" ]]; then - log_info "Found framework: $NEW_FRAMEWORK" - fi - - if [[ -n "$NEW_DB" ]] && [[ "$NEW_DB" != "N/A" ]]; then - log_info "Found database: $NEW_DB" - fi - - if [[ -n "$NEW_PROJECT_TYPE" ]]; then - log_info "Found project type: $NEW_PROJECT_TYPE" - fi -} - -format_technology_stack() { - local lang="$1" - local framework="$2" - local parts=() - - # Add non-empty parts - [[ -n "$lang" && "$lang" != "NEEDS CLARIFICATION" ]] && parts+=("$lang") - [[ -n "$framework" && "$framework" != "NEEDS CLARIFICATION" && "$framework" != "N/A" ]] && parts+=("$framework") - - # Join with proper formatting - if [[ ${#parts[@]} -eq 0 ]]; then - echo "" - elif [[ ${#parts[@]} -eq 1 ]]; then - echo "${parts[0]}" - else - # Join multiple parts with " + " - local result="${parts[0]}" - for ((i=1; i<${#parts[@]}; i++)); do - result="$result + ${parts[i]}" - done - echo "$result" - fi -} - -#============================================================================== -# Template and Content Generation Functions -#============================================================================== - -get_project_structure() { - local project_type="$1" - - if [[ "$project_type" == *"web"* ]]; then - echo "backend/\\nfrontend/\\ntests/" - else - echo "src/\\ntests/" - fi -} - -get_commands_for_language() { - local lang="$1" - - case "$lang" in - *"Python"*) - echo "cd src && pytest && ruff check ." - ;; - *"Rust"*) - echo "cargo test && cargo clippy" - ;; - *"JavaScript"*|*"TypeScript"*) - echo "npm test \\&\\& npm run lint" - ;; - *) - echo "# Add commands for $lang" - ;; - esac -} - -get_language_conventions() { - local lang="$1" - echo "$lang: Follow standard conventions" -} - -create_new_agent_file() { - local target_file="$1" - local temp_file="$2" - local project_name="$3" - local current_date="$4" - - if [[ ! -f "$TEMPLATE_FILE" ]]; then - log_error "Template not found at $TEMPLATE_FILE" - return 1 - fi - - if [[ ! -r "$TEMPLATE_FILE" ]]; then - log_error "Template file is not readable: $TEMPLATE_FILE" - return 1 - fi - - log_info "Creating new agent context file from template..." - - if ! cp "$TEMPLATE_FILE" "$temp_file"; then - log_error "Failed to copy template file" - return 1 - fi - - # Replace template placeholders - local project_structure - project_structure=$(get_project_structure "$NEW_PROJECT_TYPE") - - local commands - commands=$(get_commands_for_language "$NEW_LANG") - - local language_conventions - language_conventions=$(get_language_conventions "$NEW_LANG") - - # Perform substitutions with error checking using safer approach - # Escape special characters for sed by using a different delimiter or escaping - local escaped_lang=$(printf '%s\n' "$NEW_LANG" | sed 's/[\[\.*^$()+{}|]/\\&/g') - local escaped_framework=$(printf '%s\n' "$NEW_FRAMEWORK" | sed 's/[\[\.*^$()+{}|]/\\&/g') - local escaped_branch=$(printf '%s\n' "$CURRENT_BRANCH" | sed 's/[\[\.*^$()+{}|]/\\&/g') - - # Build technology stack and recent change strings conditionally - local tech_stack - if [[ -n "$escaped_lang" && -n "$escaped_framework" ]]; then - tech_stack="- $escaped_lang + $escaped_framework ($escaped_branch)" - elif [[ -n "$escaped_lang" ]]; then - tech_stack="- $escaped_lang ($escaped_branch)" - elif [[ -n "$escaped_framework" ]]; then - tech_stack="- $escaped_framework ($escaped_branch)" - else - tech_stack="- ($escaped_branch)" - fi - - local recent_change - if [[ -n "$escaped_lang" && -n "$escaped_framework" ]]; then - recent_change="- $escaped_branch: Added $escaped_lang + $escaped_framework" - elif [[ -n "$escaped_lang" ]]; then - recent_change="- $escaped_branch: Added $escaped_lang" - elif [[ -n "$escaped_framework" ]]; then - recent_change="- $escaped_branch: Added $escaped_framework" - else - recent_change="- $escaped_branch: Added" - fi - - local substitutions=( - "s|\[PROJECT NAME\]|$project_name|" - "s|\[DATE\]|$current_date|" - "s|\[EXTRACTED FROM ALL PLAN.MD FILES\]|$tech_stack|" - "s|\[ACTUAL STRUCTURE FROM PLANS\]|$project_structure|g" - "s|\[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES\]|$commands|" - "s|\[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE\]|$language_conventions|" - "s|\[LAST 3 FEATURES AND WHAT THEY ADDED\]|$recent_change|" - ) - - for substitution in "${substitutions[@]}"; do - if ! sed -i.bak -e "$substitution" "$temp_file"; then - log_error "Failed to perform substitution: $substitution" - rm -f "$temp_file" "$temp_file.bak" - return 1 - fi - done - - # Convert \n sequences to actual newlines - newline=$(printf '\n') - sed -i.bak2 "s/\\\\n/${newline}/g" "$temp_file" - - # Clean up backup files - rm -f "$temp_file.bak" "$temp_file.bak2" - - return 0 -} - - - - -update_existing_agent_file() { - local target_file="$1" - local current_date="$2" - - log_info "Updating existing agent context file..." - - # Use a single temporary file for atomic update - local temp_file - temp_file=$(mktemp) || { - log_error "Failed to create temporary file" - return 1 - } - - # Process the file in one pass - local tech_stack=$(format_technology_stack "$NEW_LANG" "$NEW_FRAMEWORK") - local new_tech_entries=() - local new_change_entry="" - - # Prepare new technology entries - if [[ -n "$tech_stack" ]] && ! grep -q "$tech_stack" "$target_file"; then - new_tech_entries+=("- $tech_stack ($CURRENT_BRANCH)") - fi - - if [[ -n "$NEW_DB" ]] && [[ "$NEW_DB" != "N/A" ]] && [[ "$NEW_DB" != "NEEDS CLARIFICATION" ]] && ! grep -q "$NEW_DB" "$target_file"; then - new_tech_entries+=("- $NEW_DB ($CURRENT_BRANCH)") - fi - - # Prepare new change entry - if [[ -n "$tech_stack" ]]; then - new_change_entry="- $CURRENT_BRANCH: Added $tech_stack" - elif [[ -n "$NEW_DB" ]] && [[ "$NEW_DB" != "N/A" ]] && [[ "$NEW_DB" != "NEEDS CLARIFICATION" ]]; then - new_change_entry="- $CURRENT_BRANCH: Added $NEW_DB" - fi - - # Check if sections exist in the file - local has_active_technologies=0 - local has_recent_changes=0 - - if grep -q "^## Active Technologies" "$target_file" 2>/dev/null; then - has_active_technologies=1 - fi - - if grep -q "^## Recent Changes" "$target_file" 2>/dev/null; then - has_recent_changes=1 - fi - - # Process file line by line - local in_tech_section=false - local in_changes_section=false - local tech_entries_added=false - local changes_entries_added=false - local existing_changes_count=0 - local file_ended=false - - while IFS= read -r line || [[ -n "$line" ]]; do - # Handle Active Technologies section - if [[ "$line" == "## Active Technologies" ]]; then - echo "$line" >> "$temp_file" - in_tech_section=true - continue - elif [[ $in_tech_section == true ]] && [[ "$line" =~ ^##[[:space:]] ]]; then - # Add new tech entries before closing the section - if [[ $tech_entries_added == false ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then - printf '%s\n' "${new_tech_entries[@]}" >> "$temp_file" - tech_entries_added=true - fi - echo "$line" >> "$temp_file" - in_tech_section=false - continue - elif [[ $in_tech_section == true ]] && [[ -z "$line" ]]; then - # Add new tech entries before empty line in tech section - if [[ $tech_entries_added == false ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then - printf '%s\n' "${new_tech_entries[@]}" >> "$temp_file" - tech_entries_added=true - fi - echo "$line" >> "$temp_file" - continue - fi - - # Handle Recent Changes section - if [[ "$line" == "## Recent Changes" ]]; then - echo "$line" >> "$temp_file" - # Add new change entry right after the heading - if [[ -n "$new_change_entry" ]]; then - echo "$new_change_entry" >> "$temp_file" - fi - in_changes_section=true - changes_entries_added=true - continue - elif [[ $in_changes_section == true ]] && [[ "$line" =~ ^##[[:space:]] ]]; then - echo "$line" >> "$temp_file" - in_changes_section=false - continue - elif [[ $in_changes_section == true ]] && [[ "$line" == "- "* ]]; then - # Keep only first 2 existing changes - if [[ $existing_changes_count -lt 2 ]]; then - echo "$line" >> "$temp_file" - ((existing_changes_count++)) - fi - continue - fi - - # Update timestamp - if [[ "$line" =~ \*\*Last\ updated\*\*:.*[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] ]]; then - echo "$line" | sed "s/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/$current_date/" >> "$temp_file" - else - echo "$line" >> "$temp_file" - fi - done < "$target_file" - - # Post-loop check: if we're still in the Active Technologies section and haven't added new entries - if [[ $in_tech_section == true ]] && [[ $tech_entries_added == false ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then - printf '%s\n' "${new_tech_entries[@]}" >> "$temp_file" - tech_entries_added=true - fi - - # If sections don't exist, add them at the end of the file - if [[ $has_active_technologies -eq 0 ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then - echo "" >> "$temp_file" - echo "## Active Technologies" >> "$temp_file" - printf '%s\n' "${new_tech_entries[@]}" >> "$temp_file" - tech_entries_added=true - fi - - if [[ $has_recent_changes -eq 0 ]] && [[ -n "$new_change_entry" ]]; then - echo "" >> "$temp_file" - echo "## Recent Changes" >> "$temp_file" - echo "$new_change_entry" >> "$temp_file" - changes_entries_added=true - fi - - # Move temp file to target atomically - if ! mv "$temp_file" "$target_file"; then - log_error "Failed to update target file" - rm -f "$temp_file" - return 1 - fi - - return 0 -} -#============================================================================== -# Main Agent File Update Function -#============================================================================== - -update_agent_file() { - local target_file="$1" - local agent_name="$2" - - if [[ -z "$target_file" ]] || [[ -z "$agent_name" ]]; then - log_error "update_agent_file requires target_file and agent_name parameters" - return 1 - fi - - log_info "Updating $agent_name context file: $target_file" - - local project_name - project_name=$(basename "$REPO_ROOT") - local current_date - current_date=$(date +%Y-%m-%d) - - # Create directory if it doesn't exist - local target_dir - target_dir=$(dirname "$target_file") - if [[ ! -d "$target_dir" ]]; then - if ! mkdir -p "$target_dir"; then - log_error "Failed to create directory: $target_dir" - return 1 - fi - fi - - if [[ ! -f "$target_file" ]]; then - # Create new file from template - local temp_file - temp_file=$(mktemp) || { - log_error "Failed to create temporary file" - return 1 - } - - if create_new_agent_file "$target_file" "$temp_file" "$project_name" "$current_date"; then - if mv "$temp_file" "$target_file"; then - log_success "Created new $agent_name context file" - else - log_error "Failed to move temporary file to $target_file" - rm -f "$temp_file" - return 1 - fi - else - log_error "Failed to create new agent file" - rm -f "$temp_file" - return 1 - fi - else - # Update existing file - if [[ ! -r "$target_file" ]]; then - log_error "Cannot read existing file: $target_file" - return 1 - fi - - if [[ ! -w "$target_file" ]]; then - log_error "Cannot write to existing file: $target_file" - return 1 - fi - - if update_existing_agent_file "$target_file" "$current_date"; then - log_success "Updated existing $agent_name context file" - else - log_error "Failed to update existing agent file" - return 1 - fi - fi - - return 0 -} - -#============================================================================== -# Agent Selection and Processing -#============================================================================== - -update_specific_agent() { - local agent_type="$1" - - case "$agent_type" in - claude) - update_agent_file "$CLAUDE_FILE" "Claude Code" - ;; - gemini) - update_agent_file "$GEMINI_FILE" "Gemini CLI" - ;; - copilot) - update_agent_file "$COPILOT_FILE" "GitHub Copilot" - ;; - cursor-agent) - update_agent_file "$CURSOR_FILE" "Cursor IDE" - ;; - qwen) - update_agent_file "$QWEN_FILE" "Qwen Code" - ;; - opencode) - update_agent_file "$AGENTS_FILE" "opencode" - ;; - codex) - update_agent_file "$AGENTS_FILE" "Codex CLI" - ;; - windsurf) - update_agent_file "$WINDSURF_FILE" "Windsurf" - ;; - kilocode) - update_agent_file "$KILOCODE_FILE" "Kilo Code" - ;; - auggie) - update_agent_file "$AUGGIE_FILE" "Auggie CLI" - ;; - roo) - update_agent_file "$ROO_FILE" "Roo Code" - ;; - codebuddy) - update_agent_file "$CODEBUDDY_FILE" "CodeBuddy CLI" - ;; - qoder) - update_agent_file "$QODER_FILE" "Qoder CLI" - ;; - amp) - update_agent_file "$AMP_FILE" "Amp" - ;; - shai) - update_agent_file "$SHAI_FILE" "SHAI" - ;; - q) - update_agent_file "$Q_FILE" "Amazon Q Developer CLI" - ;; - agy) - update_agent_file "$AGY_FILE" "Antigravity" - ;; - bob) - update_agent_file "$BOB_FILE" "IBM Bob" - ;; - *) - log_error "Unknown agent type '$agent_type'" - log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|amp|shai|q|agy|bob|qoder" - exit 1 - ;; - esac -} - -update_all_existing_agents() { - local found_agent=false - - # Check each possible agent file and update if it exists - if [[ -f "$CLAUDE_FILE" ]]; then - update_agent_file "$CLAUDE_FILE" "Claude Code" - found_agent=true - fi - - if [[ -f "$GEMINI_FILE" ]]; then - update_agent_file "$GEMINI_FILE" "Gemini CLI" - found_agent=true - fi - - if [[ -f "$COPILOT_FILE" ]]; then - update_agent_file "$COPILOT_FILE" "GitHub Copilot" - found_agent=true - fi - - if [[ -f "$CURSOR_FILE" ]]; then - update_agent_file "$CURSOR_FILE" "Cursor IDE" - found_agent=true - fi - - if [[ -f "$QWEN_FILE" ]]; then - update_agent_file "$QWEN_FILE" "Qwen Code" - found_agent=true - fi - - if [[ -f "$AGENTS_FILE" ]]; then - update_agent_file "$AGENTS_FILE" "Codex/opencode" - found_agent=true - fi - - if [[ -f "$WINDSURF_FILE" ]]; then - update_agent_file "$WINDSURF_FILE" "Windsurf" - found_agent=true - fi - - if [[ -f "$KILOCODE_FILE" ]]; then - update_agent_file "$KILOCODE_FILE" "Kilo Code" - found_agent=true - fi - - if [[ -f "$AUGGIE_FILE" ]]; then - update_agent_file "$AUGGIE_FILE" "Auggie CLI" - found_agent=true - fi - - if [[ -f "$ROO_FILE" ]]; then - update_agent_file "$ROO_FILE" "Roo Code" - found_agent=true - fi - - if [[ -f "$CODEBUDDY_FILE" ]]; then - update_agent_file "$CODEBUDDY_FILE" "CodeBuddy CLI" - found_agent=true - fi - - if [[ -f "$SHAI_FILE" ]]; then - update_agent_file "$SHAI_FILE" "SHAI" - found_agent=true - fi - - if [[ -f "$QODER_FILE" ]]; then - update_agent_file "$QODER_FILE" "Qoder CLI" - found_agent=true - fi - - if [[ -f "$Q_FILE" ]]; then - update_agent_file "$Q_FILE" "Amazon Q Developer CLI" - found_agent=true - fi - - if [[ -f "$AGY_FILE" ]]; then - update_agent_file "$AGY_FILE" "Antigravity" - found_agent=true - fi - - if [[ -f "$BOB_FILE" ]]; then - update_agent_file "$BOB_FILE" "IBM Bob" - found_agent=true - fi - - # If no agent files exist, create a default Claude file - if [[ "$found_agent" == false ]]; then - log_info "No existing agent files found, creating default Claude file..." - update_agent_file "$CLAUDE_FILE" "Claude Code" - fi -} -print_summary() { - echo - log_info "Summary of changes:" - - if [[ -n "$NEW_LANG" ]]; then - echo " - Added language: $NEW_LANG" - fi - - if [[ -n "$NEW_FRAMEWORK" ]]; then - echo " - Added framework: $NEW_FRAMEWORK" - fi - - if [[ -n "$NEW_DB" ]] && [[ "$NEW_DB" != "N/A" ]]; then - echo " - Added database: $NEW_DB" - fi - - echo - - log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|codebuddy|shai|q|agy|bob|qoder]" -} - -#============================================================================== -# Main Execution -#============================================================================== - -main() { - # Validate environment before proceeding - validate_environment - - log_info "=== Updating agent context files for feature $CURRENT_BRANCH ===" - - # Parse the plan file to extract project information - if ! parse_plan_data "$NEW_PLAN"; then - log_error "Failed to parse plan data" - exit 1 - fi - - # Process based on agent type argument - local success=true - - if [[ -z "$AGENT_TYPE" ]]; then - # No specific agent provided - update all existing agent files - log_info "No agent specified, updating all existing agent files..." - if ! update_all_existing_agents; then - success=false - fi - else - # Specific agent provided - update only that agent - log_info "Updating specific agent: $AGENT_TYPE" - if ! update_specific_agent "$AGENT_TYPE"; then - success=false - fi - fi - - # Print summary - print_summary - - if [[ "$success" == true ]]; then - log_success "Agent context update completed successfully" - exit 0 - else - log_error "Agent context update completed with errors" - exit 1 - fi -} - -# Execute main function if script is run directly -if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then - main "$@" -fi - diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index b0be273545..ffc6d73b3c 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -1,7 +1,39 @@ #!/usr/bin/env pwsh # Common PowerShell functions analogous to common.sh +# Find repository root by searching upward for .specify directory +# This is the primary marker for spec-kit projects +function Find-SpecifyRoot { + param([string]$StartDir = (Get-Location).Path) + + # Normalize to absolute path to prevent issues with relative paths + # Use -LiteralPath to handle paths with wildcard characters ([, ], *, ?) + $resolved = Resolve-Path -LiteralPath $StartDir -ErrorAction SilentlyContinue + $current = if ($resolved) { $resolved.Path } else { $null } + if (-not $current) { return $null } + + while ($true) { + if (Test-Path -LiteralPath (Join-Path $current ".specify") -PathType Container) { + return $current + } + $parent = Split-Path $current -Parent + if ([string]::IsNullOrEmpty($parent) -or $parent -eq $current) { + return $null + } + $current = $parent + } +} + +# Get repository root, prioritizing .specify directory over git +# This prevents using a parent git repo when spec-kit is initialized in a subdirectory function Get-RepoRoot { + # First, look for .specify directory (spec-kit's own marker) + $specifyRoot = Find-SpecifyRoot + if ($specifyRoot) { + return $specifyRoot + } + + # Fallback to git if no .specify found try { $result = git rev-parse --show-toplevel 2>$null if ($LASTEXITCODE -eq 0) { @@ -10,9 +42,10 @@ function Get-RepoRoot { } catch { # Git command failed } - - # Fall back to script location for non-git repos - return (Resolve-Path (Join-Path $PSScriptRoot "../../..")).Path + + # Final fallback to script location for non-git repos + # Use -LiteralPath to handle paths with wildcard characters + return (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "../../..")).Path } function Get-CurrentBranch { @@ -20,35 +53,48 @@ function Get-CurrentBranch { if ($env:SPECIFY_FEATURE) { return $env:SPECIFY_FEATURE } - - # Then check git if available - try { - $result = git rev-parse --abbrev-ref HEAD 2>$null - if ($LASTEXITCODE -eq 0) { - return $result + + # Then check git if available at the spec-kit root (not parent) + $repoRoot = Get-RepoRoot + if (Test-HasGit) { + try { + $result = git -C $repoRoot rev-parse --abbrev-ref HEAD 2>$null + if ($LASTEXITCODE -eq 0) { + return $result + } + } catch { + # Git command failed } - } catch { - # Git command failed } - + # For non-git repos, try to find the latest feature directory - $repoRoot = Get-RepoRoot $specsDir = Join-Path $repoRoot "specs" if (Test-Path $specsDir) { $latestFeature = "" $highest = 0 - + $latestTimestamp = "" + Get-ChildItem -Path $specsDir -Directory | ForEach-Object { - if ($_.Name -match '^(\d{3})-') { - $num = [int]$matches[1] + if ($_.Name -match '^(\d{8}-\d{6})-') { + # Timestamp-based branch: compare lexicographically + $ts = $matches[1] + if ($ts -gt $latestTimestamp) { + $latestTimestamp = $ts + $latestFeature = $_.Name + } + } elseif ($_.Name -match '^(\d{3,})-') { + $num = [long]$matches[1] if ($num -gt $highest) { $highest = $num - $latestFeature = $_.Name + # Only update if no timestamp branch found yet + if (-not $latestTimestamp) { + $latestFeature = $_.Name + } } } } - + if ($latestFeature) { return $latestFeature } @@ -58,15 +104,39 @@ function Get-CurrentBranch { return "main" } +# Check if we have git available at the spec-kit root level +# Returns true only if git is installed and the repo root is inside a git work tree +# Handles both regular repos (.git directory) and worktrees/submodules (.git file) function Test-HasGit { + # First check if git command is available (before calling Get-RepoRoot which may use git) + if (-not (Get-Command git -ErrorAction SilentlyContinue)) { + return $false + } + $repoRoot = Get-RepoRoot + # Check if .git exists (directory or file for worktrees/submodules) + # Use -LiteralPath to handle paths with wildcard characters + if (-not (Test-Path -LiteralPath (Join-Path $repoRoot ".git"))) { + return $false + } + # Verify it's actually a valid git work tree try { - git rev-parse --show-toplevel 2>$null | Out-Null + $null = git -C $repoRoot rev-parse --is-inside-work-tree 2>$null return ($LASTEXITCODE -eq 0) } catch { return $false } } +# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name"). +# Only when the full name is exactly two slash-free segments; otherwise returns the raw name. +function Get-SpecKitEffectiveBranchName { + param([string]$Branch) + if ($Branch -match '^([^/]+)/([^/]+)$') { + return $Matches[2] + } + return $Branch +} + function Test-FeatureBranch { param( [string]$Branch, @@ -78,25 +148,175 @@ function Test-FeatureBranch { Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation" return $true } + + $raw = $Branch + $Branch = Get-SpecKitEffectiveBranchName $raw - if ($Branch -notmatch '^[0-9]{3}-') { - Write-Output "ERROR: Not on a feature branch. Current branch: $Branch" - Write-Output "Feature branches should be named like: 001-feature-name" + # Accept sequential prefix (3+ digits) but exclude malformed timestamps + # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022") + $hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$') + $isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp) + if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') { + [Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw") + [Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name") return $false } return $true } -function Get-FeatureDir { - param([string]$RepoRoot, [string]$Branch) - Join-Path $RepoRoot "specs/$Branch" +# True when .specify/feature.json pins an existing feature directory that matches the +# active FEATURE_DIR from Get-FeaturePathsEnv (so /speckit.plan can skip git branch pattern checks). +function Test-FeatureJsonMatchesFeatureDir { + param( + [Parameter(Mandatory = $true)][string]$RepoRoot, + [Parameter(Mandatory = $true)][string]$ActiveFeatureDir + ) + + $featureJson = Join-Path (Join-Path $RepoRoot '.specify') 'feature.json' + if (-not (Test-Path -LiteralPath $featureJson -PathType Leaf)) { + return $false + } + + try { + $raw = Get-Content -LiteralPath $featureJson -Raw + $cfg = $raw | ConvertFrom-Json + } catch { + return $false + } + + $fd = $cfg.feature_directory + if ([string]::IsNullOrWhiteSpace([string]$fd)) { + return $false + } + + if (-not [System.IO.Path]::IsPathRooted($fd)) { + $fd = Join-Path $RepoRoot $fd + } + + if (-not (Test-Path -LiteralPath $fd -PathType Container)) { + return $false + } + + # Resolve both paths to canonical absolute form. Prefer Resolve-Path (follows + # symlinks and is the canonical PS way); fall back to [Path]::GetFullPath when + # Resolve-Path can't produce a value. Mirrors the pattern used by Find-SpecifyRoot. + $resolvedJson = Resolve-Path -LiteralPath $fd -ErrorAction SilentlyContinue + if ($resolvedJson) { + $normJson = $resolvedJson.Path + } else { + $normJson = [System.IO.Path]::GetFullPath($fd) + } + + $resolvedActive = Resolve-Path -LiteralPath $ActiveFeatureDir -ErrorAction SilentlyContinue + if ($resolvedActive) { + $normActive = $resolvedActive.Path + } else { + $normActive = [System.IO.Path]::GetFullPath($ActiveFeatureDir) + } + + # Use case-insensitive compare only on Windows; POSIX filesystems are case-sensitive. + # PowerShell 5.1 is Windows-only and does not define $IsWindows, so treat its + # absence as "we're on Windows". + if ($null -ne $IsWindows) { + $onWindows = $IsWindows + } else { + $onWindows = $true + } + + if ($onWindows) { + $comparison = [System.StringComparison]::OrdinalIgnoreCase + } else { + $comparison = [System.StringComparison]::Ordinal + } + + return [string]::Equals($normJson, $normActive, $comparison) +} + +# Resolve specs/ by numeric/timestamp prefix (mirrors scripts/bash/common.sh find_feature_dir_by_prefix). +function Find-FeatureDirByPrefix { + param( + [Parameter(Mandatory = $true)][string]$RepoRoot, + [Parameter(Mandatory = $true)][string]$Branch + ) + $specsDir = Join-Path $RepoRoot 'specs' + $branchName = Get-SpecKitEffectiveBranchName $Branch + + $prefix = $null + if ($branchName -match '^(\d{8}-\d{6})-') { + $prefix = $Matches[1] + } elseif ($branchName -match '^(\d{3,})-') { + $prefix = $Matches[1] + } else { + return (Join-Path $specsDir $branchName) + } + + $dirMatches = @() + if (Test-Path -LiteralPath $specsDir -PathType Container) { + $dirMatches = @(Get-ChildItem -LiteralPath $specsDir -Filter "$prefix-*" -Directory -ErrorAction SilentlyContinue) + } + + if ($dirMatches.Count -eq 0) { + return (Join-Path $specsDir $branchName) + } + if ($dirMatches.Count -eq 1) { + return $dirMatches[0].FullName + } + $names = ($dirMatches | ForEach-Object { $_.Name }) -join ' ' + [Console]::Error.WriteLine("ERROR: Multiple spec directories found with prefix '$prefix': $names") + [Console]::Error.WriteLine('Please ensure only one spec directory exists per prefix.') + return $null +} + +# Branch-based prefix resolution; mirrors bash get_feature_paths failure (stderr + exit 1). +function Get-FeatureDirFromBranchPrefixOrExit { + param( + [Parameter(Mandatory = $true)][string]$RepoRoot, + [Parameter(Mandatory = $true)][string]$CurrentBranch + ) + $resolved = Find-FeatureDirByPrefix -RepoRoot $RepoRoot -Branch $CurrentBranch + if ($null -eq $resolved) { + [Console]::Error.WriteLine('ERROR: Failed to resolve feature directory') + exit 1 + } + return $resolved } function Get-FeaturePathsEnv { $repoRoot = Get-RepoRoot $currentBranch = Get-CurrentBranch $hasGit = Test-HasGit - $featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch + + # Resolve feature directory. Priority: + # 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override) + # 2. .specify/feature.json "feature_directory" key (persisted by /speckit.specify) + # 3. Branch-name-based prefix lookup (same as scripts/bash/common.sh) + $featureJson = Join-Path $repoRoot '.specify/feature.json' + if ($env:SPECIFY_FEATURE_DIRECTORY) { + $featureDir = $env:SPECIFY_FEATURE_DIRECTORY + # Normalize relative paths to absolute under repo root + if (-not [System.IO.Path]::IsPathRooted($featureDir)) { + $featureDir = Join-Path $repoRoot $featureDir + } + } elseif (Test-Path $featureJson) { + $featureJsonRaw = Get-Content -LiteralPath $featureJson -Raw + try { + $featureConfig = $featureJsonRaw | ConvertFrom-Json + } catch { + [Console]::Error.WriteLine("ERROR: Failed to parse .specify/feature.json: $_") + exit 1 + } + if ($featureConfig.feature_directory) { + $featureDir = $featureConfig.feature_directory + # Normalize relative paths to absolute under repo root + if (-not [System.IO.Path]::IsPathRooted($featureDir)) { + $featureDir = Join-Path $repoRoot $featureDir + } + } else { + $featureDir = Get-FeatureDirFromBranchPrefixOrExit -RepoRoot $repoRoot -CurrentBranch $currentBranch + } + } else { + $featureDir = Get-FeatureDirFromBranchPrefixOrExit -RepoRoot $repoRoot -CurrentBranch $currentBranch + } [PSCustomObject]@{ REPO_ROOT = $repoRoot @@ -135,3 +355,289 @@ function Test-DirHasFiles { } } +# Find a usable Python 3 executable (python3, python, or py -3). +# Returns the command/arguments as an array, or $null if none found. +function Get-Python3Command { + if (Get-Command python3 -ErrorAction SilentlyContinue) { return @('python3') } + if (Get-Command python -ErrorAction SilentlyContinue) { + $ver = & python --version 2>&1 + if ($ver -match 'Python 3') { return @('python') } + } + if (Get-Command py -ErrorAction SilentlyContinue) { + $ver = & py -3 --version 2>&1 + if ($ver -match 'Python 3') { return @('py', '-3') } + } + return $null +} + +# Resolve a template name to a file path using the priority stack: +# 1. .specify/templates/overrides/ +# 2. .specify/presets//templates/ (sorted by priority from .registry) +# 3. .specify/extensions//templates/ +# 4. .specify/templates/ (core) +function Resolve-Template { + param( + [Parameter(Mandatory=$true)][string]$TemplateName, + [Parameter(Mandatory=$true)][string]$RepoRoot + ) + + $base = Join-Path $RepoRoot '.specify/templates' + + # Priority 1: Project overrides + $override = Join-Path $base "overrides/$TemplateName.md" + if (Test-Path $override) { return $override } + + # Priority 2: Installed presets (sorted by priority from .registry) + $presetsDir = Join-Path $RepoRoot '.specify/presets' + if (Test-Path $presetsDir) { + $registryFile = Join-Path $presetsDir '.registry' + $sortedPresets = @() + if (Test-Path $registryFile) { + try { + $registryData = Get-Content $registryFile -Raw | ConvertFrom-Json + $presets = $registryData.presets + if ($presets) { + $sortedPresets = $presets.PSObject.Properties | + Where-Object { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } | + Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } | + ForEach-Object { $_.Name } + } + } catch { + # Fallback: alphabetical directory order + $sortedPresets = @() + } + } + + if ($sortedPresets.Count -gt 0) { + foreach ($presetId in $sortedPresets) { + $candidate = Join-Path $presetsDir "$presetId/templates/$TemplateName.md" + if (Test-Path $candidate) { return $candidate } + } + } else { + # Fallback: alphabetical directory order + foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' }) { + $candidate = Join-Path $preset.FullName "templates/$TemplateName.md" + if (Test-Path $candidate) { return $candidate } + } + } + } + + # Priority 3: Extension-provided templates + $extDir = Join-Path $RepoRoot '.specify/extensions' + if (Test-Path $extDir) { + foreach ($ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) { + $candidate = Join-Path $ext.FullName "templates/$TemplateName.md" + if (Test-Path $candidate) { return $candidate } + } + } + + # Priority 4: Core templates + $core = Join-Path $base "$TemplateName.md" + if (Test-Path $core) { return $core } + + return $null +} + +# Resolve a template name to composed content using composition strategies. +# Reads strategy metadata from preset manifests and composes content +# from multiple layers using prepend, append, or wrap strategies. +function Resolve-TemplateContent { + param( + [Parameter(Mandatory=$true)][string]$TemplateName, + [Parameter(Mandatory=$true)][string]$RepoRoot + ) + + $base = Join-Path $RepoRoot '.specify/templates' + + # Collect all layers (highest priority first) + $layerPaths = @() + $layerStrategies = @() + + # Priority 1: Project overrides (always "replace") + $override = Join-Path $base "overrides/$TemplateName.md" + if (Test-Path $override) { + $layerPaths += $override + $layerStrategies += 'replace' + } + + # Priority 2: Installed presets (sorted by priority from .registry) + $presetsDir = Join-Path $RepoRoot '.specify/presets' + if (Test-Path $presetsDir) { + $registryFile = Join-Path $presetsDir '.registry' + $sortedPresets = @() + if (Test-Path $registryFile) { + try { + $registryData = Get-Content $registryFile -Raw | ConvertFrom-Json + $presets = $registryData.presets + if ($presets) { + $sortedPresets = $presets.PSObject.Properties | + Where-Object { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } | + Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } | + ForEach-Object { $_.Name } + } + } catch { + $sortedPresets = @() + } + } + + if ($sortedPresets.Count -gt 0) { + $pyCmd = Get-Python3Command + if (-not $pyCmd) { + # Check if any preset has strategy fields that would be ignored + foreach ($pid in $sortedPresets) { + $mf = Join-Path $presetsDir "$pid/preset.yml" + if ((Test-Path $mf) -and (Select-String -Path $mf -Pattern 'strategy:' -Quiet -ErrorAction SilentlyContinue)) { + Write-Warning "No Python 3 found; preset composition strategies will be ignored" + break + } + } + } + $yamlWarned = $false + foreach ($presetId in $sortedPresets) { + # Read strategy and file path from preset manifest + $strategy = 'replace' + $manifestFilePath = '' + $manifest = Join-Path $presetsDir "$presetId/preset.yml" + if ((Test-Path $manifest) -and $pyCmd) { + try { + # Use Python to parse YAML manifest for strategy and file path + $pyArgs = if ($pyCmd.Count -gt 1) { $pyCmd[1..($pyCmd.Count-1)] } else { @() } + $pyStderrFile = [System.IO.Path]::GetTempFileName() + $stratResult = & $pyCmd[0] @pyArgs -c @" +import sys +try: + import yaml +except ImportError: + print('yaml_missing', file=sys.stderr) + print('replace\t') + sys.exit(0) +try: + with open(sys.argv[1]) as f: + data = yaml.safe_load(f) + for t in data.get('provides', {}).get('templates', []): + if t.get('name') == sys.argv[2] and t.get('type', 'template') == 'template': + print(t.get('strategy', 'replace') + '\t' + t.get('file', '')) + sys.exit(0) + print('replace\t') +except Exception: + print('replace\t') +"@ $manifest $TemplateName 2>$pyStderrFile + if ($stratResult) { + $parts = $stratResult.Trim() -split "`t", 2 + $strategy = $parts[0].ToLowerInvariant() + if ($parts.Count -gt 1 -and $parts[1]) { $manifestFilePath = $parts[1] } + } + if (-not $yamlWarned -and (Test-Path $pyStderrFile) -and (Get-Content $pyStderrFile -Raw -ErrorAction SilentlyContinue) -match 'yaml_missing') { + Write-Warning "PyYAML not available; composition strategies may be ignored" + $yamlWarned = $true + } + Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue + } catch { + $strategy = 'replace' + if ($pyStderrFile) { Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue } + } + } + # Try manifest file path first, then convention path + $candidate = $null + if ($manifestFilePath) { + # Reject absolute paths and parent traversal + if ([System.IO.Path]::IsPathRooted($manifestFilePath) -or $manifestFilePath -match '\.\.[\\/]') { + $manifestFilePath = '' + } + } + if ($manifestFilePath) { + $mf = Join-Path $presetsDir "$presetId/$manifestFilePath" + if (Test-Path $mf) { $candidate = $mf } + } + if (-not $candidate) { + $cf = Join-Path $presetsDir "$presetId/templates/$TemplateName.md" + if (Test-Path $cf) { $candidate = $cf } + } + if ($candidate) { + $layerPaths += $candidate + $layerStrategies += $strategy + } + } + } else { + # Fallback: alphabetical directory order (no registry or parse failure) + foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' }) { + $candidate = Join-Path $preset.FullName "templates/$TemplateName.md" + if (Test-Path $candidate) { + $layerPaths += $candidate + $layerStrategies += 'replace' + } + } + } + } + + # Priority 3: Extension-provided templates (always "replace") + $extDir = Join-Path $RepoRoot '.specify/extensions' + if (Test-Path $extDir) { + foreach ($ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) { + $candidate = Join-Path $ext.FullName "templates/$TemplateName.md" + if (Test-Path $candidate) { + $layerPaths += $candidate + $layerStrategies += 'replace' + } + } + } + + # Priority 4: Core templates (always "replace") + $core = Join-Path $base "$TemplateName.md" + if (Test-Path $core) { + $layerPaths += $core + $layerStrategies += 'replace' + } + + if ($layerPaths.Count -eq 0) { return $null } + + # If the top (highest-priority) layer is replace, it wins entirely — + # lower layers are irrelevant regardless of their strategies. + if ($layerStrategies[0] -eq 'replace') { + return (Get-Content $layerPaths[0] -Raw) + } + + # Check if any layer uses a non-replace strategy + $hasComposition = $false + foreach ($s in $layerStrategies) { + if ($s -ne 'replace') { $hasComposition = $true; break } + } + + if (-not $hasComposition) { + return (Get-Content $layerPaths[0] -Raw) + } + + # Find the effective base: scan from highest priority (index 0) downward + # to find the nearest replace layer. Only compose layers above that base. + $baseIdx = -1 + for ($i = 0; $i -lt $layerPaths.Count; $i++) { + if ($layerStrategies[$i] -eq 'replace') { + $baseIdx = $i + break + } + } + if ($baseIdx -lt 0) { return $null } + + $content = Get-Content $layerPaths[$baseIdx] -Raw + + for ($i = $baseIdx - 1; $i -ge 0; $i--) { + $path = $layerPaths[$i] + $strat = $layerStrategies[$i] + $layerContent = Get-Content $path -Raw + + switch ($strat) { + 'replace' { $content = $layerContent } + 'prepend' { $content = "$layerContent`n`n$content" } + 'append' { $content = "$content`n`n$layerContent" } + 'wrap' { + if (-not $layerContent.Contains('{CORE_TEMPLATE}')) { + throw "Wrap strategy missing {CORE_TEMPLATE} placeholder" + } + $content = $layerContent.Replace('{CORE_TEMPLATE}', $content) + } + default { throw "Unknown strategy: $strat" } + } + } + + return $content +} \ No newline at end of file diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 2f0172e35d..2f23283fc4 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -3,71 +3,81 @@ [CmdletBinding()] param( [switch]$Json, + [switch]$AllowExistingBranch, + [switch]$DryRun, [string]$ShortName, - [int]$Number = 0, + [Parameter()] + [long]$Number = 0, + [switch]$Timestamp, [switch]$Help, - [Parameter(ValueFromRemainingArguments = $true)] + [Parameter(Position = 0, ValueFromRemainingArguments = $true)] [string[]]$FeatureDescription ) $ErrorActionPreference = 'Stop' # Show help if requested if ($Help) { - Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-ShortName ] [-Number N] " + Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " Write-Host "" Write-Host "Options:" Write-Host " -Json Output in JSON format" + Write-Host " -DryRun Compute branch name and paths without creating branches, directories, or files" + Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing" Write-Host " -ShortName Provide a custom short name (2-4 words) for the branch" Write-Host " -Number N Specify branch number manually (overrides auto-detection)" + Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" Write-Host " -Help Show this help message" Write-Host "" Write-Host "Examples:" Write-Host " ./create-new-feature.ps1 'Add user authentication system' -ShortName 'user-auth'" Write-Host " ./create-new-feature.ps1 'Implement OAuth2 integration for API'" + Write-Host " ./create-new-feature.ps1 -Timestamp -ShortName 'user-auth' 'Add user authentication'" exit 0 } # Check if feature description provided if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) { - Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-ShortName ] " + Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " exit 1 } $featureDesc = ($FeatureDescription -join ' ').Trim() -# Resolve repository root. Prefer git information when available, but fall back -# to searching for repository markers so the workflow still functions in repositories that -# were initialized with --no-git. -function Find-RepositoryRoot { - param( - [string]$StartDir, - [string[]]$Markers = @('.git', '.specify') - ) - $current = Resolve-Path $StartDir - while ($true) { - foreach ($marker in $Markers) { - if (Test-Path (Join-Path $current $marker)) { - return $current - } - } - $parent = Split-Path $current -Parent - if ($parent -eq $current) { - # Reached filesystem root without finding markers - return $null - } - $current = $parent - } +# Validate description is not empty after trimming (e.g., user passed only whitespace) +if ([string]::IsNullOrWhiteSpace($featureDesc)) { + Write-Error "Error: Feature description cannot be empty or contain only whitespace" + exit 1 } function Get-HighestNumberFromSpecs { param([string]$SpecsDir) - - $highest = 0 + + [long]$highest = 0 if (Test-Path $SpecsDir) { Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object { - if ($_.Name -match '^(\d+)') { - $num = [int]$matches[1] - if ($num -gt $highest) { $highest = $num } + # Match sequential prefixes (>=3 digits), but skip timestamp dirs. + if ($_.Name -match '^(\d{3,})-' -and $_.Name -notmatch '^\d{8}-\d{6}-') { + [long]$num = 0 + if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) { + $highest = $num + } + } + } + } + return $highest +} + +# Extract the highest sequential feature number from a list of branch/ref names. +# Shared by Get-HighestNumberFromBranches and Get-HighestNumberFromRemoteRefs. +function Get-HighestNumberFromNames { + param([string[]]$Names) + + [long]$highest = 0 + foreach ($name in $Names) { + if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') { + [long]$num = 0 + if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) { + $highest = $num } } } @@ -76,44 +86,68 @@ function Get-HighestNumberFromSpecs { function Get-HighestNumberFromBranches { param() - - $highest = 0 + try { $branches = git branch -a 2>$null - if ($LASTEXITCODE -eq 0) { - foreach ($branch in $branches) { - # Clean branch name: remove leading markers and remote prefixes - $cleanBranch = $branch.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', '' - - # Extract feature number if branch matches pattern ###-* - if ($cleanBranch -match '^(\d+)-') { - $num = [int]$matches[1] - if ($num -gt $highest) { $highest = $num } - } + if ($LASTEXITCODE -eq 0 -and $branches) { + $cleanNames = $branches | ForEach-Object { + $_.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', '' } + return Get-HighestNumberFromNames -Names $cleanNames } } catch { - # If git command fails, return 0 Write-Verbose "Could not check Git branches: $_" } + return 0 +} + +function Get-HighestNumberFromRemoteRefs { + [long]$highest = 0 + try { + $remotes = git remote 2>$null + if ($remotes) { + foreach ($remote in $remotes) { + $env:GIT_TERMINAL_PROMPT = '0' + $refs = git ls-remote --heads $remote 2>$null + $env:GIT_TERMINAL_PROMPT = $null + if ($LASTEXITCODE -eq 0 -and $refs) { + $refNames = $refs | ForEach-Object { + if ($_ -match 'refs/heads/(.+)$') { $matches[1] } + } | Where-Object { $_ } + $remoteHighest = Get-HighestNumberFromNames -Names $refNames + if ($remoteHighest -gt $highest) { $highest = $remoteHighest } + } + } + } + } catch { + Write-Verbose "Could not query remote refs: $_" + } return $highest } +# Return next available branch number. When SkipFetch is true, queries remotes +# via ls-remote (read-only) instead of fetching. function Get-NextBranchNumber { param( - [string]$SpecsDir + [string]$SpecsDir, + [switch]$SkipFetch ) - # Fetch all remotes to get latest branch info (suppress errors if no remotes) - try { - git fetch --all --prune 2>$null | Out-Null - } catch { - # Ignore fetch errors + if ($SkipFetch) { + # Side-effect-free: query remotes via ls-remote + $highestBranch = Get-HighestNumberFromBranches + $highestRemote = Get-HighestNumberFromRemoteRefs + $highestBranch = [Math]::Max($highestBranch, $highestRemote) + } else { + # Fetch all remotes to get latest branch info (suppress errors if no remotes) + try { + git fetch --all --prune 2>$null | Out-Null + } catch { + # Ignore fetch errors + } + $highestBranch = Get-HighestNumberFromBranches } - # Get highest number from ALL branches (not just matching short name) - $highestBranch = Get-HighestNumberFromBranches - # Get highest number from ALL specs (not just matching short name) $highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir @@ -126,36 +160,29 @@ function Get-NextBranchNumber { function ConvertTo-CleanBranchName { param([string]$Name) - + return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', '' } -$fallbackRoot = (Find-RepositoryRoot -StartDir $PSScriptRoot) -if (-not $fallbackRoot) { - Write-Error "Error: Could not determine repository root. Please run this script from within the repository." - exit 1 -} +# Load common functions (includes Get-RepoRoot, Test-HasGit, Resolve-Template) +. "$PSScriptRoot/common.ps1" -try { - $repoRoot = git rev-parse --show-toplevel 2>$null - if ($LASTEXITCODE -eq 0) { - $hasGit = $true - } else { - throw "Git not available" - } -} catch { - $repoRoot = $fallbackRoot - $hasGit = $false -} +# Use common.ps1 functions which prioritize .specify over git +$repoRoot = Get-RepoRoot + +# Check if git is available at this repo root (not a parent) +$hasGit = Test-HasGit Set-Location $repoRoot $specsDir = Join-Path $repoRoot 'specs' -New-Item -ItemType Directory -Path $specsDir -Force | Out-Null +if (-not $DryRun) { + New-Item -ItemType Directory -Path $specsDir -Force | Out-Null +} # Function to generate branch name with stop word filtering and length filtering function Get-BranchName { param([string]$Description) - + # Common stop words to filter out $stopWords = @( 'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from', @@ -164,17 +191,17 @@ function Get-BranchName { 'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their', 'want', 'need', 'add', 'get', 'set' ) - + # Convert to lowercase and extract words (alphanumeric only) $cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' ' $words = $cleanName -split '\s+' | Where-Object { $_ } - + # Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original) $meaningfulWords = @() foreach ($word in $words) { # Skip stop words if ($stopWords -contains $word) { continue } - + # Keep words that are length >= 3 OR appear as uppercase in original (likely acronyms) if ($word.Length -ge 3) { $meaningfulWords += $word @@ -183,7 +210,7 @@ function Get-BranchName { $meaningfulWords += $word } } - + # If we have meaningful words, use first 3-4 of them if ($meaningfulWords.Count -gt 0) { $maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 } @@ -206,78 +233,150 @@ if ($ShortName) { $branchSuffix = Get-BranchName -Description $featureDesc } -# Determine branch number -if ($Number -eq 0) { - if ($hasGit) { - # Check existing branches on remotes - $Number = Get-NextBranchNumber -SpecsDir $specsDir - } else { - # Fall back to local directory check - $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 - } +# Warn if -Number and -Timestamp are both specified +if ($Timestamp -and $Number -ne 0) { + Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used" + $Number = 0 } -$featureNum = ('{0:000}' -f $Number) -$branchName = "$featureNum-$branchSuffix" +# Determine branch prefix +if ($Timestamp) { + $featureNum = Get-Date -Format 'yyyyMMdd-HHmmss' + $branchName = "$featureNum-$branchSuffix" +} else { + # Determine branch number + if ($Number -eq 0) { + if ($DryRun -and $hasGit) { + # Dry-run: query remotes via ls-remote (side-effect-free, no fetch) + $Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch + } elseif ($DryRun) { + # Dry-run without git: local spec dirs only + $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 + } elseif ($hasGit) { + # Check existing branches on remotes + $Number = Get-NextBranchNumber -SpecsDir $specsDir + } else { + # Fall back to local directory check + $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 + } + } + + $featureNum = ('{0:000}' -f $Number) + $branchName = "$featureNum-$branchSuffix" +} # GitHub enforces a 244-byte limit on branch names # Validate and truncate if necessary $maxBranchLength = 244 if ($branchName.Length -gt $maxBranchLength) { # Calculate how much we need to trim from suffix - # Account for: feature number (3) + hyphen (1) = 4 chars - $maxSuffixLength = $maxBranchLength - 4 - + # Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4 + $prefixLength = $featureNum.Length + 1 + $maxSuffixLength = $maxBranchLength - $prefixLength + # Truncate suffix $truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength)) # Remove trailing hyphen if truncation created one $truncatedSuffix = $truncatedSuffix -replace '-$', '' - + $originalBranchName = $branchName $branchName = "$featureNum-$truncatedSuffix" - + Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit" Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)" Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)" } -if ($hasGit) { - try { - git checkout -b $branchName | Out-Null - } catch { - Write-Warning "Failed to create git branch: $branchName" +$featureDir = Join-Path $specsDir $branchName +$specFile = Join-Path $featureDir 'spec.md' + +if (-not $DryRun) { + if ($hasGit) { + $branchCreated = $false + $branchCreateError = '' + try { + $branchCreateError = git checkout -q -b $branchName 2>&1 | Out-String + if ($LASTEXITCODE -eq 0) { + $branchCreated = $true + } + } catch { + $branchCreateError = $_.Exception.Message + } + + if (-not $branchCreated) { + $currentBranch = '' + try { $currentBranch = (git rev-parse --abbrev-ref HEAD 2>$null).Trim() } catch {} + # Check if branch already exists + $existingBranch = git branch --list $branchName 2>$null + if ($existingBranch) { + if ($AllowExistingBranch) { + # If we're already on the branch, continue without another checkout. + if ($currentBranch -eq $branchName) { + # Already on the target branch — nothing to do + } else { + # Otherwise switch to the existing branch instead of failing. + $switchBranchError = git checkout -q $branchName 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { + if ($switchBranchError) { + Write-Error "Error: Branch '$branchName' exists but could not be checked out.`n$($switchBranchError.Trim())" + } else { + Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again." + } + exit 1 + } + } + } elseif ($Timestamp) { + Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName." + exit 1 + } else { + Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number." + exit 1 + } + } else { + if ($branchCreateError) { + Write-Error "Error: Failed to create git branch '$branchName'.`n$($branchCreateError.Trim())" + } else { + Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again." + } + exit 1 + } + } + } else { + Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName" } -} else { - Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName" -} -$featureDir = Join-Path $specsDir $branchName -New-Item -ItemType Directory -Path $featureDir -Force | Out-Null + New-Item -ItemType Directory -Path $featureDir -Force | Out-Null -$template = Join-Path $repoRoot '.specify/templates/spec-template.md' -$specFile = Join-Path $featureDir 'spec.md' -if (Test-Path $template) { - Copy-Item $template $specFile -Force -} else { - New-Item -ItemType File -Path $specFile | Out-Null -} + if (-not (Test-Path -PathType Leaf $specFile)) { + $template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot + if ($template -and (Test-Path $template)) { + Copy-Item $template $specFile -Force + } else { + New-Item -ItemType File -Path $specFile -Force | Out-Null + } + } -# Set the SPECIFY_FEATURE environment variable for the current session -$env:SPECIFY_FEATURE = $branchName + # Set the SPECIFY_FEATURE environment variable for the current session + $env:SPECIFY_FEATURE = $branchName +} if ($Json) { - $obj = [PSCustomObject]@{ + $obj = [PSCustomObject]@{ BRANCH_NAME = $branchName SPEC_FILE = $specFile FEATURE_NUM = $featureNum HAS_GIT = $hasGit } + if ($DryRun) { + $obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true + } $obj | ConvertTo-Json -Compress } else { Write-Output "BRANCH_NAME: $branchName" Write-Output "SPEC_FILE: $specFile" Write-Output "FEATURE_NUM: $featureNum" Write-Output "HAS_GIT: $hasGit" - Write-Output "SPECIFY_FEATURE environment variable set to: $branchName" + if (-not $DryRun) { + Write-Output "SPECIFY_FEATURE environment variable set to: $branchName" + } } - diff --git a/scripts/powershell/setup-plan.ps1 b/scripts/powershell/setup-plan.ps1 index d0ed582fa9..15ae557544 100644 --- a/scripts/powershell/setup-plan.ps1 +++ b/scripts/powershell/setup-plan.ps1 @@ -23,21 +23,23 @@ if ($Help) { # Get all paths and variables from common functions $paths = Get-FeaturePathsEnv -# Check if we're on a proper feature branch (only for git repos) -if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GIT)) { - exit 1 +# If feature.json pins an existing feature directory, branch naming is not required. +if (-not (Test-FeatureJsonMatchesFeatureDir -RepoRoot $paths.REPO_ROOT -ActiveFeatureDir $paths.FEATURE_DIR)) { + if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GIT)) { + exit 1 + } } # Ensure the feature directory exists New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null # Copy plan template if it exists, otherwise note it or create empty file -$template = Join-Path $paths.REPO_ROOT '.specify/templates/plan-template.md' -if (Test-Path $template) { +$template = Resolve-Template -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT +if ($template -and (Test-Path $template)) { Copy-Item $template $paths.IMPL_PLAN -Force Write-Output "Copied plan template to $($paths.IMPL_PLAN)" } else { - Write-Warning "Plan template not found at $template" + Write-Warning "Plan template not found" # Create a basic plan file if template doesn't exist New-Item -ItemType File -Path $paths.IMPL_PLAN -Force | Out-Null } diff --git a/scripts/powershell/update-agent-context.ps1 b/scripts/powershell/update-agent-context.ps1 deleted file mode 100644 index eb46b5bece..0000000000 --- a/scripts/powershell/update-agent-context.ps1 +++ /dev/null @@ -1,451 +0,0 @@ -#!/usr/bin/env pwsh -<#! -.SYNOPSIS -Update agent context files with information from plan.md (PowerShell version) - -.DESCRIPTION -Mirrors the behavior of scripts/bash/update-agent-context.sh: - 1. Environment Validation - 2. Plan Data Extraction - 3. Agent File Management (create from template or update existing) - 4. Content Generation (technology stack, recent changes, timestamp) - 5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, q, agy, bob, qoder) - -.PARAMETER AgentType -Optional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist). - -.EXAMPLE -./update-agent-context.ps1 -AgentType claude - -.EXAMPLE -./update-agent-context.ps1 # Updates all existing agent files - -.NOTES -Relies on common helper functions in common.ps1 -#> -param( - [Parameter(Position=0)] - [ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','q','agy','bob','qoder')] - [string]$AgentType -) - -$ErrorActionPreference = 'Stop' - -# Import common helpers -$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path -. (Join-Path $ScriptDir 'common.ps1') - -# Acquire environment paths -$envData = Get-FeaturePathsEnv -$REPO_ROOT = $envData.REPO_ROOT -$CURRENT_BRANCH = $envData.CURRENT_BRANCH -$HAS_GIT = $envData.HAS_GIT -$IMPL_PLAN = $envData.IMPL_PLAN -$NEW_PLAN = $IMPL_PLAN - -# Agent file paths -$CLAUDE_FILE = Join-Path $REPO_ROOT 'CLAUDE.md' -$GEMINI_FILE = Join-Path $REPO_ROOT 'GEMINI.md' -$COPILOT_FILE = Join-Path $REPO_ROOT '.github/agents/copilot-instructions.md' -$CURSOR_FILE = Join-Path $REPO_ROOT '.cursor/rules/specify-rules.mdc' -$QWEN_FILE = Join-Path $REPO_ROOT 'QWEN.md' -$AGENTS_FILE = Join-Path $REPO_ROOT 'AGENTS.md' -$WINDSURF_FILE = Join-Path $REPO_ROOT '.windsurf/rules/specify-rules.md' -$KILOCODE_FILE = Join-Path $REPO_ROOT '.kilocode/rules/specify-rules.md' -$AUGGIE_FILE = Join-Path $REPO_ROOT '.augment/rules/specify-rules.md' -$ROO_FILE = Join-Path $REPO_ROOT '.roo/rules/specify-rules.md' -$CODEBUDDY_FILE = Join-Path $REPO_ROOT 'CODEBUDDY.md' -$QODER_FILE = Join-Path $REPO_ROOT 'QODER.md' -$AMP_FILE = Join-Path $REPO_ROOT 'AGENTS.md' -$SHAI_FILE = Join-Path $REPO_ROOT 'SHAI.md' -$Q_FILE = Join-Path $REPO_ROOT 'AGENTS.md' -$AGY_FILE = Join-Path $REPO_ROOT '.agent/rules/specify-rules.md' -$BOB_FILE = Join-Path $REPO_ROOT 'AGENTS.md' - -$TEMPLATE_FILE = Join-Path $REPO_ROOT '.specify/templates/agent-file-template.md' - -# Parsed plan data placeholders -$script:NEW_LANG = '' -$script:NEW_FRAMEWORK = '' -$script:NEW_DB = '' -$script:NEW_PROJECT_TYPE = '' - -function Write-Info { - param( - [Parameter(Mandatory=$true)] - [string]$Message - ) - Write-Host "INFO: $Message" -} - -function Write-Success { - param( - [Parameter(Mandatory=$true)] - [string]$Message - ) - Write-Host "$([char]0x2713) $Message" -} - -function Write-WarningMsg { - param( - [Parameter(Mandatory=$true)] - [string]$Message - ) - Write-Warning $Message -} - -function Write-Err { - param( - [Parameter(Mandatory=$true)] - [string]$Message - ) - Write-Host "ERROR: $Message" -ForegroundColor Red -} - -function Validate-Environment { - if (-not $CURRENT_BRANCH) { - Write-Err 'Unable to determine current feature' - 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' } - exit 1 - } - if (-not (Test-Path $NEW_PLAN)) { - Write-Err "No plan.md found at $NEW_PLAN" - Write-Info 'Ensure you are working on a feature with a corresponding spec directory' - if (-not $HAS_GIT) { Write-Info 'Use: $env:SPECIFY_FEATURE=your-feature-name or create a new feature first' } - exit 1 - } - if (-not (Test-Path $TEMPLATE_FILE)) { - Write-Err "Template file not found at $TEMPLATE_FILE" - Write-Info 'Run specify init to scaffold .specify/templates, or add agent-file-template.md there.' - exit 1 - } -} - -function Extract-PlanField { - param( - [Parameter(Mandatory=$true)] - [string]$FieldPattern, - [Parameter(Mandatory=$true)] - [string]$PlanFile - ) - if (-not (Test-Path $PlanFile)) { return '' } - # Lines like **Language/Version**: Python 3.12 - $regex = "^\*\*$([Regex]::Escape($FieldPattern))\*\*: (.+)$" - Get-Content -LiteralPath $PlanFile -Encoding utf8 | ForEach-Object { - if ($_ -match $regex) { - $val = $Matches[1].Trim() - if ($val -notin @('NEEDS CLARIFICATION','N/A')) { return $val } - } - } | Select-Object -First 1 -} - -function Parse-PlanData { - param( - [Parameter(Mandatory=$true)] - [string]$PlanFile - ) - if (-not (Test-Path $PlanFile)) { Write-Err "Plan file not found: $PlanFile"; return $false } - Write-Info "Parsing plan data from $PlanFile" - $script:NEW_LANG = Extract-PlanField -FieldPattern 'Language/Version' -PlanFile $PlanFile - $script:NEW_FRAMEWORK = Extract-PlanField -FieldPattern 'Primary Dependencies' -PlanFile $PlanFile - $script:NEW_DB = Extract-PlanField -FieldPattern 'Storage' -PlanFile $PlanFile - $script:NEW_PROJECT_TYPE = Extract-PlanField -FieldPattern 'Project Type' -PlanFile $PlanFile - - if ($NEW_LANG) { Write-Info "Found language: $NEW_LANG" } else { Write-WarningMsg 'No language information found in plan' } - if ($NEW_FRAMEWORK) { Write-Info "Found framework: $NEW_FRAMEWORK" } - if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Info "Found database: $NEW_DB" } - if ($NEW_PROJECT_TYPE) { Write-Info "Found project type: $NEW_PROJECT_TYPE" } - return $true -} - -function Format-TechnologyStack { - param( - [Parameter(Mandatory=$false)] - [string]$Lang, - [Parameter(Mandatory=$false)] - [string]$Framework - ) - $parts = @() - if ($Lang -and $Lang -ne 'NEEDS CLARIFICATION') { $parts += $Lang } - if ($Framework -and $Framework -notin @('NEEDS CLARIFICATION','N/A')) { $parts += $Framework } - if (-not $parts) { return '' } - return ($parts -join ' + ') -} - -function Get-ProjectStructure { - param( - [Parameter(Mandatory=$false)] - [string]$ProjectType - ) - if ($ProjectType -match 'web') { return "backend/`nfrontend/`ntests/" } else { return "src/`ntests/" } -} - -function Get-CommandsForLanguage { - param( - [Parameter(Mandatory=$false)] - [string]$Lang - ) - switch -Regex ($Lang) { - 'Python' { return "cd src; pytest; ruff check ." } - 'Rust' { return "cargo test; cargo clippy" } - 'JavaScript|TypeScript' { return "npm test; npm run lint" } - default { return "# Add commands for $Lang" } - } -} - -function Get-LanguageConventions { - param( - [Parameter(Mandatory=$false)] - [string]$Lang - ) - if ($Lang) { "${Lang}: Follow standard conventions" } else { 'General: Follow standard conventions' } -} - -function New-AgentFile { - param( - [Parameter(Mandatory=$true)] - [string]$TargetFile, - [Parameter(Mandatory=$true)] - [string]$ProjectName, - [Parameter(Mandatory=$true)] - [datetime]$Date - ) - if (-not (Test-Path $TEMPLATE_FILE)) { Write-Err "Template not found at $TEMPLATE_FILE"; return $false } - $temp = New-TemporaryFile - Copy-Item -LiteralPath $TEMPLATE_FILE -Destination $temp -Force - - $projectStructure = Get-ProjectStructure -ProjectType $NEW_PROJECT_TYPE - $commands = Get-CommandsForLanguage -Lang $NEW_LANG - $languageConventions = Get-LanguageConventions -Lang $NEW_LANG - - $escaped_lang = $NEW_LANG - $escaped_framework = $NEW_FRAMEWORK - $escaped_branch = $CURRENT_BRANCH - - $content = Get-Content -LiteralPath $temp -Raw -Encoding utf8 - $content = $content -replace '\[PROJECT NAME\]',$ProjectName - $content = $content -replace '\[DATE\]',$Date.ToString('yyyy-MM-dd') - - # Build the technology stack string safely - $techStackForTemplate = "" - if ($escaped_lang -and $escaped_framework) { - $techStackForTemplate = "- $escaped_lang + $escaped_framework ($escaped_branch)" - } elseif ($escaped_lang) { - $techStackForTemplate = "- $escaped_lang ($escaped_branch)" - } elseif ($escaped_framework) { - $techStackForTemplate = "- $escaped_framework ($escaped_branch)" - } - - $content = $content -replace '\[EXTRACTED FROM ALL PLAN.MD FILES\]',$techStackForTemplate - # For project structure we manually embed (keep newlines) - $escapedStructure = [Regex]::Escape($projectStructure) - $content = $content -replace '\[ACTUAL STRUCTURE FROM PLANS\]',$escapedStructure - # Replace escaped newlines placeholder after all replacements - $content = $content -replace '\[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES\]',$commands - $content = $content -replace '\[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE\]',$languageConventions - - # Build the recent changes string safely - $recentChangesForTemplate = "" - if ($escaped_lang -and $escaped_framework) { - $recentChangesForTemplate = "- ${escaped_branch}: Added ${escaped_lang} + ${escaped_framework}" - } elseif ($escaped_lang) { - $recentChangesForTemplate = "- ${escaped_branch}: Added ${escaped_lang}" - } elseif ($escaped_framework) { - $recentChangesForTemplate = "- ${escaped_branch}: Added ${escaped_framework}" - } - - $content = $content -replace '\[LAST 3 FEATURES AND WHAT THEY ADDED\]',$recentChangesForTemplate - # Convert literal \n sequences introduced by Escape to real newlines - $content = $content -replace '\\n',[Environment]::NewLine - - $parent = Split-Path -Parent $TargetFile - if (-not (Test-Path $parent)) { New-Item -ItemType Directory -Path $parent | Out-Null } - Set-Content -LiteralPath $TargetFile -Value $content -NoNewline -Encoding utf8 - Remove-Item $temp -Force - return $true -} - -function Update-ExistingAgentFile { - param( - [Parameter(Mandatory=$true)] - [string]$TargetFile, - [Parameter(Mandatory=$true)] - [datetime]$Date - ) - if (-not (Test-Path $TargetFile)) { return (New-AgentFile -TargetFile $TargetFile -ProjectName (Split-Path $REPO_ROOT -Leaf) -Date $Date) } - - $techStack = Format-TechnologyStack -Lang $NEW_LANG -Framework $NEW_FRAMEWORK - $newTechEntries = @() - if ($techStack) { - $escapedTechStack = [Regex]::Escape($techStack) - if (-not (Select-String -Pattern $escapedTechStack -Path $TargetFile -Quiet)) { - $newTechEntries += "- $techStack ($CURRENT_BRANCH)" - } - } - if ($NEW_DB -and $NEW_DB -notin @('N/A','NEEDS CLARIFICATION')) { - $escapedDB = [Regex]::Escape($NEW_DB) - if (-not (Select-String -Pattern $escapedDB -Path $TargetFile -Quiet)) { - $newTechEntries += "- $NEW_DB ($CURRENT_BRANCH)" - } - } - $newChangeEntry = '' - if ($techStack) { $newChangeEntry = "- ${CURRENT_BRANCH}: Added ${techStack}" } - elseif ($NEW_DB -and $NEW_DB -notin @('N/A','NEEDS CLARIFICATION')) { $newChangeEntry = "- ${CURRENT_BRANCH}: Added ${NEW_DB}" } - - $lines = Get-Content -LiteralPath $TargetFile -Encoding utf8 - $output = New-Object System.Collections.Generic.List[string] - $inTech = $false; $inChanges = $false; $techAdded = $false; $changeAdded = $false; $existingChanges = 0 - - for ($i=0; $i -lt $lines.Count; $i++) { - $line = $lines[$i] - if ($line -eq '## Active Technologies') { - $output.Add($line) - $inTech = $true - continue - } - if ($inTech -and $line -match '^##\s') { - if (-not $techAdded -and $newTechEntries.Count -gt 0) { $newTechEntries | ForEach-Object { $output.Add($_) }; $techAdded = $true } - $output.Add($line); $inTech = $false; continue - } - if ($inTech -and [string]::IsNullOrWhiteSpace($line)) { - if (-not $techAdded -and $newTechEntries.Count -gt 0) { $newTechEntries | ForEach-Object { $output.Add($_) }; $techAdded = $true } - $output.Add($line); continue - } - if ($line -eq '## Recent Changes') { - $output.Add($line) - if ($newChangeEntry) { $output.Add($newChangeEntry); $changeAdded = $true } - $inChanges = $true - continue - } - if ($inChanges -and $line -match '^##\s') { $output.Add($line); $inChanges = $false; continue } - if ($inChanges -and $line -match '^- ') { - if ($existingChanges -lt 2) { $output.Add($line); $existingChanges++ } - continue - } - if ($line -match '\*\*Last updated\*\*: .*\d{4}-\d{2}-\d{2}') { - $output.Add(($line -replace '\d{4}-\d{2}-\d{2}',$Date.ToString('yyyy-MM-dd'))) - continue - } - $output.Add($line) - } - - # Post-loop check: if we're still in the Active Technologies section and haven't added new entries - if ($inTech -and -not $techAdded -and $newTechEntries.Count -gt 0) { - $newTechEntries | ForEach-Object { $output.Add($_) } - } - - Set-Content -LiteralPath $TargetFile -Value ($output -join [Environment]::NewLine) -Encoding utf8 - return $true -} - -function Update-AgentFile { - param( - [Parameter(Mandatory=$true)] - [string]$TargetFile, - [Parameter(Mandatory=$true)] - [string]$AgentName - ) - if (-not $TargetFile -or -not $AgentName) { Write-Err 'Update-AgentFile requires TargetFile and AgentName'; return $false } - Write-Info "Updating $AgentName context file: $TargetFile" - $projectName = Split-Path $REPO_ROOT -Leaf - $date = Get-Date - - $dir = Split-Path -Parent $TargetFile - if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir | Out-Null } - - if (-not (Test-Path $TargetFile)) { - 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 } - } else { - try { - if (Update-ExistingAgentFile -TargetFile $TargetFile -Date $date) { Write-Success "Updated existing $AgentName context file" } else { Write-Err 'Failed to update agent file'; return $false } - } catch { - Write-Err "Cannot access or update existing file: $TargetFile. $_" - return $false - } - } - return $true -} - -function Update-SpecificAgent { - param( - [Parameter(Mandatory=$true)] - [string]$Type - ) - switch ($Type) { - 'claude' { Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code' } - 'gemini' { Update-AgentFile -TargetFile $GEMINI_FILE -AgentName 'Gemini CLI' } - 'copilot' { Update-AgentFile -TargetFile $COPILOT_FILE -AgentName 'GitHub Copilot' } - 'cursor-agent' { Update-AgentFile -TargetFile $CURSOR_FILE -AgentName 'Cursor IDE' } - 'qwen' { Update-AgentFile -TargetFile $QWEN_FILE -AgentName 'Qwen Code' } - 'opencode' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'opencode' } - 'codex' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex CLI' } - 'windsurf' { Update-AgentFile -TargetFile $WINDSURF_FILE -AgentName 'Windsurf' } - 'kilocode' { Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code' } - 'auggie' { Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI' } - 'roo' { Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code' } - 'codebuddy' { Update-AgentFile -TargetFile $CODEBUDDY_FILE -AgentName 'CodeBuddy CLI' } - 'qoder' { Update-AgentFile -TargetFile $QODER_FILE -AgentName 'Qoder CLI' } - 'amp' { Update-AgentFile -TargetFile $AMP_FILE -AgentName 'Amp' } - 'shai' { Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI' } - 'q' { Update-AgentFile -TargetFile $Q_FILE -AgentName 'Amazon Q Developer CLI' } - 'agy' { Update-AgentFile -TargetFile $AGY_FILE -AgentName 'Antigravity' } - 'bob' { Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob' } - default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qoder'; return $false } - } -} - -function Update-AllExistingAgents { - $found = $false - $ok = $true - if (Test-Path $CLAUDE_FILE) { if (-not (Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false }; $found = $true } - if (Test-Path $GEMINI_FILE) { if (-not (Update-AgentFile -TargetFile $GEMINI_FILE -AgentName 'Gemini CLI')) { $ok = $false }; $found = $true } - if (Test-Path $COPILOT_FILE) { if (-not (Update-AgentFile -TargetFile $COPILOT_FILE -AgentName 'GitHub Copilot')) { $ok = $false }; $found = $true } - if (Test-Path $CURSOR_FILE) { if (-not (Update-AgentFile -TargetFile $CURSOR_FILE -AgentName 'Cursor IDE')) { $ok = $false }; $found = $true } - if (Test-Path $QWEN_FILE) { if (-not (Update-AgentFile -TargetFile $QWEN_FILE -AgentName 'Qwen Code')) { $ok = $false }; $found = $true } - if (Test-Path $AGENTS_FILE) { if (-not (Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex/opencode')) { $ok = $false }; $found = $true } - if (Test-Path $WINDSURF_FILE) { if (-not (Update-AgentFile -TargetFile $WINDSURF_FILE -AgentName 'Windsurf')) { $ok = $false }; $found = $true } - if (Test-Path $KILOCODE_FILE) { if (-not (Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code')) { $ok = $false }; $found = $true } - if (Test-Path $AUGGIE_FILE) { if (-not (Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI')) { $ok = $false }; $found = $true } - if (Test-Path $ROO_FILE) { if (-not (Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code')) { $ok = $false }; $found = $true } - if (Test-Path $CODEBUDDY_FILE) { if (-not (Update-AgentFile -TargetFile $CODEBUDDY_FILE -AgentName 'CodeBuddy CLI')) { $ok = $false }; $found = $true } - if (Test-Path $QODER_FILE) { if (-not (Update-AgentFile -TargetFile $QODER_FILE -AgentName 'Qoder CLI')) { $ok = $false }; $found = $true } - if (Test-Path $SHAI_FILE) { if (-not (Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI')) { $ok = $false }; $found = $true } - if (Test-Path $Q_FILE) { if (-not (Update-AgentFile -TargetFile $Q_FILE -AgentName 'Amazon Q Developer CLI')) { $ok = $false }; $found = $true } - if (Test-Path $AGY_FILE) { if (-not (Update-AgentFile -TargetFile $AGY_FILE -AgentName 'Antigravity')) { $ok = $false }; $found = $true } - if (Test-Path $BOB_FILE) { if (-not (Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob')) { $ok = $false }; $found = $true } - if (-not $found) { - Write-Info 'No existing agent files found, creating default Claude file...' - if (-not (Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false } - } - return $ok -} - -function Print-Summary { - Write-Host '' - Write-Info 'Summary of changes:' - if ($NEW_LANG) { Write-Host " - Added language: $NEW_LANG" } - if ($NEW_FRAMEWORK) { Write-Host " - Added framework: $NEW_FRAMEWORK" } - if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Host " - Added database: $NEW_DB" } - Write-Host '' - Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|q|agy|bob|qoder]' -} - -function Main { - Validate-Environment - Write-Info "=== Updating agent context files for feature $CURRENT_BRANCH ===" - if (-not (Parse-PlanData -PlanFile $NEW_PLAN)) { Write-Err 'Failed to parse plan data'; exit 1 } - $success = $true - if ($AgentType) { - Write-Info "Updating specific agent: $AgentType" - if (-not (Update-SpecificAgent -Type $AgentType)) { $success = $false } - } - else { - Write-Info 'No agent specified, updating all existing agent files...' - if (-not (Update-AllExistingAgents)) { $success = $false } - } - Print-Summary - if ($success) { Write-Success 'Agent context update completed successfully'; exit 0 } else { Write-Err 'Agent context update completed with errors'; exit 1 } -} - -Main - diff --git a/spec-driven.md b/spec-driven.md index 70b9789708..090071a3c4 100644 --- a/spec-driven.md +++ b/spec-driven.md @@ -78,7 +78,7 @@ The SDD methodology is significantly enhanced through three powerful commands th This command transforms a simple feature description (the user-prompt) into a complete, structured specification with automatic repository management: -1. **Automatic Feature Numbering**: Scans existing specs to determine the next feature number (e.g., 001, 002, 003) +1. **Automatic Feature Numbering**: Scans existing specs to determine the next feature number (e.g., 001, 002, 003, …, 1000 — expands beyond 3 digits automatically) 2. **Branch Creation**: Generates a semantic branch name from your description and creates it automatically 3. **Template-Based Generation**: Copies and customizes the feature specification template with your requirements 4. **Directory Structure**: Creates the proper `specs/[branch-name]/` structure for all related documents diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 70c5bd27c5..1c3e63ec03 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -6,7 +6,9 @@ # "rich", # "platformdirs", # "readchar", -# "httpx", +# "json5", +# "pyyaml", +# "packaging", # ] # /// """ @@ -30,16 +32,21 @@ import zipfile import tempfile import shutil -import shlex import json +import json5 +import stat +import shlex +import urllib.error +import urllib.request +import yaml from pathlib import Path -from typing import Optional, Tuple + +from packaging.version import InvalidVersion, Version +from typing import Any, Optional import typer -import httpx from rich.console import Console from rich.panel import Panel -from rich.progress import Progress, SpinnerColumn, TextColumn from rich.text import Text from rich.live import Live from rich.align import Align @@ -49,202 +56,93 @@ # For cross-platform keyboard input import readchar -import ssl -import truststore -from datetime import datetime, timezone - -ssl_context = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) -client = httpx.Client(verify=ssl_context) - -def _github_token(cli_token: str | None = None) -> str | None: - """Return sanitized GitHub token (cli arg takes precedence) or None.""" - return ((cli_token or os.getenv("GH_TOKEN") or os.getenv("GITHUB_TOKEN") or "").strip()) or None - -def _github_auth_headers(cli_token: str | None = None) -> dict: - """Return Authorization header dict only when a non-empty token exists.""" - token = _github_token(cli_token) - return {"Authorization": f"Bearer {token}"} if token else {} - -def _parse_rate_limit_headers(headers: httpx.Headers) -> dict: - """Extract and parse GitHub rate-limit headers.""" - info = {} - - # Standard GitHub rate-limit headers - if "X-RateLimit-Limit" in headers: - info["limit"] = headers.get("X-RateLimit-Limit") - if "X-RateLimit-Remaining" in headers: - info["remaining"] = headers.get("X-RateLimit-Remaining") - if "X-RateLimit-Reset" in headers: - reset_epoch = int(headers.get("X-RateLimit-Reset", "0")) - if reset_epoch: - reset_time = datetime.fromtimestamp(reset_epoch, tz=timezone.utc) - info["reset_epoch"] = reset_epoch - info["reset_time"] = reset_time - info["reset_local"] = reset_time.astimezone() - - # Retry-After header (seconds or HTTP-date) - if "Retry-After" in headers: - retry_after = headers.get("Retry-After") - try: - info["retry_after_seconds"] = int(retry_after) - except ValueError: - # HTTP-date format - not implemented, just store as string - info["retry_after"] = retry_after - - return info - -def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) -> str: - """Format a user-friendly error message with rate-limit information.""" - rate_info = _parse_rate_limit_headers(headers) - - lines = [f"GitHub API returned status {status_code} for {url}"] - lines.append("") - - if rate_info: - lines.append("[bold]Rate Limit Information:[/bold]") - if "limit" in rate_info: - lines.append(f" • Rate Limit: {rate_info['limit']} requests/hour") - if "remaining" in rate_info: - lines.append(f" • Remaining: {rate_info['remaining']}") - if "reset_local" in rate_info: - reset_str = rate_info["reset_local"].strftime("%Y-%m-%d %H:%M:%S %Z") - lines.append(f" • Resets at: {reset_str}") - if "retry_after_seconds" in rate_info: - lines.append(f" • Retry after: {rate_info['retry_after_seconds']} seconds") - lines.append("") - - # Add troubleshooting guidance - lines.append("[bold]Troubleshooting Tips:[/bold]") - lines.append(" • If you're on a shared CI or corporate environment, you may be rate-limited.") - lines.append(" • Consider using a GitHub token via --github-token or the GH_TOKEN/GITHUB_TOKEN") - lines.append(" environment variable to increase rate limits.") - lines.append(" • Authenticated requests have a limit of 5,000/hour vs 60/hour for unauthenticated.") - - return "\n".join(lines) - -# Agent configuration with name, folder, install URL, and CLI tool requirement -AGENT_CONFIG = { - "copilot": { - "name": "GitHub Copilot", - "folder": ".github/", - "install_url": None, # IDE-based, no CLI check needed - "requires_cli": False, - }, - "claude": { - "name": "Claude Code", - "folder": ".claude/", - "install_url": "https://docs.anthropic.com/en/docs/claude-code/setup", - "requires_cli": True, - }, - "gemini": { - "name": "Gemini CLI", - "folder": ".gemini/", - "install_url": "https://github.com/google-gemini/gemini-cli", - "requires_cli": True, - }, - "cursor-agent": { - "name": "Cursor", - "folder": ".cursor/", - "install_url": None, # IDE-based - "requires_cli": False, - }, - "qwen": { - "name": "Qwen Code", - "folder": ".qwen/", - "install_url": "https://github.com/QwenLM/qwen-code", - "requires_cli": True, - }, - "opencode": { - "name": "opencode", - "folder": ".opencode/", - "install_url": "https://opencode.ai", - "requires_cli": True, - }, - "codex": { - "name": "Codex CLI", - "folder": ".codex/", - "install_url": "https://github.com/openai/codex", - "requires_cli": True, - }, - "windsurf": { - "name": "Windsurf", - "folder": ".windsurf/", - "install_url": None, # IDE-based - "requires_cli": False, - }, - "kilocode": { - "name": "Kilo Code", - "folder": ".kilocode/", - "install_url": None, # IDE-based - "requires_cli": False, - }, - "auggie": { - "name": "Auggie CLI", - "folder": ".augment/", - "install_url": "https://docs.augmentcode.com/cli/setup-auggie/install-auggie-cli", - "requires_cli": True, - }, - "codebuddy": { - "name": "CodeBuddy", - "folder": ".codebuddy/", - "install_url": "https://www.codebuddy.ai/cli", - "requires_cli": True, - }, - "qoder": { - "name": "Qoder CLI", - "folder": ".qoder/", - "install_url": "https://qoder.com/cli", - "requires_cli": True, - }, - "roo": { - "name": "Roo Code", - "folder": ".roo/", - "install_url": None, # IDE-based - "requires_cli": False, - }, - "q": { - "name": "Amazon Q Developer CLI", - "folder": ".amazonq/", - "install_url": "https://aws.amazon.com/developer/learning/q-developer-cli/", - "requires_cli": True, - }, - "amp": { - "name": "Amp", - "folder": ".agents/", - "install_url": "https://ampcode.com/manual#install", - "requires_cli": True, - }, - "shai": { - "name": "SHAI", - "folder": ".shai/", - "install_url": "https://github.com/ovh/shai", - "requires_cli": True, - }, - "agy": { - "name": "Antigravity", - "folder": ".agent/", - "install_url": None, # IDE-based - "requires_cli": False, - }, - "bob": { - "name": "IBM Bob", - "folder": ".bob/", - "install_url": None, # IDE-based - "requires_cli": False, - }, + +GITHUB_API_LATEST = "https://api.github.com/repos/github/spec-kit/releases/latest" + +def _build_agent_config() -> dict[str, dict[str, Any]]: + """Derive AGENT_CONFIG from INTEGRATION_REGISTRY.""" + from .integrations import INTEGRATION_REGISTRY + config: dict[str, dict[str, Any]] = {} + for key, integration in INTEGRATION_REGISTRY.items(): + if integration.config: + config[key] = dict(integration.config) + return config + +AGENT_CONFIG = _build_agent_config() + +AI_ASSISTANT_ALIASES = { + "kiro": "kiro-cli", } +# Agents that use TOML command format (others use Markdown) +_TOML_AGENTS = frozenset({"gemini", "tabnine"}) + +def _build_ai_assistant_help() -> str: + """Build the --ai help text from AGENT_CONFIG so it stays in sync with runtime config.""" + + non_generic_agents = sorted(agent for agent in AGENT_CONFIG if agent != "generic") + base_help = ( + f"AI assistant to use: {', '.join(non_generic_agents)}, " + "or generic (requires --ai-commands-dir)." + ) + + if not AI_ASSISTANT_ALIASES: + return base_help + + alias_phrases = [] + for alias, target in sorted(AI_ASSISTANT_ALIASES.items()): + alias_phrases.append(f"'{alias}' as an alias for '{target}'") + + if len(alias_phrases) == 1: + aliases_text = alias_phrases[0] + else: + aliases_text = ', '.join(alias_phrases[:-1]) + ' and ' + alias_phrases[-1] + + return base_help + " Use " + aliases_text + "." +AI_ASSISTANT_HELP = _build_ai_assistant_help() + + +def _build_integration_equivalent( + integration_key: str, + ai_commands_dir: str | None = None, +) -> str: + """Build the modern --integration equivalent for legacy --ai usage.""" + + parts = [f"--integration {integration_key}"] + if integration_key == "generic" and ai_commands_dir: + parts.append( + f'--integration-options="--commands-dir {shlex.quote(ai_commands_dir)}"' + ) + return " ".join(parts) + + +def _build_ai_deprecation_warning( + integration_key: str, + ai_commands_dir: str | None = None, +) -> str: + """Build the legacy --ai deprecation warning message.""" + + replacement = _build_integration_equivalent( + integration_key, + ai_commands_dir=ai_commands_dir, + ) + return ( + "[bold]--ai[/bold] is deprecated and will no longer be available in version 1.0.0 or later.\n\n" + f"Use [bold]{replacement}[/bold] instead." + ) + SCRIPT_TYPE_CHOICES = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"} CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude" +CLAUDE_NPM_LOCAL_PATH = Path.home() / ".claude" / "local" / "node_modules" / ".bin" / "claude" BANNER = """ ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•—ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•— ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•— ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•—ā–ˆā–ˆā•—ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•—ā–ˆā–ˆā•— ā–ˆā–ˆā•— ā–ˆā–ˆā•”ā•ā•ā•ā•ā•ā–ˆā–ˆā•”ā•ā•ā–ˆā–ˆā•—ā–ˆā–ˆā•”ā•ā•ā•ā•ā•ā–ˆā–ˆā•”ā•ā•ā•ā•ā•ā–ˆā–ˆā•‘ā–ˆā–ˆā•”ā•ā•ā•ā•ā•ā•šā–ˆā–ˆā•— ā–ˆā–ˆā•”ā• -ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•—ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•”ā•ā–ˆā–ˆā–ˆā–ˆā–ˆā•— ā–ˆā–ˆā•‘ ā–ˆā–ˆā•‘ā–ˆā–ˆā–ˆā–ˆā–ˆā•— ā•šā–ˆā–ˆā–ˆā–ˆā•”ā• -ā•šā•ā•ā•ā•ā–ˆā–ˆā•‘ā–ˆā–ˆā•”ā•ā•ā•ā• ā–ˆā–ˆā•”ā•ā•ā• ā–ˆā–ˆā•‘ ā–ˆā–ˆā•‘ā–ˆā–ˆā•”ā•ā•ā• ā•šā–ˆā–ˆā•”ā• -ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•‘ā–ˆā–ˆā•‘ ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•—ā•šā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•—ā–ˆā–ˆā•‘ā–ˆā–ˆā•‘ ā–ˆā–ˆā•‘ -ā•šā•ā•ā•ā•ā•ā•ā•ā•šā•ā• ā•šā•ā•ā•ā•ā•ā•ā• ā•šā•ā•ā•ā•ā•ā•ā•šā•ā•ā•šā•ā• ā•šā•ā• +ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•—ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•”ā•ā–ˆā–ˆā–ˆā–ˆā–ˆā•— ā–ˆā–ˆā•‘ ā–ˆā–ˆā•‘ā–ˆā–ˆā–ˆā–ˆā–ˆā•— ā•šā–ˆā–ˆā–ˆā–ˆā•”ā• +ā•šā•ā•ā•ā•ā–ˆā–ˆā•‘ā–ˆā–ˆā•”ā•ā•ā•ā• ā–ˆā–ˆā•”ā•ā•ā• ā–ˆā–ˆā•‘ ā–ˆā–ˆā•‘ā–ˆā–ˆā•”ā•ā•ā• ā•šā–ˆā–ˆā•”ā• +ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•‘ā–ˆā–ˆā•‘ ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•—ā•šā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•—ā–ˆā–ˆā•‘ā–ˆā–ˆā•‘ ā–ˆā–ˆā•‘ +ā•šā•ā•ā•ā•ā•ā•ā•ā•šā•ā• ā•šā•ā•ā•ā•ā•ā•ā• ā•šā•ā•ā•ā•ā•ā•ā•šā•ā•ā•šā•ā• ā•šā•ā• """ TAGLINE = "GitHub Spec Kit - Spec-Driven Development Toolkit" @@ -356,12 +254,12 @@ def get_key(): def select_with_arrows(options: dict, prompt_text: str = "Select an option", default_key: str = None) -> str: """ Interactive selection using arrow keys with Rich Live display. - + Args: options: Dict with keys as option keys and values as descriptions prompt_text: Text to show above the options default_key: Default option key to start with - + Returns: Selected option key """ @@ -428,7 +326,7 @@ def run_selection_loop(): return selected_key -console = Console() +console = Console(highlight=False) class BannerGroup(TyperGroup): """Custom group that shows banner before help.""" @@ -461,8 +359,16 @@ def show_banner(): console.print(Align.center(Text(TAGLINE, style="italic bright_yellow"))) console.print() +def _version_callback(value: bool): + if value: + console.print(f"specify {get_speckit_version()}") + raise typer.Exit() + @app.callback() -def callback(ctx: typer.Context): +def callback( + ctx: typer.Context, + version: bool = typer.Option(False, "--version", "-V", callback=_version_callback, is_eager=True, help="Show version and exit."), +): """Show banner when no subcommand is provided.""" if ctx.invoked_subcommand is None and "--help" not in sys.argv and "-h" not in sys.argv: show_banner() @@ -489,45 +395,52 @@ def run_command(cmd: list[str], check_return: bool = True, capture: bool = False def check_tool(tool: str, tracker: StepTracker = None) -> bool: """Check if a tool is installed. Optionally update tracker. - + Args: tool: Name of the tool to check tracker: Optional StepTracker to update with results - + Returns: True if tool is found, False otherwise """ - # Special handling for Claude CLI after `claude migrate-installer` + # Special handling for Claude CLI local installs # See: https://github.com/github/spec-kit/issues/123 - # The migrate-installer command REMOVES the original executable from PATH - # and creates an alias at ~/.claude/local/claude instead - # This path should be prioritized over other claude executables in PATH + # See: https://github.com/github/spec-kit/issues/550 + # Claude Code can be installed in two local paths: + # 1. ~/.claude/local/claude (after `claude migrate-installer`) + # 2. ~/.claude/local/node_modules/.bin/claude (npm-local install, e.g. via nvm) + # Neither path may be on the system PATH, so we check them explicitly. if tool == "claude": - if CLAUDE_LOCAL_PATH.exists() and CLAUDE_LOCAL_PATH.is_file(): + if CLAUDE_LOCAL_PATH.is_file() or CLAUDE_NPM_LOCAL_PATH.is_file(): if tracker: tracker.complete(tool, "available") return True - - found = shutil.which(tool) is not None - + + if tool == "kiro-cli": + # Kiro currently supports both executable names. Prefer kiro-cli and + # accept kiro as a compatibility fallback. + found = shutil.which("kiro-cli") is not None or shutil.which("kiro") is not None + else: + found = shutil.which(tool) is not None + if tracker: if found: tracker.complete(tool, "available") else: tracker.error(tool, "not found") - + return found + def is_git_repo(path: Path = None) -> bool: """Check if the specified path is inside a git repository.""" if path is None: path = Path.cwd() - + if not path.is_dir(): return False try: - # Use git command to check if inside a work tree subprocess.run( ["git", "rev-parse", "--is-inside-work-tree"], check=True, @@ -538,16 +451,9 @@ def is_git_repo(path: Path = None) -> bool: except (subprocess.CalledProcessError, FileNotFoundError): return False -def init_git_repo(project_path: Path, quiet: bool = False) -> Tuple[bool, Optional[str]]: - """Initialize a git repository in the specified path. - - Args: - project_path: Path to initialize git repository in - quiet: if True suppress console output (tracker handles status) - - Returns: - Tuple of (success: bool, error_message: Optional[str]) - """ + +def init_git_repo(project_path: Path, quiet: bool = False) -> tuple[bool, Optional[str]]: + """Initialize a git repository in the specified path.""" try: original_cwd = Path.cwd() os.chdir(project_path) @@ -559,52 +465,96 @@ def init_git_repo(project_path: Path, quiet: bool = False) -> Tuple[bool, Option if not quiet: console.print("[green]āœ“[/green] Git repository initialized") return True, None - except subprocess.CalledProcessError as e: error_msg = f"Command: {' '.join(e.cmd)}\nExit code: {e.returncode}" if e.stderr: error_msg += f"\nError: {e.stderr.strip()}" elif e.stdout: error_msg += f"\nOutput: {e.stdout.strip()}" - if not quiet: console.print(f"[red]Error initializing git repository:[/red] {e}") return False, error_msg finally: os.chdir(original_cwd) + def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker=None) -> None: - """Handle merging or copying of .vscode/settings.json files.""" + """Handle merging or copying of .vscode/settings.json files. + + Note: when merge produces changes, rewritten output is normalized JSON and + existing JSONC comments/trailing commas are not preserved. + """ def log(message, color="green"): if verbose and not tracker: console.print(f"[{color}]{message}[/] {rel_path}") + def atomic_write_json(target_file: Path, payload: dict[str, Any]) -> None: + """Atomically write JSON while preserving existing mode bits when possible.""" + temp_path: Optional[Path] = None + try: + with tempfile.NamedTemporaryFile( + mode='w', + encoding='utf-8', + dir=target_file.parent, + prefix=f"{target_file.name}.", + suffix=".tmp", + delete=False, + ) as f: + temp_path = Path(f.name) + json.dump(payload, f, indent=4) + f.write('\n') + + if target_file.exists(): + try: + existing_stat = target_file.stat() + os.chmod(temp_path, stat.S_IMODE(existing_stat.st_mode)) + if hasattr(os, "chown"): + try: + os.chown(temp_path, existing_stat.st_uid, existing_stat.st_gid) + except PermissionError: + # Best-effort owner/group preservation without requiring elevated privileges. + pass + except OSError: + # Best-effort metadata preservation; data safety is prioritized. + pass + + os.replace(temp_path, target_file) + except Exception: + if temp_path and temp_path.exists(): + temp_path.unlink() + raise + try: with open(sub_item, 'r', encoding='utf-8') as f: - new_settings = json.load(f) + # json5 natively supports comments and trailing commas (JSONC) + new_settings = json5.load(f) if dest_file.exists(): merged = merge_json_files(dest_file, new_settings, verbose=verbose and not tracker) - with open(dest_file, 'w', encoding='utf-8') as f: - json.dump(merged, f, indent=4) - f.write('\n') - log("Merged:", "green") + if merged is not None: + atomic_write_json(dest_file, merged) + log("Merged:", "green") + log("Note: comments/trailing commas are normalized when rewritten", "yellow") + else: + log("Skipped merge (preserved existing settings)", "yellow") else: shutil.copy2(sub_item, dest_file) log("Copied (no existing settings.json):", "blue") except Exception as e: - log(f"Warning: Could not merge, copying instead: {e}", "yellow") - shutil.copy2(sub_item, dest_file) + log(f"Warning: Could not merge settings: {e}", "yellow") + if not dest_file.exists(): + shutil.copy2(sub_item, dest_file) + -def merge_json_files(existing_path: Path, new_content: dict, verbose: bool = False) -> dict: +def merge_json_files(existing_path: Path, new_content: Any, verbose: bool = False) -> Optional[dict[str, Any]]: """Merge new JSON content into existing JSON file. - Performs a deep merge where: + Performs a polite deep merge where: - New keys are added - - Existing keys are preserved unless overwritten by new content - - Nested dictionaries are merged recursively - - Lists and other values are replaced (not merged) + - Existing keys are preserved (not overwritten) unless both values are dictionaries + - Nested dictionaries are merged recursively only when both sides are dictionaries + - Lists and other values are preserved from base if they exist Args: existing_path: Path to existing JSON file @@ -612,330 +562,300 @@ def merge_json_files(existing_path: Path, new_content: dict, verbose: bool = Fal verbose: Whether to print merge details Returns: - Merged JSON content as dict + Merged JSON content as dict, or None if the existing file should be left untouched. """ - try: - with open(existing_path, 'r', encoding='utf-8') as f: - existing_content = json.load(f) - except (FileNotFoundError, json.JSONDecodeError): - # If file doesn't exist or is invalid, just use new content + # Load existing content first to have a safe fallback + existing_content = None + exists = existing_path.exists() + + if exists: + try: + with open(existing_path, 'r', encoding='utf-8') as f: + # Handle comments (JSONC) natively with json5 + # Note: json5 handles BOM automatically + existing_content = json5.load(f) + except FileNotFoundError: + # Handle race condition where file is deleted after exists() check + exists = False + except Exception as e: + if verbose: + console.print(f"[yellow]Warning: Could not read or parse existing JSON in {existing_path.name} ({e}).[/yellow]") + # Skip merge to preserve existing file if unparseable or inaccessible (e.g. PermissionError) + return None + + # Validate template content + if not isinstance(new_content, dict): + if verbose: + console.print(f"[yellow]Warning: Template content for {existing_path.name} is not a dictionary. Preserving existing settings.[/yellow]") + return None + + if not exists: return new_content - def deep_merge(base: dict, update: dict) -> dict: - """Recursively merge update dict into base dict.""" + # If existing content parsed but is not a dict, skip merge to avoid data loss + if not isinstance(existing_content, dict): + if verbose: + console.print(f"[yellow]Warning: Existing JSON in {existing_path.name} is not an object. Skipping merge to avoid data loss.[/yellow]") + return None + + def deep_merge_polite(base: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]: + """Recursively merge update dict into base dict, preserving base values.""" result = base.copy() for key, value in update.items(): - if key in result and isinstance(result[key], dict) and isinstance(value, dict): + if key not in result: + # Add new key + result[key] = value + elif isinstance(result[key], dict) and isinstance(value, dict): # Recursively merge nested dictionaries - result[key] = deep_merge(result[key], value) + result[key] = deep_merge_polite(result[key], value) else: - # Add new key or replace existing value - result[key] = value + # Key already exists and values are not both dicts; preserve existing value. + # This ensures user settings aren't overwritten by template defaults. + pass return result - merged = deep_merge(existing_content, new_content) + merged = deep_merge_polite(existing_content, new_content) + + # Detect if anything actually changed. If not, return None so the caller + # can skip rewriting the file (preserving user's comments/formatting). + if merged == existing_content: + return None if verbose: console.print(f"[cyan]Merged JSON file:[/cyan] {existing_path.name}") return merged -def 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]: - repo_owner = "github" - repo_name = "spec-kit" - if client is None: - client = httpx.Client(verify=ssl_context) +def _locate_core_pack() -> Path | None: + """Return the filesystem path to the bundled core_pack directory, or None. - if verbose: - console.print("[cyan]Fetching latest release information...[/cyan]") - api_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/latest" + Only present in wheel installs: hatchling's force-include copies + templates/, scripts/ etc. into specify_cli/core_pack/ at build time. - try: - response = client.get( - api_url, - timeout=30, - follow_redirects=True, - headers=_github_auth_headers(github_token), - ) - status = response.status_code - if status != 200: - # Format detailed error message with rate-limit info - error_msg = _format_rate_limit_error(status, response.headers, api_url) - if debug: - error_msg += f"\n\n[dim]Response body (truncated 500):[/dim]\n{response.text[:500]}" - raise RuntimeError(error_msg) - try: - release_data = response.json() - except ValueError as je: - raise RuntimeError(f"Failed to parse release JSON: {je}\nRaw (truncated 400): {response.text[:400]}") - except Exception as e: - console.print(f"[red]Error fetching release information[/red]") - console.print(Panel(str(e), title="Fetch Error", border_style="red")) - raise typer.Exit(1) + Source-checkout and editable installs do NOT have this directory. + Callers that need to work in both environments must check the repo-root + trees (templates/, scripts/) as a fallback when this returns None. + """ + # Wheel install: core_pack is a sibling directory of this file + candidate = Path(__file__).parent / "core_pack" + if candidate.is_dir(): + return candidate + return None - assets = release_data.get("assets", []) - pattern = f"spec-kit-template-{ai_assistant}-{script_type}" - matching_assets = [ - asset for asset in assets - if pattern in asset["name"] and asset["name"].endswith(".zip") - ] - asset = matching_assets[0] if matching_assets else None +def _locate_bundled_extension(extension_id: str) -> Path | None: + """Return the path to a bundled extension, or None. - if asset is None: - console.print(f"[red]No matching release asset found[/red] for [bold]{ai_assistant}[/bold] (expected pattern: [bold]{pattern}[/bold])") - asset_names = [a.get('name', '?') for a in assets] - console.print(Panel("\n".join(asset_names) or "(no assets)", title="Available Assets", border_style="yellow")) - raise typer.Exit(1) + Checks the wheel's core_pack first, then falls back to the + source-checkout ``extensions//`` directory. + """ + import re as _re + if not _re.match(r'^[a-z0-9-]+$', extension_id): + return None - download_url = asset["browser_download_url"] - filename = asset["name"] - file_size = asset["size"] + core = _locate_core_pack() + if core is not None: + candidate = core / "extensions" / extension_id + if (candidate / "extension.yml").is_file(): + return candidate - if verbose: - console.print(f"[cyan]Found template:[/cyan] {filename}") - console.print(f"[cyan]Size:[/cyan] {file_size:,} bytes") - console.print(f"[cyan]Release:[/cyan] {release_data['tag_name']}") + # Source-checkout / editable install: look relative to repo root + repo_root = Path(__file__).parent.parent.parent + candidate = repo_root / "extensions" / extension_id + if (candidate / "extension.yml").is_file(): + return candidate - zip_path = download_dir / filename - if verbose: - console.print(f"[cyan]Downloading template...[/cyan]") + return None - try: - with client.stream( - "GET", - download_url, - timeout=60, - follow_redirects=True, - headers=_github_auth_headers(github_token), - ) as response: - if response.status_code != 200: - # Handle rate-limiting on download as well - error_msg = _format_rate_limit_error(response.status_code, response.headers, download_url) - if debug: - error_msg += f"\n\n[dim]Response body (truncated 400):[/dim]\n{response.text[:400]}" - raise RuntimeError(error_msg) - total_size = int(response.headers.get('content-length', 0)) - with open(zip_path, 'wb') as f: - if total_size == 0: - for chunk in response.iter_bytes(chunk_size=8192): - f.write(chunk) - else: - if show_progress: - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), - console=console, - ) as progress: - task = progress.add_task("Downloading...", total=total_size) - downloaded = 0 - for chunk in response.iter_bytes(chunk_size=8192): - f.write(chunk) - downloaded += len(chunk) - progress.update(task, completed=downloaded) - else: - for chunk in response.iter_bytes(chunk_size=8192): - f.write(chunk) - except Exception as e: - console.print(f"[red]Error downloading template[/red]") - detail = str(e) - if zip_path.exists(): - zip_path.unlink() - console.print(Panel(detail, title="Download Error", border_style="red")) - raise typer.Exit(1) - if verbose: - console.print(f"Downloaded: {filename}") - metadata = { - "filename": filename, - "size": file_size, - "release": release_data["tag_name"], - "asset_url": download_url - } - return zip_path, metadata -def 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: - """Download the latest release and extract it to create a new project. - Returns project_path. Uses tracker if provided (with keys: fetch, download, extract, cleanup) +def _locate_bundled_workflow(workflow_id: str) -> Path | None: + """Return the path to a bundled workflow directory, or None. + + Checks the wheel's core_pack first, then falls back to the + source-checkout ``workflows//`` directory. """ - current_dir = Path.cwd() + import re as _re + if not _re.match(r'^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$', workflow_id): + return None - if tracker: - tracker.start("fetch", "contacting GitHub API") - try: - zip_path, meta = download_template_from_github( - ai_assistant, - current_dir, - script_type=script_type, - verbose=verbose and tracker is None, - show_progress=(tracker is None), - client=client, - debug=debug, - github_token=github_token - ) - if tracker: - tracker.complete("fetch", f"release {meta['release']} ({meta['size']:,} bytes)") - tracker.add("download", "Download template") - tracker.complete("download", meta['filename']) - except Exception as e: - if tracker: - tracker.error("fetch", str(e)) - else: - if verbose: - console.print(f"[red]Error downloading template:[/red] {e}") - raise + core = _locate_core_pack() + if core is not None: + candidate = core / "workflows" / workflow_id + if (candidate / "workflow.yml").is_file(): + return candidate - if tracker: - tracker.add("extract", "Extract template") - tracker.start("extract") - elif verbose: - console.print("Extracting template...") + # Source-checkout / editable install: look relative to repo root + repo_root = Path(__file__).parent.parent.parent + candidate = repo_root / "workflows" / workflow_id + if (candidate / "workflow.yml").is_file(): + return candidate - try: - if not is_current_dir: - project_path.mkdir(parents=True) + return None - with zipfile.ZipFile(zip_path, 'r') as zip_ref: - zip_contents = zip_ref.namelist() - if tracker: - tracker.start("zip-list") - tracker.complete("zip-list", f"{len(zip_contents)} entries") - elif verbose: - console.print(f"[cyan]ZIP contains {len(zip_contents)} items[/cyan]") - - if is_current_dir: - with tempfile.TemporaryDirectory() as temp_dir: - temp_path = Path(temp_dir) - zip_ref.extractall(temp_path) - - extracted_items = list(temp_path.iterdir()) - if tracker: - tracker.start("extracted-summary") - tracker.complete("extracted-summary", f"temp {len(extracted_items)} items") - elif verbose: - console.print(f"[cyan]Extracted {len(extracted_items)} items to temp location[/cyan]") - - source_dir = temp_path - if len(extracted_items) == 1 and extracted_items[0].is_dir(): - source_dir = extracted_items[0] - if tracker: - tracker.add("flatten", "Flatten nested directory") - tracker.complete("flatten") - elif verbose: - console.print(f"[cyan]Found nested directory structure[/cyan]") - - for item in source_dir.iterdir(): - dest_path = project_path / item.name - if item.is_dir(): - if dest_path.exists(): - if verbose and not tracker: - console.print(f"[yellow]Merging directory:[/yellow] {item.name}") - for sub_item in item.rglob('*'): - if sub_item.is_file(): - rel_path = sub_item.relative_to(item) - dest_file = dest_path / rel_path - dest_file.parent.mkdir(parents=True, exist_ok=True) - # Special handling for .vscode/settings.json - merge instead of overwrite - if dest_file.name == "settings.json" and dest_file.parent.name == ".vscode": - handle_vscode_settings(sub_item, dest_file, rel_path, verbose, tracker) - else: - shutil.copy2(sub_item, dest_file) - else: - shutil.copytree(item, dest_path) - else: - if dest_path.exists() and verbose and not tracker: - console.print(f"[yellow]Overwriting file:[/yellow] {item.name}") - shutil.copy2(item, dest_path) - if verbose and not tracker: - console.print(f"[cyan]Template files merged into current directory[/cyan]") - else: - zip_ref.extractall(project_path) - extracted_items = list(project_path.iterdir()) - if tracker: - tracker.start("extracted-summary") - tracker.complete("extracted-summary", f"{len(extracted_items)} top-level items") - elif verbose: - console.print(f"[cyan]Extracted {len(extracted_items)} items to {project_path}:[/cyan]") - for item in extracted_items: - console.print(f" - {item.name} ({'dir' if item.is_dir() else 'file'})") +def _locate_bundled_preset(preset_id: str) -> Path | None: + """Return the path to a bundled preset, or None. - if len(extracted_items) == 1 and extracted_items[0].is_dir(): - nested_dir = extracted_items[0] - temp_move_dir = project_path.parent / f"{project_path.name}_temp" + Checks the wheel's core_pack first, then falls back to the + source-checkout ``presets//`` directory. + """ + import re as _re + if not _re.match(r'^[a-z0-9-]+$', preset_id): + return None - shutil.move(str(nested_dir), str(temp_move_dir)) + core = _locate_core_pack() + if core is not None: + candidate = core / "presets" / preset_id + if (candidate / "preset.yml").is_file(): + return candidate - project_path.rmdir() + # Source-checkout / editable install: look relative to repo root + repo_root = Path(__file__).parent.parent.parent + candidate = repo_root / "presets" / preset_id + if (candidate / "preset.yml").is_file(): + return candidate - shutil.move(str(temp_move_dir), str(project_path)) - if tracker: - tracker.add("flatten", "Flatten nested directory") - tracker.complete("flatten") - elif verbose: - console.print(f"[cyan]Flattened nested directory structure[/cyan]") + return None - except Exception as e: - if tracker: - tracker.error("extract", str(e)) - else: - if verbose: - console.print(f"[red]Error extracting template:[/red] {e}") - if debug: - console.print(Panel(str(e), title="Extraction Error", border_style="red")) - if not is_current_dir and project_path.exists(): - shutil.rmtree(project_path) - raise typer.Exit(1) - else: - if tracker: - tracker.complete("extract") - finally: - if tracker: - tracker.add("cleanup", "Remove temporary archive") +def _install_shared_infra( + project_path: Path, + script_type: str, + tracker: StepTracker | None = None, + force: bool = False, + invoke_separator: str = ".", +) -> bool: + """Install shared infrastructure files into *project_path*. - if zip_path.exists(): - zip_path.unlink() - if tracker: - tracker.complete("cleanup") - elif verbose: - console.print(f"Cleaned up: {zip_path.name}") + Copies ``.specify/scripts/`` and ``.specify/templates/`` from the + bundled core_pack or source checkout. Tracks all installed files + in ``speckit.manifest.json``. + + Page templates are processed to resolve ``__SPECKIT_COMMAND___`` + placeholders using *invoke_separator* (``"."`` for markdown agents, + ``"-"`` for skills agents). + + When *force* is ``True``, existing files are overwritten with the + latest bundled versions. When ``False`` (default), only missing + files are added and existing ones are skipped. - return project_path + Returns ``True`` on success. + """ + from .integrations.base import IntegrationBase + from .integrations.manifest import IntegrationManifest + + core = _locate_core_pack() + manifest = IntegrationManifest("speckit", project_path, version=get_speckit_version()) + + # Scripts + if core and (core / "scripts").is_dir(): + scripts_src = core / "scripts" + else: + repo_root = Path(__file__).parent.parent.parent + scripts_src = repo_root / "scripts" + + skipped_files: list[str] = [] + + if scripts_src.is_dir(): + dest_scripts = project_path / ".specify" / "scripts" + dest_scripts.mkdir(parents=True, exist_ok=True) + variant_dir = "bash" if script_type == "sh" else "powershell" + variant_src = scripts_src / variant_dir + if variant_src.is_dir(): + dest_variant = dest_scripts / variant_dir + dest_variant.mkdir(parents=True, exist_ok=True) + for src_path in variant_src.rglob("*"): + if src_path.is_file(): + rel_path = src_path.relative_to(variant_src) + dst_path = dest_variant / rel_path + if dst_path.exists() and not force: + skipped_files.append(str(dst_path.relative_to(project_path))) + else: + dst_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src_path, dst_path) + rel = dst_path.relative_to(project_path).as_posix() + manifest.record_existing(rel) + + # Page templates (not command templates, not vscode-settings.json) + if core and (core / "templates").is_dir(): + templates_src = core / "templates" + else: + repo_root = Path(__file__).parent.parent.parent + templates_src = repo_root / "templates" + + if templates_src.is_dir(): + dest_templates = project_path / ".specify" / "templates" + dest_templates.mkdir(parents=True, exist_ok=True) + for f in templates_src.iterdir(): + if f.is_file() and f.name != "vscode-settings.json" and not f.name.startswith("."): + dst = dest_templates / f.name + if dst.exists() and not force: + skipped_files.append(str(dst.relative_to(project_path))) + else: + content = f.read_text(encoding="utf-8") + content = IntegrationBase.resolve_command_refs( + content, invoke_separator + ) + dst.write_text(content, encoding="utf-8") + rel = dst.relative_to(project_path).as_posix() + manifest.record_existing(rel) + + if skipped_files: + console.print( + f"[yellow]⚠[/yellow] {len(skipped_files)} shared infrastructure file(s) already exist and were not updated:" + ) + for f in skipped_files: + console.print(f" {f}") + console.print( + "To refresh shared infrastructure, run " + "[cyan]specify init --here --force[/cyan] or " + "[cyan]specify integration upgrade --force[/cyan]." + ) + + manifest.save() + return True def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = None) -> None: - """Ensure POSIX .sh scripts under .specify/scripts (recursively) have execute bits (no-op on Windows).""" + """Ensure POSIX .sh scripts under .specify/scripts and .specify/extensions (recursively) have execute bits (no-op on Windows).""" if os.name == "nt": return # Windows: skip silently - scripts_root = project_path / ".specify" / "scripts" - if not scripts_root.is_dir(): - return + scan_roots = [ + project_path / ".specify" / "scripts", + project_path / ".specify" / "extensions", + ] failures: list[str] = [] updated = 0 - for script in scripts_root.rglob("*.sh"): - try: - if script.is_symlink() or not script.is_file(): - continue + for scripts_root in scan_roots: + if not scripts_root.is_dir(): + continue + for script in scripts_root.rglob("*.sh"): try: - with script.open("rb") as f: - if f.read(2) != b"#!": - continue - except Exception: - continue - st = script.stat(); mode = st.st_mode - if mode & 0o111: - continue - new_mode = mode - if mode & 0o400: new_mode |= 0o100 - if mode & 0o040: new_mode |= 0o010 - if mode & 0o004: new_mode |= 0o001 - if not (new_mode & 0o100): - new_mode |= 0o100 - os.chmod(script, new_mode) - updated += 1 - except Exception as e: - failures.append(f"{script.relative_to(scripts_root)}: {e}") + if script.is_symlink() or not script.is_file(): + continue + try: + with script.open("rb") as f: + if f.read(2) != b"#!": + continue + except Exception: + continue + st = script.stat() + mode = st.st_mode + if mode & 0o111: + continue + new_mode = mode + if mode & 0o400: + new_mode |= 0o100 + if mode & 0o040: + new_mode |= 0o010 + if mode & 0o004: + new_mode |= 0o001 + if not (new_mode & 0o100): + new_mode |= 0o100 + os.chmod(script, new_mode) + updated += 1 + except Exception as e: + failures.append(f"{script.relative_to(project_path)}: {e}") if tracker: detail = f"{updated} updated" + (f", {len(failures)} failed" if failures else "") tracker.add("chmod", "Set script permissions recursively") @@ -975,7 +895,7 @@ def ensure_constitution_from_template(project_path: Path, tracker: StepTracker | tracker.add("constitution", "Constitution setup") tracker.complete("constitution", "copied from template") else: - console.print(f"[cyan]Initialized constitution from template[/cyan]") + console.print("[cyan]Initialized constitution from template[/cyan]") except Exception as e: if tracker: tracker.add("constitution", "Constitution setup") @@ -983,30 +903,105 @@ def ensure_constitution_from_template(project_path: Path, tracker: StepTracker | else: console.print(f"[yellow]Warning: Could not initialize constitution: {e}[/yellow]") + +INIT_OPTIONS_FILE = ".specify/init-options.json" + + +def save_init_options(project_path: Path, options: dict[str, Any]) -> None: + """Persist the CLI options used during ``specify init``. + + Writes a small JSON file to ``.specify/init-options.json`` so that + later operations (e.g. preset install) can adapt their behaviour + without scanning the filesystem. + """ + dest = project_path / INIT_OPTIONS_FILE + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(json.dumps(options, indent=2, sort_keys=True)) + + +def load_init_options(project_path: Path) -> dict[str, Any]: + """Load the init options previously saved by ``specify init``. + + Returns an empty dict if the file does not exist or cannot be parsed. + """ + path = project_path / INIT_OPTIONS_FILE + if not path.exists(): + return {} + try: + return json.loads(path.read_text()) + except (json.JSONDecodeError, OSError): + return {} + + +def _get_skills_dir(project_path: Path, selected_ai: str) -> Path: + """Resolve the agent-specific skills directory. + + Returns ``project_path / / "skills"``, falling back + to ``project_path / ".agents/skills"`` for unknown agents. + """ + agent_config = AGENT_CONFIG.get(selected_ai, {}) + agent_folder = agent_config.get("folder", "") + if agent_folder: + return project_path / agent_folder.rstrip("/") / "skills" + return project_path / ".agents" / "skills" + + +# Constants kept for backward compatibility with presets and extensions. +DEFAULT_SKILLS_DIR = ".agents/skills" +SKILL_DESCRIPTIONS = { + "specify": "Create or update feature specifications from natural language descriptions.", + "plan": "Generate technical implementation plans from feature specifications.", + "tasks": "Break down implementation plans into actionable task lists.", + "implement": "Execute all tasks from the task breakdown to build the feature.", + "analyze": "Perform cross-artifact consistency analysis across spec.md, plan.md, and tasks.md.", + "clarify": "Structured clarification workflow for underspecified requirements.", + "constitution": "Create or update project governing principles and development guidelines.", + "checklist": "Generate custom quality checklists for validating requirements completeness and clarity.", + "taskstoissues": "Convert tasks from tasks.md into GitHub issues.", +} + + @app.command() def init( project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"), - ai_assistant: str = typer.Option(None, "--ai", help="AI assistant to use: claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, codebuddy, amp, shai, q, agy, bob, or qoder "), + ai_assistant: str = typer.Option(None, "--ai", help=AI_ASSISTANT_HELP), + ai_commands_dir: str = typer.Option(None, "--ai-commands-dir", help="Directory for agent command files (required with --ai generic, e.g. .myagent/commands/)"), script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"), ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for AI agent tools like Claude Code"), no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization"), here: bool = typer.Option(False, "--here", help="Initialize project in the current directory instead of creating a new one"), force: bool = typer.Option(False, "--force", help="Force merge/overwrite when using --here (skip confirmation)"), - skip_tls: bool = typer.Option(False, "--skip-tls", help="Skip SSL/TLS verification (not recommended)"), - debug: bool = typer.Option(False, "--debug", help="Show verbose diagnostic output for network and extraction failures"), - 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)"), + skip_tls: bool = typer.Option(False, "--skip-tls", help="Deprecated (no-op). Previously: skip SSL/TLS verification.", hidden=True), + debug: bool = typer.Option(False, "--debug", help="Deprecated (no-op). Previously: show verbose diagnostic output.", hidden=True), + github_token: str = typer.Option(None, "--github-token", help="Deprecated (no-op). Previously: GitHub token for API requests.", hidden=True), + ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"), + offline: bool = typer.Option(False, "--offline", help="Deprecated (no-op). All scaffolding now uses bundled assets.", hidden=True), + preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"), + branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, …, 1000, … — expands past 999 automatically) or 'timestamp' (YYYYMMDD-HHMMSS)"), + integration: str = typer.Option(None, "--integration", help="Use the new integration system (e.g. --integration copilot). Mutually exclusive with --ai."), + integration_options: str = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'), ): """ - Initialize a new Specify project from the latest template. - + Initialize a new Specify project. + + By default, project files are downloaded from the latest GitHub release. + Use --offline to scaffold from assets bundled inside the specify-cli + package instead (no internet access required, ideal for air-gapped or + enterprise environments). + + NOTE: Starting with v0.6.0, bundled assets will be used by default and + the --offline flag will be removed. The GitHub download path will be + retired because bundled assets eliminate the need for network access, + avoid proxy/firewall issues, and guarantee that templates always match + the installed CLI version. + This command will: 1. Check that required tools are installed (git is optional) 2. Let you choose your AI assistant - 3. Download the appropriate template from GitHub - 4. Extract the template to a new project directory or current directory - 5. Initialize a fresh git repository (if not --no-git and no existing repo) - 6. Optionally set up AI assistant commands - + 3. Download template from GitHub (or use bundled assets with --offline) + 4. Initialize a fresh git repository (if not --no-git and no existing repo) + 5. Optionally set up AI assistant commands + Examples: specify init my-project specify init my-project --ai claude @@ -1015,13 +1010,83 @@ def init( specify init . --ai claude # Initialize in current directory specify init . # Initialize in current directory (interactive AI selection) specify init --here --ai claude # Alternative syntax for current directory - specify init --here --ai codex + specify init --here --ai codex --ai-skills specify init --here --ai codebuddy + specify init --here --ai vibe # Initialize with Mistral Vibe support specify init --here specify init --here --force # Skip confirmation when current directory not empty + specify init my-project --ai claude # Claude installs skills by default + specify init --here --ai gemini --ai-skills + specify init my-project --ai generic --ai-commands-dir .myagent/commands/ # Unsupported agent + specify init my-project --offline # Use bundled assets (no network access) + specify init my-project --ai claude --preset healthcare-compliance # With preset """ show_banner() + ai_deprecation_warning: str | None = None + + # Detect when option values are likely misinterpreted flags (parameter ordering issue) + if ai_assistant and ai_assistant.startswith("--"): + console.print(f"[red]Error:[/red] Invalid value for --ai: '{ai_assistant}'") + console.print("[yellow]Hint:[/yellow] Did you forget to provide a value for --ai?") + console.print("[yellow]Example:[/yellow] specify init --ai claude --here") + console.print(f"[yellow]Available agents:[/yellow] {', '.join(AGENT_CONFIG.keys())}") + raise typer.Exit(1) + + if ai_commands_dir and ai_commands_dir.startswith("--"): + console.print(f"[red]Error:[/red] Invalid value for --ai-commands-dir: '{ai_commands_dir}'") + console.print("[yellow]Hint:[/yellow] Did you forget to provide a value for --ai-commands-dir?") + console.print("[yellow]Example:[/yellow] specify init --ai generic --ai-commands-dir .myagent/commands/") + raise typer.Exit(1) + + if ai_assistant: + ai_assistant = AI_ASSISTANT_ALIASES.get(ai_assistant, ai_assistant) + + # --integration and --ai are mutually exclusive + if integration and ai_assistant: + console.print("[red]Error:[/red] --integration and --ai are mutually exclusive") + raise typer.Exit(1) + + # Resolve the integration — either from --integration or --ai + from .integrations import INTEGRATION_REGISTRY, get_integration + if integration: + resolved_integration = get_integration(integration) + if not resolved_integration: + console.print(f"[red]Error:[/red] Unknown integration: '{integration}'") + available = ", ".join(sorted(INTEGRATION_REGISTRY)) + console.print(f"[yellow]Available integrations:[/yellow] {available}") + raise typer.Exit(1) + ai_assistant = integration + elif ai_assistant: + resolved_integration = get_integration(ai_assistant) + if not resolved_integration: + console.print(f"[red]Error:[/red] Unknown agent '{ai_assistant}'. Choose from: {', '.join(sorted(INTEGRATION_REGISTRY))}") + raise typer.Exit(1) + ai_deprecation_warning = _build_ai_deprecation_warning( + resolved_integration.key, + ai_commands_dir=ai_commands_dir, + ) + + # Deprecation warnings for --ai-skills and --ai-commands-dir (only when + # an integration has been resolved from --ai or --integration) + if ai_assistant or integration: + if ai_skills: + from .integrations.base import SkillsIntegration as _SkillsCheck + if isinstance(resolved_integration, _SkillsCheck): + console.print( + "[dim]Note: --ai-skills is not needed; " + "skills are the default for this integration.[/dim]" + ) + else: + console.print( + "[dim]Note: --ai-skills has no effect with " + f"{resolved_integration.key}; this integration uses commands, not skills.[/dim]" + ) + if ai_commands_dir and resolved_integration.key != "generic": + console.print( + "[dim]Note: --ai-commands-dir is deprecated; " + 'use [bold]--integration generic --integration-options="--commands-dir "[/bold] instead.[/dim]' + ) if project_name == ".": here = True @@ -1035,9 +1100,21 @@ def init( console.print("[red]Error:[/red] Must specify either a project name, use '.' for current directory, or use --here flag") raise typer.Exit(1) + if ai_skills and not ai_assistant: + console.print("[red]Error:[/red] --ai-skills requires --ai to be specified") + console.print("[yellow]Usage:[/yellow] specify init --ai --ai-skills") + raise typer.Exit(1) + + BRANCH_NUMBERING_CHOICES = {"sequential", "timestamp"} + if branch_numbering and branch_numbering not in BRANCH_NUMBERING_CHOICES: + console.print(f"[red]Error:[/red] Invalid --branch-numbering value '{branch_numbering}'. Choose from: {', '.join(sorted(BRANCH_NUMBERING_CHOICES))}") + raise typer.Exit(1) + + dir_existed_before = False if here: project_name = Path.cwd().name project_path = Path.cwd() + dir_existed_before = True existing_items = list(project_path.iterdir()) if existing_items: @@ -1052,16 +1129,58 @@ def init( raise typer.Exit(0) else: project_path = Path(project_name).resolve() + dir_existed_before = project_path.exists() if project_path.exists(): - error_panel = Panel( - f"Directory '[cyan]{project_name}[/cyan]' already exists\n" - "Please choose a different project name or remove the existing directory.", - title="[red]Directory Conflict[/red]", - border_style="red", - padding=(1, 2) - ) - console.print() - console.print(error_panel) + if not project_path.is_dir(): + console.print(f"[red]Error:[/red] '{project_name}' exists but is not a directory.") + raise typer.Exit(1) + existing_items = list(project_path.iterdir()) + if force: + if existing_items: + console.print(f"[yellow]Warning:[/yellow] Directory '{project_name}' is not empty ({len(existing_items)} items)") + console.print("[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]") + console.print(f"[cyan]--force supplied: merging into existing directory '[cyan]{project_name}[/cyan]'[/cyan]") + else: + error_panel = Panel( + f"Directory already exists: '[cyan]{project_name}[/cyan]'\n" + "Please choose a different project name or remove the existing directory.\n" + "Use [bold]--force[/bold] to merge into the existing directory.", + title="[red]Directory Conflict[/red]", + border_style="red", + padding=(1, 2) + ) + console.print() + console.print(error_panel) + raise typer.Exit(1) + + if ai_assistant: + if ai_assistant not in AGENT_CONFIG: + console.print(f"[red]Error:[/red] Invalid AI assistant '{ai_assistant}'. Choose from: {', '.join(AGENT_CONFIG.keys())}") + raise typer.Exit(1) + selected_ai = ai_assistant + else: + # Create options dict for selection (agent_key: display_name) + ai_choices = {key: config["name"] for key, config in AGENT_CONFIG.items()} + selected_ai = select_with_arrows( + ai_choices, + "Choose your AI assistant:", + "copilot" + ) + + # Auto-promote interactively selected agents to the integration path + if not ai_assistant: + resolved_integration = get_integration(selected_ai) + if not resolved_integration: + console.print(f"[red]Error:[/red] Unknown agent '{selected_ai}'") + raise typer.Exit(1) + + # Validate --ai-commands-dir usage. + # Skip validation when --integration-options is provided — the integration + # will validate its own options in setup(). + if selected_ai == "generic" and not integration_options: + if not ai_commands_dir: + console.print("[red]Error:[/red] --ai-commands-dir is required when using --ai generic or --integration generic") + console.print('[dim]Example: specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/"[/dim]') raise typer.Exit(1) current_dir = Path.cwd() @@ -1084,20 +1203,6 @@ def init( if not should_init_git: console.print("[yellow]Git not found - will skip repository initialization[/yellow]") - if ai_assistant: - if ai_assistant not in AGENT_CONFIG: - console.print(f"[red]Error:[/red] Invalid AI assistant '{ai_assistant}'. Choose from: {', '.join(AGENT_CONFIG.keys())}") - raise typer.Exit(1) - selected_ai = ai_assistant - else: - # Create options dict for selection (agent_key: display_name) - ai_choices = {key: config["name"] for key, config in AGENT_CONFIG.items()} - selected_ai = select_with_arrows( - ai_choices, - "Choose your AI assistant:", - "copilot" - ) - if not ignore_agent_tools: agent_config = AGENT_CONFIG.get(selected_ai) if agent_config and agent_config["requires_cli"]: @@ -1142,66 +1247,239 @@ def init( tracker.complete("ai-select", f"{selected_ai}") tracker.add("script-select", "Select script type") tracker.complete("script-select", selected_script) + + tracker.add("integration", "Install integration") + tracker.add("shared-infra", "Install shared infrastructure") + for key, label in [ - ("fetch", "Fetch latest release"), - ("download", "Download template"), - ("extract", "Extract template"), - ("zip-list", "Archive contents"), - ("extracted-summary", "Extraction summary"), ("chmod", "Ensure scripts executable"), ("constitution", "Constitution setup"), - ("cleanup", "Cleanup"), - ("git", "Initialize git repository"), - ("final", "Finalize") + ("git", "Install git extension"), + ("workflow", "Install bundled workflow"), + ("final", "Finalize"), ]: tracker.add(key, label) - # Track git error message outside Live context so it persists - git_error_message = None - with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live: tracker.attach_refresh(lambda: live.update(tracker.render())) try: - verify = not skip_tls - local_ssl_context = ssl_context if verify else False - local_client = httpx.Client(verify=local_ssl_context) + # Integration-based scaffolding + from .integrations.manifest import IntegrationManifest + tracker.start("integration") + manifest = IntegrationManifest( + resolved_integration.key, project_path, version=get_speckit_version() + ) + + # Forward all legacy CLI flags to the integration as parsed_options. + # Integrations receive every option and decide what to use; + # irrelevant keys are simply ignored by the integration's setup(). + integration_parsed_options: dict[str, Any] = {} + if ai_commands_dir: + integration_parsed_options["commands_dir"] = ai_commands_dir + if ai_skills: + integration_parsed_options["skills"] = True + # Parse --integration-options and merge into parsed_options so + # flags like --skills reach the integration's setup(). + if integration_options: + extra = _parse_integration_options(resolved_integration, integration_options) + if extra: + integration_parsed_options.update(extra) + + resolved_integration.setup( + project_path, manifest, + parsed_options=integration_parsed_options or None, + script_type=selected_script, + raw_options=integration_options, + ) + manifest.save() - download_and_extract_template(project_path, selected_ai, selected_script, here, verbose=False, tracker=tracker, client=local_client, debug=debug, github_token=github_token) + # Write .specify/integration.json + integration_json = project_path / ".specify" / "integration.json" + integration_json.parent.mkdir(parents=True, exist_ok=True) + integration_json.write_text(json.dumps({ + "integration": resolved_integration.key, + "version": get_speckit_version(), + }, indent=2) + "\n", encoding="utf-8") - ensure_executable_scripts(project_path, tracker=tracker) + tracker.complete("integration", resolved_integration.config.get("name", resolved_integration.key)) + + # Install shared infrastructure (scripts, templates) + tracker.start("shared-infra") + _install_shared_infra(project_path, selected_script, tracker=tracker, force=force, invoke_separator=resolved_integration.effective_invoke_separator(integration_parsed_options)) + tracker.complete("shared-infra", f"scripts ({selected_script}) + templates") ensure_constitution_from_template(project_path, tracker=tracker) if not no_git: tracker.start("git") + git_messages = [] + git_has_error = False + # Step 1: Initialize git repo if needed if is_git_repo(project_path): - tracker.complete("git", "existing repo detected") + git_messages.append("existing repo detected") elif should_init_git: success, error_msg = init_git_repo(project_path, quiet=True) if success: - tracker.complete("git", "initialized") + git_messages.append("initialized") + else: + git_has_error = True + # Sanitize multi-line error_msg to single line for tracker + if error_msg: + sanitized = error_msg.replace('\n', ' ').strip() + git_messages.append(f"init failed: {sanitized[:120]}") + else: + git_messages.append("init failed") + else: + git_messages.append("git not available") + # Step 2: Install bundled git extension + try: + from .extensions import ExtensionManager + bundled_path = _locate_bundled_extension("git") + if bundled_path: + manager = ExtensionManager(project_path) + if manager.registry.is_installed("git"): + git_messages.append("extension already installed") + else: + manager.install_from_directory( + bundled_path, get_speckit_version() + ) + git_messages.append("extension installed") else: - tracker.error("git", "init failed") - git_error_message = error_msg + git_has_error = True + git_messages.append("bundled extension not found") + except Exception as ext_err: + git_has_error = True + sanitized_ext = str(ext_err).replace('\n', ' ').strip() + git_messages.append( + f"extension install failed: {sanitized_ext[:120]}" + ) + summary = "; ".join(git_messages) + if git_has_error: + tracker.error("git", summary) else: - tracker.skip("git", "git not available") + tracker.complete("git", summary) else: tracker.skip("git", "--no-git flag") - tracker.complete("final", "project ready") - except Exception as e: - tracker.error("final", str(e)) - console.print(Panel(f"Initialization failed: {e}", title="Failure", border_style="red")) - if debug: - _env_pairs = [ - ("Python", sys.version.split()[0]), - ("Platform", sys.platform), - ("CWD", str(Path.cwd())), - ] + # Install bundled speckit workflow + try: + bundled_wf = _locate_bundled_workflow("speckit") + if bundled_wf: + from .workflows.catalog import WorkflowRegistry + from .workflows.engine import WorkflowDefinition + wf_registry = WorkflowRegistry(project_path) + if wf_registry.is_installed("speckit"): + tracker.complete("workflow", "already installed") + else: + import shutil as _shutil + dest_wf = project_path / ".specify" / "workflows" / "speckit" + dest_wf.mkdir(parents=True, exist_ok=True) + _shutil.copy2( + bundled_wf / "workflow.yml", + dest_wf / "workflow.yml", + ) + definition = WorkflowDefinition.from_yaml(dest_wf / "workflow.yml") + wf_registry.add("speckit", { + "name": definition.name, + "version": definition.version, + "description": definition.description, + "source": "bundled", + }) + tracker.complete("workflow", "speckit installed") + else: + tracker.skip("workflow", "bundled workflow not found") + except Exception as wf_err: + sanitized_wf = str(wf_err).replace('\n', ' ').strip() + tracker.error("workflow", f"install failed: {sanitized_wf[:120]}") + + # Fix permissions after all installs (scripts + extensions) + ensure_executable_scripts(project_path, tracker=tracker) + + # Persist the CLI options so later operations (e.g. preset add) + # can adapt their behaviour without re-scanning the filesystem. + # Must be saved BEFORE preset install so _get_skills_dir() works. + init_opts = { + "ai": selected_ai, + "integration": resolved_integration.key, + "branch_numbering": branch_numbering or "sequential", + "context_file": resolved_integration.context_file, + "here": here, + "script": selected_script, + "speckit_version": get_speckit_version(), + } + # Ensure ai_skills is set for SkillsIntegration so downstream + # tools (extensions, presets) emit SKILL.md overrides correctly. + # Also set for integrations running in skills mode (e.g. Copilot + # with --skills). + from .integrations.base import SkillsIntegration as _SkillsPersist + if isinstance(resolved_integration, _SkillsPersist) or getattr(resolved_integration, "_skills_mode", False): + init_opts["ai_skills"] = True + save_init_options(project_path, init_opts) + + # Install preset if specified + if preset: + try: + from .presets import PresetManager, PresetCatalog, PresetError + preset_manager = PresetManager(project_path) + speckit_ver = get_speckit_version() + + # Try local directory first, then bundled, then catalog + local_path = Path(preset).resolve() + if local_path.is_dir() and (local_path / "preset.yml").exists(): + preset_manager.install_from_directory(local_path, speckit_ver) + else: + bundled_path = _locate_bundled_preset(preset) + if bundled_path: + preset_manager.install_from_directory(bundled_path, speckit_ver) + else: + preset_catalog = PresetCatalog(project_path) + pack_info = preset_catalog.get_pack_info(preset) + if not pack_info: + console.print(f"[yellow]Warning:[/yellow] Preset '{preset}' not found in catalog. Skipping.") + elif pack_info.get("bundled") and not pack_info.get("download_url"): + from .extensions import REINSTALL_COMMAND + console.print( + f"[yellow]Warning:[/yellow] Preset '{preset}' is bundled with spec-kit " + f"but could not be found in the installed package." + ) + console.print( + "This usually means the spec-kit installation is incomplete or corrupted." + ) + console.print(f"Try reinstalling: {REINSTALL_COMMAND}") + else: + zip_path = None + try: + zip_path = preset_catalog.download_pack(preset) + preset_manager.install_from_zip(zip_path, speckit_ver) + except PresetError as preset_err: + console.print(f"[yellow]Warning:[/yellow] Failed to install preset '{preset}': {preset_err}") + finally: + if zip_path is not None: + # Clean up downloaded ZIP to avoid cache accumulation + try: + zip_path.unlink(missing_ok=True) + except OSError: + # Best-effort cleanup; failure to delete is non-fatal + pass + except Exception as preset_err: + console.print(f"[yellow]Warning:[/yellow] Failed to install preset: {preset_err}") + + tracker.complete("final", "project ready") + except (typer.Exit, SystemExit): + raise + except Exception as e: + tracker.error("final", str(e)) + console.print(Panel(f"Initialization failed: {e}", title="Failure", border_style="red")) + if debug: + _env_pairs = [ + ("Python", sys.version.split()[0]), + ("Platform", sys.platform), + ("CWD", str(Path.cwd())), + ] _label_width = max(len(k) for k, _ in _env_pairs) env_lines = [f"{k.ljust(_label_width)} → [bright_black]{v}[/bright_black]" for k, v in _env_pairs] console.print(Panel("\n".join(env_lines), title="Debug Environment", border_style="magenta")) - if not here and project_path.exists(): + if not here and project_path.exists() and not dir_existed_before: shutil.rmtree(project_path) raise typer.Exit(1) finally: @@ -1209,37 +1487,31 @@ def init( console.print(tracker.render()) console.print("\n[bold green]Project ready.[/bold green]") - - # Show git error details if initialization failed - if git_error_message: - console.print() - git_error_panel = Panel( - f"[yellow]Warning:[/yellow] Git repository initialization failed\n\n" - f"{git_error_message}\n\n" - f"[dim]You can initialize git manually later with:[/dim]\n" - f"[cyan]cd {project_path if not here else '.'}[/cyan]\n" - f"[cyan]git init[/cyan]\n" - f"[cyan]git add .[/cyan]\n" - f"[cyan]git commit -m \"Initial commit\"[/cyan]", - title="[red]Git Initialization Failed[/red]", - border_style="red", - padding=(1, 2) - ) - console.print(git_error_panel) # Agent folder security notice agent_config = AGENT_CONFIG.get(selected_ai) if agent_config: - agent_folder = agent_config["folder"] - security_notice = Panel( - f"Some agents may store credentials, auth tokens, or other identifying and private artifacts in the agent folder within your project.\n" - f"Consider adding [cyan]{agent_folder}[/cyan] (or parts of it) to [cyan].gitignore[/cyan] to prevent accidental credential leakage.", - title="[yellow]Agent Folder Security[/yellow]", - border_style="yellow", - padding=(1, 2) + agent_folder = ai_commands_dir if selected_ai == "generic" else agent_config["folder"] + if agent_folder: + security_notice = Panel( + f"Some agents may store credentials, auth tokens, or other identifying and private artifacts in the agent folder within your project.\n" + f"Consider adding [cyan]{agent_folder}[/cyan] (or parts of it) to [cyan].gitignore[/cyan] to prevent accidental credential leakage.", + title="[yellow]Agent Folder Security[/yellow]", + border_style="yellow", + padding=(1, 2) + ) + console.print() + console.print(security_notice) + + if ai_deprecation_warning: + deprecation_notice = Panel( + ai_deprecation_warning, + title="[bold red]Deprecation Warning[/bold red]", + border_style="red", + padding=(1, 2), ) console.print() - console.print(security_notice) + console.print(deprecation_notice) steps_lines = [] if not here: @@ -1249,38 +1521,69 @@ def init( steps_lines.append("1. You're already in the project directory!") step_num = 2 - # Add Codex-specific setup step if needed - if selected_ai == "codex": - codex_path = project_path / ".codex" - quoted_path = shlex.quote(str(codex_path)) - if os.name == "nt": # Windows - cmd = f"setx CODEX_HOME {quoted_path}" - else: # Unix-like systems - cmd = f"export CODEX_HOME={quoted_path}" - - steps_lines.append(f"{step_num}. Set [cyan]CODEX_HOME[/cyan] environment variable before running Codex: [cyan]{cmd}[/cyan]") + # Determine skill display mode for the next-steps panel. + # Skills integrations (codex, kimi, agy, trae, cursor-agent) should show skill invocation syntax. + from .integrations.base import SkillsIntegration as _SkillsInt + _is_skills_integration = isinstance(resolved_integration, _SkillsInt) or getattr(resolved_integration, "_skills_mode", False) + + codex_skill_mode = selected_ai == "codex" and (ai_skills or _is_skills_integration) + claude_skill_mode = selected_ai == "claude" and (ai_skills or _is_skills_integration) + kimi_skill_mode = selected_ai == "kimi" + agy_skill_mode = selected_ai == "agy" and _is_skills_integration + trae_skill_mode = selected_ai == "trae" + cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _is_skills_integration) + copilot_skill_mode = selected_ai == "copilot" and _is_skills_integration + native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode or cursor_agent_skill_mode or copilot_skill_mode + + if codex_skill_mode and not ai_skills: + # Integration path installed skills; show the helpful notice + steps_lines.append(f"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]") step_num += 1 - - steps_lines.append(f"{step_num}. Start using slash commands with your AI agent:") - - steps_lines.append(" 2.1 [cyan]/speckit.constitution[/] - Establish project principles") - steps_lines.append(" 2.2 [cyan]/speckit.specify[/] - Create baseline specification") - steps_lines.append(" 2.3 [cyan]/speckit.plan[/] - Create implementation plan") - steps_lines.append(" 2.4 [cyan]/speckit.tasks[/] - Generate actionable tasks") - steps_lines.append(" 2.5 [cyan]/speckit.implement[/] - Execute implementation") + if claude_skill_mode and not ai_skills: + steps_lines.append(f"{step_num}. Start Claude in this project directory; spec-kit skills were installed to [cyan].claude/skills[/cyan]") + step_num += 1 + if cursor_agent_skill_mode and not ai_skills: + steps_lines.append(f"{step_num}. Start Cursor Agent in this project directory; spec-kit skills were installed to [cyan].cursor/skills[/cyan]") + step_num += 1 + usage_label = "skills" if native_skill_mode else "slash commands" + + def _display_cmd(name: str) -> str: + if codex_skill_mode or agy_skill_mode or trae_skill_mode: + return f"$speckit-{name}" + if claude_skill_mode: + return f"/speckit-{name}" + if kimi_skill_mode: + return f"/skill:speckit-{name}" + if cursor_agent_skill_mode or copilot_skill_mode: + return f"/speckit-{name}" + return f"/speckit.{name}" + + steps_lines.append(f"{step_num}. Start using {usage_label} with your AI agent:") + + steps_lines.append(f" {step_num}.1 [cyan]{_display_cmd('constitution')}[/] - Establish project principles") + steps_lines.append(f" {step_num}.2 [cyan]{_display_cmd('specify')}[/] - Create baseline specification") + steps_lines.append(f" {step_num}.3 [cyan]{_display_cmd('plan')}[/] - Create implementation plan") + steps_lines.append(f" {step_num}.4 [cyan]{_display_cmd('tasks')}[/] - Generate actionable tasks") + steps_lines.append(f" {step_num}.5 [cyan]{_display_cmd('implement')}[/] - Execute implementation") steps_panel = Panel("\n".join(steps_lines), title="Next Steps", border_style="cyan", padding=(1,2)) console.print() console.print(steps_panel) + enhancement_intro = ( + "Optional skills that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]" + if native_skill_mode + else "Optional commands that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]" + ) enhancement_lines = [ - "Optional commands that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]", + enhancement_intro, "", - f"ā—‹ [cyan]/speckit.clarify[/] [bright_black](optional)[/bright_black] - Ask structured questions to de-risk ambiguous areas before planning (run before [cyan]/speckit.plan[/] if used)", - f"ā—‹ [cyan]/speckit.analyze[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]/speckit.tasks[/], before [cyan]/speckit.implement[/])", - f"ā—‹ [cyan]/speckit.checklist[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]/speckit.plan[/])" + f"ā—‹ [cyan]{_display_cmd('clarify')}[/] [bright_black](optional)[/bright_black] - Ask structured questions to de-risk ambiguous areas before planning (run before [cyan]{_display_cmd('plan')}[/] if used)", + f"ā—‹ [cyan]{_display_cmd('analyze')}[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]{_display_cmd('tasks')}[/], before [cyan]{_display_cmd('implement')}[/])", + f"ā—‹ [cyan]{_display_cmd('checklist')}[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]{_display_cmd('plan')}[/])" ] - enhancements_panel = Panel("\n".join(enhancement_lines), title="Enhancement Commands", border_style="cyan", padding=(1,2)) + enhancements_title = "Enhancement Skills" if native_skill_mode else "Enhancement Commands" + enhancements_panel = Panel("\n".join(enhancement_lines), title=enhancements_title, border_style="cyan", padding=(1,2)) console.print() console.print(enhancements_panel) @@ -1297,6 +1600,8 @@ def check(): agent_results = {} for agent_key, agent_config in AGENT_CONFIG.items(): + if agent_key == "generic": + continue # Generic is not a real agent to check agent_name = agent_config["name"] requires_cli = agent_config["requires_cli"] @@ -1311,10 +1616,10 @@ def check(): # Check VS Code variants (not in agent config) tracker.add("code", "Visual Studio Code") - code_ok = check_tool("code", tracker=tracker) + check_tool("code", tracker=tracker) tracker.add("code-insiders", "Visual Studio Code Insiders") - code_insiders_ok = check_tool("code-insiders", tracker=tracker) + check_tool("code-insiders", tracker=tracker) console.print(tracker.render()) @@ -1330,65 +1635,16 @@ def check(): def version(): """Display version and system information.""" import platform - import importlib.metadata - + show_banner() - - # Get CLI version from package metadata - cli_version = "unknown" - try: - cli_version = importlib.metadata.version("specify-cli") - except Exception: - # Fallback: try reading from pyproject.toml if running from source - try: - import tomllib - pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml" - if pyproject_path.exists(): - with open(pyproject_path, "rb") as f: - data = tomllib.load(f) - cli_version = data.get("project", {}).get("version", "unknown") - except Exception: - pass - - # Fetch latest template release version - repo_owner = "github" - repo_name = "spec-kit" - api_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/latest" - - template_version = "unknown" - release_date = "unknown" - - try: - response = client.get( - api_url, - timeout=10, - follow_redirects=True, - headers=_github_auth_headers(), - ) - if response.status_code == 200: - release_data = response.json() - template_version = release_data.get("tag_name", "unknown") - # Remove 'v' prefix if present - if template_version.startswith("v"): - template_version = template_version[1:] - release_date = release_data.get("published_at", "unknown") - if release_date != "unknown": - # Format the date nicely - try: - dt = datetime.fromisoformat(release_date.replace('Z', '+00:00')) - release_date = dt.strftime("%Y-%m-%d") - except Exception: - pass - except Exception: - pass + + cli_version = get_speckit_version() info_table = Table(show_header=False, box=None, padding=(0, 2)) info_table.add_column("Key", style="cyan", justify="right") info_table.add_column("Value", style="white") info_table.add_row("CLI Version", cli_version) - info_table.add_row("Template Version", template_version) - info_table.add_row("Released", release_date) info_table.add_row("", "") info_table.add_row("Python", platform.python_version()) info_table.add_row("Platform", platform.system()) @@ -1405,6 +1661,163 @@ def version(): console.print(panel) console.print() +def _get_installed_version() -> str: + """Return the installed specify-cli distribution version or 'unknown'. + + Uses importlib.metadata so the value reflects what was actually installed + by pip/uv/pipx — not a value read from pyproject.toml. This is + intentional for `specify self check`, which should reason about the + installed distribution rather than a source-tree fallback. Callers must + treat the sentinel string 'unknown' as an indeterminate value (see FR-020). + """ + + import importlib.metadata + + metadata_errors = [importlib.metadata.PackageNotFoundError] + invalid_metadata_error = getattr(importlib.metadata, "InvalidMetadataError", None) + if invalid_metadata_error is not None: + metadata_errors.append(invalid_metadata_error) + + try: + return importlib.metadata.version("specify-cli") + except tuple(metadata_errors): + return "unknown" + +def _normalize_tag(tag: str) -> str: + """Strip exactly one leading 'v' from a release tag. + + Returns the rest of the string unchanged. This handles the common + 'vX.Y.Z' tag convention in this repo; it MUST NOT strip more + aggressively (e.g., two leading 'v's keeps one). + """ + return tag[1:] if tag.startswith("v") else tag + +def _is_newer(latest: str, current: str) -> bool: + """Return True iff `latest` is strictly greater than `current` under PEP 440. + + Returns False whenever either side is 'unknown' or fails to parse; this + keeps the comparison indeterminate (rather than crashing or falsely + recommending a downgrade) on edge inputs. + """ + if latest == "unknown" or current == "unknown": + return False + try: + return Version(latest) > Version(current) + except InvalidVersion: + return False + + +def _fetch_latest_release_tag() -> tuple[str | None, str | None]: + """Return (tag, failure_category). Exactly one outbound call, 5 s timeout. + + On success: (tag_name, None). + On a documented network/HTTP failure (added in T029/T030): (None, category). + On anything else — including a malformed response body — the exception + propagates; there is no catch-all (research D-006). + """ + req = urllib.request.Request( + GITHUB_API_LATEST, + headers={"Accept": "application/vnd.github+json"}, + ) + token = None + for env_var in ("GH_TOKEN", "GITHUB_TOKEN"): + candidate = os.environ.get(env_var) + if candidate is not None: + candidate = candidate.strip() + if candidate: + token = candidate + break + if token: + req.add_header("Authorization", f"Bearer {token}") + try: + with urllib.request.urlopen(req, timeout=5) as resp: + payload = json.loads(resp.read().decode("utf-8")) + tag = payload.get("tag_name") + if not isinstance(tag, str) or not tag: + raise ValueError("GitHub API response missing valid tag_name") + return tag, None + except urllib.error.HTTPError as e: + # Order matters: HTTPError is a subclass of URLError. + if e.code == 403: + return None, "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)" + return None, f"HTTP {e.code}" + except (urllib.error.URLError, OSError): + return None, "offline or timeout" + + +# ===== Self Commands ===== +self_app = typer.Typer( + name="self", + help="Manage the specify CLI itself (read-only check and reserved upgrade command).", + add_completion=False, +) +app.add_typer(self_app, name="self") + +@self_app.command("check") +def self_check() -> None: + """Check whether a newer specify-cli release is available. Read-only. + + This command only checks for updates; it does not modify your installation. + The reserved (and currently non-destructive) `specify self upgrade` command + is the name that a future release will use for actual self-upgrade — its + behavior is not implemented in this release and is intentionally out of + scope here. See `specify self upgrade --help` for its current status. + """ + + installed = _get_installed_version() + tag, failure_reason = _fetch_latest_release_tag() + + if tag is None: + # Graceful-failure path (FR-008). `failure_reason` is one of the + # enumerated strings produced by _fetch_latest_release_tag() — it + # never contains a URL, headers, response body, or traceback. + assert failure_reason is not None + console.print(f"Installed: {installed}") + console.print(f"[yellow]Could not check latest release:[/yellow] {failure_reason}") + return + + latest_normalized = _normalize_tag(tag) + + if installed == "unknown": + # FR-020: surface the latest release and the recovery action even + # when the local distribution metadata is unavailable. + console.print("Current version could not be determined.") + console.print(f"Latest release: {latest_normalized}") + console.print("\nTo reinstall:") + console.print(" uv tool install specify-cli --force \\") + console.print(f" --from git+https://github.com/github/spec-kit.git@{tag}") + return + + if _is_newer(latest_normalized, installed): + console.print(f"[green]Update available:[/green] {installed} → {latest_normalized}") + console.print("\nTo upgrade:") + console.print(" uv tool install specify-cli --force \\") + console.print(f" --from git+https://github.com/github/spec-kit.git@{tag}") + return + + # Installed is parseable AND is >= latest → "up to date" (FR-006). + # Also reached when the tag is unparseable (InvalidVersion) → _is_newer + # returns False, and the up-to-date branch is the safer default per + # FR-004 / test T016. + console.print(f"[green]Up to date:[/green] {installed}") + + +@self_app.command("upgrade") +def self_upgrade() -> None: + """Reserved command surface for self-upgrade; not implemented in this release. + + This command is a documented non-destructive stub in this release: it + performs no outbound network request, no install-method detection, and + invokes no installer. It prints a three-line guidance message and exits 0. + Actual self-upgrade is planned as follow-up work. + + Use `specify self check` today to see whether a newer release is available + and to get a copy-pasteable reinstall command. + """ + console.print("specify self upgrade is not implemented yet.") + console.print("Run 'specify self check' to see whether a newer release is available.") + console.print("Actual self-upgrade is planned as follow-up work.") + # ===== Extension Commands ===== @@ -1415,6 +1828,27 @@ def version(): ) app.add_typer(extension_app, name="extension") +catalog_app = typer.Typer( + name="catalog", + help="Manage extension catalogs", + add_completion=False, +) +extension_app.add_typer(catalog_app, name="catalog") + +preset_app = typer.Typer( + name="preset", + help="Manage spec-kit presets", + add_completion=False, +) +app.add_typer(preset_app, name="preset") + +preset_catalog_app = typer.Typer( + name="catalog", + help="Manage preset catalogs", + add_completion=False, +) +preset_app.add_typer(preset_catalog_app, name="catalog") + def get_speckit_version() -> str: """Get current spec-kit version.""" @@ -1437,612 +1871,3452 @@ def get_speckit_version() -> str: return "unknown" -@extension_app.command("list") -def extension_list( - available: bool = typer.Option(False, "--available", help="Show available extensions from catalog"), - all_extensions: bool = typer.Option(False, "--all", help="Show both installed and available"), -): - """List installed extensions.""" - from .extensions import ExtensionManager - - project_root = Path.cwd() +# ===== Integration Commands ===== - # Check if we're in a spec-kit project - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) +integration_app = typer.Typer( + name="integration", + help="Manage AI agent integrations", + add_completion=False, +) +app.add_typer(integration_app, name="integration") - manager = ExtensionManager(project_root) - installed = manager.list_installed() - if not installed and not (available or all_extensions): - console.print("[yellow]No extensions installed.[/yellow]") - console.print("\nInstall an extension with:") - console.print(" specify extension add ") - return +INTEGRATION_JSON = ".specify/integration.json" - if installed: - console.print("\n[bold cyan]Installed Extensions:[/bold cyan]\n") - for ext in installed: - status_icon = "āœ“" if ext["enabled"] else "āœ—" - status_color = "green" if ext["enabled"] else "red" +def _read_integration_json(project_root: Path) -> dict[str, Any]: + """Load ``.specify/integration.json``. Returns ``{}`` when missing.""" + path = project_root / INTEGRATION_JSON + if not path.exists(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + console.print(f"[red]Error:[/red] {path} contains invalid JSON.") + console.print(f"Please fix or delete {INTEGRATION_JSON} and retry.") + console.print(f"[dim]Details:[/dim] {exc}") + raise typer.Exit(1) + except OSError as exc: + console.print(f"[red]Error:[/red] Could not read {path}.") + console.print(f"Please fix file permissions or delete {INTEGRATION_JSON} and retry.") + console.print(f"[dim]Details:[/dim] {exc}") + raise typer.Exit(1) + if not isinstance(data, dict): + console.print(f"[red]Error:[/red] {path} must contain a JSON object, got {type(data).__name__}.") + console.print(f"Please fix or delete {INTEGRATION_JSON} and retry.") + raise typer.Exit(1) + return data + + +def _write_integration_json( + project_root: Path, + integration_key: str, +) -> None: + """Write ``.specify/integration.json`` for *integration_key*.""" + dest = project_root / INTEGRATION_JSON + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(json.dumps({ + "integration": integration_key, + "version": get_speckit_version(), + }, indent=2) + "\n", encoding="utf-8") + + +def _remove_integration_json(project_root: Path) -> None: + """Remove ``.specify/integration.json`` if it exists.""" + path = project_root / INTEGRATION_JSON + if path.exists(): + path.unlink() + + +def _normalize_script_type(script_type: str, source: str) -> str: + """Normalize and validate a script type from CLI/config sources.""" + normalized = script_type.strip().lower() + if normalized in SCRIPT_TYPE_CHOICES: + return normalized + console.print( + f"[red]Error:[/red] Invalid script type {script_type!r} from {source}. " + f"Expected one of: {', '.join(sorted(SCRIPT_TYPE_CHOICES.keys()))}." + ) + raise typer.Exit(1) - console.print(f" [{status_color}]{status_icon}[/{status_color}] [bold]{ext['name']}[/bold] (v{ext['version']})") - console.print(f" {ext['description']}") - console.print(f" Commands: {ext['command_count']} | Hooks: {ext['hook_count']} | Status: {'Enabled' if ext['enabled'] else 'Disabled'}") - console.print() - if available or all_extensions: - console.print("\nInstall an extension:") - console.print(" [cyan]specify extension add [/cyan]") +def _resolve_script_type(project_root: Path, script_type: str | None) -> str: + """Resolve the script type from the CLI flag or init-options.json.""" + if script_type: + return _normalize_script_type(script_type, "--script") + opts = load_init_options(project_root) + saved = opts.get("script") + if isinstance(saved, str) and saved.strip(): + return _normalize_script_type(saved, ".specify/init-options.json") + return "ps" if os.name == "nt" else "sh" -@extension_app.command("add") -def extension_add( - extension: str = typer.Argument(help="Extension name or path"), - dev: bool = typer.Option(False, "--dev", help="Install from local directory"), - from_url: Optional[str] = typer.Option(None, "--from", help="Install from custom URL"), +@integration_app.command("list") +def integration_list( + catalog: bool = typer.Option(False, "--catalog", help="Browse full catalog (built-in + community)"), ): - """Install an extension.""" - from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError + """List available integrations and installed status.""" + from .integrations import INTEGRATION_REGISTRY project_root = Path.cwd() - # Check if we're in a spec-kit project specify_dir = project_root / ".specify" if not specify_dir.exists(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") raise typer.Exit(1) - manager = ExtensionManager(project_root) - speckit_version = get_speckit_version() - - try: - with console.status(f"[cyan]Installing extension: {extension}[/cyan]"): - if dev: - # Install from local directory - source_path = Path(extension).expanduser().resolve() - if not source_path.exists(): - console.print(f"[red]Error:[/red] Directory not found: {source_path}") - raise typer.Exit(1) - - if not (source_path / "extension.yml").exists(): - console.print(f"[red]Error:[/red] No extension.yml found in {source_path}") - raise typer.Exit(1) - - manifest = manager.install_from_directory(source_path, speckit_version) - - elif from_url: - # Install from URL (ZIP file) - import urllib.request - import urllib.error - from urllib.parse import urlparse - - # Validate URL - parsed = urlparse(from_url) - is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1") - - if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost): - console.print("[red]Error:[/red] URL must use HTTPS for security.") - console.print("HTTP is only allowed for localhost URLs.") - raise typer.Exit(1) + current = _read_integration_json(project_root) + installed_key = current.get("integration") - # Warn about untrusted sources - console.print("[yellow]Warning:[/yellow] Installing from external URL.") - console.print("Only install extensions from sources you trust.\n") - console.print(f"Downloading from {from_url}...") + if catalog: + from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError - # Download ZIP to temp location - download_dir = project_root / ".specify" / "extensions" / ".cache" / "downloads" - download_dir.mkdir(parents=True, exist_ok=True) - zip_path = download_dir / f"{extension}-url-download.zip" + ic = IntegrationCatalog(project_root) + try: + entries = ic.search() + except IntegrationCatalogError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) - try: - with urllib.request.urlopen(from_url, timeout=60) as response: - zip_data = response.read() - zip_path.write_bytes(zip_data) + if not entries: + console.print("[yellow]No integrations found in catalog.[/yellow]") + return + + table = Table(title="Integration Catalog") + table.add_column("ID", style="cyan") + table.add_column("Name") + table.add_column("Version") + table.add_column("Source") + table.add_column("Status") + + for entry in sorted(entries, key=lambda e: e["id"]): + eid = entry["id"] + cat_name = entry.get("_catalog_name", "") + install_allowed = entry.get("_install_allowed", True) + if eid == installed_key: + status = "[green]installed[/green]" + elif eid in INTEGRATION_REGISTRY: + status = "built-in" + elif install_allowed is False: + status = "discovery-only" + else: + status = "" + table.add_row( + eid, + entry.get("name", eid), + entry.get("version", ""), + cat_name, + status, + ) - # Install from downloaded ZIP - manifest = manager.install_from_zip(zip_path, speckit_version) - except urllib.error.URLError as e: - console.print(f"[red]Error:[/red] Failed to download from {from_url}: {e}") - raise typer.Exit(1) - finally: - # Clean up downloaded ZIP - if zip_path.exists(): - zip_path.unlink() + console.print(table) + return - else: - # Install from catalog - catalog = ExtensionCatalog(project_root) - - # Check if extension exists in catalog - ext_info = catalog.get_extension_info(extension) - if not ext_info: - console.print(f"[red]Error:[/red] Extension '{extension}' not found in catalog") - console.print("\nSearch available extensions:") - console.print(" specify extension search") - raise typer.Exit(1) + table = Table(title="AI Agent Integrations") + table.add_column("Key", style="cyan") + table.add_column("Name") + table.add_column("Status") + table.add_column("CLI Required") - # Download extension ZIP - console.print(f"Downloading {ext_info['name']} v{ext_info.get('version', 'unknown')}...") - zip_path = catalog.download_extension(extension) + for key in sorted(INTEGRATION_REGISTRY.keys()): + integration = INTEGRATION_REGISTRY[key] + cfg = integration.config or {} + name = cfg.get("name", key) + requires_cli = cfg.get("requires_cli", False) - try: - # Install from downloaded ZIP - manifest = manager.install_from_zip(zip_path, speckit_version) - finally: - # Clean up downloaded ZIP - if zip_path.exists(): - zip_path.unlink() + if key == installed_key: + status = "[green]installed[/green]" + else: + status = "" - console.print(f"\n[green]āœ“[/green] Extension installed successfully!") - console.print(f"\n[bold]{manifest.name}[/bold] (v{manifest.version})") - console.print(f" {manifest.description}") - console.print(f"\n[bold cyan]Provided commands:[/bold cyan]") - for cmd in manifest.commands: - console.print(f" • {cmd['name']} - {cmd.get('description', '')}") + cli_req = "yes" if requires_cli else "no (IDE)" + table.add_row(key, name, status, cli_req) - console.print(f"\n[yellow]⚠[/yellow] Configuration may be required") - console.print(f" Check: .specify/extensions/{manifest.id}/") + console.print(table) - except ValidationError as e: - console.print(f"\n[red]Validation Error:[/red] {e}") - raise typer.Exit(1) - except CompatibilityError as e: - console.print(f"\n[red]Compatibility Error:[/red] {e}") - raise typer.Exit(1) - except ExtensionError as e: - console.print(f"\n[red]Error:[/red] {e}") - raise typer.Exit(1) + if installed_key: + console.print(f"\n[dim]Current integration:[/dim] [cyan]{installed_key}[/cyan]") + else: + console.print("\n[yellow]No integration currently installed.[/yellow]") + console.print("Install one with: [cyan]specify integration install [/cyan]") -@extension_app.command("remove") -def extension_remove( - extension: str = typer.Argument(help="Extension ID to remove"), - keep_config: bool = typer.Option(False, "--keep-config", help="Don't remove config files"), - force: bool = typer.Option(False, "--force", help="Skip confirmation"), +@integration_app.command("install") +def integration_install( + key: str = typer.Argument(help="Integration key to install (e.g. claude, copilot)"), + script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"), + integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'), ): - """Uninstall an extension.""" - from .extensions import ExtensionManager + """Install an integration into an existing project.""" + from .integrations import INTEGRATION_REGISTRY, get_integration + from .integrations.manifest import IntegrationManifest project_root = Path.cwd() - # Check if we're in a spec-kit project specify_dir = project_root / ".specify" if not specify_dir.exists(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") raise typer.Exit(1) - manager = ExtensionManager(project_root) + integration = get_integration(key) + if integration is None: + console.print(f"[red]Error:[/red] Unknown integration '{key}'") + available = ", ".join(sorted(INTEGRATION_REGISTRY.keys())) + console.print(f"Available integrations: {available}") + raise typer.Exit(1) + + current = _read_integration_json(project_root) + installed_key = current.get("integration") + + if installed_key and installed_key == key: + console.print(f"[yellow]Integration '{key}' is already installed.[/yellow]") + console.print("Run [cyan]specify integration uninstall[/cyan] first, then reinstall.") + raise typer.Exit(0) - # Check if extension is installed - if not manager.registry.is_installed(extension): - console.print(f"[red]Error:[/red] Extension '{extension}' is not installed") + if installed_key: + console.print(f"[red]Error:[/red] Integration '{installed_key}' is already installed.") + console.print(f"Run [cyan]specify integration uninstall[/cyan] first, or use [cyan]specify integration switch {key}[/cyan].") raise typer.Exit(1) - # Get extension info - ext_manifest = manager.get_extension(extension) - if ext_manifest: - ext_name = ext_manifest.name - cmd_count = len(ext_manifest.commands) - else: - ext_name = extension - cmd_count = 0 + selected_script = _resolve_script_type(project_root, script) - # Confirm removal - if not force: - console.print(f"\n[yellow]⚠ This will remove:[/yellow]") - console.print(f" • {cmd_count} commands from AI agent") - console.print(f" • Extension directory: .specify/extensions/{extension}/") - if not keep_config: - console.print(f" • Config files (will be backed up)") - console.print() + # Build parsed options from --integration-options so the integration + # can determine its effective invoke separator before shared infra + # is installed. + parsed_options: dict[str, Any] | None = None + if integration_options: + parsed_options = _parse_integration_options(integration, integration_options) - confirm = typer.confirm("Continue?") - if not confirm: - console.print("Cancelled") - raise typer.Exit(0) + # Ensure shared infrastructure is present (safe to run unconditionally; + # _install_shared_infra merges missing files without overwriting). + _install_shared_infra(project_root, selected_script, invoke_separator=integration.effective_invoke_separator(parsed_options)) + if os.name != "nt": + ensure_executable_scripts(project_root) - # Remove extension - success = manager.remove(extension, keep_config=keep_config) + manifest = IntegrationManifest( + integration.key, project_root, version=get_speckit_version() + ) - if success: - console.print(f"\n[green]āœ“[/green] Extension '{ext_name}' removed successfully") - if keep_config: - console.print(f"\nConfig files preserved in .specify/extensions/{extension}/") + try: + integration.setup( + project_root, manifest, + parsed_options=parsed_options, + script_type=selected_script, + raw_options=integration_options, + ) + manifest.save() + _write_integration_json(project_root, integration.key) + _update_init_options_for_integration(project_root, integration, script_type=selected_script) + + except Exception as e: + # Attempt rollback of any files written by setup + try: + integration.teardown(project_root, manifest, force=True) + except Exception as rollback_err: + # Suppress so the original setup error remains the primary failure + console.print(f"[yellow]Warning:[/yellow] Failed to roll back integration changes: {rollback_err}") + _remove_integration_json(project_root) + console.print(f"[red]Error:[/red] Failed to install integration: {e}") + raise typer.Exit(1) + + name = (integration.config or {}).get("name", key) + console.print(f"\n[green]āœ“[/green] Integration '{name}' installed successfully") + + +def _parse_integration_options(integration: Any, raw_options: str) -> dict[str, Any] | None: + """Parse --integration-options string into a dict matching the integration's declared options. + + Returns ``None`` when no options are provided. + """ + import shlex + parsed: dict[str, Any] = {} + tokens = shlex.split(raw_options) + declared_options = list(integration.options()) + declared = {opt.name.lstrip("-"): opt for opt in declared_options} + allowed = ", ".join(sorted(opt.name for opt in declared_options)) + i = 0 + while i < len(tokens): + token = tokens[i] + if not token.startswith("-"): + console.print(f"[red]Error:[/red] Unexpected integration option value '{token}'.") + if allowed: + console.print(f"Allowed options: {allowed}") + raise typer.Exit(1) + name = token.lstrip("-") + value: str | None = None + # Handle --name=value syntax + if "=" in name: + name, value = name.split("=", 1) + opt = declared.get(name) + if not opt: + console.print(f"[red]Error:[/red] Unknown integration option '{token}'.") + if allowed: + console.print(f"Allowed options: {allowed}") + raise typer.Exit(1) + key = name.replace("-", "_") + if opt.is_flag: + if value is not None: + console.print(f"[red]Error:[/red] Option '{opt.name}' is a flag and does not accept a value.") + raise typer.Exit(1) + parsed[key] = True + i += 1 + elif value is not None: + parsed[key] = value + i += 1 + elif i + 1 < len(tokens) and not tokens[i + 1].startswith("-"): + parsed[key] = tokens[i + 1] + i += 2 else: - console.print(f"\nConfig files backed up to .specify/extensions/.backup/{extension}/") - console.print(f"\nTo reinstall: specify extension add {extension}") + console.print(f"[red]Error:[/red] Option '{opt.name}' requires a value.") + raise typer.Exit(1) + return parsed or None + + +def _update_init_options_for_integration( + project_root: Path, + integration: Any, + script_type: str | None = None, +) -> None: + """Update ``init-options.json`` to reflect *integration* as the active one.""" + from .integrations.base import SkillsIntegration + opts = load_init_options(project_root) + opts["integration"] = integration.key + opts["ai"] = integration.key + opts["context_file"] = integration.context_file + if script_type: + opts["script"] = script_type + if isinstance(integration, SkillsIntegration) or getattr(integration, "_skills_mode", False): + opts["ai_skills"] = True else: - console.print(f"[red]Error:[/red] Failed to remove extension") - raise typer.Exit(1) + opts.pop("ai_skills", None) + save_init_options(project_root, opts) -@extension_app.command("search") -def extension_search( - query: str = typer.Argument(None, help="Search query (optional)"), - tag: Optional[str] = typer.Option(None, "--tag", help="Filter by tag"), - author: Optional[str] = typer.Option(None, "--author", help="Filter by author"), - verified: bool = typer.Option(False, "--verified", help="Show only verified extensions"), +@integration_app.command("uninstall") +def integration_uninstall( + key: str = typer.Argument(None, help="Integration key to uninstall (default: current integration)"), + force: bool = typer.Option(False, "--force", help="Remove files even if modified"), ): - """Search for available extensions in catalog.""" - from .extensions import ExtensionCatalog, ExtensionError + """Uninstall an integration, safely preserving modified files.""" + from .integrations import get_integration + from .integrations.manifest import IntegrationManifest project_root = Path.cwd() - # Check if we're in a spec-kit project specify_dir = project_root / ".specify" if not specify_dir.exists(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") raise typer.Exit(1) - catalog = ExtensionCatalog(project_root) - - try: - console.print("šŸ” Searching extension catalog...") - results = catalog.search(query=query, tag=tag, author=author, verified_only=verified) + current = _read_integration_json(project_root) + installed_key = current.get("integration") - if not results: - console.print("\n[yellow]No extensions found matching criteria[/yellow]") - if query or tag or author or verified: - console.print("\nTry:") - console.print(" • Broader search terms") - console.print(" • Remove filters") - console.print(" • specify extension search (show all)") + if key is None: + if not installed_key: + console.print("[yellow]No integration is currently installed.[/yellow]") raise typer.Exit(0) + key = installed_key - console.print(f"\n[green]Found {len(results)} extension(s):[/green]\n") - - for ext in results: - # Extension header - verified_badge = " [green]āœ“ Verified[/green]" if ext.get("verified") else "" - console.print(f"[bold]{ext['name']}[/bold] (v{ext['version']}){verified_badge}") - console.print(f" {ext['description']}") - - # Metadata - console.print(f"\n [dim]Author:[/dim] {ext.get('author', 'Unknown')}") - if ext.get('tags'): - tags_str = ", ".join(ext['tags']) - console.print(f" [dim]Tags:[/dim] {tags_str}") - - # Stats - stats = [] - if ext.get('downloads') is not None: - stats.append(f"Downloads: {ext['downloads']:,}") - if ext.get('stars') is not None: - stats.append(f"Stars: {ext['stars']}") - if stats: - console.print(f" [dim]{' | '.join(stats)}[/dim]") - - # Links - if ext.get('repository'): - console.print(f" [dim]Repository:[/dim] {ext['repository']}") + if installed_key and installed_key != key: + console.print(f"[red]Error:[/red] Integration '{key}' is not the currently installed integration ('{installed_key}').") + raise typer.Exit(1) - # Install command - console.print(f"\n [cyan]Install:[/cyan] specify extension add {ext['id']}") - console.print() + integration = get_integration(key) + + manifest_path = project_root / ".specify" / "integrations" / f"{key}.manifest.json" + if not manifest_path.exists(): + console.print(f"[yellow]No manifest found for integration '{key}'. Nothing to uninstall.[/yellow]") + _remove_integration_json(project_root) + # Clear integration-related keys from init-options.json + opts = load_init_options(project_root) + if opts.get("integration") == key or opts.get("ai") == key: + opts.pop("integration", None) + opts.pop("ai", None) + opts.pop("ai_skills", None) + opts.pop("context_file", None) + save_init_options(project_root, opts) + raise typer.Exit(0) - except ExtensionError as e: - console.print(f"\n[red]Error:[/red] {e}") - console.print("\nTip: The catalog may be temporarily unavailable. Try again later.") + try: + manifest = IntegrationManifest.load(key, project_root) + except (ValueError, FileNotFoundError) as exc: + console.print(f"[red]Error:[/red] Integration manifest for '{key}' is unreadable.") + console.print(f"Manifest: {manifest_path}") + console.print( + f"To recover, delete the unreadable manifest, run " + f"[cyan]specify integration uninstall {key}[/cyan] to clear stale metadata, " + f"then run [cyan]specify integration install {key}[/cyan] to regenerate." + ) + console.print(f"[dim]Details:[/dim] {exc}") raise typer.Exit(1) - -@extension_app.command("info") -def extension_info( - extension: str = typer.Argument(help="Extension ID or name"), + removed, skipped = manifest.uninstall(project_root, force=force) + + # Remove managed context section from the agent context file + if integration: + integration.remove_context_section(project_root) + + _remove_integration_json(project_root) + + # Update init-options.json to clear the integration + opts = load_init_options(project_root) + if opts.get("integration") == key or opts.get("ai") == key: + opts.pop("integration", None) + opts.pop("ai", None) + opts.pop("ai_skills", None) + opts.pop("context_file", None) + save_init_options(project_root, opts) + + name = (integration.config or {}).get("name", key) if integration else key + console.print(f"\n[green]āœ“[/green] Integration '{name}' uninstalled") + if removed: + console.print(f" Removed {len(removed)} file(s)") + if skipped: + console.print(f"\n[yellow]⚠[/yellow] {len(skipped)} modified file(s) were preserved:") + for path in skipped: + rel = path.relative_to(project_root) if path.is_absolute() else path + console.print(f" {rel}") + + +@integration_app.command("switch") +def integration_switch( + target: str = typer.Argument(help="Integration key to switch to"), + script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"), + force: bool = typer.Option(False, "--force", help="Force removal of modified files during uninstall"), + integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the target integration'), ): - """Show detailed information about an extension.""" - from .extensions import ExtensionCatalog, ExtensionManager, ExtensionError + """Switch from the current integration to a different one.""" + from .integrations import INTEGRATION_REGISTRY, get_integration + from .integrations.manifest import IntegrationManifest project_root = Path.cwd() - # Check if we're in a spec-kit project specify_dir = project_root / ".specify" if not specify_dir.exists(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") raise typer.Exit(1) - catalog = ExtensionCatalog(project_root) - manager = ExtensionManager(project_root) - - try: - ext_info = catalog.get_extension_info(extension) - - if not ext_info: - console.print(f"[red]Error:[/red] Extension '{extension}' not found in catalog") - console.print("\nTry: specify extension search") - raise typer.Exit(1) - - # Header - verified_badge = " [green]āœ“ Verified[/green]" if ext_info.get("verified") else "" - console.print(f"\n[bold]{ext_info['name']}[/bold] (v{ext_info['version']}){verified_badge}") - console.print(f"ID: {ext_info['id']}") - console.print() - - # Description - console.print(f"{ext_info['description']}") - console.print() + target_integration = get_integration(target) + if target_integration is None: + console.print(f"[red]Error:[/red] Unknown integration '{target}'") + available = ", ".join(sorted(INTEGRATION_REGISTRY.keys())) + console.print(f"Available integrations: {available}") + raise typer.Exit(1) - # Author and License - console.print(f"[dim]Author:[/dim] {ext_info.get('author', 'Unknown')}") - console.print(f"[dim]License:[/dim] {ext_info.get('license', 'Unknown')}") - console.print() + current = _read_integration_json(project_root) + installed_key = current.get("integration") - # Requirements - if ext_info.get('requires'): - console.print("[bold]Requirements:[/bold]") - reqs = ext_info['requires'] - if reqs.get('speckit_version'): - console.print(f" • Spec Kit: {reqs['speckit_version']}") - if reqs.get('tools'): - for tool in reqs['tools']: - tool_name = tool['name'] - tool_version = tool.get('version', 'any') - required = " (required)" if tool.get('required') else " (optional)" - console.print(f" • {tool_name}: {tool_version}{required}") - console.print() + if installed_key == target: + console.print(f"[yellow]Integration '{target}' is already installed. Nothing to switch.[/yellow]") + raise typer.Exit(0) - # Provides - if ext_info.get('provides'): - console.print("[bold]Provides:[/bold]") - provides = ext_info['provides'] - if provides.get('commands'): - console.print(f" • Commands: {provides['commands']}") - if provides.get('hooks'): - console.print(f" • Hooks: {provides['hooks']}") - console.print() + selected_script = _resolve_script_type(project_root, script) - # Tags - if ext_info.get('tags'): - tags_str = ", ".join(ext_info['tags']) - console.print(f"[bold]Tags:[/bold] {tags_str}") - console.print() + # Phase 1: Uninstall current integration (if any) + if installed_key: + current_integration = get_integration(installed_key) + manifest_path = project_root / ".specify" / "integrations" / f"{installed_key}.manifest.json" - # Statistics - stats = [] - if ext_info.get('downloads') is not None: - stats.append(f"Downloads: {ext_info['downloads']:,}") - if ext_info.get('stars') is not None: - stats.append(f"Stars: {ext_info['stars']}") - if stats: - console.print(f"[bold]Statistics:[/bold] {' | '.join(stats)}") - console.print() + if current_integration and manifest_path.exists(): + console.print(f"Uninstalling current integration: [cyan]{installed_key}[/cyan]") + try: + old_manifest = IntegrationManifest.load(installed_key, project_root) + except (ValueError, FileNotFoundError) as exc: + console.print(f"[red]Error:[/red] Could not read integration manifest for '{installed_key}': {manifest_path}") + console.print(f"[dim]{exc}[/dim]") + console.print( + f"To recover, delete the unreadable manifest at {manifest_path}, " + f"run [cyan]specify integration uninstall {installed_key}[/cyan], then retry." + ) + raise typer.Exit(1) + removed, skipped = old_manifest.uninstall(project_root, force=force) + current_integration.remove_context_section(project_root) + if removed: + console.print(f" Removed {len(removed)} file(s)") + if skipped: + console.print(f" [yellow]⚠[/yellow] {len(skipped)} modified file(s) preserved") + elif not current_integration and manifest_path.exists(): + # Integration removed from registry but manifest exists — use manifest-only uninstall + console.print(f"Uninstalling unknown integration '{installed_key}' via manifest") + try: + old_manifest = IntegrationManifest.load(installed_key, project_root) + removed, skipped = old_manifest.uninstall(project_root, force=force) + if removed: + console.print(f" Removed {len(removed)} file(s)") + if skipped: + console.print(f" [yellow]⚠[/yellow] {len(skipped)} modified file(s) preserved") + except (ValueError, FileNotFoundError) as exc: + console.print(f"[yellow]Warning:[/yellow] Could not read manifest for '{installed_key}': {exc}") + else: + console.print(f"[red]Error:[/red] Integration '{installed_key}' is installed but has no manifest.") + console.print( + f"Run [cyan]specify integration uninstall {installed_key}[/cyan] to clear metadata, " + f"then retry [cyan]specify integration switch {target}[/cyan]." + ) + raise typer.Exit(1) - # Links - console.print("[bold]Links:[/bold]") - if ext_info.get('repository'): - console.print(f" • Repository: {ext_info['repository']}") - if ext_info.get('homepage'): - console.print(f" • Homepage: {ext_info['homepage']}") - if ext_info.get('documentation'): - console.print(f" • Documentation: {ext_info['documentation']}") - if ext_info.get('changelog'): - console.print(f" • Changelog: {ext_info['changelog']}") - console.print() + # Clear metadata so a failed Phase 2 doesn't leave stale references + _remove_integration_json(project_root) + opts = load_init_options(project_root) + opts.pop("integration", None) + opts.pop("ai", None) + opts.pop("ai_skills", None) + opts.pop("context_file", None) + save_init_options(project_root, opts) + + # Build parsed options from --integration-options so the integration + # can determine its effective invoke separator before shared infra + # is installed. + parsed_options: dict[str, Any] | None = None + if integration_options: + parsed_options = _parse_integration_options(target_integration, integration_options) + + # Ensure shared infrastructure is present (safe to run unconditionally; + # _install_shared_infra merges missing files without overwriting). + _install_shared_infra(project_root, selected_script, invoke_separator=target_integration.effective_invoke_separator(parsed_options)) + if os.name != "nt": + ensure_executable_scripts(project_root) + + # Phase 2: Install target integration + console.print(f"Installing integration: [cyan]{target}[/cyan]") + manifest = IntegrationManifest( + target_integration.key, project_root, version=get_speckit_version() + ) - # Installation status and command - is_installed = manager.registry.is_installed(ext_info['id']) - if is_installed: - console.print("[green]āœ“ Installed[/green]") - console.print(f"\nTo remove: specify extension remove {ext_info['id']}") - else: - console.print("[yellow]Not installed[/yellow]") - console.print(f"\n[cyan]Install:[/cyan] specify extension add {ext_info['id']}") + try: + target_integration.setup( + project_root, manifest, + parsed_options=parsed_options, + script_type=selected_script, + raw_options=integration_options, + ) + manifest.save() + _write_integration_json(project_root, target_integration.key) + _update_init_options_for_integration(project_root, target_integration, script_type=selected_script) - except ExtensionError as e: - console.print(f"\n[red]Error:[/red] {e}") + except Exception as e: + # Attempt rollback of any files written by setup + try: + target_integration.teardown(project_root, manifest, force=True) + except Exception as rollback_err: + # Suppress so the original setup error remains the primary failure + console.print(f"[yellow]Warning:[/yellow] Failed to roll back integration '{target}': {rollback_err}") + _remove_integration_json(project_root) + console.print(f"[red]Error:[/red] Failed to install integration '{target}': {e}") raise typer.Exit(1) + name = (target_integration.config or {}).get("name", target) + console.print(f"\n[green]āœ“[/green] Switched to integration '{name}'") -@extension_app.command("update") -def extension_update( - extension: str = typer.Argument(None, help="Extension ID to update (or all)"), + +@integration_app.command("upgrade") +def integration_upgrade( + key: str | None = typer.Argument(None, help="Integration key to upgrade (default: current integration)"), + force: bool = typer.Option(False, "--force", help="Force upgrade even if files are modified"), + script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"), + integration_options: str | None = typer.Option(None, "--integration-options", help="Options for the integration"), ): - """Update extension(s) to latest version.""" - from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError - from packaging import version as pkg_version + """Upgrade an integration by reinstalling with diff-aware file handling. + + Compares manifest hashes to detect locally modified files and + blocks the upgrade unless --force is used. + """ + from .integrations import get_integration + from .integrations.manifest import IntegrationManifest project_root = Path.cwd() - # Check if we're in a spec-kit project specify_dir = project_root / ".specify" if not specify_dir.exists(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") raise typer.Exit(1) - manager = ExtensionManager(project_root) - catalog = ExtensionCatalog(project_root) - - try: - # Get list of extensions to update - if extension: - # Update specific extension - if not manager.registry.is_installed(extension): - console.print(f"[red]Error:[/red] Extension '{extension}' is not installed") - raise typer.Exit(1) - extensions_to_update = [extension] - else: - # Update all extensions - installed = manager.list_installed() - extensions_to_update = [ext["id"] for ext in installed] + current = _read_integration_json(project_root) + installed_key = current.get("integration") - if not extensions_to_update: - console.print("[yellow]No extensions installed[/yellow]") + if key is None: + if not installed_key: + console.print("[yellow]No integration is currently installed.[/yellow]") raise typer.Exit(0) + key = installed_key - console.print("šŸ”„ Checking for updates...\n") + if installed_key and installed_key != key: + console.print( + f"[red]Error:[/red] Integration '{key}' is not the currently installed integration ('{installed_key}')." + ) + console.print(f"Use [cyan]specify integration switch {key}[/cyan] instead.") + raise typer.Exit(1) - updates_available = [] + integration = get_integration(key) + if integration is None: + console.print(f"[red]Error:[/red] Unknown integration '{key}'") + raise typer.Exit(1) - for ext_id in extensions_to_update: - # Get installed version - metadata = manager.registry.get(ext_id) - installed_version = pkg_version.Version(metadata["version"]) + manifest_path = project_root / ".specify" / "integrations" / f"{key}.manifest.json" + if not manifest_path.exists(): + console.print(f"[yellow]No manifest found for integration '{key}'. Nothing to upgrade.[/yellow]") + console.print(f"Run [cyan]specify integration install {key}[/cyan] to perform a fresh install.") + raise typer.Exit(0) - # Get catalog info - ext_info = catalog.get_extension_info(ext_id) - if not ext_info: - console.print(f"⚠ {ext_id}: Not found in catalog (skipping)") - continue + try: + old_manifest = IntegrationManifest.load(key, project_root) + except (ValueError, FileNotFoundError) as exc: + console.print(f"[red]Error:[/red] Integration manifest for '{key}' is unreadable: {exc}") + raise typer.Exit(1) - catalog_version = pkg_version.Version(ext_info["version"]) + # Detect modified files via manifest hashes + modified = old_manifest.check_modified() + if modified and not force: + console.print(f"[yellow]⚠[/yellow] {len(modified)} file(s) have been modified since installation:") + for rel in modified: + console.print(f" {rel}") + console.print("\nUse [cyan]--force[/cyan] to overwrite modified files, or resolve manually.") + raise typer.Exit(1) - if catalog_version > installed_version: - updates_available.append( - { - "id": ext_id, - "installed": str(installed_version), - "available": str(catalog_version), - "download_url": ext_info.get("download_url"), - } - ) - else: - console.print(f"āœ“ {ext_id}: Up to date (v{installed_version})") + selected_script = _resolve_script_type(project_root, script) - if not updates_available: - console.print("\n[green]All extensions are up to date![/green]") - raise typer.Exit(0) + # Build parsed options from --integration-options so the integration + # can determine its effective invoke separator before shared infra + # is installed. + parsed_options: dict[str, Any] | None = None + if integration_options: + parsed_options = _parse_integration_options(integration, integration_options) - # Show available updates - console.print("\n[bold]Updates available:[/bold]\n") - for update in updates_available: - console.print( - f" • {update['id']}: {update['installed']} → {update['available']}" - ) + # Ensure shared infrastructure is up to date; --force overwrites existing files. + _install_shared_infra(project_root, selected_script, force=force, invoke_separator=integration.effective_invoke_separator(parsed_options)) + if os.name != "nt": + ensure_executable_scripts(project_root) - console.print() - confirm = typer.confirm("Update these extensions?") - if not confirm: - console.print("Cancelled") - raise typer.Exit(0) + # Phase 1: Install new files (overwrites existing; old-only files remain) + console.print(f"Upgrading integration: [cyan]{key}[/cyan]") + new_manifest = IntegrationManifest(key, project_root, version=get_speckit_version()) - # Perform updates - console.print() - for update in updates_available: - ext_id = update["id"] - console.print(f"šŸ“¦ Updating {ext_id}...") + try: + integration.setup( + project_root, + new_manifest, + parsed_options=parsed_options, + script_type=selected_script, + raw_options=integration_options, + ) + new_manifest.save() + _write_integration_json(project_root, key) + _update_init_options_for_integration(project_root, integration, script_type=selected_script) + except Exception as exc: + # Don't teardown — setup overwrites in-place, so teardown would + # delete files that were working before the upgrade. Just report. + console.print(f"[red]Error:[/red] Failed to upgrade integration: {exc}") + console.print("[yellow]The previous integration files may still be in place.[/yellow]") + raise typer.Exit(1) - # TODO: Implement download and reinstall from URL - # For now, just show message - console.print( - f"[yellow]Note:[/yellow] Automatic update not yet implemented. " - f"Please update manually:" - ) - console.print(f" specify extension remove {ext_id} --keep-config") - console.print(f" specify extension add {ext_id}") + # Phase 2: Remove stale files from old manifest that are not in the new one + old_files = old_manifest.files + new_files = new_manifest.files + stale_keys = set(old_files) - set(new_files) + if stale_keys: + stale_manifest = IntegrationManifest(key, project_root, version="stale-cleanup") + stale_manifest._files = {k: old_files[k] for k in stale_keys} + stale_removed, _ = stale_manifest.uninstall(project_root, force=True) + if stale_removed: + console.print(f" Removed {len(stale_removed)} stale file(s) from previous install") - console.print( - "\n[cyan]Tip:[/cyan] Automatic updates will be available in a future version" - ) + name = (integration.config or {}).get("name", key) + console.print(f"\n[green]āœ“[/green] Integration '{name}' upgraded successfully") - except ExtensionError as e: - console.print(f"\n[red]Error:[/red] {e}") - raise typer.Exit(1) +# ===== Preset Commands ===== -@extension_app.command("enable") -def extension_enable( - extension: str = typer.Argument(help="Extension ID to enable"), -): - """Enable a disabled extension.""" - from .extensions import ExtensionManager, HookExecutor + +@preset_app.command("list") +def preset_list(): + """List installed presets.""" + from .presets import PresetManager project_root = Path.cwd() - # Check if we're in a spec-kit project specify_dir = project_root / ".specify" if not specify_dir.exists(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") raise typer.Exit(1) - manager = ExtensionManager(project_root) - hook_executor = HookExecutor(project_root) - - if not manager.registry.is_installed(extension): - console.print(f"[red]Error:[/red] Extension '{extension}' is not installed") - raise typer.Exit(1) - - # Update registry - metadata = manager.registry.get(extension) - if metadata.get("enabled", True): - console.print(f"[yellow]Extension '{extension}' is already enabled[/yellow]") - raise typer.Exit(0) - - metadata["enabled"] = True - manager.registry.add(extension, metadata) + manager = PresetManager(project_root) + installed = manager.list_installed() - # Enable hooks in extensions.yml - config = hook_executor.get_project_config() - if "hooks" in config: - for hook_name in config["hooks"]: - for hook in config["hooks"][hook_name]: - if hook.get("extension") == extension: - hook["enabled"] = True - hook_executor.save_project_config(config) + if not installed: + console.print("[yellow]No presets installed.[/yellow]") + console.print("\nInstall a preset with:") + console.print(" [cyan]specify preset add [/cyan]") + return - console.print(f"[green]āœ“[/green] Extension '{extension}' enabled") + console.print("\n[bold cyan]Installed Presets:[/bold cyan]\n") + for pack in installed: + status = "[green]enabled[/green]" if pack.get("enabled", True) else "[red]disabled[/red]" + pri = pack.get('priority', 10) + console.print(f" [bold]{pack['name']}[/bold] ({pack['id']}) v{pack['version']} — {status} — priority {pri}") + console.print(f" {pack['description']}") + if pack.get("tags"): + tags_str = ", ".join(pack["tags"]) + console.print(f" [dim]Tags: {tags_str}[/dim]") + console.print(f" [dim]Templates: {pack['template_count']}[/dim]") + console.print() -@extension_app.command("disable") -def extension_disable( - extension: str = typer.Argument(help="Extension ID to disable"), +@preset_app.command("add") +def preset_add( + preset_id: str = typer.Argument(None, help="Preset ID to install from catalog"), + from_url: str = typer.Option(None, "--from", help="Install from a URL (ZIP file)"), + dev: str = typer.Option(None, "--dev", help="Install from local directory (development mode)"), + priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"), ): - """Disable an extension without removing it.""" - from .extensions import ExtensionManager, HookExecutor + """Install a preset.""" + from .presets import ( + PresetManager, + PresetCatalog, + PresetError, + PresetValidationError, + PresetCompatibilityError, + ) project_root = Path.cwd() - # Check if we're in a spec-kit project specify_dir = project_root / ".specify" if not specify_dir.exists(): console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") console.print("Run this command from a spec-kit project root") raise typer.Exit(1) - manager = ExtensionManager(project_root) - hook_executor = HookExecutor(project_root) - - if not manager.registry.is_installed(extension): - console.print(f"[red]Error:[/red] Extension '{extension}' is not installed") + # Validate priority + if priority < 1: + console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)") raise typer.Exit(1) - # Update registry - metadata = manager.registry.get(extension) - if not metadata.get("enabled", True): - console.print(f"[yellow]Extension '{extension}' is already disabled[/yellow]") - raise typer.Exit(0) + manager = PresetManager(project_root) + speckit_version = get_speckit_version() - metadata["enabled"] = False - manager.registry.add(extension, metadata) + try: + if dev: + dev_path = Path(dev).resolve() + if not dev_path.exists(): + console.print(f"[red]Error:[/red] Directory not found: {dev}") + raise typer.Exit(1) - # Disable hooks in extensions.yml - config = hook_executor.get_project_config() - if "hooks" in config: - for hook_name in config["hooks"]: - for hook in config["hooks"][hook_name]: - if hook.get("extension") == extension: - hook["enabled"] = False - hook_executor.save_project_config(config) + console.print(f"Installing preset from [cyan]{dev_path}[/cyan]...") + manifest = manager.install_from_directory(dev_path, speckit_version, priority) + console.print(f"[green]āœ“[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") + + elif from_url: + # Validate URL scheme before downloading + from urllib.parse import urlparse as _urlparse + _parsed = _urlparse(from_url) + _is_localhost = _parsed.hostname in ("localhost", "127.0.0.1", "::1") + if _parsed.scheme != "https" and not (_parsed.scheme == "http" and _is_localhost): + console.print(f"[red]Error:[/red] URL must use HTTPS (got {_parsed.scheme}://). HTTP is only allowed for localhost.") + raise typer.Exit(1) - console.print(f"[green]āœ“[/green] Extension '{extension}' disabled") - console.print(f"\nCommands will no longer be available. Hooks will not execute.") - console.print(f"To re-enable: specify extension enable {extension}") + console.print(f"Installing preset from [cyan]{from_url}[/cyan]...") + import urllib.request + import urllib.error + import tempfile + with tempfile.TemporaryDirectory() as tmpdir: + zip_path = Path(tmpdir) / "preset.zip" + try: + with urllib.request.urlopen(from_url, timeout=60) as response: + zip_path.write_bytes(response.read()) + except urllib.error.URLError as e: + console.print(f"[red]Error:[/red] Failed to download: {e}") + raise typer.Exit(1) -def main(): - app() + manifest = manager.install_from_zip(zip_path, speckit_version, priority) -if __name__ == "__main__": - main() + console.print(f"[green]āœ“[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") + + elif preset_id: + # Try bundled preset first, then catalog + bundled_path = _locate_bundled_preset(preset_id) + if bundled_path: + console.print(f"Installing bundled preset [cyan]{preset_id}[/cyan]...") + manifest = manager.install_from_directory(bundled_path, speckit_version, priority) + console.print(f"[green]āœ“[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") + else: + catalog = PresetCatalog(project_root) + pack_info = catalog.get_pack_info(preset_id) + + if not pack_info: + console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in catalog") + raise typer.Exit(1) + # Bundled presets should have been caught above; if we reach + # here the bundled files are missing from the installation. + if pack_info.get("bundled") and not pack_info.get("download_url"): + from .extensions import REINSTALL_COMMAND + console.print( + f"[red]Error:[/red] Preset '{preset_id}' is bundled with spec-kit " + f"but could not be found in the installed package." + ) + console.print( + "\nThis usually means the spec-kit installation is incomplete or corrupted." + ) + console.print("Try reinstalling spec-kit:") + console.print(f" {REINSTALL_COMMAND}") + raise typer.Exit(1) + + if not pack_info.get("_install_allowed", True): + catalog_name = pack_info.get("_catalog_name", "unknown") + console.print(f"[red]Error:[/red] Preset '{preset_id}' is from the '{catalog_name}' catalog which is discovery-only (install not allowed).") + console.print("Add the catalog with --install-allowed or install from the preset's repository directly with --from.") + raise typer.Exit(1) + + console.print(f"Installing preset [cyan]{pack_info.get('name', preset_id)}[/cyan]...") + + try: + zip_path = catalog.download_pack(preset_id) + manifest = manager.install_from_zip(zip_path, speckit_version, priority) + console.print(f"[green]āœ“[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") + finally: + if 'zip_path' in locals() and zip_path.exists(): + zip_path.unlink(missing_ok=True) + else: + console.print("[red]Error:[/red] Specify a preset ID, --from URL, or --dev path") + raise typer.Exit(1) + + except PresetCompatibilityError as e: + console.print(f"[red]Compatibility Error:[/red] {e}") + raise typer.Exit(1) + except PresetValidationError as e: + console.print(f"[red]Validation Error:[/red] {e}") + raise typer.Exit(1) + except PresetError as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(1) + + +@preset_app.command("remove") +def preset_remove( + preset_id: str = typer.Argument(..., help="Preset ID to remove"), +): + """Remove an installed preset.""" + from .presets import PresetManager + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + manager = PresetManager(project_root) + + if not manager.registry.is_installed(preset_id): + console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") + raise typer.Exit(1) + + if manager.remove(preset_id): + console.print(f"[green]āœ“[/green] Preset '{preset_id}' removed successfully") + else: + console.print(f"[red]Error:[/red] Failed to remove preset '{preset_id}'") + raise typer.Exit(1) + + +@preset_app.command("search") +def preset_search( + query: str = typer.Argument(None, help="Search query"), + tag: str = typer.Option(None, "--tag", help="Filter by tag"), + author: str = typer.Option(None, "--author", help="Filter by author"), +): + """Search for presets in the catalog.""" + from .presets import PresetCatalog, PresetError + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + catalog = PresetCatalog(project_root) + + try: + results = catalog.search(query=query, tag=tag, author=author) + except PresetError as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(1) + + if not results: + console.print("[yellow]No presets found matching your criteria.[/yellow]") + return + + console.print(f"\n[bold cyan]Presets ({len(results)} found):[/bold cyan]\n") + for pack in results: + console.print(f" [bold]{pack.get('name', pack['id'])}[/bold] ({pack['id']}) v{pack.get('version', '?')}") + console.print(f" {pack.get('description', '')}") + if pack.get("tags"): + tags_str = ", ".join(pack["tags"]) + console.print(f" [dim]Tags: {tags_str}[/dim]") + console.print() + + +@preset_app.command("resolve") +def preset_resolve( + template_name: str = typer.Argument(..., help="Template name to resolve (e.g., spec-template)"), +): + """Show which template will be resolved for a given name.""" + from .presets import PresetResolver + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + resolver = PresetResolver(project_root) + layers = resolver.collect_all_layers(template_name) + + if layers: + # Use the highest-priority layer for display because the final output + # may be composed and may not map to resolve_with_source()'s single path. + display_layer = layers[0] + console.print(f" [bold]{template_name}[/bold]: {display_layer['path']}") + console.print(f" [dim](top layer from: {display_layer['source']})[/dim]") + + has_composition = ( + layers[0]["strategy"] != "replace" + and any(layer["strategy"] != "replace" for layer in layers) + ) + if has_composition: + # Verify composition is actually possible + try: + composed = resolver.resolve_content(template_name) + except Exception as exc: + composed = None + console.print(f" [yellow]Warning: composition error: {exc}[/yellow]") + if composed is None: + console.print(" [yellow]Warning: composition cannot produce output (no base layer with 'replace' strategy)[/yellow]") + else: + console.print(" [dim]Final output is composed from multiple preset layers; the path above is the highest-priority contributing layer.[/dim]") + console.print("\n [bold]Composition chain:[/bold]") + # Compute the effective base: first replace layer scanning from + # highest priority (matching resolve_content top-down logic). + # Only show layers from the base upward (lower layers are ignored). + effective_base_idx = None + for idx, lyr in enumerate(layers): + if lyr["strategy"] == "replace": + effective_base_idx = idx + break + # Show only contributing layers (base and above) + if effective_base_idx is not None: + contributing = layers[:effective_base_idx + 1] + else: + contributing = layers + for i, layer in enumerate(reversed(contributing)): + strategy_label = layer["strategy"] + if strategy_label == "replace" and i == 0: + strategy_label = "base" + console.print(f" {i + 1}. [{strategy_label}] {layer['source']} → {layer['path']}") + else: + # No layers found — fall back to resolve_with_source for non-composition cases + result = resolver.resolve_with_source(template_name) + if result: + console.print(f" [bold]{template_name}[/bold]: {result['path']}") + console.print(f" [dim](from: {result['source']})[/dim]") + else: + console.print(f" [yellow]{template_name}[/yellow]: not found") + console.print(" [dim]No template with this name exists in the resolution stack[/dim]") + + +@preset_app.command("info") +def preset_info( + preset_id: str = typer.Argument(..., help="Preset ID to get info about"), +): + """Show detailed information about a preset.""" + from .extensions import normalize_priority + from .presets import PresetCatalog, PresetManager, PresetError + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + # Check if installed locally first + manager = PresetManager(project_root) + local_pack = manager.get_pack(preset_id) + + if local_pack: + console.print(f"\n[bold cyan]Preset: {local_pack.name}[/bold cyan]\n") + console.print(f" ID: {local_pack.id}") + console.print(f" Version: {local_pack.version}") + console.print(f" Description: {local_pack.description}") + if local_pack.author: + console.print(f" Author: {local_pack.author}") + if local_pack.tags: + console.print(f" Tags: {', '.join(local_pack.tags)}") + console.print(f" Templates: {len(local_pack.templates)}") + for tmpl in local_pack.templates: + console.print(f" - {tmpl['name']} ({tmpl['type']}): {tmpl.get('description', '')}") + repo = local_pack.data.get("preset", {}).get("repository") + if repo: + console.print(f" Repository: {repo}") + license_val = local_pack.data.get("preset", {}).get("license") + if license_val: + console.print(f" License: {license_val}") + console.print("\n [green]Status: installed[/green]") + # Get priority from registry + pack_metadata = manager.registry.get(preset_id) + priority = normalize_priority(pack_metadata.get("priority") if isinstance(pack_metadata, dict) else None) + console.print(f" [dim]Priority:[/dim] {priority}") + console.print() + return + + # Fall back to catalog + catalog = PresetCatalog(project_root) + try: + pack_info = catalog.get_pack_info(preset_id) + except PresetError: + pack_info = None + + if not pack_info: + console.print(f"[red]Error:[/red] Preset '{preset_id}' not found (not installed and not in catalog)") + raise typer.Exit(1) + + console.print(f"\n[bold cyan]Preset: {pack_info.get('name', preset_id)}[/bold cyan]\n") + console.print(f" ID: {pack_info['id']}") + console.print(f" Version: {pack_info.get('version', '?')}") + console.print(f" Description: {pack_info.get('description', '')}") + if pack_info.get("author"): + console.print(f" Author: {pack_info['author']}") + if pack_info.get("tags"): + console.print(f" Tags: {', '.join(pack_info['tags'])}") + if pack_info.get("repository"): + console.print(f" Repository: {pack_info['repository']}") + if pack_info.get("license"): + console.print(f" License: {pack_info['license']}") + console.print("\n [yellow]Status: not installed[/yellow]") + console.print(f" Install with: [cyan]specify preset add {preset_id}[/cyan]") + console.print() + + +@preset_app.command("set-priority") +def preset_set_priority( + preset_id: str = typer.Argument(help="Preset ID"), + priority: int = typer.Argument(help="New priority (lower = higher precedence)"), +): + """Set the resolution priority of an installed preset.""" + from .presets import PresetManager + + project_root = Path.cwd() + + # Check if we're in a spec-kit project + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + # Validate priority + if priority < 1: + console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)") + raise typer.Exit(1) + + manager = PresetManager(project_root) + + # Check if preset is installed + if not manager.registry.is_installed(preset_id): + console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") + raise typer.Exit(1) + + # Get current metadata + metadata = manager.registry.get(preset_id) + if metadata is None or not isinstance(metadata, dict): + console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)") + raise typer.Exit(1) + + from .extensions import normalize_priority + raw_priority = metadata.get("priority") + # Only skip if the stored value is already a valid int equal to requested priority + # This ensures corrupted values (e.g., "high") get repaired even when setting to default (10) + if isinstance(raw_priority, int) and raw_priority == priority: + console.print(f"[yellow]Preset '{preset_id}' already has priority {priority}[/yellow]") + raise typer.Exit(0) + + old_priority = normalize_priority(raw_priority) + + # Update priority + manager.registry.update(preset_id, {"priority": priority}) + + console.print(f"[green]āœ“[/green] Preset '{preset_id}' priority changed: {old_priority} → {priority}") + console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]") + + +@preset_app.command("enable") +def preset_enable( + preset_id: str = typer.Argument(help="Preset ID to enable"), +): + """Enable a disabled preset.""" + from .presets import PresetManager + + project_root = Path.cwd() + + # Check if we're in a spec-kit project + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + manager = PresetManager(project_root) + + # Check if preset is installed + if not manager.registry.is_installed(preset_id): + console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") + raise typer.Exit(1) + + # Get current metadata + metadata = manager.registry.get(preset_id) + if metadata is None or not isinstance(metadata, dict): + console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)") + raise typer.Exit(1) + + if metadata.get("enabled", True): + console.print(f"[yellow]Preset '{preset_id}' is already enabled[/yellow]") + raise typer.Exit(0) + + # Enable the preset + manager.registry.update(preset_id, {"enabled": True}) + + console.print(f"[green]āœ“[/green] Preset '{preset_id}' enabled") + console.print("\nTemplates from this preset will now be included in resolution.") + console.print("[dim]Note: Previously registered commands/skills remain active.[/dim]") + + +@preset_app.command("disable") +def preset_disable( + preset_id: str = typer.Argument(help="Preset ID to disable"), +): + """Disable a preset without removing it.""" + from .presets import PresetManager + + project_root = Path.cwd() + + # Check if we're in a spec-kit project + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + manager = PresetManager(project_root) + + # Check if preset is installed + if not manager.registry.is_installed(preset_id): + console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") + raise typer.Exit(1) + + # Get current metadata + metadata = manager.registry.get(preset_id) + if metadata is None or not isinstance(metadata, dict): + console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)") + raise typer.Exit(1) + + if not metadata.get("enabled", True): + console.print(f"[yellow]Preset '{preset_id}' is already disabled[/yellow]") + raise typer.Exit(0) + + # Disable the preset + manager.registry.update(preset_id, {"enabled": False}) + + console.print(f"[green]āœ“[/green] Preset '{preset_id}' disabled") + console.print("\nTemplates from this preset will be skipped during resolution.") + console.print("[dim]Note: Previously registered commands/skills remain active until preset removal.[/dim]") + console.print(f"To re-enable: specify preset enable {preset_id}") + + +# ===== Preset Catalog Commands ===== + + +@preset_catalog_app.command("list") +def preset_catalog_list(): + """List all active preset catalogs.""" + from .presets import PresetCatalog, PresetValidationError + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + catalog = PresetCatalog(project_root) + + try: + active_catalogs = catalog.get_active_catalogs() + except PresetValidationError as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(1) + + console.print("\n[bold cyan]Active Preset Catalogs:[/bold cyan]\n") + for entry in active_catalogs: + install_str = ( + "[green]install allowed[/green]" + if entry.install_allowed + else "[yellow]discovery only[/yellow]" + ) + console.print(f" [bold]{entry.name}[/bold] (priority {entry.priority})") + if entry.description: + console.print(f" {entry.description}") + console.print(f" URL: {entry.url}") + console.print(f" Install: {install_str}") + console.print() + + config_path = project_root / ".specify" / "preset-catalogs.yml" + user_config_path = Path.home() / ".specify" / "preset-catalogs.yml" + if os.environ.get("SPECKIT_PRESET_CATALOG_URL"): + console.print("[dim]Catalog configured via SPECKIT_PRESET_CATALOG_URL environment variable.[/dim]") + else: + try: + proj_loaded = config_path.exists() and catalog._load_catalog_config(config_path) is not None + except PresetValidationError: + proj_loaded = False + if proj_loaded: + console.print(f"[dim]Config: {config_path.relative_to(project_root)}[/dim]") + else: + try: + user_loaded = user_config_path.exists() and catalog._load_catalog_config(user_config_path) is not None + except PresetValidationError: + user_loaded = False + if user_loaded: + console.print("[dim]Config: ~/.specify/preset-catalogs.yml[/dim]") + else: + console.print("[dim]Using built-in default catalog stack.[/dim]") + console.print( + "[dim]Add .specify/preset-catalogs.yml to customize.[/dim]" + ) + + +@preset_catalog_app.command("add") +def preset_catalog_add( + url: str = typer.Argument(help="Catalog URL (must use HTTPS)"), + name: str = typer.Option(..., "--name", help="Catalog name"), + priority: int = typer.Option(10, "--priority", help="Priority (lower = higher priority)"), + install_allowed: bool = typer.Option( + False, "--install-allowed/--no-install-allowed", + help="Allow presets from this catalog to be installed", + ), + description: str = typer.Option("", "--description", help="Description of the catalog"), +): + """Add a catalog to .specify/preset-catalogs.yml.""" + from .presets import PresetCatalog, PresetValidationError + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + # Validate URL + tmp_catalog = PresetCatalog(project_root) + try: + tmp_catalog._validate_catalog_url(url) + except PresetValidationError as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(1) + + config_path = specify_dir / "preset-catalogs.yml" + + # Load existing config + if config_path.exists(): + try: + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + except Exception as e: + console.print(f"[red]Error:[/red] Failed to read {config_path}: {e}") + raise typer.Exit(1) + else: + config = {} + + catalogs = config.get("catalogs", []) + if not isinstance(catalogs, list): + console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.") + raise typer.Exit(1) + + # Check for duplicate name + for existing in catalogs: + if isinstance(existing, dict) and existing.get("name") == name: + console.print(f"[yellow]Warning:[/yellow] A catalog named '{name}' already exists.") + console.print("Use 'specify preset catalog remove' first, or choose a different name.") + raise typer.Exit(1) + + catalogs.append({ + "name": name, + "url": url, + "priority": priority, + "install_allowed": install_allowed, + "description": description, + }) + + config["catalogs"] = catalogs + config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8") + + install_label = "install allowed" if install_allowed else "discovery only" + console.print(f"\n[green]āœ“[/green] Added catalog '[bold]{name}[/bold]' ({install_label})") + console.print(f" URL: {url}") + console.print(f" Priority: {priority}") + console.print(f"\nConfig saved to {config_path.relative_to(project_root)}") + + +@preset_catalog_app.command("remove") +def preset_catalog_remove( + name: str = typer.Argument(help="Catalog name to remove"), +): + """Remove a catalog from .specify/preset-catalogs.yml.""" + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + config_path = specify_dir / "preset-catalogs.yml" + if not config_path.exists(): + console.print("[red]Error:[/red] No preset catalog config found. Nothing to remove.") + raise typer.Exit(1) + + try: + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + except Exception: + console.print("[red]Error:[/red] Failed to read preset catalog config.") + raise typer.Exit(1) + + catalogs = config.get("catalogs", []) + if not isinstance(catalogs, list): + console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.") + raise typer.Exit(1) + original_count = len(catalogs) + catalogs = [c for c in catalogs if isinstance(c, dict) and c.get("name") != name] + + if len(catalogs) == original_count: + console.print(f"[red]Error:[/red] Catalog '{name}' not found.") + raise typer.Exit(1) + + config["catalogs"] = catalogs + config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8") + + console.print(f"[green]āœ“[/green] Removed catalog '{name}'") + if not catalogs: + console.print("\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]") + + +# ===== Extension Commands ===== + + +def _resolve_installed_extension( + argument: str, + installed_extensions: list, + command_name: str = "command", + allow_not_found: bool = False, +) -> tuple[Optional[str], Optional[str]]: + """Resolve an extension argument (ID or display name) to an installed extension. + + Args: + argument: Extension ID or display name provided by user + installed_extensions: List of installed extension dicts from manager.list_installed() + command_name: Name of the command for error messages (e.g., "enable", "disable") + allow_not_found: If True, return (None, None) when not found instead of raising + + Returns: + Tuple of (extension_id, display_name), or (None, None) if allow_not_found=True and not found + + Raises: + typer.Exit: If extension not found (and allow_not_found=False) or name is ambiguous + """ + from rich.table import Table + + # First, try exact ID match + for ext in installed_extensions: + if ext["id"] == argument: + return (ext["id"], ext["name"]) + + # If not found by ID, try display name match + name_matches = [ext for ext in installed_extensions if ext["name"].lower() == argument.lower()] + + if len(name_matches) == 1: + # Unique display-name match + return (name_matches[0]["id"], name_matches[0]["name"]) + elif len(name_matches) > 1: + # Ambiguous display-name match + console.print( + f"[red]Error:[/red] Extension name '{argument}' is ambiguous. " + "Multiple installed extensions share this name:" + ) + table = Table(title="Matching extensions") + table.add_column("ID", style="cyan", no_wrap=True) + table.add_column("Name", style="white") + table.add_column("Version", style="green") + for ext in name_matches: + table.add_row(ext.get("id", ""), ext.get("name", ""), str(ext.get("version", ""))) + console.print(table) + console.print("\nPlease rerun using the extension ID:") + console.print(f" [bold]specify extension {command_name} [/bold]") + raise typer.Exit(1) + else: + # No match by ID or display name + if allow_not_found: + return (None, None) + console.print(f"[red]Error:[/red] Extension '{argument}' is not installed") + raise typer.Exit(1) + + +def _resolve_catalog_extension( + argument: str, + catalog, + command_name: str = "info", +) -> tuple[Optional[dict], Optional[Exception]]: + """Resolve an extension argument (ID or display name) from the catalog. + + Args: + argument: Extension ID or display name provided by user + catalog: ExtensionCatalog instance + command_name: Name of the command for error messages + + Returns: + Tuple of (extension_info, catalog_error) + - If found: (ext_info_dict, None) + - If catalog error: (None, error) + - If not found: (None, None) + """ + from rich.table import Table + from .extensions import ExtensionError + + try: + # First try by ID + ext_info = catalog.get_extension_info(argument) + if ext_info: + return (ext_info, None) + + # Try by display name - search using argument as query, then filter for exact match + search_results = catalog.search(query=argument) + name_matches = [ext for ext in search_results if ext["name"].lower() == argument.lower()] + + if len(name_matches) == 1: + return (name_matches[0], None) + elif len(name_matches) > 1: + # Ambiguous display-name match in catalog + console.print( + f"[red]Error:[/red] Extension name '{argument}' is ambiguous. " + "Multiple catalog extensions share this name:" + ) + table = Table(title="Matching extensions") + table.add_column("ID", style="cyan", no_wrap=True) + table.add_column("Name", style="white") + table.add_column("Version", style="green") + table.add_column("Catalog", style="dim") + for ext in name_matches: + table.add_row( + ext.get("id", ""), + ext.get("name", ""), + str(ext.get("version", "")), + ext.get("_catalog_name", ""), + ) + console.print(table) + console.print("\nPlease rerun using the extension ID:") + console.print(f" [bold]specify extension {command_name} [/bold]") + raise typer.Exit(1) + + # Not found + return (None, None) + + except ExtensionError as e: + return (None, e) + + +@extension_app.command("list") +def extension_list( + available: bool = typer.Option(False, "--available", help="Show available extensions from catalog"), + all_extensions: bool = typer.Option(False, "--all", help="Show both installed and available"), +): + """List installed extensions.""" + from .extensions import ExtensionManager + + project_root = Path.cwd() + + # Check if we're in a spec-kit project + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + manager = ExtensionManager(project_root) + installed = manager.list_installed() + + if not installed and not (available or all_extensions): + console.print("[yellow]No extensions installed.[/yellow]") + console.print("\nInstall an extension with:") + console.print(" specify extension add ") + return + + if installed: + console.print("\n[bold cyan]Installed Extensions:[/bold cyan]\n") + + for ext in installed: + status_icon = "āœ“" if ext["enabled"] else "āœ—" + status_color = "green" if ext["enabled"] else "red" + + console.print(f" [{status_color}]{status_icon}[/{status_color}] [bold]{ext['name']}[/bold] (v{ext['version']})") + console.print(f" [dim]{ext['id']}[/dim]") + console.print(f" {ext['description']}") + console.print(f" Commands: {ext['command_count']} | Hooks: {ext['hook_count']} | Priority: {ext['priority']} | Status: {'Enabled' if ext['enabled'] else 'Disabled'}") + console.print() + + if available or all_extensions: + console.print("\nInstall an extension:") + console.print(" [cyan]specify extension add [/cyan]") + + +@catalog_app.command("list") +def catalog_list(): + """List all active extension catalogs.""" + from .extensions import ExtensionCatalog, ValidationError + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + catalog = ExtensionCatalog(project_root) + + try: + active_catalogs = catalog.get_active_catalogs() + except ValidationError as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(1) + + console.print("\n[bold cyan]Active Extension Catalogs:[/bold cyan]\n") + for entry in active_catalogs: + install_str = ( + "[green]install allowed[/green]" + if entry.install_allowed + else "[yellow]discovery only[/yellow]" + ) + console.print(f" [bold]{entry.name}[/bold] (priority {entry.priority})") + if entry.description: + console.print(f" {entry.description}") + console.print(f" URL: {entry.url}") + console.print(f" Install: {install_str}") + console.print() + + config_path = project_root / ".specify" / "extension-catalogs.yml" + user_config_path = Path.home() / ".specify" / "extension-catalogs.yml" + if os.environ.get("SPECKIT_CATALOG_URL"): + console.print("[dim]Catalog configured via SPECKIT_CATALOG_URL environment variable.[/dim]") + else: + try: + proj_loaded = config_path.exists() and catalog._load_catalog_config(config_path) is not None + except ValidationError: + proj_loaded = False + if proj_loaded: + console.print(f"[dim]Config: {config_path.relative_to(project_root)}[/dim]") + else: + try: + user_loaded = user_config_path.exists() and catalog._load_catalog_config(user_config_path) is not None + except ValidationError: + user_loaded = False + if user_loaded: + console.print("[dim]Config: ~/.specify/extension-catalogs.yml[/dim]") + else: + console.print("[dim]Using built-in default catalog stack.[/dim]") + console.print( + "[dim]Add .specify/extension-catalogs.yml to customize.[/dim]" + ) + + +@catalog_app.command("add") +def catalog_add( + url: str = typer.Argument(help="Catalog URL (must use HTTPS)"), + name: str = typer.Option(..., "--name", help="Catalog name"), + priority: int = typer.Option(10, "--priority", help="Priority (lower = higher priority)"), + install_allowed: bool = typer.Option( + False, "--install-allowed/--no-install-allowed", + help="Allow extensions from this catalog to be installed", + ), + description: str = typer.Option("", "--description", help="Description of the catalog"), +): + """Add a catalog to .specify/extension-catalogs.yml.""" + from .extensions import ExtensionCatalog, ValidationError + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + # Validate URL + tmp_catalog = ExtensionCatalog(project_root) + try: + tmp_catalog._validate_catalog_url(url) + except ValidationError as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(1) + + config_path = specify_dir / "extension-catalogs.yml" + + # Load existing config + if config_path.exists(): + try: + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + except Exception as e: + console.print(f"[red]Error:[/red] Failed to read {config_path}: {e}") + raise typer.Exit(1) + else: + config = {} + + catalogs = config.get("catalogs", []) + if not isinstance(catalogs, list): + console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.") + raise typer.Exit(1) + + # Check for duplicate name + for existing in catalogs: + if isinstance(existing, dict) and existing.get("name") == name: + console.print(f"[yellow]Warning:[/yellow] A catalog named '{name}' already exists.") + console.print("Use 'specify extension catalog remove' first, or choose a different name.") + raise typer.Exit(1) + + catalogs.append({ + "name": name, + "url": url, + "priority": priority, + "install_allowed": install_allowed, + "description": description, + }) + + config["catalogs"] = catalogs + config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8") + + install_label = "install allowed" if install_allowed else "discovery only" + console.print(f"\n[green]āœ“[/green] Added catalog '[bold]{name}[/bold]' ({install_label})") + console.print(f" URL: {url}") + console.print(f" Priority: {priority}") + console.print(f"\nConfig saved to {config_path.relative_to(project_root)}") + + +@catalog_app.command("remove") +def catalog_remove( + name: str = typer.Argument(help="Catalog name to remove"), +): + """Remove a catalog from .specify/extension-catalogs.yml.""" + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + config_path = specify_dir / "extension-catalogs.yml" + if not config_path.exists(): + console.print("[red]Error:[/red] No catalog config found. Nothing to remove.") + raise typer.Exit(1) + + try: + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + except Exception: + console.print("[red]Error:[/red] Failed to read catalog config.") + raise typer.Exit(1) + + catalogs = config.get("catalogs", []) + if not isinstance(catalogs, list): + console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.") + raise typer.Exit(1) + original_count = len(catalogs) + catalogs = [c for c in catalogs if isinstance(c, dict) and c.get("name") != name] + + if len(catalogs) == original_count: + console.print(f"[red]Error:[/red] Catalog '{name}' not found.") + raise typer.Exit(1) + + config["catalogs"] = catalogs + config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8") + + console.print(f"[green]āœ“[/green] Removed catalog '{name}'") + if not catalogs: + console.print("\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]") + + +@extension_app.command("add") +def extension_add( + extension: str = typer.Argument(help="Extension name or path"), + dev: bool = typer.Option(False, "--dev", help="Install from local directory"), + from_url: Optional[str] = typer.Option(None, "--from", help="Install from custom URL"), + priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"), +): + """Install an extension.""" + from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError, REINSTALL_COMMAND + + project_root = Path.cwd() + + # Check if we're in a spec-kit project + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + # Validate priority + if priority < 1: + console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)") + raise typer.Exit(1) + + manager = ExtensionManager(project_root) + speckit_version = get_speckit_version() + + try: + with console.status(f"[cyan]Installing extension: {extension}[/cyan]"): + if dev: + # Install from local directory + source_path = Path(extension).expanduser().resolve() + if not source_path.exists(): + console.print(f"[red]Error:[/red] Directory not found: {source_path}") + raise typer.Exit(1) + + if not (source_path / "extension.yml").exists(): + console.print(f"[red]Error:[/red] No extension.yml found in {source_path}") + raise typer.Exit(1) + + manifest = manager.install_from_directory(source_path, speckit_version, priority=priority) + + elif from_url: + # Install from URL (ZIP file) + import urllib.request + import urllib.error + from urllib.parse import urlparse + + # Validate URL + parsed = urlparse(from_url) + is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1") + + if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost): + console.print("[red]Error:[/red] URL must use HTTPS for security.") + console.print("HTTP is only allowed for localhost URLs.") + raise typer.Exit(1) + + # Warn about untrusted sources + console.print("[yellow]Warning:[/yellow] Installing from external URL.") + console.print("Only install extensions from sources you trust.\n") + console.print(f"Downloading from {from_url}...") + + # Download ZIP to temp location + download_dir = project_root / ".specify" / "extensions" / ".cache" / "downloads" + download_dir.mkdir(parents=True, exist_ok=True) + zip_path = download_dir / f"{extension}-url-download.zip" + + try: + with urllib.request.urlopen(from_url, timeout=60) as response: + zip_data = response.read() + zip_path.write_bytes(zip_data) + + # Install from downloaded ZIP + manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority) + except urllib.error.URLError as e: + console.print(f"[red]Error:[/red] Failed to download from {from_url}: {e}") + raise typer.Exit(1) + finally: + # Clean up downloaded ZIP + if zip_path.exists(): + zip_path.unlink() + + else: + # Try bundled extensions first (shipped with spec-kit) + bundled_path = _locate_bundled_extension(extension) + if bundled_path is not None: + manifest = manager.install_from_directory(bundled_path, speckit_version, priority=priority) + else: + # Install from catalog (also resolves display names to IDs) + catalog = ExtensionCatalog(project_root) + + # Check if extension exists in catalog (supports both ID and display name) + ext_info, catalog_error = _resolve_catalog_extension(extension, catalog, "add") + if catalog_error: + console.print(f"[red]Error:[/red] Could not query extension catalog: {catalog_error}") + raise typer.Exit(1) + if not ext_info: + console.print(f"[red]Error:[/red] Extension '{extension}' not found in catalog") + console.print("\nSearch available extensions:") + console.print(" specify extension search") + raise typer.Exit(1) + + # If catalog resolved a display name to an ID, check bundled again + resolved_id = ext_info['id'] + if resolved_id != extension: + bundled_path = _locate_bundled_extension(resolved_id) + if bundled_path is not None: + manifest = manager.install_from_directory(bundled_path, speckit_version, priority=priority) + + if bundled_path is None: + # Bundled extensions without a download URL must come from the local package + if ext_info.get("bundled") and not ext_info.get("download_url"): + console.print( + f"[red]Error:[/red] Extension '{ext_info['id']}' is bundled with spec-kit " + f"but could not be found in the installed package." + ) + console.print( + "\nThis usually means the spec-kit installation is incomplete or corrupted." + ) + console.print("Try reinstalling spec-kit:") + console.print(f" {REINSTALL_COMMAND}") + raise typer.Exit(1) + + # Enforce install_allowed policy + if not ext_info.get("_install_allowed", True): + catalog_name = ext_info.get("_catalog_name", "community") + console.print( + f"[red]Error:[/red] '{extension}' is available in the " + f"'{catalog_name}' catalog but installation is not allowed from that catalog." + ) + console.print( + f"\nTo enable installation, add '{extension}' to an approved catalog " + f"(install_allowed: true) in .specify/extension-catalogs.yml." + ) + raise typer.Exit(1) + + # Download extension ZIP (use resolved ID, not original argument which may be display name) + extension_id = ext_info['id'] + console.print(f"Downloading {ext_info['name']} v{ext_info.get('version', 'unknown')}...") + zip_path = catalog.download_extension(extension_id) + + try: + # Install from downloaded ZIP + manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority) + finally: + # Clean up downloaded ZIP + if zip_path.exists(): + zip_path.unlink() + + console.print("\n[green]āœ“[/green] Extension installed successfully!") + console.print(f"\n[bold]{manifest.name}[/bold] (v{manifest.version})") + console.print(f" {manifest.description}") + + for warning in manifest.warnings: + console.print(f"\n[yellow]⚠ Compatibility warning:[/yellow] {warning}") + + console.print("\n[bold cyan]Provided commands:[/bold cyan]") + for cmd in manifest.commands: + console.print(f" • {cmd['name']} - {cmd.get('description', '')}") + + # Report agent skills registration + reg_meta = manager.registry.get(manifest.id) + reg_skills = reg_meta.get("registered_skills", []) if reg_meta else [] + # Normalize to guard against corrupted registry entries + if not isinstance(reg_skills, list): + reg_skills = [] + if reg_skills: + console.print(f"\n[green]āœ“[/green] {len(reg_skills)} agent skill(s) auto-registered") + + console.print("\n[yellow]⚠[/yellow] Configuration may be required") + console.print(f" Check: .specify/extensions/{manifest.id}/") + + except ValidationError as e: + console.print(f"\n[red]Validation Error:[/red] {e}") + raise typer.Exit(1) + except CompatibilityError as e: + console.print(f"\n[red]Compatibility Error:[/red] {e}") + raise typer.Exit(1) + except ExtensionError as e: + console.print(f"\n[red]Error:[/red] {e}") + raise typer.Exit(1) + + +@extension_app.command("remove") +def extension_remove( + extension: str = typer.Argument(help="Extension ID or name to remove"), + keep_config: bool = typer.Option(False, "--keep-config", help="Don't remove config files"), + force: bool = typer.Option(False, "--force", help="Skip confirmation"), +): + """Uninstall an extension.""" + from .extensions import ExtensionManager + + project_root = Path.cwd() + + # Check if we're in a spec-kit project + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + manager = ExtensionManager(project_root) + + # Resolve extension ID from argument (handles ambiguous names) + installed = manager.list_installed() + extension_id, display_name = _resolve_installed_extension(extension, installed, "remove") + + # Get extension info for command and skill counts + ext_manifest = manager.get_extension(extension_id) + reg_meta = manager.registry.get(extension_id) + # Derive cmd_count from the registry's registered_commands (includes aliases) + # rather than from the manifest (primary commands only). Use max() across + # agents to get the per-agent count; sum() would double-count since users + # think in logical commands, not per-agent file counts. + # Use get() without a default so we can distinguish "key missing" (fall back + # to manifest) from "key present but empty dict" (zero commands registered). + registered_commands = reg_meta.get("registered_commands") if isinstance(reg_meta, dict) else None + if isinstance(registered_commands, dict): + cmd_count = max( + (len(v) for v in registered_commands.values() if isinstance(v, list)), + default=0, + ) + else: + cmd_count = len(ext_manifest.commands) if ext_manifest else 0 + raw_skills = reg_meta.get("registered_skills") if reg_meta else None + skill_count = len(raw_skills) if isinstance(raw_skills, list) else 0 + + # Confirm removal + if not force: + console.print("\n[yellow]⚠ This will remove:[/yellow]") + console.print(f" • {cmd_count} command{'s' if cmd_count != 1 else ''} per agent") + if skill_count: + console.print(f" • {skill_count} agent skill(s)") + console.print(f" • Extension directory: .specify/extensions/{extension_id}/") + if not keep_config: + console.print(" • Config files (will be backed up)") + console.print() + + confirm = typer.confirm("Continue?") + if not confirm: + console.print("Cancelled") + raise typer.Exit(0) + + # Remove extension + success = manager.remove(extension_id, keep_config=keep_config) + + if success: + console.print(f"\n[green]āœ“[/green] Extension '{display_name}' removed successfully") + if keep_config: + console.print(f"\nConfig files preserved in .specify/extensions/{extension_id}/") + else: + console.print(f"\nConfig files backed up to .specify/extensions/.backup/{extension_id}/") + console.print(f"\nTo reinstall: specify extension add {extension_id}") + else: + console.print("[red]Error:[/red] Failed to remove extension") + raise typer.Exit(1) + + +@extension_app.command("search") +def extension_search( + query: str = typer.Argument(None, help="Search query (optional)"), + tag: Optional[str] = typer.Option(None, "--tag", help="Filter by tag"), + author: Optional[str] = typer.Option(None, "--author", help="Filter by author"), + verified: bool = typer.Option(False, "--verified", help="Show only verified extensions"), +): + """Search for available extensions in catalog.""" + from .extensions import ExtensionCatalog, ExtensionError + + project_root = Path.cwd() + + # Check if we're in a spec-kit project + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + catalog = ExtensionCatalog(project_root) + + try: + console.print("šŸ” Searching extension catalog...") + results = catalog.search(query=query, tag=tag, author=author, verified_only=verified) + + if not results: + console.print("\n[yellow]No extensions found matching criteria[/yellow]") + if query or tag or author or verified: + console.print("\nTry:") + console.print(" • Broader search terms") + console.print(" • Remove filters") + console.print(" • specify extension search (show all)") + raise typer.Exit(0) + + console.print(f"\n[green]Found {len(results)} extension(s):[/green]\n") + + for ext in results: + # Extension header + verified_badge = " [green]āœ“ Verified[/green]" if ext.get("verified") else "" + console.print(f"[bold]{ext['name']}[/bold] (v{ext['version']}){verified_badge}") + console.print(f" {ext['description']}") + + # Metadata + console.print(f"\n [dim]Author:[/dim] {ext.get('author', 'Unknown')}") + if ext.get('tags'): + tags_str = ", ".join(ext['tags']) + console.print(f" [dim]Tags:[/dim] {tags_str}") + + # Source catalog + catalog_name = ext.get("_catalog_name", "") + install_allowed = ext.get("_install_allowed", True) + if catalog_name: + if install_allowed: + console.print(f" [dim]Catalog:[/dim] {catalog_name}") + else: + console.print(f" [dim]Catalog:[/dim] {catalog_name} [yellow](discovery only — not installable)[/yellow]") + + # Stats + stats = [] + if ext.get('downloads') is not None: + stats.append(f"Downloads: {ext['downloads']:,}") + if ext.get('stars') is not None: + stats.append(f"Stars: {ext['stars']}") + if stats: + console.print(f" [dim]{' | '.join(stats)}[/dim]") + + # Links + if ext.get('repository'): + console.print(f" [dim]Repository:[/dim] {ext['repository']}") + + # Install command (show warning if not installable) + if install_allowed: + console.print(f"\n [cyan]Install:[/cyan] specify extension add {ext['id']}") + else: + console.print(f"\n [yellow]⚠[/yellow] Not directly installable from '{catalog_name}'.") + console.print( + f" Add to an approved catalog with install_allowed: true, " + f"or install from a ZIP URL: specify extension add {ext['id']} --from " + ) + console.print() + + except ExtensionError as e: + console.print(f"\n[red]Error:[/red] {e}") + console.print("\nTip: The catalog may be temporarily unavailable. Try again later.") + raise typer.Exit(1) + + +@extension_app.command("info") +def extension_info( + extension: str = typer.Argument(help="Extension ID or name"), +): + """Show detailed information about an extension.""" + from .extensions import ExtensionCatalog, ExtensionManager, normalize_priority + + project_root = Path.cwd() + + # Check if we're in a spec-kit project + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + catalog = ExtensionCatalog(project_root) + manager = ExtensionManager(project_root) + installed = manager.list_installed() + + # Try to resolve from installed extensions first (by ID or name) + # Use allow_not_found=True since the extension may be catalog-only + resolved_installed_id, resolved_installed_name = _resolve_installed_extension( + extension, installed, "info", allow_not_found=True + ) + + # Try catalog lookup (with error handling) + # If we resolved an installed extension by display name, use its ID for catalog lookup + # to ensure we get the correct catalog entry (not a different extension with same name) + lookup_key = resolved_installed_id if resolved_installed_id else extension + ext_info, catalog_error = _resolve_catalog_extension(lookup_key, catalog, "info") + + # Case 1: Found in catalog - show full catalog info + if ext_info: + _print_extension_info(ext_info, manager) + return + + # Case 2: Installed locally but catalog lookup failed or not in catalog + if resolved_installed_id: + # Get local manifest info + ext_manifest = manager.get_extension(resolved_installed_id) + metadata = manager.registry.get(resolved_installed_id) + metadata_is_dict = isinstance(metadata, dict) + if not metadata_is_dict: + console.print( + "[yellow]Warning:[/yellow] Extension metadata appears to be corrupted; " + "some information may be unavailable." + ) + version = metadata.get("version", "unknown") if metadata_is_dict else "unknown" + + console.print(f"\n[bold]{resolved_installed_name}[/bold] (v{version})") + console.print(f"ID: {resolved_installed_id}") + console.print() + + if ext_manifest: + console.print(f"{ext_manifest.description}") + console.print() + # Author is optional in extension.yml, safely retrieve it + author = ext_manifest.data.get("extension", {}).get("author") + if author: + console.print(f"[dim]Author:[/dim] {author}") + console.print() + + if ext_manifest.commands: + console.print("[bold]Commands:[/bold]") + for cmd in ext_manifest.commands: + console.print(f" • {cmd['name']}: {cmd.get('description', '')}") + console.print() + + # Show catalog status + if catalog_error: + console.print(f"[yellow]Catalog unavailable:[/yellow] {catalog_error}") + console.print("[dim]Note: Using locally installed extension; catalog info could not be verified.[/dim]") + else: + console.print("[yellow]Note:[/yellow] Not found in catalog (custom/local extension)") + + console.print() + console.print("[green]āœ“ Installed[/green]") + priority = normalize_priority(metadata.get("priority") if metadata_is_dict else None) + console.print(f"[dim]Priority:[/dim] {priority}") + console.print(f"\nTo remove: specify extension remove {resolved_installed_id}") + return + + # Case 3: Not found anywhere + if catalog_error: + console.print(f"[red]Error:[/red] Could not query extension catalog: {catalog_error}") + console.print("\nTry again when online, or use the extension ID directly.") + else: + console.print(f"[red]Error:[/red] Extension '{extension}' not found") + console.print("\nTry: specify extension search") + raise typer.Exit(1) + + +def _print_extension_info(ext_info: dict, manager): + """Print formatted extension info from catalog data.""" + from .extensions import normalize_priority + + # Header + verified_badge = " [green]āœ“ Verified[/green]" if ext_info.get("verified") else "" + console.print(f"\n[bold]{ext_info['name']}[/bold] (v{ext_info['version']}){verified_badge}") + console.print(f"ID: {ext_info['id']}") + console.print() + + # Description + console.print(f"{ext_info['description']}") + console.print() + + # Author and License + console.print(f"[dim]Author:[/dim] {ext_info.get('author', 'Unknown')}") + console.print(f"[dim]License:[/dim] {ext_info.get('license', 'Unknown')}") + + # Source catalog + if ext_info.get("_catalog_name"): + install_allowed = ext_info.get("_install_allowed", True) + install_note = "" if install_allowed else " [yellow](discovery only)[/yellow]" + console.print(f"[dim]Source catalog:[/dim] {ext_info['_catalog_name']}{install_note}") + console.print() + + # Requirements + if ext_info.get('requires'): + console.print("[bold]Requirements:[/bold]") + reqs = ext_info['requires'] + if reqs.get('speckit_version'): + console.print(f" • Spec Kit: {reqs['speckit_version']}") + if reqs.get('tools'): + for tool in reqs['tools']: + tool_name = tool['name'] + tool_version = tool.get('version', 'any') + required = " (required)" if tool.get('required') else " (optional)" + console.print(f" • {tool_name}: {tool_version}{required}") + console.print() + + # Provides + if ext_info.get('provides'): + console.print("[bold]Provides:[/bold]") + provides = ext_info['provides'] + if provides.get('commands'): + console.print(f" • Commands: {provides['commands']}") + if provides.get('hooks'): + console.print(f" • Hooks: {provides['hooks']}") + console.print() + + # Tags + if ext_info.get('tags'): + tags_str = ", ".join(ext_info['tags']) + console.print(f"[bold]Tags:[/bold] {tags_str}") + console.print() + + # Statistics + stats = [] + if ext_info.get('downloads') is not None: + stats.append(f"Downloads: {ext_info['downloads']:,}") + if ext_info.get('stars') is not None: + stats.append(f"Stars: {ext_info['stars']}") + if stats: + console.print(f"[bold]Statistics:[/bold] {' | '.join(stats)}") + console.print() + + # Links + console.print("[bold]Links:[/bold]") + if ext_info.get('repository'): + console.print(f" • Repository: {ext_info['repository']}") + if ext_info.get('homepage'): + console.print(f" • Homepage: {ext_info['homepage']}") + if ext_info.get('documentation'): + console.print(f" • Documentation: {ext_info['documentation']}") + if ext_info.get('changelog'): + console.print(f" • Changelog: {ext_info['changelog']}") + console.print() + + # Installation status and command + is_installed = manager.registry.is_installed(ext_info['id']) + install_allowed = ext_info.get("_install_allowed", True) + if is_installed: + console.print("[green]āœ“ Installed[/green]") + metadata = manager.registry.get(ext_info['id']) + priority = normalize_priority(metadata.get("priority") if isinstance(metadata, dict) else None) + console.print(f"[dim]Priority:[/dim] {priority}") + console.print(f"\nTo remove: specify extension remove {ext_info['id']}") + elif install_allowed: + console.print("[yellow]Not installed[/yellow]") + console.print(f"\n[cyan]Install:[/cyan] specify extension add {ext_info['id']}") + else: + catalog_name = ext_info.get("_catalog_name", "community") + console.print("[yellow]Not installed[/yellow]") + console.print( + f"\n[yellow]⚠[/yellow] '{ext_info['id']}' is available in the '{catalog_name}' catalog " + f"but not in your approved catalog. Add it to .specify/extension-catalogs.yml " + f"with install_allowed: true to enable installation." + ) + + +@extension_app.command("update") +def extension_update( + extension: str = typer.Argument(None, help="Extension ID or name to update (or all)"), +): + """Update extension(s) to latest version.""" + from .extensions import ( + ExtensionManager, + ExtensionCatalog, + ExtensionError, + ValidationError, + CommandRegistrar, + HookExecutor, + normalize_priority, + ) + from packaging import version as pkg_version + import shutil + + project_root = Path.cwd() + + # Check if we're in a spec-kit project + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + manager = ExtensionManager(project_root) + catalog = ExtensionCatalog(project_root) + speckit_version = get_speckit_version() + + try: + # Get list of extensions to update + installed = manager.list_installed() + if extension: + # Update specific extension - resolve ID from argument (handles ambiguous names) + extension_id, _ = _resolve_installed_extension(extension, installed, "update") + extensions_to_update = [extension_id] + else: + # Update all extensions + extensions_to_update = [ext["id"] for ext in installed] + + if not extensions_to_update: + console.print("[yellow]No extensions installed[/yellow]") + raise typer.Exit(0) + + console.print("šŸ”„ Checking for updates...\n") + + updates_available = [] + + for ext_id in extensions_to_update: + # Get installed version + metadata = manager.registry.get(ext_id) + if metadata is None or not isinstance(metadata, dict) or "version" not in metadata: + console.print(f"⚠ {ext_id}: Registry entry corrupted or missing (skipping)") + continue + try: + installed_version = pkg_version.Version(metadata["version"]) + except pkg_version.InvalidVersion: + console.print( + f"⚠ {ext_id}: Invalid installed version '{metadata.get('version')}' in registry (skipping)" + ) + continue + + # Get catalog info + ext_info = catalog.get_extension_info(ext_id) + if not ext_info: + console.print(f"⚠ {ext_id}: Not found in catalog (skipping)") + continue + + # Check if installation is allowed from this catalog + if not ext_info.get("_install_allowed", True): + console.print(f"⚠ {ext_id}: Updates not allowed from '{ext_info.get('_catalog_name', 'catalog')}' (skipping)") + continue + + try: + catalog_version = pkg_version.Version(ext_info["version"]) + except pkg_version.InvalidVersion: + console.print( + f"⚠ {ext_id}: Invalid catalog version '{ext_info.get('version')}' (skipping)" + ) + continue + + if catalog_version > installed_version: + updates_available.append( + { + "id": ext_id, + "name": ext_info.get("name", ext_id), # Display name for status messages + "installed": str(installed_version), + "available": str(catalog_version), + "download_url": ext_info.get("download_url"), + } + ) + else: + console.print(f"āœ“ {ext_id}: Up to date (v{installed_version})") + + if not updates_available: + console.print("\n[green]All extensions are up to date![/green]") + raise typer.Exit(0) + + # Show available updates + console.print("\n[bold]Updates available:[/bold]\n") + for update in updates_available: + console.print( + f" • {update['id']}: {update['installed']} → {update['available']}" + ) + + console.print() + confirm = typer.confirm("Update these extensions?") + if not confirm: + console.print("Cancelled") + raise typer.Exit(0) + + # Perform updates with atomic backup/restore + console.print() + updated_extensions = [] + failed_updates = [] + registrar = CommandRegistrar() + hook_executor = HookExecutor(project_root) + + for update in updates_available: + extension_id = update["id"] + ext_name = update["name"] # Use display name for user-facing messages + console.print(f"šŸ“¦ Updating {ext_name}...") + + # Backup paths + backup_base = manager.extensions_dir / ".backup" / f"{extension_id}-update" + backup_ext_dir = backup_base / "extension" + backup_commands_dir = backup_base / "commands" + backup_config_dir = backup_base / "config" + + # Store backup state + backup_registry_entry = None + backup_hooks = None # None means no hooks key in config; {} means hooks key existed + backed_up_command_files = {} + + try: + # 1. Backup registry entry (always, even if extension dir doesn't exist) + backup_registry_entry = manager.registry.get(extension_id) + + # 2. Backup extension directory + extension_dir = manager.extensions_dir / extension_id + if extension_dir.exists(): + backup_base.mkdir(parents=True, exist_ok=True) + if backup_ext_dir.exists(): + shutil.rmtree(backup_ext_dir) + shutil.copytree(extension_dir, backup_ext_dir) + + # Backup config files separately so they can be restored + # after a successful install (install_from_directory clears dest dir). + config_files = list(extension_dir.glob("*-config.yml")) + list( + extension_dir.glob("*-config.local.yml") + ) + for cfg_file in config_files: + backup_config_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(cfg_file, backup_config_dir / cfg_file.name) + + # 3. Backup command files for all agents + from .agents import CommandRegistrar as _AgentReg + registered_commands = backup_registry_entry.get("registered_commands", {}) + for agent_name, cmd_names in registered_commands.items(): + if agent_name not in registrar.AGENT_CONFIGS: + continue + agent_config = registrar.AGENT_CONFIGS[agent_name] + commands_dir = project_root / agent_config["dir"] + + for cmd_name in cmd_names: + output_name = _AgentReg._compute_output_name(agent_name, cmd_name, agent_config) + cmd_file = commands_dir / f"{output_name}{agent_config['extension']}" + if cmd_file.exists(): + backup_cmd_path = backup_commands_dir / agent_name / cmd_file.name + backup_cmd_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(cmd_file, backup_cmd_path) + backed_up_command_files[str(cmd_file)] = str(backup_cmd_path) + + # Also backup copilot prompt files + if agent_name == "copilot": + prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md" + if prompt_file.exists(): + backup_prompt_path = backup_commands_dir / "copilot-prompts" / prompt_file.name + backup_prompt_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(prompt_file, backup_prompt_path) + backed_up_command_files[str(prompt_file)] = str(backup_prompt_path) + + # 4. Backup hooks from extensions.yml + # Use backup_hooks=None to indicate config had no "hooks" key (don't create on restore) + # Use backup_hooks={} to indicate config had "hooks" key with no hooks for this extension + config = hook_executor.get_project_config() + if "hooks" in config: + backup_hooks = {} # Config has hooks key - preserve this fact + for hook_name, hook_list in config["hooks"].items(): + ext_hooks = [h for h in hook_list if h.get("extension") == extension_id] + if ext_hooks: + backup_hooks[hook_name] = ext_hooks + + # 5. Download new version + zip_path = catalog.download_extension(extension_id) + try: + # 6. Validate extension ID from ZIP BEFORE modifying installation + # Handle both root-level and nested extension.yml (GitHub auto-generated ZIPs) + with zipfile.ZipFile(zip_path, "r") as zf: + import yaml + manifest_data = None + namelist = zf.namelist() + + # First try root-level extension.yml + if "extension.yml" in namelist: + with zf.open("extension.yml") as f: + manifest_data = yaml.safe_load(f) or {} + else: + # Look for extension.yml in a single top-level subdirectory + # (e.g., "repo-name-branch/extension.yml") + manifest_paths = [n for n in namelist if n.endswith("/extension.yml") and n.count("/") == 1] + if len(manifest_paths) == 1: + with zf.open(manifest_paths[0]) as f: + manifest_data = yaml.safe_load(f) or {} + + if manifest_data is None: + raise ValueError("Downloaded extension archive is missing 'extension.yml'") + + zip_extension_id = manifest_data.get("extension", {}).get("id") + if zip_extension_id != extension_id: + raise ValueError( + f"Extension ID mismatch: expected '{extension_id}', got '{zip_extension_id}'" + ) + + # 7. Remove old extension (handles command file cleanup and registry removal) + manager.remove(extension_id, keep_config=True) + + # 8. Install new version + _ = manager.install_from_zip(zip_path, speckit_version) + + # Restore user config files from backup after successful install. + new_extension_dir = manager.extensions_dir / extension_id + if backup_config_dir.exists() and new_extension_dir.exists(): + for cfg_file in backup_config_dir.iterdir(): + if cfg_file.is_file(): + shutil.copy2(cfg_file, new_extension_dir / cfg_file.name) + + # 9. Restore metadata from backup (installed_at, enabled state) + if backup_registry_entry and isinstance(backup_registry_entry, dict): + # Copy current registry entry to avoid mutating internal + # registry state before explicit restore(). + current_metadata = manager.registry.get(extension_id) + if current_metadata is None or not isinstance(current_metadata, dict): + raise RuntimeError( + f"Registry entry for '{extension_id}' missing or corrupted after install — update incomplete" + ) + new_metadata = dict(current_metadata) + + # Preserve the original installation timestamp + if "installed_at" in backup_registry_entry: + new_metadata["installed_at"] = backup_registry_entry["installed_at"] + + # Preserve the original priority (normalized to handle corruption) + if "priority" in backup_registry_entry: + new_metadata["priority"] = normalize_priority(backup_registry_entry["priority"]) + + # If extension was disabled before update, disable it again + if not backup_registry_entry.get("enabled", True): + new_metadata["enabled"] = False + + # Use restore() instead of update() because update() always + # preserves the existing installed_at, ignoring our override + manager.registry.restore(extension_id, new_metadata) + + # Also disable hooks in extensions.yml if extension was disabled + if not backup_registry_entry.get("enabled", True): + config = hook_executor.get_project_config() + if "hooks" in config: + for hook_name in config["hooks"]: + for hook in config["hooks"][hook_name]: + if hook.get("extension") == extension_id: + hook["enabled"] = False + hook_executor.save_project_config(config) + finally: + # Clean up downloaded ZIP + if zip_path.exists(): + zip_path.unlink() + + # 10. Clean up backup on success + if backup_base.exists(): + shutil.rmtree(backup_base) + + console.print(f" [green]āœ“[/green] Updated to v{update['available']}") + updated_extensions.append(ext_name) + + except KeyboardInterrupt: + raise + except Exception as e: + console.print(f" [red]āœ—[/red] Failed: {e}") + failed_updates.append((ext_name, str(e))) + + # Rollback on failure + console.print(f" [yellow]↩[/yellow] Rolling back {ext_name}...") + + try: + # Restore extension directory + # Only perform destructive rollback if backup exists (meaning we + # actually modified the extension). This avoids deleting a valid + # installation when failure happened before changes were made. + extension_dir = manager.extensions_dir / extension_id + if backup_ext_dir.exists(): + if extension_dir.exists(): + shutil.rmtree(extension_dir) + shutil.copytree(backup_ext_dir, extension_dir) + + # Remove any NEW command files created by failed install + # (files that weren't in the original backup) + try: + new_registry_entry = manager.registry.get(extension_id) + if new_registry_entry is None or not isinstance(new_registry_entry, dict): + new_registered_commands = {} + else: + new_registered_commands = new_registry_entry.get("registered_commands", {}) + for agent_name, cmd_names in new_registered_commands.items(): + if agent_name not in registrar.AGENT_CONFIGS: + continue + agent_config = registrar.AGENT_CONFIGS[agent_name] + commands_dir = project_root / agent_config["dir"] + + for cmd_name in cmd_names: + output_name = _AgentReg._compute_output_name(agent_name, cmd_name, agent_config) + cmd_file = commands_dir / f"{output_name}{agent_config['extension']}" + # Delete if it exists and wasn't in our backup + if cmd_file.exists() and str(cmd_file) not in backed_up_command_files: + cmd_file.unlink() + + # Also handle copilot prompt files + if agent_name == "copilot": + prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md" + if prompt_file.exists() and str(prompt_file) not in backed_up_command_files: + prompt_file.unlink() + except KeyError: + pass # No new registry entry exists, nothing to clean up + + # Restore backed up command files + for original_path, backup_path in backed_up_command_files.items(): + backup_file = Path(backup_path) + if backup_file.exists(): + original_file = Path(original_path) + original_file.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(backup_file, original_file) + + # Restore hooks in extensions.yml + # - backup_hooks=None means original config had no "hooks" key + # - backup_hooks={} or {...} means config had hooks key + config = hook_executor.get_project_config() + if "hooks" in config: + modified = False + + if backup_hooks is None: + # Original config had no "hooks" key; remove it entirely + del config["hooks"] + modified = True + else: + # Remove any hooks for this extension added by failed install + for hook_name, hooks_list in config["hooks"].items(): + original_len = len(hooks_list) + config["hooks"][hook_name] = [ + h for h in hooks_list + if h.get("extension") != extension_id + ] + if len(config["hooks"][hook_name]) != original_len: + modified = True + + # Add back the backed up hooks if any + if backup_hooks: + for hook_name, hooks in backup_hooks.items(): + if hook_name not in config["hooks"]: + config["hooks"][hook_name] = [] + config["hooks"][hook_name].extend(hooks) + modified = True + + if modified: + hook_executor.save_project_config(config) + + # Restore registry entry (use restore() since entry was removed) + if backup_registry_entry: + manager.registry.restore(extension_id, backup_registry_entry) + + console.print(" [green]āœ“[/green] Rollback successful") + # Clean up backup directory only on successful rollback + if backup_base.exists(): + shutil.rmtree(backup_base) + except Exception as rollback_error: + console.print(f" [red]āœ—[/red] Rollback failed: {rollback_error}") + console.print(f" [dim]Backup preserved at: {backup_base}[/dim]") + + # Summary + console.print() + if updated_extensions: + console.print(f"[green]āœ“[/green] Successfully updated {len(updated_extensions)} extension(s)") + if failed_updates: + console.print(f"[red]āœ—[/red] Failed to update {len(failed_updates)} extension(s):") + for ext_name, error in failed_updates: + console.print(f" • {ext_name}: {error}") + raise typer.Exit(1) + + except ValidationError as e: + console.print(f"\n[red]Validation Error:[/red] {e}") + raise typer.Exit(1) + except ExtensionError as e: + console.print(f"\n[red]Error:[/red] {e}") + raise typer.Exit(1) + + +@extension_app.command("enable") +def extension_enable( + extension: str = typer.Argument(help="Extension ID or name to enable"), +): + """Enable a disabled extension.""" + from .extensions import ExtensionManager, HookExecutor + + project_root = Path.cwd() + + # Check if we're in a spec-kit project + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + manager = ExtensionManager(project_root) + hook_executor = HookExecutor(project_root) + + # Resolve extension ID from argument (handles ambiguous names) + installed = manager.list_installed() + extension_id, display_name = _resolve_installed_extension(extension, installed, "enable") + + # Update registry + metadata = manager.registry.get(extension_id) + if metadata is None or not isinstance(metadata, dict): + console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)") + raise typer.Exit(1) + + if metadata.get("enabled", True): + console.print(f"[yellow]Extension '{display_name}' is already enabled[/yellow]") + raise typer.Exit(0) + + manager.registry.update(extension_id, {"enabled": True}) + + # Enable hooks in extensions.yml + config = hook_executor.get_project_config() + if "hooks" in config: + for hook_name in config["hooks"]: + for hook in config["hooks"][hook_name]: + if hook.get("extension") == extension_id: + hook["enabled"] = True + hook_executor.save_project_config(config) + + console.print(f"[green]āœ“[/green] Extension '{display_name}' enabled") + + +@extension_app.command("disable") +def extension_disable( + extension: str = typer.Argument(help="Extension ID or name to disable"), +): + """Disable an extension without removing it.""" + from .extensions import ExtensionManager, HookExecutor + + project_root = Path.cwd() + + # Check if we're in a spec-kit project + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + manager = ExtensionManager(project_root) + hook_executor = HookExecutor(project_root) + + # Resolve extension ID from argument (handles ambiguous names) + installed = manager.list_installed() + extension_id, display_name = _resolve_installed_extension(extension, installed, "disable") + + # Update registry + metadata = manager.registry.get(extension_id) + if metadata is None or not isinstance(metadata, dict): + console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)") + raise typer.Exit(1) + + if not metadata.get("enabled", True): + console.print(f"[yellow]Extension '{display_name}' is already disabled[/yellow]") + raise typer.Exit(0) + + manager.registry.update(extension_id, {"enabled": False}) + + # Disable hooks in extensions.yml + config = hook_executor.get_project_config() + if "hooks" in config: + for hook_name in config["hooks"]: + for hook in config["hooks"][hook_name]: + if hook.get("extension") == extension_id: + hook["enabled"] = False + hook_executor.save_project_config(config) + + console.print(f"[green]āœ“[/green] Extension '{display_name}' disabled") + console.print("\nCommands will no longer be available. Hooks will not execute.") + console.print(f"To re-enable: specify extension enable {extension_id}") + + +@extension_app.command("set-priority") +def extension_set_priority( + extension: str = typer.Argument(help="Extension ID or name"), + priority: int = typer.Argument(help="New priority (lower = higher precedence)"), +): + """Set the resolution priority of an installed extension.""" + from .extensions import ExtensionManager + + project_root = Path.cwd() + + # Check if we're in a spec-kit project + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + # Validate priority + if priority < 1: + console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)") + raise typer.Exit(1) + + manager = ExtensionManager(project_root) + + # Resolve extension ID from argument (handles ambiguous names) + installed = manager.list_installed() + extension_id, display_name = _resolve_installed_extension(extension, installed, "set-priority") + + # Get current metadata + metadata = manager.registry.get(extension_id) + if metadata is None or not isinstance(metadata, dict): + console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)") + raise typer.Exit(1) + + from .extensions import normalize_priority + raw_priority = metadata.get("priority") + # Only skip if the stored value is already a valid int equal to requested priority + # This ensures corrupted values (e.g., "high") get repaired even when setting to default (10) + if isinstance(raw_priority, int) and raw_priority == priority: + console.print(f"[yellow]Extension '{display_name}' already has priority {priority}[/yellow]") + raise typer.Exit(0) + + old_priority = normalize_priority(raw_priority) + + # Update priority + manager.registry.update(extension_id, {"priority": priority}) + + console.print(f"[green]āœ“[/green] Extension '{display_name}' priority changed: {old_priority} → {priority}") + console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]") + + +# ===== Workflow Commands ===== + +workflow_app = typer.Typer( + name="workflow", + help="Manage and run automation workflows", + add_completion=False, +) +app.add_typer(workflow_app, name="workflow") + +workflow_catalog_app = typer.Typer( + name="catalog", + help="Manage workflow catalogs", + add_completion=False, +) +workflow_app.add_typer(workflow_catalog_app, name="catalog") + + +@workflow_app.command("run") +def workflow_run( + source: str = typer.Argument(..., help="Workflow ID or YAML file path"), + input_values: list[str] | None = typer.Option( + None, "--input", "-i", help="Input values as key=value pairs" + ), +): + """Run a workflow from an installed ID or local YAML path.""" + from .workflows.engine import WorkflowEngine + + project_root = Path.cwd() + if not (project_root / ".specify").exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + engine = WorkflowEngine(project_root) + engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026") + + try: + definition = engine.load_workflow(source) + except FileNotFoundError: + console.print(f"[red]Error:[/red] Workflow not found: {source}") + raise typer.Exit(1) + except ValueError as exc: + console.print(f"[red]Error:[/red] Invalid workflow: {exc}") + raise typer.Exit(1) + + # Validate + errors = engine.validate(definition) + if errors: + console.print("[red]Workflow validation failed:[/red]") + for err in errors: + console.print(f" • {err}") + raise typer.Exit(1) + + # Parse inputs + inputs: dict[str, Any] = {} + if input_values: + for kv in input_values: + if "=" not in kv: + console.print(f"[red]Error:[/red] Invalid input format: {kv!r} (expected key=value)") + raise typer.Exit(1) + key, _, value = kv.partition("=") + inputs[key.strip()] = value.strip() + + console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})") + console.print(f"[dim]Version: {definition.version}[/dim]\n") + + try: + state = engine.execute(definition, inputs) + except ValueError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + except Exception as exc: + console.print(f"[red]Workflow failed:[/red] {exc}") + raise typer.Exit(1) + + status_colors = { + "completed": "green", + "paused": "yellow", + "failed": "red", + "aborted": "red", + } + color = status_colors.get(state.status.value, "white") + console.print(f"\n[{color}]Status: {state.status.value}[/{color}]") + console.print(f"[dim]Run ID: {state.run_id}[/dim]") + + if state.status.value == "paused": + console.print(f"\nResume with: [cyan]specify workflow resume {state.run_id}[/cyan]") + + +@workflow_app.command("resume") +def workflow_resume( + run_id: str = typer.Argument(..., help="Run ID to resume"), +): + """Resume a paused or failed workflow run.""" + from .workflows.engine import WorkflowEngine + + project_root = Path.cwd() + if not (project_root / ".specify").exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + engine = WorkflowEngine(project_root) + engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026") + + try: + state = engine.resume(run_id) + except FileNotFoundError: + console.print(f"[red]Error:[/red] Run not found: {run_id}") + raise typer.Exit(1) + except ValueError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + except Exception as exc: + console.print(f"[red]Resume failed:[/red] {exc}") + raise typer.Exit(1) + + status_colors = { + "completed": "green", + "paused": "yellow", + "failed": "red", + "aborted": "red", + } + color = status_colors.get(state.status.value, "white") + console.print(f"\n[{color}]Status: {state.status.value}[/{color}]") + + +@workflow_app.command("status") +def workflow_status( + run_id: str | None = typer.Argument(None, help="Run ID to inspect (shows all if omitted)"), +): + """Show workflow run status.""" + from .workflows.engine import WorkflowEngine + + project_root = Path.cwd() + if not (project_root / ".specify").exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + engine = WorkflowEngine(project_root) + + if run_id: + try: + from .workflows.engine import RunState + state = RunState.load(run_id, project_root) + except FileNotFoundError: + console.print(f"[red]Error:[/red] Run not found: {run_id}") + raise typer.Exit(1) + + status_colors = { + "completed": "green", + "paused": "yellow", + "failed": "red", + "aborted": "red", + "running": "blue", + "created": "dim", + } + color = status_colors.get(state.status.value, "white") + + console.print(f"\n[bold cyan]Workflow Run: {state.run_id}[/bold cyan]") + console.print(f" Workflow: {state.workflow_id}") + console.print(f" Status: [{color}]{state.status.value}[/{color}]") + console.print(f" Created: {state.created_at}") + console.print(f" Updated: {state.updated_at}") + + if state.current_step_id: + console.print(f" Current: {state.current_step_id}") + + if state.step_results: + console.print(f"\n [bold]Steps ({len(state.step_results)}):[/bold]") + for step_id, step_data in state.step_results.items(): + s = step_data.get("status", "unknown") + sc = {"completed": "green", "failed": "red", "paused": "yellow"}.get(s, "white") + console.print(f" [{sc}]ā—[/{sc}] {step_id}: {s}") + else: + runs = engine.list_runs() + if not runs: + console.print("[yellow]No workflow runs found.[/yellow]") + return + + console.print("\n[bold cyan]Workflow Runs:[/bold cyan]\n") + for run_data in runs: + s = run_data.get("status", "unknown") + sc = {"completed": "green", "failed": "red", "paused": "yellow", "running": "blue"}.get(s, "white") + console.print( + f" [{sc}]ā—[/{sc}] {run_data['run_id']} " + f"{run_data.get('workflow_id', '?')} " + f"[{sc}]{s}[/{sc}] " + f"[dim]{run_data.get('updated_at', '?')}[/dim]" + ) + + +@workflow_app.command("list") +def workflow_list(): + """List installed workflows.""" + from .workflows.catalog import WorkflowRegistry + + project_root = Path.cwd() + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + + registry = WorkflowRegistry(project_root) + installed = registry.list() + + if not installed: + console.print("[yellow]No workflows installed.[/yellow]") + console.print("\nInstall a workflow with:") + console.print(" [cyan]specify workflow add [/cyan]") + return + + console.print("\n[bold cyan]Installed Workflows:[/bold cyan]\n") + for wf_id, wf_data in installed.items(): + console.print(f" [bold]{wf_data.get('name', wf_id)}[/bold] ({wf_id}) v{wf_data.get('version', '?')}") + desc = wf_data.get("description", "") + if desc: + console.print(f" {desc}") + console.print() + + +@workflow_app.command("add") +def workflow_add( + source: str = typer.Argument(..., help="Workflow ID, URL, or local path"), +): + """Install a workflow from catalog, URL, or local path.""" + from .workflows.catalog import WorkflowCatalog, WorkflowRegistry, WorkflowCatalogError + from .workflows.engine import WorkflowDefinition + + project_root = Path.cwd() + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + + registry = WorkflowRegistry(project_root) + workflows_dir = project_root / ".specify" / "workflows" + + def _validate_and_install_local(yaml_path: Path, source_label: str) -> None: + """Validate and install a workflow from a local YAML file.""" + try: + definition = WorkflowDefinition.from_yaml(yaml_path) + except (ValueError, yaml.YAMLError) as exc: + console.print(f"[red]Error:[/red] Invalid workflow YAML: {exc}") + raise typer.Exit(1) + if not definition.id or not definition.id.strip(): + console.print("[red]Error:[/red] Workflow definition has an empty or missing 'id'") + raise typer.Exit(1) + + from .workflows.engine import validate_workflow + errors = validate_workflow(definition) + if errors: + console.print("[red]Error:[/red] Workflow validation failed:") + for err in errors: + console.print(f" \u2022 {err}") + raise typer.Exit(1) + + dest_dir = workflows_dir / definition.id + dest_dir.mkdir(parents=True, exist_ok=True) + import shutil + shutil.copy2(yaml_path, dest_dir / "workflow.yml") + registry.add(definition.id, { + "name": definition.name, + "version": definition.version, + "description": definition.description, + "source": source_label, + }) + console.print(f"[green]āœ“[/green] Workflow '{definition.name}' ({definition.id}) installed") + + # Try as URL (http/https) + if source.startswith("http://") or source.startswith("https://"): + from ipaddress import ip_address + from urllib.parse import urlparse + from urllib.request import urlopen # noqa: S310 + + parsed_src = urlparse(source) + src_host = parsed_src.hostname or "" + src_loopback = src_host == "localhost" + if not src_loopback: + try: + src_loopback = ip_address(src_host).is_loopback + except ValueError: + # Host is not an IP literal (e.g., a DNS name); keep default non-loopback. + pass + if parsed_src.scheme != "https" and not (parsed_src.scheme == "http" and src_loopback): + console.print("[red]Error:[/red] Only HTTPS URLs are allowed, except HTTP for localhost.") + raise typer.Exit(1) + + import tempfile + try: + with urlopen(source, timeout=30) as resp: # noqa: S310 + final_url = resp.geturl() + final_parsed = urlparse(final_url) + final_host = final_parsed.hostname or "" + final_lb = final_host == "localhost" + if not final_lb: + try: + final_lb = ip_address(final_host).is_loopback + except ValueError: + # Redirect host is not an IP literal; keep loopback as determined above. + pass + if final_parsed.scheme != "https" and not (final_parsed.scheme == "http" and final_lb): + console.print(f"[red]Error:[/red] URL redirected to non-HTTPS: {final_url}") + raise typer.Exit(1) + with tempfile.NamedTemporaryFile(suffix=".yml", delete=False) as tmp: + tmp.write(resp.read()) + tmp_path = Path(tmp.name) + except typer.Exit: + raise + except Exception as exc: + console.print(f"[red]Error:[/red] Failed to download workflow: {exc}") + raise typer.Exit(1) + try: + _validate_and_install_local(tmp_path, source) + finally: + tmp_path.unlink(missing_ok=True) + return + + # Try as a local file/directory + source_path = Path(source) + if source_path.exists(): + if source_path.is_file() and source_path.suffix in (".yml", ".yaml"): + _validate_and_install_local(source_path, str(source_path)) + return + elif source_path.is_dir(): + wf_file = source_path / "workflow.yml" + if not wf_file.exists(): + console.print(f"[red]Error:[/red] No workflow.yml found in {source}") + raise typer.Exit(1) + _validate_and_install_local(wf_file, str(source_path)) + return + + # Try from catalog + catalog = WorkflowCatalog(project_root) + try: + info = catalog.get_workflow_info(source) + except WorkflowCatalogError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + if not info: + console.print(f"[red]Error:[/red] Workflow '{source}' not found in catalog") + raise typer.Exit(1) + + if not info.get("_install_allowed", True): + console.print(f"[yellow]Warning:[/yellow] Workflow '{source}' is from a discovery-only catalog") + console.print("Direct installation is not enabled for this catalog source.") + raise typer.Exit(1) + + workflow_url = info.get("url") + if not workflow_url: + console.print(f"[red]Error:[/red] Workflow '{source}' does not have an install URL in the catalog") + raise typer.Exit(1) + + # Validate URL scheme (HTTPS required, HTTP allowed for localhost only) + from ipaddress import ip_address + from urllib.parse import urlparse + + parsed_url = urlparse(workflow_url) + url_host = parsed_url.hostname or "" + is_loopback = False + if url_host == "localhost": + is_loopback = True + else: + try: + is_loopback = ip_address(url_host).is_loopback + except ValueError: + # Host is not an IP literal (e.g., a regular hostname); treat as non-loopback. + pass + if parsed_url.scheme != "https" and not (parsed_url.scheme == "http" and is_loopback): + console.print( + f"[red]Error:[/red] Workflow '{source}' has an invalid install URL. " + "Only HTTPS URLs are allowed, except HTTP for localhost/loopback." + ) + raise typer.Exit(1) + + workflow_dir = workflows_dir / source + # Validate that source is a safe directory name (no path traversal) + try: + workflow_dir.resolve().relative_to(workflows_dir.resolve()) + except ValueError: + console.print(f"[red]Error:[/red] Invalid workflow ID: {source!r}") + raise typer.Exit(1) + workflow_file = workflow_dir / "workflow.yml" + + try: + from urllib.request import urlopen # noqa: S310 — URL comes from catalog + + workflow_dir.mkdir(parents=True, exist_ok=True) + with urlopen(workflow_url, timeout=30) as response: # noqa: S310 + # Validate final URL after redirects + final_url = response.geturl() + final_parsed = urlparse(final_url) + final_host = final_parsed.hostname or "" + final_loopback = final_host == "localhost" + if not final_loopback: + try: + final_loopback = ip_address(final_host).is_loopback + except ValueError: + # Host is not an IP literal (e.g., a regular hostname); treat as non-loopback. + pass + if final_parsed.scheme != "https" and not (final_parsed.scheme == "http" and final_loopback): + if workflow_dir.exists(): + import shutil + shutil.rmtree(workflow_dir, ignore_errors=True) + console.print( + f"[red]Error:[/red] Workflow '{source}' redirected to non-HTTPS URL: {final_url}" + ) + raise typer.Exit(1) + workflow_file.write_bytes(response.read()) + except Exception as exc: + if workflow_dir.exists(): + import shutil + shutil.rmtree(workflow_dir, ignore_errors=True) + console.print(f"[red]Error:[/red] Failed to install workflow '{source}' from catalog: {exc}") + raise typer.Exit(1) + + # Validate the downloaded workflow before registering + try: + definition = WorkflowDefinition.from_yaml(workflow_file) + except (ValueError, yaml.YAMLError) as exc: + import shutil + shutil.rmtree(workflow_dir, ignore_errors=True) + console.print(f"[red]Error:[/red] Downloaded workflow is invalid: {exc}") + raise typer.Exit(1) + + from .workflows.engine import validate_workflow + errors = validate_workflow(definition) + if errors: + import shutil + shutil.rmtree(workflow_dir, ignore_errors=True) + console.print("[red]Error:[/red] Downloaded workflow validation failed:") + for err in errors: + console.print(f" \u2022 {err}") + raise typer.Exit(1) + + # Enforce that the workflow's internal ID matches the catalog key + if definition.id and definition.id != source: + import shutil + shutil.rmtree(workflow_dir, ignore_errors=True) + console.print( + f"[red]Error:[/red] Workflow ID in YAML ({definition.id!r}) " + f"does not match catalog key ({source!r}). " + f"The catalog entry may be misconfigured." + ) + raise typer.Exit(1) + + registry.add(source, { + "name": definition.name or info.get("name", source), + "version": definition.version or info.get("version", "0.0.0"), + "description": definition.description or info.get("description", ""), + "source": "catalog", + "catalog_name": info.get("_catalog_name", ""), + "url": workflow_url, + }) + console.print(f"[green]āœ“[/green] Workflow '{info.get('name', source)}' installed from catalog") + + +@workflow_app.command("remove") +def workflow_remove( + workflow_id: str = typer.Argument(..., help="Workflow ID to uninstall"), +): + """Uninstall a workflow.""" + from .workflows.catalog import WorkflowRegistry + + project_root = Path.cwd() + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + + registry = WorkflowRegistry(project_root) + + if not registry.is_installed(workflow_id): + console.print(f"[red]Error:[/red] Workflow '{workflow_id}' is not installed") + raise typer.Exit(1) + + # Remove workflow files + workflow_dir = project_root / ".specify" / "workflows" / workflow_id + if workflow_dir.exists(): + import shutil + shutil.rmtree(workflow_dir) + + registry.remove(workflow_id) + console.print(f"[green]āœ“[/green] Workflow '{workflow_id}' removed") + + +@workflow_app.command("search") +def workflow_search( + query: str | None = typer.Argument(None, help="Search query"), + tag: str | None = typer.Option(None, "--tag", help="Filter by tag"), +): + """Search workflow catalogs.""" + from .workflows.catalog import WorkflowCatalog, WorkflowCatalogError + + project_root = Path.cwd() + if not (project_root / ".specify").exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + catalog = WorkflowCatalog(project_root) + + try: + results = catalog.search(query=query, tag=tag) + except WorkflowCatalogError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + if not results: + console.print("[yellow]No workflows found.[/yellow]") + return + + console.print(f"\n[bold cyan]Workflows ({len(results)}):[/bold cyan]\n") + for wf in results: + console.print(f" [bold]{wf.get('name', wf.get('id', '?'))}[/bold] ({wf.get('id', '?')}) v{wf.get('version', '?')}") + desc = wf.get("description", "") + if desc: + console.print(f" {desc}") + tags = wf.get("tags", []) + if tags: + console.print(f" [dim]Tags: {', '.join(tags)}[/dim]") + console.print() + + +@workflow_app.command("info") +def workflow_info( + workflow_id: str = typer.Argument(..., help="Workflow ID"), +): + """Show workflow details and step graph.""" + from .workflows.catalog import WorkflowCatalog, WorkflowRegistry, WorkflowCatalogError + from .workflows.engine import WorkflowEngine + + project_root = Path.cwd() + if not (project_root / ".specify").exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + + # Check installed first + registry = WorkflowRegistry(project_root) + installed = registry.get(workflow_id) + + engine = WorkflowEngine(project_root) + + definition = None + try: + definition = engine.load_workflow(workflow_id) + except FileNotFoundError: + # Local workflow definition not found on disk; fall back to + # catalog/registry lookup below. + pass + + if definition: + console.print(f"\n[bold cyan]{definition.name}[/bold cyan] ({definition.id})") + console.print(f" Version: {definition.version}") + if definition.author: + console.print(f" Author: {definition.author}") + if definition.description: + console.print(f" Description: {definition.description}") + if definition.default_integration: + console.print(f" Integration: {definition.default_integration}") + if installed: + console.print(" [green]Installed[/green]") + + if definition.inputs: + console.print("\n [bold]Inputs:[/bold]") + for name, inp in definition.inputs.items(): + if isinstance(inp, dict): + req = "required" if inp.get("required") else "optional" + console.print(f" {name} ({inp.get('type', 'string')}) — {req}") + + if definition.steps: + console.print(f"\n [bold]Steps ({len(definition.steps)}):[/bold]") + for step in definition.steps: + stype = step.get("type", "command") + console.print(f" → {step.get('id', '?')} [{stype}]") + return + + # Try catalog + catalog = WorkflowCatalog(project_root) + try: + info = catalog.get_workflow_info(workflow_id) + except WorkflowCatalogError: + info = None + + if info: + console.print(f"\n[bold cyan]{info.get('name', workflow_id)}[/bold cyan] ({workflow_id})") + console.print(f" Version: {info.get('version', '?')}") + if info.get("description"): + console.print(f" Description: {info['description']}") + if info.get("tags"): + console.print(f" Tags: {', '.join(info['tags'])}") + console.print(" [yellow]Not installed[/yellow]") + else: + console.print(f"[red]Error:[/red] Workflow '{workflow_id}' not found") + raise typer.Exit(1) + + +@workflow_catalog_app.command("list") +def workflow_catalog_list(): + """List configured workflow catalog sources.""" + from .workflows.catalog import WorkflowCatalog, WorkflowCatalogError + + project_root = Path.cwd() + catalog = WorkflowCatalog(project_root) + + try: + configs = catalog.get_catalog_configs() + except WorkflowCatalogError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + console.print("\n[bold cyan]Workflow Catalog Sources:[/bold cyan]\n") + for i, cfg in enumerate(configs): + install_status = "[green]install allowed[/green]" if cfg["install_allowed"] else "[yellow]discovery only[/yellow]" + console.print(f" [{i}] [bold]{cfg['name']}[/bold] — {install_status}") + console.print(f" {cfg['url']}") + if cfg.get("description"): + console.print(f" [dim]{cfg['description']}[/dim]") + console.print() + + +@workflow_catalog_app.command("add") +def workflow_catalog_add( + url: str = typer.Argument(..., help="Catalog URL to add"), + name: str = typer.Option(None, "--name", help="Catalog name"), +): + """Add a workflow catalog source.""" + from .workflows.catalog import WorkflowCatalog, WorkflowValidationError + + project_root = Path.cwd() + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + + catalog = WorkflowCatalog(project_root) + try: + catalog.add_catalog(url, name) + except WorkflowValidationError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + console.print(f"[green]āœ“[/green] Catalog source added: {url}") + + +@workflow_catalog_app.command("remove") +def workflow_catalog_remove( + index: int = typer.Argument(..., help="Catalog index to remove (from 'catalog list')"), +): + """Remove a workflow catalog source by index.""" + from .workflows.catalog import WorkflowCatalog, WorkflowValidationError + + project_root = Path.cwd() + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + + catalog = WorkflowCatalog(project_root) + try: + removed_name = catalog.remove_catalog(index) + except WorkflowValidationError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + console.print(f"[green]āœ“[/green] Catalog source '{removed_name}' removed") + + +def main(): + app() + +if __name__ == "__main__": + main() diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py new file mode 100644 index 0000000000..726b0fd2a6 --- /dev/null +++ b/src/specify_cli/agents.py @@ -0,0 +1,746 @@ +""" +Agent Command Registrar for Spec Kit + +Shared infrastructure for registering commands with AI agents. +Used by both the extension system and the preset system to write +command files into agent-specific directories in the correct format. +""" + +import os +from pathlib import Path +from typing import Dict, List, Any, Optional + +import platform +import re +from copy import deepcopy +import yaml + + +def _build_agent_configs() -> dict[str, Any]: + """Derive CommandRegistrar.AGENT_CONFIGS from INTEGRATION_REGISTRY.""" + from specify_cli.integrations import INTEGRATION_REGISTRY + + configs: dict[str, dict[str, Any]] = {} + for key, integration in INTEGRATION_REGISTRY.items(): + if key == "generic": + continue + if integration.registrar_config: + configs[key] = dict(integration.registrar_config) + return configs + + +class CommandRegistrar: + """Handles registration of commands with AI agents. + + Supports writing command files in Markdown or TOML format to the + appropriate agent directory, with correct argument placeholders + and companion files (e.g. Copilot .prompt.md). + """ + + # Derived from INTEGRATION_REGISTRY — single source of truth. + # Populated lazily via _ensure_configs() on first use. + AGENT_CONFIGS: dict[str, dict[str, Any]] = {} + _configs_loaded: bool = False + + def __init__(self) -> None: + self._ensure_configs() + + def __init_subclass__(cls, **kwargs: Any) -> None: + super().__init_subclass__(**kwargs) + cls._ensure_configs() + + @classmethod + def _ensure_configs(cls) -> None: + if not cls._configs_loaded: + try: + cls.AGENT_CONFIGS = _build_agent_configs() + cls._configs_loaded = True + except ImportError: + pass # Circular import during module init; retry on next access + + @staticmethod + def parse_frontmatter(content: str) -> tuple[dict, str]: + """Parse YAML frontmatter from Markdown content. + + Args: + content: Markdown content with YAML frontmatter + + Returns: + Tuple of (frontmatter_dict, body_content) + """ + if not content.startswith("---"): + return {}, content + + # Find second --- + end_marker = content.find("---", 3) + if end_marker == -1: + return {}, content + + frontmatter_str = content[3:end_marker].strip() + body = content[end_marker + 3 :].strip() + + try: + frontmatter = yaml.safe_load(frontmatter_str) or {} + except yaml.YAMLError: + frontmatter = {} + + if not isinstance(frontmatter, dict): + frontmatter = {} + + return frontmatter, body + + @staticmethod + def render_frontmatter(fm: dict) -> str: + """Render frontmatter dictionary as YAML. + + Args: + fm: Frontmatter dictionary + + Returns: + YAML-formatted frontmatter with delimiters + """ + if not fm: + return "" + + yaml_str = yaml.dump( + fm, default_flow_style=False, sort_keys=False, allow_unicode=True + ) + return f"---\n{yaml_str}---\n" + + def _adjust_script_paths(self, frontmatter: dict) -> dict: + """Normalize script paths in frontmatter to generated project locations. + + Rewrites known repo-relative and top-level script paths under the + ``scripts`` key (for example ``../../scripts/``, + ``../../templates/``, ``../../memory/``, ``scripts/``, ``templates/``, and + ``memory/``) to the ``.specify/...`` paths used in generated projects. + + Args: + frontmatter: Frontmatter dictionary + + Returns: + Modified frontmatter with normalized project paths + """ + frontmatter = deepcopy(frontmatter) + + scripts = frontmatter.get("scripts") + if isinstance(scripts, dict): + for key, script_path in scripts.items(): + if isinstance(script_path, str): + scripts[key] = self.rewrite_project_relative_paths(script_path) + return frontmatter + + @staticmethod + def rewrite_project_relative_paths(text: str) -> str: + """Rewrite repo-relative paths to their generated project locations.""" + if not isinstance(text, str) or not text: + return text + + for old, new in ( + ("../../memory/", ".specify/memory/"), + ("../../scripts/", ".specify/scripts/"), + ("../../templates/", ".specify/templates/"), + ): + text = text.replace(old, new) + + # Only rewrite top-level style references so extension-local paths like + # ".specify/extensions//scripts/..." remain intact. + text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?memory/', r"\1.specify/memory/", text) + text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?scripts/', r"\1.specify/scripts/", text) + text = re.sub( + r'(^|[\s`"\'(])(?:\.?/)?templates/', r"\1.specify/templates/", text + ) + + return text.replace(".specify/.specify/", ".specify/").replace( + ".specify.specify/", ".specify/" + ) + + def render_markdown_command( + self, frontmatter: dict, body: str, source_id: str, context_note: str = None + ) -> str: + """Render command in Markdown format. + + Args: + frontmatter: Command frontmatter + body: Command body content + source_id: Source identifier (extension or preset ID) + context_note: Custom context comment (default: ) + + Returns: + Formatted Markdown command file content + """ + if context_note is None: + context_note = f"\n\n" + return self.render_frontmatter(frontmatter) + "\n" + context_note + body + + def render_toml_command(self, frontmatter: dict, body: str, source_id: str) -> str: + """Render command in TOML format. + + Args: + frontmatter: Command frontmatter + body: Command body content + source_id: Source identifier (extension or preset ID) + + Returns: + Formatted TOML command file content + """ + toml_lines = [] + + if "description" in frontmatter: + toml_lines.append( + f"description = {self._render_basic_toml_string(frontmatter['description'])}" + ) + toml_lines.append("") + + toml_lines.append(f"# Source: {source_id}") + toml_lines.append("") + + # Keep TOML output valid even when body contains triple-quote delimiters. + # Prefer multiline forms, then fall back to escaped basic string. + if '"""' not in body: + toml_lines.append('prompt = """') + toml_lines.append(body) + toml_lines.append('"""') + elif "'''" not in body: + toml_lines.append("prompt = '''") + toml_lines.append(body) + toml_lines.append("'''") + else: + toml_lines.append(f"prompt = {self._render_basic_toml_string(body)}") + + return "\n".join(toml_lines) + + @staticmethod + def _render_basic_toml_string(value: str) -> str: + """Render *value* as a TOML basic string literal.""" + escaped = ( + value.replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + ) + return f'"{escaped}"' + + def render_yaml_command( + self, + frontmatter: dict, + body: str, + source_id: str, + cmd_name: str = "", + ) -> str: + """Render command in YAML recipe format for Goose. + + Args: + frontmatter: Command frontmatter + body: Command body content + source_id: Source identifier (extension or preset ID) + cmd_name: Command name used as title fallback + + Returns: + Formatted YAML recipe file content + """ + from specify_cli.integrations.base import YamlIntegration + + title = frontmatter.get("title", "") or frontmatter.get("name", "") + if not isinstance(title, str): + title = str(title) if title is not None else "" + if not title and cmd_name: + title = YamlIntegration._human_title(cmd_name) + if not title and source_id: + title = YamlIntegration._human_title(Path(str(source_id)).stem) + if not title: + title = "Command" + + description = frontmatter.get("description", "") + if not isinstance(description, str): + description = str(description) if description is not None else "" + return YamlIntegration._render_yaml(title, description, body, source_id) + + def render_skill_command( + self, + agent_name: str, + skill_name: str, + frontmatter: dict, + body: str, + source_id: str, + source_file: str, + project_root: Path, + ) -> str: + """Render a command override as a SKILL.md file. + + SKILL-target agents should receive the same skills-oriented + frontmatter shape used elsewhere in the project instead of the + original command frontmatter. + + Technical debt note: + Spec-kit currently has multiple SKILL.md generators (template packaging, + init-time conversion, and extension/preset overrides). Keep the skill + frontmatter keys aligned (name/description/compatibility/metadata, with + metadata.author and metadata.source subkeys) to avoid drift across agents. + """ + if not isinstance(frontmatter, dict): + frontmatter = {} + + agent_config = self.AGENT_CONFIGS.get(agent_name, {}) + if agent_config.get("extension") == "/SKILL.md": + body = self.resolve_skill_placeholders( + agent_name, frontmatter, body, project_root + ) + + description = frontmatter.get( + "description", f"Spec-kit workflow command: {skill_name}" + ) + skill_frontmatter = self.build_skill_frontmatter( + agent_name, + skill_name, + description, + f"{source_id}:{source_file}", + ) + return self.render_frontmatter(skill_frontmatter) + "\n" + body + + @staticmethod + def build_skill_frontmatter( + agent_name: str, + skill_name: str, + description: str, + source: str, + ) -> dict: + """Build consistent SKILL.md frontmatter across all skill generators.""" + skill_frontmatter = { + "name": skill_name, + "description": description, + "compatibility": "Requires spec-kit project structure with .specify/ directory", + "metadata": { + "author": "github-spec-kit", + "source": source, + }, + } + return skill_frontmatter + + @staticmethod + def resolve_skill_placeholders( + agent_name: str, frontmatter: dict, body: str, project_root: Path + ) -> str: + """Resolve script placeholders for skills-backed agents.""" + try: + from . import load_init_options + except ImportError: + return body + + if not isinstance(frontmatter, dict): + frontmatter = {} + + scripts = frontmatter.get("scripts", {}) or {} + if not isinstance(scripts, dict): + scripts = {} + + init_opts = load_init_options(project_root) + if not isinstance(init_opts, dict): + init_opts = {} + + script_variant = init_opts.get("script") + if script_variant not in {"sh", "ps"}: + fallback_order = [] + default_variant = ( + "ps" if platform.system().lower().startswith("win") else "sh" + ) + secondary_variant = "sh" if default_variant == "ps" else "ps" + + if default_variant in scripts: + fallback_order.append(default_variant) + if secondary_variant in scripts: + fallback_order.append(secondary_variant) + + for key in scripts: + if key not in fallback_order: + fallback_order.append(key) + + script_variant = fallback_order[0] if fallback_order else None + + script_command = scripts.get(script_variant) if script_variant else None + if script_command: + script_command = script_command.replace("{ARGS}", "$ARGUMENTS") + body = body.replace("{SCRIPT}", script_command) + + body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name) + + # Resolve __CONTEXT_FILE__ from init-options + context_file = init_opts.get("context_file") or "" + body = body.replace("__CONTEXT_FILE__", context_file) + + return CommandRegistrar.rewrite_project_relative_paths(body) + + def _convert_argument_placeholder( + self, content: str, from_placeholder: str, to_placeholder: str + ) -> str: + """Convert argument placeholder format. + + Args: + content: Command content + from_placeholder: Source placeholder (e.g., "$ARGUMENTS") + to_placeholder: Target placeholder (e.g., "{{args}}") + + Returns: + Content with converted placeholders + """ + return content.replace(from_placeholder, to_placeholder) + + @staticmethod + def _compute_output_name( + agent_name: str, cmd_name: str, agent_config: Dict[str, Any] + ) -> str: + """Compute the on-disk command or skill name for an agent.""" + if agent_config["extension"] != "/SKILL.md": + return cmd_name + + short_name = cmd_name + if short_name.startswith("speckit."): + short_name = short_name[len("speckit.") :] + short_name = short_name.replace(".", "-") + + return f"speckit-{short_name}" + + @staticmethod + def _ensure_inside(candidate: Path, base: Path) -> None: + """Validate that a write target stays within the expected base directory. + + Uses lexical normalization so traversal via ``..`` or absolute paths is + rejected while intentionally symlinked sub-directories remain + supported. + + Args: + candidate: Path that will be written. + base: Directory the write must remain within. + + Raises: + ValueError: If the normalized candidate path escapes ``base``. + """ + normalized = Path(os.path.normpath(candidate)) + base_normalized = Path(os.path.normpath(base)) + if not normalized.is_relative_to(base_normalized): + raise ValueError( + f"Output path {candidate!r} escapes directory {base!r}" + ) + + def register_commands( + self, + agent_name: str, + commands: List[Dict[str, Any]], + source_id: str, + source_dir: Path, + project_root: Path, + context_note: str = None, + ) -> List[str]: + """Register commands for a specific agent. + + Args: + agent_name: Agent name (claude, gemini, copilot, etc.) + commands: List of command info dicts with 'name', 'file', and optional 'aliases' + source_id: Identifier of the source (extension or preset ID) + source_dir: Directory containing command source files + project_root: Path to project root + context_note: Custom context comment for markdown output + + Returns: + List of registered command names + + Raises: + ValueError: If agent is not supported + """ + self._ensure_configs() + if agent_name not in self.AGENT_CONFIGS: + raise ValueError(f"Unsupported agent: {agent_name}") + + agent_config = self.AGENT_CONFIGS[agent_name] + commands_dir = project_root / agent_config["dir"] + commands_dir.mkdir(parents=True, exist_ok=True) + + registered = [] + + for cmd_info in commands: + cmd_name = cmd_info["name"] + cmd_file = cmd_info["file"] + + source_file = source_dir / cmd_file + if not source_file.exists(): + continue + + content = source_file.read_text(encoding="utf-8") + frontmatter, body = self.parse_frontmatter(content) + + if frontmatter.get("strategy") == "wrap": + from .presets import _substitute_core_template + body, core_frontmatter = _substitute_core_template(body, cmd_name, project_root, self) + frontmatter = dict(frontmatter) + for key in ("scripts", "agent_scripts"): + if key not in frontmatter and key in core_frontmatter: + frontmatter[key] = core_frontmatter[key] + frontmatter.pop("strategy", None) + + frontmatter = self._adjust_script_paths(frontmatter) + + for key in agent_config.get("strip_frontmatter_keys", []): + frontmatter.pop(key, None) + + if agent_config.get("inject_name") and not frontmatter.get("name"): + # Use custom name formatter if provided (e.g., Forge's hyphenated format) + format_name = agent_config.get("format_name") + frontmatter["name"] = format_name(cmd_name) if format_name else cmd_name + + body = self._convert_argument_placeholder( + body, "$ARGUMENTS", agent_config["args"] + ) + + output_name = self._compute_output_name(agent_name, cmd_name, agent_config) + + if agent_config["extension"] == "/SKILL.md": + output = self.render_skill_command( + agent_name, + output_name, + frontmatter, + body, + source_id, + cmd_file, + project_root, + ) + elif agent_config["format"] == "markdown": + body = self.resolve_skill_placeholders(agent_name, frontmatter, body, project_root) + body = self._convert_argument_placeholder(body, "$ARGUMENTS", agent_config["args"]) + output = self.render_markdown_command(frontmatter, body, source_id, context_note) + elif agent_config["format"] == "toml": + body = self.resolve_skill_placeholders(agent_name, frontmatter, body, project_root) + body = self._convert_argument_placeholder(body, "$ARGUMENTS", agent_config["args"]) + output = self.render_toml_command(frontmatter, body, source_id) + elif agent_config["format"] == "yaml": + output = self.render_yaml_command( + frontmatter, body, source_id, cmd_name + ) + else: + raise ValueError(f"Unsupported format: {agent_config['format']}") + + dest_file = commands_dir / f"{output_name}{agent_config['extension']}" + self._ensure_inside(dest_file, commands_dir) + dest_file.parent.mkdir(parents=True, exist_ok=True) + dest_file.write_text(output, encoding="utf-8") + + if agent_name == "copilot": + self.write_copilot_prompt(project_root, cmd_name) + + registered.append(cmd_name) + + for alias in cmd_info.get("aliases", []): + alias_output_name = self._compute_output_name( + agent_name, alias, agent_config + ) + + # For agents with inject_name, render with alias-specific frontmatter + if agent_config.get("inject_name"): + alias_frontmatter = deepcopy(frontmatter) + # Use custom name formatter if provided (e.g., Forge's hyphenated format) + format_name = agent_config.get("format_name") + alias_frontmatter["name"] = ( + format_name(alias) if format_name else alias + ) + + if agent_config["extension"] == "/SKILL.md": + alias_output = self.render_skill_command( + agent_name, + alias_output_name, + alias_frontmatter, + body, + source_id, + cmd_file, + project_root, + ) + elif agent_config["format"] == "markdown": + alias_output = self.render_markdown_command( + alias_frontmatter, body, source_id, context_note + ) + elif agent_config["format"] == "toml": + alias_output = self.render_toml_command( + alias_frontmatter, body, source_id + ) + elif agent_config["format"] == "yaml": + alias_output = self.render_yaml_command( + alias_frontmatter, body, source_id, alias + ) + else: + raise ValueError( + f"Unsupported format: {agent_config['format']}" + ) + else: + # For other agents, reuse the primary output + alias_output = output + if agent_config["extension"] == "/SKILL.md": + alias_output = self.render_skill_command( + agent_name, + alias_output_name, + frontmatter, + body, + source_id, + cmd_file, + project_root, + ) + + alias_file = ( + commands_dir / f"{alias_output_name}{agent_config['extension']}" + ) + self._ensure_inside(alias_file, commands_dir) + alias_file.parent.mkdir(parents=True, exist_ok=True) + alias_file.write_text(alias_output, encoding="utf-8") + if agent_name == "copilot": + self.write_copilot_prompt(project_root, alias) + registered.append(alias) + + return registered + + @staticmethod + def write_copilot_prompt(project_root: Path, cmd_name: str) -> None: + """Generate a companion .prompt.md file for a Copilot agent command. + + Args: + project_root: Path to project root + cmd_name: Command name (e.g. 'speckit.my-ext.example') + """ + prompts_dir = project_root / ".github" / "prompts" + prompts_dir.mkdir(parents=True, exist_ok=True) + prompt_file = prompts_dir / f"{cmd_name}.prompt.md" + CommandRegistrar._ensure_inside(prompt_file, prompts_dir) + prompt_file.write_text(f"---\nagent: {cmd_name}\n---\n", encoding="utf-8") + + def register_commands_for_all_agents( + self, + commands: List[Dict[str, Any]], + source_id: str, + source_dir: Path, + project_root: Path, + context_note: str = None, + ) -> Dict[str, List[str]]: + """Register commands for all detected agents in the project. + + Args: + commands: List of command info dicts + source_id: Identifier of the source (extension or preset ID) + source_dir: Directory containing command source files + project_root: Path to project root + context_note: Custom context comment for markdown output + + Returns: + Dictionary mapping agent names to list of registered commands + """ + results = {} + + self._ensure_configs() + for agent_name, agent_config in self.AGENT_CONFIGS.items(): + agent_dir = project_root / agent_config["dir"] + + if agent_dir.exists(): + try: + registered = self.register_commands( + agent_name, + commands, + source_id, + source_dir, + project_root, + context_note=context_note, + ) + if registered: + results[agent_name] = registered + except ValueError: + continue + + return results + + def register_commands_for_non_skill_agents( + self, + commands: List[Dict[str, Any]], + source_id: str, + source_dir: Path, + project_root: Path, + context_note: Optional[str] = None, + ) -> Dict[str, List[str]]: + """Register commands for all non-skill agents in the project. + + Like register_commands_for_all_agents but skips skill-based agents + (those with extension '/SKILL.md'). Used by reconciliation to avoid + overwriting properly formatted SKILL.md files. + + Args: + commands: List of command info dicts + source_id: Identifier of the source + source_dir: Directory containing command source files + project_root: Path to project root + context_note: Custom context comment for markdown output + + Returns: + Dictionary mapping agent names to list of registered commands + """ + results = {} + self._ensure_configs() + for agent_name, agent_config in self.AGENT_CONFIGS.items(): + if agent_config.get("extension") == "/SKILL.md": + continue + agent_dir = project_root / agent_config["dir"] + if agent_dir.exists(): + try: + registered = self.register_commands( + agent_name, commands, source_id, + source_dir, project_root, + context_note=context_note, + ) + if registered: + results[agent_name] = registered + except ValueError: + continue + return results + + def unregister_commands( + self, registered_commands: Dict[str, List[str]], project_root: Path + ) -> None: + """Remove previously registered command files from agent directories. + + Args: + registered_commands: Dict mapping agent names to command name lists + project_root: Path to project root + """ + self._ensure_configs() + for agent_name, cmd_names in registered_commands.items(): + if agent_name not in self.AGENT_CONFIGS: + continue + + agent_config = self.AGENT_CONFIGS[agent_name] + commands_dir = project_root / agent_config["dir"] + + for cmd_name in cmd_names: + output_name = self._compute_output_name( + agent_name, cmd_name, agent_config + ) + cmd_file = commands_dir / f"{output_name}{agent_config['extension']}" + if cmd_file.exists(): + cmd_file.unlink() + # For SKILL.md agents each command lives in its own subdirectory + # (e.g. .agents/skills/speckit-ext-cmd/SKILL.md). Remove the + # parent dir when it becomes empty to avoid orphaned directories. + parent = cmd_file.parent + if parent != commands_dir and parent.exists(): + try: + parent.rmdir() # no-op if dir still has other files + except OSError: + pass + + if agent_name == "copilot": + prompt_file = ( + project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md" + ) + if prompt_file.exists(): + prompt_file.unlink() + + +# Populate AGENT_CONFIGS after class definition. +# Catches ImportError from circular imports during module loading; +# _configs_loaded stays False so the next explicit access retries. +try: + CommandRegistrar._ensure_configs() +except ImportError: + pass diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 08ce2beab3..26ceab4034 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -8,18 +8,68 @@ import json import hashlib +import os import tempfile import zipfile import shutil +import copy +from dataclasses import dataclass from pathlib import Path -from typing import Optional, Dict, List, Any +from typing import Optional, Dict, List, Any, Callable, Set from datetime import datetime, timezone import re +import pathspec + import yaml from packaging import version as pkg_version from packaging.specifiers import SpecifierSet, InvalidSpecifier +_FALLBACK_CORE_COMMAND_NAMES = frozenset({ + "analyze", + "checklist", + "clarify", + "constitution", + "implement", + "plan", + "specify", + "tasks", + "taskstoissues", +}) +EXTENSION_COMMAND_NAME_PATTERN = re.compile(r"^speckit\.([a-z0-9-]+)\.([a-z0-9-]+)$") + +REINSTALL_COMMAND = "uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git" + + +def _load_core_command_names() -> frozenset[str]: + """Discover bundled core command names from the packaged templates. + + Prefer the wheel-time ``core_pack`` bundle when present, and fall back to + the source checkout when running from the repository. If neither is + available, use the baked-in fallback set so validation still works. + """ + candidate_dirs = [ + Path(__file__).parent / "core_pack" / "commands", + Path(__file__).resolve().parent.parent.parent / "templates" / "commands", + ] + + for commands_dir in candidate_dirs: + if not commands_dir.is_dir(): + continue + + command_names = { + command_file.stem + for command_file in commands_dir.iterdir() + if command_file.is_file() and command_file.suffix == ".md" + } + if command_names: + return frozenset(command_names) + + return _FALLBACK_CORE_COMMAND_NAMES + + +CORE_COMMAND_NAMES = _load_core_command_names() + class ExtensionError(Exception): """Base exception for extension-related errors.""" @@ -36,6 +86,36 @@ class CompatibilityError(ExtensionError): pass +def normalize_priority(value: Any, default: int = 10) -> int: + """Normalize a stored priority value for sorting and display. + + Corrupted registry data may contain missing, non-numeric, or non-positive + values. In those cases, fall back to the default priority. + + Args: + value: Priority value to normalize (may be int, str, None, etc.) + default: Default priority to use for invalid values (default: 10) + + Returns: + Normalized priority as positive integer (>= 1) + """ + try: + priority = int(value) + except (TypeError, ValueError): + return default + return priority if priority >= 1 else default + + +@dataclass +class CatalogEntry: + """Represents a single catalog entry in the catalog stack.""" + url: str + name: str + priority: int + install_allowed: bool + description: str = "" + + class ExtensionManifest: """Represents and validates an extension manifest (extension.yml).""" @@ -52,6 +132,7 @@ def __init__(self, manifest_path: Path): ValidationError: If manifest is invalid """ self.path = manifest_path + self.warnings: List[str] = [] self.data = self._load_yaml(manifest_path) self._validate() @@ -59,11 +140,16 @@ def _load_yaml(self, path: Path) -> dict: """Load YAML file safely.""" try: with open(path, 'r') as f: - return yaml.safe_load(f) or {} + data = yaml.safe_load(f) except yaml.YAMLError as e: raise ValidationError(f"Invalid YAML in {path}: {e}") except FileNotFoundError: raise ValidationError(f"Manifest not found: {path}") + if not isinstance(data, dict): + raise ValidationError( + f"Manifest must be a YAML mapping, got {type(data).__name__}: {path}" + ) + return data def _validate(self): """Validate manifest structure and required fields.""" @@ -105,20 +191,130 @@ def _validate(self): # Validate provides section provides = self.data["provides"] - if "commands" not in provides or not provides["commands"]: - raise ValidationError("Extension must provide at least one command") + commands = provides.get("commands", []) + hooks = self.data.get("hooks") + + if "commands" in provides and not isinstance(commands, list): + raise ValidationError( + "Invalid provides.commands: expected a list" + ) + if "hooks" in self.data and not isinstance(hooks, dict): + raise ValidationError( + "Invalid hooks: expected a mapping" + ) + + has_commands = bool(commands) + has_hooks = bool(hooks) - # Validate commands - for cmd in provides["commands"]: + if not has_commands and not has_hooks: + raise ValidationError( + "Extension must provide at least one command or hook" + ) + + # Validate hook values (if present) + if hooks: + for hook_name, hook_config in hooks.items(): + if not isinstance(hook_config, dict): + raise ValidationError( + f"Invalid hook '{hook_name}': expected a mapping" + ) + if not hook_config.get("command"): + raise ValidationError( + f"Hook '{hook_name}' missing required 'command' field" + ) + + # Validate commands; track renames so hook references can be rewritten. + rename_map: Dict[str, str] = {} + for cmd in commands: + if not isinstance(cmd, dict): + raise ValidationError( + "Each command entry in 'provides.commands' must be a mapping" + ) if "name" not in cmd or "file" not in cmd: raise ValidationError("Command missing 'name' or 'file'") # Validate command name format - if not re.match(r'^speckit\.[a-z0-9-]+\.[a-z0-9-]+$', cmd["name"]): + if not EXTENSION_COMMAND_NAME_PATTERN.match(cmd["name"]): + corrected = self._try_correct_command_name(cmd["name"], ext["id"]) + if corrected: + self.warnings.append( + f"Command name '{cmd['name']}' does not follow the required pattern " + f"'speckit.{{extension}}.{{command}}'. Registering as '{corrected}'. " + f"The extension author should update the manifest to use this name." + ) + rename_map[cmd["name"]] = corrected + cmd["name"] = corrected + else: + raise ValidationError( + f"Invalid command name '{cmd['name']}': " + "must follow pattern 'speckit.{extension}.{command}'" + ) + + # Validate alias types; no pattern enforcement on aliases — they are + # intentionally free-form to preserve community extension compatibility + # (e.g. 'speckit.verify' short aliases used by existing extensions). + aliases = cmd.get("aliases") + if aliases is None: + cmd["aliases"] = [] + aliases = [] + if not isinstance(aliases, list): + raise ValidationError( + f"Aliases for command '{cmd['name']}' must be a list" + ) + for alias in aliases: + if not isinstance(alias, str): + raise ValidationError( + f"Aliases for command '{cmd['name']}' must be strings" + ) + + # Rewrite any hook command references that pointed at a renamed command or + # an alias-form ref (ext.cmd → speckit.ext.cmd). Always emit a warning when + # the reference is changed so extension authors know to update the manifest. + for hook_name, hook_data in self.data.get("hooks", {}).items(): + if not isinstance(hook_data, dict): raise ValidationError( - f"Invalid command name '{cmd['name']}': " - "must follow pattern 'speckit.{extension}.{command}'" + f"Hook '{hook_name}' must be a mapping, got {type(hook_data).__name__}" ) + command_ref = hook_data.get("command") + if not isinstance(command_ref, str): + continue + # Step 1: apply any rename from the auto-correction pass. + after_rename = rename_map.get(command_ref, command_ref) + # Step 2: lift alias-form '{ext_id}.cmd' to canonical 'speckit.{ext_id}.cmd'. + parts = after_rename.split(".") + if len(parts) == 2 and parts[0] == ext["id"]: + final_ref = f"speckit.{ext['id']}.{parts[1]}" + else: + final_ref = after_rename + if final_ref != command_ref: + hook_data["command"] = final_ref + self.warnings.append( + f"Hook '{hook_name}' referenced command '{command_ref}'; " + f"updated to canonical form '{final_ref}'. " + f"The extension author should update the manifest." + ) + + @staticmethod + def _try_correct_command_name(name: str, ext_id: str) -> Optional[str]: + """Try to auto-correct a non-conforming command name to the required pattern. + + Handles the two legacy formats used by community extensions: + - 'speckit.command' → 'speckit.{ext_id}.command' + - '{ext_id}.command' → 'speckit.{ext_id}.command' + + The 'X.Y' form is only corrected when X matches ext_id to ensure the + result passes the install-time namespace check. Any other prefix is + uncorrectable and will produce a ValidationError at the call site. + + Returns the corrected name, or None if no safe correction is possible. + """ + parts = name.split('.') + if len(parts) == 2: + if parts[0] == 'speckit' or parts[0] == ext_id: + candidate = f"speckit.{ext_id}.{parts[1]}" + if EXTENSION_COMMAND_NAME_PATTERN.match(candidate): + return candidate + return None @property def id(self) -> str: @@ -148,7 +344,7 @@ def requires_speckit_version(self) -> str: @property def commands(self) -> List[Dict[str, Any]]: """Get list of provided commands.""" - return self.data["provides"]["commands"] + return self.data.get("provides", {}).get("commands", []) @property def hooks(self) -> Dict[str, Any]: @@ -187,7 +383,17 @@ def _load(self) -> dict: try: with open(self.registry_path, 'r') as f: - return json.load(f) + data = json.load(f) + # Validate loaded data is a dict (handles corrupted registry files) + if not isinstance(data, dict): + return { + "schema_version": self.SCHEMA_VERSION, + "extensions": {} + } + # Normalize extensions field (handles corrupted extensions value) + if not isinstance(data.get("extensions"), dict): + data["extensions"] = {} + return data except (json.JSONDecodeError, FileNotFoundError): # Corrupted or missing registry, start fresh return { @@ -209,39 +415,137 @@ def add(self, extension_id: str, metadata: dict): metadata: Extension metadata (version, source, etc.) """ self.data["extensions"][extension_id] = { - **metadata, + **copy.deepcopy(metadata), "installed_at": datetime.now(timezone.utc).isoformat() } self._save() + def update(self, extension_id: str, metadata: dict): + """Update extension metadata in registry, merging with existing entry. + + Merges the provided metadata with the existing entry, preserving any + fields not specified in the new metadata. The installed_at timestamp + is always preserved from the original entry. + + Use this method instead of add() when updating existing extension + metadata (e.g., enabling/disabling) to preserve the original + installation timestamp and other existing fields. + + Args: + extension_id: Extension ID + metadata: Extension metadata fields to update (merged with existing) + + Raises: + KeyError: If extension is not installed + """ + extensions = self.data.get("extensions") + if not isinstance(extensions, dict) or extension_id not in extensions: + raise KeyError(f"Extension '{extension_id}' is not installed") + # Merge new metadata with existing, preserving original installed_at + existing = extensions[extension_id] + # Handle corrupted registry entries (e.g., string/list instead of dict) + if not isinstance(existing, dict): + existing = {} + # Merge: existing fields preserved, new fields override (deep copy to prevent caller mutation) + merged = {**existing, **copy.deepcopy(metadata)} + # Always preserve original installed_at based on key existence, not truthiness, + # to handle cases where the field exists but may be falsy (legacy/corruption) + if "installed_at" in existing: + merged["installed_at"] = existing["installed_at"] + else: + # If not present in existing, explicitly remove from merged if caller provided it + merged.pop("installed_at", None) + extensions[extension_id] = merged + self._save() + + def restore(self, extension_id: str, metadata: dict): + """Restore extension metadata to registry without modifying timestamps. + + Use this method for rollback scenarios where you have a complete backup + of the registry entry (including installed_at) and want to restore it + exactly as it was. + + Args: + extension_id: Extension ID + metadata: Complete extension metadata including installed_at + + Raises: + ValueError: If metadata is None or not a dict + """ + if metadata is None or not isinstance(metadata, dict): + raise ValueError(f"Cannot restore '{extension_id}': metadata must be a dict") + # Ensure extensions dict exists (handle corrupted registry) + if not isinstance(self.data.get("extensions"), dict): + self.data["extensions"] = {} + self.data["extensions"][extension_id] = copy.deepcopy(metadata) + self._save() + def remove(self, extension_id: str): """Remove extension from registry. Args: extension_id: Extension ID """ - if extension_id in self.data["extensions"]: - del self.data["extensions"][extension_id] + extensions = self.data.get("extensions") + if not isinstance(extensions, dict): + return + if extension_id in extensions: + del extensions[extension_id] self._save() def get(self, extension_id: str) -> Optional[dict]: """Get extension metadata from registry. + Returns a deep copy to prevent callers from accidentally mutating + nested internal registry state without going through the write path. + Args: extension_id: Extension ID Returns: - Extension metadata or None if not found + Deep copy of extension metadata, or None if not found or corrupted """ - return self.data["extensions"].get(extension_id) + extensions = self.data.get("extensions") + if not isinstance(extensions, dict): + return None + entry = extensions.get(extension_id) + # Return None for missing or corrupted (non-dict) entries + if entry is None or not isinstance(entry, dict): + return None + return copy.deepcopy(entry) def list(self) -> Dict[str, dict]: - """Get all installed extensions. + """Get all installed extensions with valid metadata. + + Returns a deep copy of extensions with dict metadata only. + Corrupted entries (non-dict values) are filtered out. Returns: - Dictionary of extension_id -> metadata + Dictionary of extension_id -> metadata (deep copies), empty dict if corrupted """ - return self.data["extensions"] + extensions = self.data.get("extensions", {}) or {} + if not isinstance(extensions, dict): + return {} + # Filter to only valid dict entries to match type contract + return { + ext_id: copy.deepcopy(meta) + for ext_id, meta in extensions.items() + if isinstance(meta, dict) + } + + def keys(self) -> set: + """Get all extension IDs including corrupted entries. + + Lightweight method that returns IDs without deep-copying metadata. + Use this when you only need to check which extensions are tracked. + + Returns: + Set of extension IDs (includes corrupted entries) + """ + extensions = self.data.get("extensions", {}) or {} + if not isinstance(extensions, dict): + return set() + return set(extensions.keys()) def is_installed(self, extension_id: str) -> bool: """Check if extension is installed. @@ -250,9 +554,44 @@ def is_installed(self, extension_id: str) -> bool: extension_id: Extension ID Returns: - True if extension is installed + True if extension is installed, False if not or registry corrupted """ - return extension_id in self.data["extensions"] + extensions = self.data.get("extensions") + if not isinstance(extensions, dict): + return False + return extension_id in extensions + + def list_by_priority(self, include_disabled: bool = False) -> List[tuple]: + """Get all installed extensions sorted by priority. + + Lower priority number = higher precedence (checked first). + Extensions with equal priority are sorted alphabetically by ID + for deterministic ordering. + + Args: + include_disabled: If True, include disabled extensions. Default False. + + Returns: + List of (extension_id, metadata_copy) tuples sorted by priority. + Metadata is deep-copied to prevent accidental mutation. + """ + extensions = self.data.get("extensions", {}) or {} + if not isinstance(extensions, dict): + extensions = {} + sortable_extensions = [] + for ext_id, meta in extensions.items(): + if not isinstance(meta, dict): + continue + # Skip disabled extensions unless explicitly requested + if not include_disabled and not meta.get("enabled", True): + continue + metadata_copy = copy.deepcopy(meta) + metadata_copy["priority"] = normalize_priority(metadata_copy.get("priority", 10)) + sortable_extensions.append((ext_id, metadata_copy)) + return sorted( + sortable_extensions, + key=lambda item: (item[1]["priority"], item[0]), + ) class ExtensionManager: @@ -268,6 +607,474 @@ def __init__(self, project_root: Path): self.extensions_dir = project_root / ".specify" / "extensions" self.registry = ExtensionRegistry(self.extensions_dir) + @staticmethod + def _collect_manifest_command_names(manifest: ExtensionManifest) -> Dict[str, str]: + """Collect command and alias names declared by a manifest. + + Performs install-time validation for extension-specific constraints: + - primary commands must use the canonical `speckit.{extension}.{command}` shape + - primary commands must use this extension's namespace + - command namespaces must not shadow core commands + - duplicate command/alias names inside one manifest are rejected + - aliases are validated for type and uniqueness only (no pattern enforcement) + + Args: + manifest: Parsed extension manifest + + Returns: + Mapping of declared command/alias name -> kind ("command"/"alias") + + Raises: + ValidationError: If any declared name is invalid + """ + if manifest.id in CORE_COMMAND_NAMES: + raise ValidationError( + f"Extension ID '{manifest.id}' conflicts with core command namespace '{manifest.id}'" + ) + + declared_names: Dict[str, str] = {} + + for cmd in manifest.commands: + primary_name = cmd["name"] + aliases = cmd.get("aliases", []) + + if aliases is None: + aliases = [] + if not isinstance(aliases, list): + raise ValidationError( + f"Aliases for command '{primary_name}' must be a list" + ) + + for kind, name in [("command", primary_name)] + [ + ("alias", alias) for alias in aliases + ]: + if not isinstance(name, str): + raise ValidationError( + f"{kind.capitalize()} for command '{primary_name}' must be a string" + ) + + # Enforce canonical pattern only for primary command names; + # aliases are free-form to preserve community extension compat. + if kind == "command": + match = EXTENSION_COMMAND_NAME_PATTERN.match(name) + if match is None: + raise ValidationError( + f"Invalid {kind} '{name}': " + "must follow pattern 'speckit.{extension}.{command}'" + ) + + namespace = match.group(1) + if namespace != manifest.id: + raise ValidationError( + f"{kind.capitalize()} '{name}' must use extension namespace '{manifest.id}'" + ) + + if namespace in CORE_COMMAND_NAMES: + raise ValidationError( + f"{kind.capitalize()} '{name}' conflicts with core command namespace '{namespace}'" + ) + + if name in declared_names: + raise ValidationError( + f"Duplicate command or alias '{name}' in extension manifest" + ) + + declared_names[name] = kind + + return declared_names + + def _get_installed_command_name_map( + self, + exclude_extension_id: Optional[str] = None, + ) -> Dict[str, str]: + """Return registered command and alias names for installed extensions.""" + installed_names: Dict[str, str] = {} + + for ext_id in self.registry.keys(): + if ext_id == exclude_extension_id: + continue + + manifest = self.get_extension(ext_id) + if manifest is None: + continue + + for cmd in manifest.commands: + cmd_name = cmd.get("name") + if isinstance(cmd_name, str): + installed_names.setdefault(cmd_name, ext_id) + + aliases = cmd.get("aliases", []) + if not isinstance(aliases, list): + continue + + for alias in aliases: + if isinstance(alias, str): + installed_names.setdefault(alias, ext_id) + + return installed_names + + def _validate_install_conflicts(self, manifest: ExtensionManifest) -> None: + """Reject installs that would shadow core or installed extension commands.""" + declared_names = self._collect_manifest_command_names(manifest) + installed_names = self._get_installed_command_name_map( + exclude_extension_id=manifest.id + ) + + collisions = [ + f"{name} (already provided by extension '{installed_names[name]}')" + for name in sorted(declared_names) + if name in installed_names + ] + if collisions: + raise ValidationError( + "Extension commands conflict with installed extensions:\n- " + + "\n- ".join(collisions) + ) + + @staticmethod + def _load_extensionignore(source_dir: Path) -> Optional[Callable[[str, List[str]], Set[str]]]: + """Load .extensionignore and return an ignore function for shutil.copytree. + + The .extensionignore file uses .gitignore-compatible patterns (one per line). + Lines starting with '#' are comments. Blank lines are ignored. + The .extensionignore file itself is always excluded. + + Pattern semantics mirror .gitignore: + - '*' matches anything except '/' + - '**' matches zero or more directories + - '?' matches any single character except '/' + - Trailing '/' restricts a pattern to directories only + - Patterns with '/' (other than trailing) are anchored to the root + - '!' negates a previously excluded pattern + + Args: + source_dir: Path to the extension source directory + + Returns: + An ignore function compatible with shutil.copytree, or None + if no .extensionignore file exists. + """ + ignore_file = source_dir / ".extensionignore" + if not ignore_file.exists(): + return None + + lines: List[str] = ignore_file.read_text().splitlines() + + # Normalise backslashes in patterns so Windows-authored files work + normalised: List[str] = [] + for line in lines: + stripped = line.strip() + if stripped and not stripped.startswith("#"): + normalised.append(stripped.replace("\\", "/")) + else: + # Preserve blanks/comments so pathspec line numbers stay stable + normalised.append(line) + + # Always ignore the .extensionignore file itself + normalised.append(".extensionignore") + + spec = pathspec.GitIgnoreSpec.from_lines(normalised) + + def _ignore(directory: str, entries: List[str]) -> Set[str]: + ignored: Set[str] = set() + rel_dir = Path(directory).relative_to(source_dir) + for entry in entries: + rel_path = str(rel_dir / entry) if str(rel_dir) != "." else entry + # Normalise to forward slashes for consistent matching + rel_path_fwd = rel_path.replace("\\", "/") + + entry_full = Path(directory) / entry + if entry_full.is_dir(): + # Append '/' so directory-only patterns (e.g. tests/) match + if spec.match_file(rel_path_fwd + "/"): + ignored.add(entry) + else: + if spec.match_file(rel_path_fwd): + ignored.add(entry) + return ignored + + return _ignore + + def _get_skills_dir(self) -> Optional[Path]: + """Return the active skills directory for extension skill registration. + + Reads ``.specify/init-options.json`` to determine whether skills + are enabled and which agent was selected, then delegates to + the module-level ``_get_skills_dir()`` helper for the concrete path. + + Kimi is treated as a native-skills agent: if ``ai == "kimi"`` and + ``.kimi/skills`` exists, extension installs should still propagate + command skills even when ``ai_skills`` is false. + + Returns: + The skills directory ``Path``, or ``None`` if skills were not + enabled and no native-skills fallback applies. + """ + from . import load_init_options, _get_skills_dir as resolve_skills_dir + + opts = load_init_options(self.project_root) + if not isinstance(opts, dict): + opts = {} + + agent = opts.get("ai") + if not isinstance(agent, str) or not agent: + return None + + ai_skills_enabled = bool(opts.get("ai_skills")) + if not ai_skills_enabled and agent != "kimi": + return None + + skills_dir = resolve_skills_dir(self.project_root, agent) + if not skills_dir.is_dir(): + return None + + return skills_dir + + def _register_extension_skills( + self, + manifest: ExtensionManifest, + extension_dir: Path, + ) -> List[str]: + """Generate SKILL.md files for extension commands as agent skills. + + For every command in the extension manifest, creates a SKILL.md + file in the agent's skills directory following the agentskills.io + specification. This is only done when ``--ai-skills`` was used + during project initialisation. + + Args: + manifest: Extension manifest. + extension_dir: Installed extension directory. + + Returns: + List of skill names that were created (for registry storage). + """ + skills_dir = self._get_skills_dir() + if not skills_dir: + return [] + + from . import load_init_options + from .agents import CommandRegistrar + from .integrations import get_integration + import yaml + + written: List[str] = [] + opts = load_init_options(self.project_root) + if not isinstance(opts, dict): + opts = {} + selected_ai = opts.get("ai") + if not isinstance(selected_ai, str) or not selected_ai: + return [] + registrar = CommandRegistrar() + integration = get_integration(selected_ai) + + for cmd_info in manifest.commands: + cmd_name = cmd_info["name"] + cmd_file_rel = cmd_info["file"] + + # Guard against path traversal: reject absolute paths and ensure + # the resolved file stays within the extension directory. + cmd_path = Path(cmd_file_rel) + if cmd_path.is_absolute(): + continue + try: + ext_root = extension_dir.resolve() + source_file = (ext_root / cmd_path).resolve() + source_file.relative_to(ext_root) # raises ValueError if outside + except (OSError, ValueError): + continue + + if not source_file.is_file(): + continue + + # Derive skill name from command name using the same hyphenated + # convention as hook rendering and preset skill registration. + short_name_raw = cmd_name + if short_name_raw.startswith("speckit."): + short_name_raw = short_name_raw[len("speckit."):] + skill_name = f"speckit-{short_name_raw.replace('.', '-')}" + + # Check if skill already exists before creating the directory + skill_subdir = skills_dir / skill_name + skill_file = skill_subdir / "SKILL.md" + if skill_file.exists(): + # Do not overwrite user-customized skills + continue + + # Create skill directory; track whether we created it so we can clean + # up safely if reading the source file subsequently fails. + created_now = not skill_subdir.exists() + skill_subdir.mkdir(parents=True, exist_ok=True) + + # Parse the command file — guard against IsADirectoryError / decode errors + try: + content = source_file.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + if created_now: + try: + skill_subdir.rmdir() # undo the mkdir; dir is empty at this point + except OSError: + pass # best-effort cleanup + continue + frontmatter, body = registrar.parse_frontmatter(content) + frontmatter = registrar._adjust_script_paths(frontmatter) + body = registrar.resolve_skill_placeholders( + selected_ai, frontmatter, body, self.project_root + ) + + original_desc = frontmatter.get("description", "") + description = original_desc or f"Extension command: {cmd_name}" + + frontmatter_data = registrar.build_skill_frontmatter( + selected_ai, + skill_name, + description, + f"extension:{manifest.id}", + ) + frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip() + + # Derive a human-friendly title from the command name + short_name = cmd_name + if short_name.startswith("speckit."): + short_name = short_name[len("speckit."):] + title_name = short_name.replace(".", " ").replace("-", " ").title() + + skill_content = ( + f"---\n" + f"{frontmatter_text}\n" + f"---\n\n" + f"# {title_name} Skill\n\n" + f"{body}\n" + ) + if integration is not None and hasattr(integration, "post_process_skill_content"): + skill_content = integration.post_process_skill_content( + skill_content + ) + + skill_file.write_text(skill_content, encoding="utf-8") + written.append(skill_name) + + return written + + def _unregister_extension_skills(self, skill_names: List[str], extension_id: str) -> None: + """Remove SKILL.md directories for extension skills. + + Called during extension removal to clean up skill files that + were created by ``_register_extension_skills()``. + + If ``_get_skills_dir()`` returns ``None`` (e.g. the user removed + init-options.json or toggled ai_skills after installation), we + fall back to scanning all known agent skills directories so that + orphaned skill directories are still cleaned up. In that case + each candidate directory is verified against the SKILL.md + ``metadata.source`` field before removal to avoid accidentally + deleting user-created skills with the same name. + + Args: + skill_names: List of skill names to remove. + extension_id: Extension ID used to verify ownership during + fallback candidate scanning. + """ + if not skill_names: + return + + skills_dir = self._get_skills_dir() + + if skills_dir: + # Fast path: we know the exact skills directory + for skill_name in skill_names: + # Guard against path traversal from a corrupted registry entry: + # reject names that are absolute, contain path separators, or + # resolve to a path outside the skills directory. + sn_path = Path(skill_name) + if sn_path.is_absolute() or len(sn_path.parts) != 1: + continue + try: + skill_subdir = (skills_dir / skill_name).resolve() + skill_subdir.relative_to(skills_dir.resolve()) # raises if outside + except (OSError, ValueError): + continue + if not skill_subdir.is_dir(): + continue + # Safety check: only delete if SKILL.md exists and its + # metadata.source matches exactly this extension — mirroring + # the fallback branch — so a corrupted registry entry cannot + # delete an unrelated user skill. + skill_md = skill_subdir / "SKILL.md" + if not skill_md.is_file(): + continue + try: + import yaml as _yaml + raw = skill_md.read_text(encoding="utf-8") + source = "" + if raw.startswith("---"): + parts = raw.split("---", 2) + if len(parts) >= 3: + fm = _yaml.safe_load(parts[1]) or {} + source = ( + fm.get("metadata", {}).get("source", "") + if isinstance(fm, dict) + else "" + ) + if source != f"extension:{extension_id}": + continue + except (OSError, UnicodeDecodeError, Exception): + continue + shutil.rmtree(skill_subdir) + else: + # Fallback: scan all possible agent skills directories + from . import AGENT_CONFIG, DEFAULT_SKILLS_DIR + + candidate_dirs: set[Path] = set() + for cfg in AGENT_CONFIG.values(): + folder = cfg.get("folder", "") + if folder: + candidate_dirs.add(self.project_root / folder.rstrip("/") / "skills") + candidate_dirs.add(self.project_root / DEFAULT_SKILLS_DIR) + + for skills_candidate in candidate_dirs: + if not skills_candidate.is_dir(): + continue + for skill_name in skill_names: + # Same path-traversal guard as the fast path above + sn_path = Path(skill_name) + if sn_path.is_absolute() or len(sn_path.parts) != 1: + continue + try: + skill_subdir = (skills_candidate / skill_name).resolve() + skill_subdir.relative_to(skills_candidate.resolve()) # raises if outside + except (OSError, ValueError): + continue + if not skill_subdir.is_dir(): + continue + # Safety check: only delete if SKILL.md exists and its + # metadata.source matches exactly this extension. If the + # file is missing or unreadable we skip to avoid deleting + # unrelated user-created directories. + skill_md = skill_subdir / "SKILL.md" + if not skill_md.is_file(): + continue + try: + import yaml as _yaml + raw = skill_md.read_text(encoding="utf-8") + source = "" + if raw.startswith("---"): + parts = raw.split("---", 2) + if len(parts) >= 3: + fm = _yaml.safe_load(parts[1]) or {} + source = ( + fm.get("metadata", {}).get("source", "") + if isinstance(fm, dict) + else "" + ) + # Only remove skills explicitly created by this extension + if source != f"extension:{extension_id}": + continue + except (OSError, UnicodeDecodeError, Exception): + # If we can't verify, skip to avoid accidental deletion + continue + shutil.rmtree(skill_subdir) + def check_compatibility( self, manifest: ExtensionManifest, @@ -306,7 +1113,8 @@ def install_from_directory( self, source_dir: Path, speckit_version: str, - register_commands: bool = True + register_commands: bool = True, + priority: int = 10, ) -> ExtensionManifest: """Install extension from a local directory. @@ -314,14 +1122,19 @@ def install_from_directory( source_dir: Path to extension directory speckit_version: Current spec-kit version register_commands: If True, register commands with AI agents + priority: Resolution priority (lower = higher precedence, default 10) Returns: Installed extension manifest Raises: - ValidationError: If manifest is invalid + ValidationError: If manifest is invalid or priority is invalid CompatibilityError: If extension is incompatible """ + # Validate priority + if priority < 1: + raise ValidationError("Priority must be a positive integer (1 or higher)") + # Load and validate manifest manifest_path = source_dir / "extension.yml" manifest = ExtensionManifest(manifest_path) @@ -336,12 +1149,16 @@ def install_from_directory( f"Use 'specify extension remove {manifest.id}' first." ) + # Reject manifests that would shadow core commands or installed extensions. + self._validate_install_conflicts(manifest) + # Install extension dest_dir = self.extensions_dir / manifest.id if dest_dir.exists(): shutil.rmtree(dest_dir) - shutil.copytree(source_dir, dest_dir) + ignore_fn = self._load_extensionignore(source_dir) + shutil.copytree(source_dir, dest_dir, ignore=ignore_fn) # Register commands with AI agents registered_commands = {} @@ -352,6 +1169,10 @@ def install_from_directory( manifest, dest_dir, self.project_root ) + # Auto-register extension commands as agent skills when --ai-skills + # was used during project initialisation (feature parity). + registered_skills = self._register_extension_skills(manifest, dest_dir) + # Register hooks hook_executor = HookExecutor(self.project_root) hook_executor.register_hooks(manifest) @@ -362,7 +1183,9 @@ def install_from_directory( "source": "local", "manifest_hash": manifest.get_hash(), "enabled": True, - "registered_commands": registered_commands + "priority": priority, + "registered_commands": registered_commands, + "registered_skills": registered_skills, }) return manifest @@ -370,21 +1193,27 @@ def install_from_directory( def install_from_zip( self, zip_path: Path, - speckit_version: str + speckit_version: str, + priority: int = 10, ) -> ExtensionManifest: """Install extension from ZIP file. Args: zip_path: Path to extension ZIP file speckit_version: Current spec-kit version + priority: Resolution priority (lower = higher precedence, default 10) Returns: Installed extension manifest Raises: - ValidationError: If manifest is invalid + ValidationError: If manifest is invalid or priority is invalid CompatibilityError: If extension is incompatible """ + # Validate priority early + if priority < 1: + raise ValidationError("Priority must be a positive integer (1 or higher)") + with tempfile.TemporaryDirectory() as tmpdir: temp_path = Path(tmpdir) @@ -419,7 +1248,7 @@ def install_from_zip( raise ValidationError("No extension.yml found in ZIP file") # Install from extracted directory - return self.install_from_directory(extension_dir, speckit_version) + return self.install_from_directory(extension_dir, speckit_version, priority=priority) def remove(self, extension_id: str, keep_config: bool = False) -> bool: """Remove an installed extension. @@ -434,26 +1263,25 @@ def remove(self, extension_id: str, keep_config: bool = False) -> bool: if not self.registry.is_installed(extension_id): return False - # Get registered commands before removal + # Get registered commands and skills before removal metadata = self.registry.get(extension_id) - registered_commands = metadata.get("registered_commands", {}) + registered_commands = metadata.get("registered_commands", {}) if metadata else {} + raw_skills = metadata.get("registered_skills", []) if metadata else [] + # Normalize: must be a list of plain strings to avoid corrupted-registry errors + if isinstance(raw_skills, list): + registered_skills = [s for s in raw_skills if isinstance(s, str)] + else: + registered_skills = [] extension_dir = self.extensions_dir / extension_id # Unregister commands from all AI agents if registered_commands: registrar = CommandRegistrar() - for agent_name, cmd_names in registered_commands.items(): - if agent_name not in registrar.AGENT_CONFIGS: - continue - - agent_config = registrar.AGENT_CONFIGS[agent_name] - commands_dir = self.project_root / agent_config["dir"] + registrar.unregister_commands(registered_commands, self.project_root) - for cmd_name in cmd_names: - cmd_file = commands_dir / f"{cmd_name}{agent_config['extension']}" - if cmd_file.exists(): - cmd_file.unlink() + # Unregister agent skills + self._unregister_extension_skills(registered_skills, extension_id) if keep_config: # Preserve config files, only remove non-config files @@ -507,6 +1335,9 @@ def list_installed(self) -> List[Dict[str, Any]]: result = [] for ext_id, metadata in self.registry.list().items(): + # Ensure metadata is a dictionary to avoid AttributeError when using .get() + if not isinstance(metadata, dict): + metadata = {} ext_dir = self.extensions_dir / ext_id manifest_path = ext_dir / "extension.yml" @@ -515,9 +1346,10 @@ def list_installed(self) -> List[Dict[str, Any]]: result.append({ "id": ext_id, "name": manifest.name, - "version": metadata["version"], + "version": metadata.get("version", "unknown"), "description": manifest.description, "enabled": metadata.get("enabled", True), + "priority": normalize_priority(metadata.get("priority")), "installed_at": metadata.get("installed_at"), "command_count": len(manifest.commands), "hook_count": len(manifest.hooks) @@ -530,6 +1362,7 @@ def list_installed(self) -> List[Dict[str, Any]]: "version": metadata.get("version", "unknown"), "description": "āš ļø Corrupted extension", "enabled": False, + "priority": normalize_priority(metadata.get("priority")), "installed_at": metadata.get("installed_at"), "command_count": 0, "hook_count": 0 @@ -577,237 +1410,47 @@ def version_satisfies(current: str, required: str) -> bool: class CommandRegistrar: - """Handles registration of extension commands with AI agents.""" - - # Agent configurations with directory, format, and argument placeholder - AGENT_CONFIGS = { - "claude": { - "dir": ".claude/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "gemini": { - "dir": ".gemini/commands", - "format": "toml", - "args": "{{args}}", - "extension": ".toml" - }, - "copilot": { - "dir": ".github/agents", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "cursor": { - "dir": ".cursor/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "qwen": { - "dir": ".qwen/commands", - "format": "toml", - "args": "{{args}}", - "extension": ".toml" - }, - "opencode": { - "dir": ".opencode/command", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "windsurf": { - "dir": ".windsurf/workflows", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "kilocode": { - "dir": ".kilocode/rules", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "auggie": { - "dir": ".augment/rules", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "roo": { - "dir": ".roo/rules", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "codebuddy": { - "dir": ".codebuddy/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "qoder": { - "dir": ".qoder/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "q": { - "dir": ".amazonq/prompts", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "amp": { - "dir": ".agents/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "shai": { - "dir": ".shai/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "bob": { - "dir": ".bob/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - } - } - - @staticmethod - def parse_frontmatter(content: str) -> tuple[dict, str]: - """Parse YAML frontmatter from Markdown content. - - Args: - content: Markdown content with YAML frontmatter + """Handles registration of extension commands with AI agents. - Returns: - Tuple of (frontmatter_dict, body_content) - """ - if not content.startswith("---"): - return {}, content - - # Find second --- - end_marker = content.find("---", 3) - if end_marker == -1: - return {}, content + This is a backward-compatible wrapper around the shared CommandRegistrar + in agents.py. Extension-specific methods accept ExtensionManifest objects + and delegate to the generic API. + """ - frontmatter_str = content[3:end_marker].strip() - body = content[end_marker + 3:].strip() + # Re-export AGENT_CONFIGS at class level for direct attribute access + from .agents import CommandRegistrar as _AgentRegistrar + AGENT_CONFIGS = _AgentRegistrar.AGENT_CONFIGS - try: - frontmatter = yaml.safe_load(frontmatter_str) or {} - except yaml.YAMLError: - frontmatter = {} + def __init__(self): + from .agents import CommandRegistrar as _Registrar + self._registrar = _Registrar() - return frontmatter, body + # Delegate static/utility methods + @staticmethod + def parse_frontmatter(content: str) -> tuple[dict, str]: + from .agents import CommandRegistrar as _Registrar + return _Registrar.parse_frontmatter(content) @staticmethod def render_frontmatter(fm: dict) -> str: - """Render frontmatter dictionary as YAML. + from .agents import CommandRegistrar as _Registrar + return _Registrar.render_frontmatter(fm) - Args: - fm: Frontmatter dictionary - - Returns: - YAML-formatted frontmatter with delimiters - """ - if not fm: - return "" - - yaml_str = yaml.dump(fm, default_flow_style=False, sort_keys=False) - return f"---\n{yaml_str}---\n" - - def _adjust_script_paths(self, frontmatter: dict) -> dict: - """Adjust script paths from extension-relative to repo-relative. - - Args: - frontmatter: Frontmatter dictionary - - Returns: - Modified frontmatter with adjusted paths - """ - if "scripts" in frontmatter: - for key in frontmatter["scripts"]: - script_path = frontmatter["scripts"][key] - if script_path.startswith("../../scripts/"): - frontmatter["scripts"][key] = f".specify/scripts/{script_path[14:]}" - return frontmatter - - def _render_markdown_command( - self, - frontmatter: dict, - body: str, - ext_id: str - ) -> str: - """Render command in Markdown format. - - Args: - frontmatter: Command frontmatter - body: Command body content - ext_id: Extension ID + @staticmethod + def _write_copilot_prompt(project_root, cmd_name: str) -> None: + from .agents import CommandRegistrar as _Registrar + _Registrar.write_copilot_prompt(project_root, cmd_name) - Returns: - Formatted Markdown command file content - """ + def _render_markdown_command(self, frontmatter, body, ext_id): + # Preserve extension-specific comment format for backward compatibility context_note = f"\n\n\n" - return self.render_frontmatter(frontmatter) + "\n" + context_note + body - - def _render_toml_command( - self, - frontmatter: dict, - body: str, - ext_id: str - ) -> str: - """Render command in TOML format. - - Args: - frontmatter: Command frontmatter - body: Command body content - ext_id: Extension ID - - Returns: - Formatted TOML command file content - """ - # TOML format for Gemini/Qwen - toml_lines = [] - - # Add description if present - if "description" in frontmatter: - # Escape quotes in description - desc = frontmatter["description"].replace('"', '\\"') - toml_lines.append(f'description = "{desc}"') - toml_lines.append("") - - # Add extension context as comments - toml_lines.append(f"# Extension: {ext_id}") - toml_lines.append(f"# Config: .specify/extensions/{ext_id}/") - toml_lines.append("") - - # Add prompt content - toml_lines.append('prompt = """') - toml_lines.append(body) - toml_lines.append('"""') + return self._registrar.render_frontmatter(frontmatter) + "\n" + context_note + body - return "\n".join(toml_lines) - - def _convert_argument_placeholder(self, content: str, from_placeholder: str, to_placeholder: str) -> str: - """Convert argument placeholder format. - - Args: - content: Command content - from_placeholder: Source placeholder (e.g., "$ARGUMENTS") - to_placeholder: Target placeholder (e.g., "{{args}}") - - Returns: - Content with converted placeholders - """ - return content.replace(from_placeholder, to_placeholder) + def _render_toml_command(self, frontmatter, body, ext_id): + # Preserve extension-specific context comments for backward compatibility + base = self._registrar.render_toml_command(frontmatter, body, ext_id) + context_lines = f"# Extension: {ext_id}\n# Config: .specify/extensions/{ext_id}/\n" + return base.rstrip("\n") + "\n" + context_lines def register_commands_for_agent( self, @@ -816,70 +1459,14 @@ def register_commands_for_agent( extension_dir: Path, project_root: Path ) -> List[str]: - """Register extension commands for a specific agent. - - Args: - agent_name: Agent name (claude, gemini, copilot, etc.) - manifest: Extension manifest - extension_dir: Path to extension directory - project_root: Path to project root - - Returns: - List of registered command names - - Raises: - ExtensionError: If agent is not supported - """ + """Register extension commands for a specific agent.""" if agent_name not in self.AGENT_CONFIGS: raise ExtensionError(f"Unsupported agent: {agent_name}") - - agent_config = self.AGENT_CONFIGS[agent_name] - commands_dir = project_root / agent_config["dir"] - commands_dir.mkdir(parents=True, exist_ok=True) - - registered = [] - - for cmd_info in manifest.commands: - cmd_name = cmd_info["name"] - cmd_file = cmd_info["file"] - - # Read source command file - source_file = extension_dir / cmd_file - if not source_file.exists(): - continue - - content = source_file.read_text() - frontmatter, body = self.parse_frontmatter(content) - - # Adjust script paths - frontmatter = self._adjust_script_paths(frontmatter) - - # Convert argument placeholders - body = self._convert_argument_placeholder( - body, "$ARGUMENTS", agent_config["args"] - ) - - # Render in agent-specific format - if agent_config["format"] == "markdown": - output = self._render_markdown_command(frontmatter, body, manifest.id) - elif agent_config["format"] == "toml": - output = self._render_toml_command(frontmatter, body, manifest.id) - else: - raise ExtensionError(f"Unsupported format: {agent_config['format']}") - - # Write command file - dest_file = commands_dir / f"{cmd_name}{agent_config['extension']}" - dest_file.write_text(output) - - registered.append(cmd_name) - - # Register aliases - for alias in cmd_info.get("aliases", []): - alias_file = commands_dir / f"{alias}{agent_config['extension']}" - alias_file.write_text(output) - registered.append(alias) - - return registered + context_note = f"\n\n\n" + return self._registrar.register_commands( + agent_name, manifest.commands, manifest.id, extension_dir, project_root, + context_note=context_note + ) def register_commands_for_all_agents( self, @@ -887,35 +1474,20 @@ def register_commands_for_all_agents( extension_dir: Path, project_root: Path ) -> Dict[str, List[str]]: - """Register extension commands for all detected agents. - - Args: - manifest: Extension manifest - extension_dir: Path to extension directory - project_root: Path to project root - - Returns: - Dictionary mapping agent names to list of registered commands - """ - results = {} - - # Detect which agents are present in the project - for agent_name, agent_config in self.AGENT_CONFIGS.items(): - agent_dir = project_root / agent_config["dir"].split("/")[0] - - # Register if agent directory exists - if agent_dir.exists(): - try: - registered = self.register_commands_for_agent( - agent_name, manifest, extension_dir, project_root - ) - if registered: - results[agent_name] = registered - except ExtensionError: - # Skip agent on error - continue + """Register extension commands for all detected agents.""" + context_note = f"\n\n\n" + return self._registrar.register_commands_for_all_agents( + manifest.commands, manifest.id, extension_dir, project_root, + context_note=context_note + ) - return results + def unregister_commands( + self, + registered_commands: Dict[str, List[str]], + project_root: Path + ) -> None: + """Remove previously registered command files from agent directories.""" + self._registrar.unregister_commands(registered_commands, project_root) def register_commands_for_claude( self, @@ -923,16 +1495,7 @@ def register_commands_for_claude( extension_dir: Path, project_root: Path ) -> List[str]: - """Register extension commands for Claude Code agent. - - Args: - manifest: Extension manifest - extension_dir: Path to extension directory - project_root: Path to project root - - Returns: - List of registered command names - """ + """Register extension commands for Claude Code agent.""" return self.register_commands_for_agent("claude", manifest, extension_dir, project_root) @@ -940,6 +1503,7 @@ class ExtensionCatalog: """Manages extension catalog fetching, caching, and searching.""" DEFAULT_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json" + COMMUNITY_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json" CACHE_DURATION = 3600 # 1 hour in seconds def __init__(self, project_root: Path): @@ -954,43 +1518,123 @@ def __init__(self, project_root: Path): self.cache_file = self.cache_dir / "catalog.json" self.cache_metadata_file = self.cache_dir / "catalog-metadata.json" - def get_catalog_url(self) -> str: - """Get catalog URL from config or use default. - - Checks in order: - 1. SPECKIT_CATALOG_URL environment variable - 2. Default catalog URL + def _validate_catalog_url(self, url: str) -> None: + """Validate that a catalog URL uses HTTPS (localhost HTTP allowed). - Returns: - URL to fetch catalog from + Args: + url: URL to validate Raises: - ValidationError: If custom URL is invalid (non-HTTPS) + ValidationError: If URL is invalid or uses non-HTTPS scheme """ - import os - import sys from urllib.parse import urlparse - # Environment variable override (useful for testing) - if env_value := os.environ.get("SPECKIT_CATALOG_URL"): - catalog_url = env_value.strip() - parsed = urlparse(catalog_url) + parsed = urlparse(url) + is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1") + if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost): + raise ValidationError( + f"Catalog URL must use HTTPS (got {parsed.scheme}://). " + "HTTP is only allowed for localhost." + ) + if not parsed.netloc: + raise ValidationError("Catalog URL must be a valid URL with a host.") - # Require HTTPS for security (prevent man-in-the-middle attacks) - # Allow http://localhost for local development/testing - is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1") - if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost): + def _load_catalog_config(self, config_path: Path) -> Optional[List[CatalogEntry]]: + """Load catalog stack configuration from a YAML file. + + Args: + config_path: Path to extension-catalogs.yml + + Returns: + Ordered list of CatalogEntry objects, or None if file doesn't exist. + + Raises: + ValidationError: If any catalog entry has an invalid URL, + the file cannot be parsed, a priority value is invalid, + or the file exists but contains no valid catalog entries + (fail-closed for security). + """ + if not config_path.exists(): + return None + try: + data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + except (yaml.YAMLError, OSError, UnicodeError) as e: + raise ValidationError( + f"Failed to read catalog config {config_path}: {e}" + ) + catalogs_data = data.get("catalogs", []) + if not catalogs_data: + # File exists but has no catalogs key or empty list - fail closed + raise ValidationError( + f"Catalog config {config_path} exists but contains no 'catalogs' entries. " + f"Remove the file to use built-in defaults, or add valid catalog entries." + ) + if not isinstance(catalogs_data, list): + raise ValidationError( + f"Invalid catalog config: 'catalogs' must be a list, got {type(catalogs_data).__name__}" + ) + entries: List[CatalogEntry] = [] + skipped_entries: List[int] = [] + for idx, item in enumerate(catalogs_data): + if not isinstance(item, dict): raise ValidationError( - f"Invalid SPECKIT_CATALOG_URL: must use HTTPS (got {parsed.scheme}://). " - "HTTP is only allowed for localhost." + f"Invalid catalog entry at index {idx}: expected a mapping, got {type(item).__name__}" ) - - if not parsed.netloc: + url = str(item.get("url", "")).strip() + if not url: + skipped_entries.append(idx) + continue + self._validate_catalog_url(url) + try: + priority = int(item.get("priority", idx + 1)) + except (TypeError, ValueError): raise ValidationError( - "Invalid SPECKIT_CATALOG_URL: must be a valid URL with a host." + f"Invalid priority for catalog '{item.get('name', idx + 1)}': " + f"expected integer, got {item.get('priority')!r}" ) + raw_install = item.get("install_allowed", False) + if isinstance(raw_install, str): + install_allowed = raw_install.strip().lower() in ("true", "yes", "1") + else: + install_allowed = bool(raw_install) + entries.append(CatalogEntry( + url=url, + name=str(item.get("name", f"catalog-{idx + 1}")), + priority=priority, + install_allowed=install_allowed, + description=str(item.get("description", "")), + )) + entries.sort(key=lambda e: e.priority) + if not entries: + # All entries were invalid (missing URLs) - fail closed for security + raise ValidationError( + f"Catalog config {config_path} contains {len(catalogs_data)} entries but none have valid URLs " + f"(entries at indices {skipped_entries} were skipped). " + f"Each catalog entry must have a 'url' field." + ) + return entries - # Warn users when using a non-default catalog (once per instance) + def get_active_catalogs(self) -> List[CatalogEntry]: + """Get the ordered list of active catalogs. + + Resolution order: + 1. SPECKIT_CATALOG_URL env var — single catalog replacing all defaults + 2. Project-level .specify/extension-catalogs.yml + 3. User-level ~/.specify/extension-catalogs.yml + 4. Built-in default stack (default + community) + + Returns: + List of CatalogEntry objects sorted by priority (ascending) + + Raises: + ValidationError: If a catalog URL is invalid + """ + import sys + + # 1. SPECKIT_CATALOG_URL env var replaces all defaults for backward compat + if env_value := os.environ.get("SPECKIT_CATALOG_URL"): + catalog_url = env_value.strip() + self._validate_catalog_url(catalog_url) if catalog_url != self.DEFAULT_CATALOG_URL: if not getattr(self, "_non_default_catalog_warning_shown", False): print( @@ -999,11 +1643,163 @@ def get_catalog_url(self) -> str: file=sys.stderr, ) self._non_default_catalog_warning_shown = True + return [CatalogEntry(url=catalog_url, name="custom", priority=1, install_allowed=True, description="Custom catalog via SPECKIT_CATALOG_URL")] + + # 2. Project-level config overrides all defaults + project_config_path = self.project_root / ".specify" / "extension-catalogs.yml" + catalogs = self._load_catalog_config(project_config_path) + if catalogs is not None: + return catalogs + + # 3. User-level config + user_config_path = Path.home() / ".specify" / "extension-catalogs.yml" + catalogs = self._load_catalog_config(user_config_path) + if catalogs is not None: + return catalogs + + # 4. Built-in default stack + return [ + CatalogEntry(url=self.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True, description="Built-in catalog of installable extensions"), + CatalogEntry(url=self.COMMUNITY_CATALOG_URL, name="community", priority=2, install_allowed=False, description="Community-contributed extensions (discovery only)"), + ] + + def get_catalog_url(self) -> str: + """Get the primary catalog URL. + + Returns the URL of the highest-priority catalog. Kept for backward + compatibility. Use get_active_catalogs() for full multi-catalog support. + + Returns: + URL of the primary catalog + + Raises: + ValidationError: If a catalog URL is invalid + """ + active = self.get_active_catalogs() + return active[0].url if active else self.DEFAULT_CATALOG_URL + + def _fetch_single_catalog(self, entry: CatalogEntry, force_refresh: bool = False) -> Dict[str, Any]: + """Fetch a single catalog with per-URL caching. + + For the DEFAULT_CATALOG_URL, uses legacy cache files (self.cache_file / + self.cache_metadata_file) for backward compatibility. For all other URLs, + uses URL-hash-based cache files in self.cache_dir. + + Args: + entry: CatalogEntry describing the catalog to fetch + force_refresh: If True, bypass cache + + Returns: + Catalog data dictionary + + Raises: + ExtensionError: If catalog cannot be fetched or has invalid format + """ + import urllib.request + import urllib.error + + # Determine cache file paths (backward compat for default catalog) + if entry.url == self.DEFAULT_CATALOG_URL: + cache_file = self.cache_file + cache_meta_file = self.cache_metadata_file + is_valid = not force_refresh and self.is_cache_valid() + else: + url_hash = hashlib.sha256(entry.url.encode()).hexdigest()[:16] + cache_file = self.cache_dir / f"catalog-{url_hash}.json" + cache_meta_file = self.cache_dir / f"catalog-{url_hash}-metadata.json" + is_valid = False + if not force_refresh and cache_file.exists() and cache_meta_file.exists(): + try: + metadata = json.loads(cache_meta_file.read_text()) + cached_at = datetime.fromisoformat(metadata.get("cached_at", "")) + if cached_at.tzinfo is None: + cached_at = cached_at.replace(tzinfo=timezone.utc) + age = (datetime.now(timezone.utc) - cached_at).total_seconds() + is_valid = age < self.CACHE_DURATION + except (json.JSONDecodeError, ValueError, KeyError, TypeError): + # If metadata is invalid or missing expected fields, treat cache as invalid + pass + + # Use cache if valid + if is_valid: + try: + return json.loads(cache_file.read_text()) + except json.JSONDecodeError: + pass + + # Fetch from network + try: + with urllib.request.urlopen(entry.url, timeout=10) as response: + catalog_data = json.loads(response.read()) + + if "schema_version" not in catalog_data or "extensions" not in catalog_data: + raise ExtensionError(f"Invalid catalog format from {entry.url}") + + # Save to cache + self.cache_dir.mkdir(parents=True, exist_ok=True) + cache_file.write_text(json.dumps(catalog_data, indent=2)) + cache_meta_file.write_text(json.dumps({ + "cached_at": datetime.now(timezone.utc).isoformat(), + "catalog_url": entry.url, + }, indent=2)) + + return catalog_data - return catalog_url + except urllib.error.URLError as e: + raise ExtensionError(f"Failed to fetch catalog from {entry.url}: {e}") + except json.JSONDecodeError as e: + raise ExtensionError(f"Invalid JSON in catalog from {entry.url}: {e}") + + def _get_merged_extensions(self, force_refresh: bool = False) -> List[Dict[str, Any]]: + """Fetch and merge extensions from all active catalogs. + + Higher-priority (lower priority number) catalogs win on conflicts + (same extension id in two catalogs). Each extension dict is annotated with: + - _catalog_name: name of the source catalog + - _install_allowed: whether installation is allowed from this catalog - # TODO: Support custom catalogs from .specify/extension-catalogs.yml - return self.DEFAULT_CATALOG_URL + Catalogs that fail to fetch are skipped. Raises ExtensionError only if + ALL catalogs fail. + + Args: + force_refresh: If True, bypass all caches + + Returns: + List of merged extension dicts + + Raises: + ExtensionError: If all catalogs fail to fetch + """ + import sys + + active_catalogs = self.get_active_catalogs() + merged: Dict[str, Dict[str, Any]] = {} + any_success = False + + for catalog_entry in active_catalogs: + try: + catalog_data = self._fetch_single_catalog(catalog_entry, force_refresh) + any_success = True + except ExtensionError as e: + print( + f"Warning: Could not fetch catalog '{catalog_entry.name}': {e}", + file=sys.stderr, + ) + continue + + for ext_id, ext_data in catalog_data.get("extensions", {}).items(): + if ext_id not in merged: # Higher-priority catalog wins + merged[ext_id] = { + **ext_data, + "id": ext_id, + "_catalog_name": catalog_entry.name, + "_install_allowed": catalog_entry.install_allowed, + } + + if not any_success and active_catalogs: + raise ExtensionError("Failed to fetch any extension catalog") + + return list(merged.values()) def is_cache_valid(self) -> bool: """Check if cached catalog is still valid. @@ -1017,9 +1813,11 @@ def is_cache_valid(self) -> bool: try: metadata = json.loads(self.cache_metadata_file.read_text()) cached_at = datetime.fromisoformat(metadata.get("cached_at", "")) + if cached_at.tzinfo is None: + cached_at = cached_at.replace(tzinfo=timezone.utc) age_seconds = (datetime.now(timezone.utc) - cached_at).total_seconds() return age_seconds < self.CACHE_DURATION - except (json.JSONDecodeError, ValueError, KeyError): + except (json.JSONDecodeError, ValueError, KeyError, TypeError): return False def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]: @@ -1080,7 +1878,7 @@ def search( author: Optional[str] = None, verified_only: bool = False, ) -> List[Dict[str, Any]]: - """Search catalog for extensions. + """Search catalog for extensions across all active catalogs. Args: query: Search query (searches name, description, tags) @@ -1089,14 +1887,16 @@ def search( verified_only: If True, show only verified extensions Returns: - List of matching extension metadata + List of matching extension metadata, each annotated with + ``_catalog_name`` and ``_install_allowed`` from its source catalog. """ - catalog = self.fetch_catalog() - extensions = catalog.get("extensions", {}) + all_extensions = self._get_merged_extensions() results = [] - for ext_id, ext_data in extensions.items(): + for ext_data in all_extensions: + ext_id = ext_data["id"] + # Apply filters if verified_only and not ext_data.get("verified", False): continue @@ -1122,25 +1922,26 @@ def search( if query_lower not in searchable_text: continue - results.append({"id": ext_id, **ext_data}) + results.append(ext_data) return results def get_extension_info(self, extension_id: str) -> Optional[Dict[str, Any]]: """Get detailed information about a specific extension. + Searches all active catalogs in priority order. + Args: extension_id: ID of the extension Returns: - Extension metadata or None if not found + Extension metadata (annotated with ``_catalog_name`` and + ``_install_allowed``) or None if not found. """ - catalog = self.fetch_catalog() - extensions = catalog.get("extensions", {}) - - if extension_id in extensions: - return {"id": extension_id, **extensions[extension_id]} - + all_extensions = self._get_merged_extensions() + for ext_data in all_extensions: + if ext_data["id"] == extension_id: + return ext_data return None def download_extension(self, extension_id: str, target_dir: Optional[Path] = None) -> Path: @@ -1164,6 +1965,14 @@ def download_extension(self, extension_id: str, target_dir: Optional[Path] = Non if not ext_info: raise ExtensionError(f"Extension '{extension_id}' not found in catalog") + # Bundled extensions without a download URL must be installed locally + if ext_info.get("bundled") and not ext_info.get("download_url"): + raise ExtensionError( + f"Extension '{extension_id}' is bundled with spec-kit and has no download URL. " + f"It should be installed from the local package. " + f"Try reinstalling: {REINSTALL_COMMAND}" + ) + download_url = ext_info.get("download_url") if not download_url: raise ExtensionError(f"Extension '{extension_id}' has no download URL") @@ -1200,11 +2009,18 @@ def download_extension(self, extension_id: str, target_dir: Optional[Path] = Non raise ExtensionError(f"Failed to save extension ZIP: {e}") def clear_cache(self): - """Clear the catalog cache.""" + """Clear the catalog cache (both legacy and URL-hash-based files).""" if self.cache_file.exists(): self.cache_file.unlink() if self.cache_metadata_file.exists(): self.cache_metadata_file.unlink() + # Also clear any per-URL hash-based cache files + if self.cache_dir.exists(): + for extra_cache in self.cache_dir.glob("catalog-*.json"): + if extra_cache != self.cache_file: + extra_cache.unlink(missing_ok=True) + for extra_meta in self.cache_dir.glob("catalog-*-metadata.json"): + extra_meta.unlink(missing_ok=True) class ConfigManager: @@ -1241,8 +2057,8 @@ def _load_yaml_config(self, file_path: Path) -> Dict[str, Any]: return {} try: - return yaml.safe_load(file_path.read_text()) or {} - except (yaml.YAMLError, OSError): + return yaml.safe_load(file_path.read_text(encoding="utf-8")) or {} + except (yaml.YAMLError, OSError, UnicodeError): return {} def _get_extension_defaults(self) -> Dict[str, Any]: @@ -1418,6 +2234,58 @@ def __init__(self, project_root: Path): self.project_root = project_root self.extensions_dir = project_root / ".specify" / "extensions" self.config_file = project_root / ".specify" / "extensions.yml" + self._init_options_cache: Optional[Dict[str, Any]] = None + + def _load_init_options(self) -> Dict[str, Any]: + """Load persisted init options used to determine invocation style. + + Uses the shared helper from specify_cli and caches values per executor + instance to avoid repeated filesystem reads during hook rendering. + """ + if self._init_options_cache is None: + from . import load_init_options + + payload = load_init_options(self.project_root) + self._init_options_cache = payload if isinstance(payload, dict) else {} + return self._init_options_cache + + @staticmethod + def _skill_name_from_command(command: Any) -> str: + """Map a command id like speckit.plan to speckit-plan skill name.""" + if not isinstance(command, str): + return "" + command_id = command.strip() + if not command_id.startswith("speckit."): + return "" + return f"speckit-{command_id[len('speckit.'):].replace('.', '-')}" + + def _render_hook_invocation(self, command: Any) -> str: + """Render an agent-specific invocation string for a hook command.""" + if not isinstance(command, str): + return "" + + command_id = command.strip() + if not command_id: + return "" + + init_options = self._load_init_options() + selected_ai = init_options.get("ai") + codex_skill_mode = selected_ai == "codex" and bool(init_options.get("ai_skills")) + claude_skill_mode = selected_ai == "claude" and bool(init_options.get("ai_skills")) + kimi_skill_mode = selected_ai == "kimi" + cursor_skill_mode = selected_ai == "cursor-agent" and bool(init_options.get("ai_skills")) + + skill_name = self._skill_name_from_command(command_id) + if codex_skill_mode and skill_name: + return f"${skill_name}" + if claude_skill_mode and skill_name: + return f"/{skill_name}" + if kimi_skill_mode and skill_name: + return f"/skill:{skill_name}" + if cursor_skill_mode and skill_name: + return f"/{skill_name}" + + return f"/{command_id}" def get_project_config(self) -> Dict[str, Any]: """Load project-level extension configuration. @@ -1433,8 +2301,8 @@ def get_project_config(self) -> Dict[str, Any]: } try: - return yaml.safe_load(self.config_file.read_text()) or {} - except (yaml.YAMLError, OSError): + return yaml.safe_load(self.config_file.read_text(encoding="utf-8")) or {} + except (yaml.YAMLError, OSError, UnicodeError): return { "installed": [], "settings": {"auto_execute_hooks": True}, @@ -1449,7 +2317,8 @@ def save_project_config(self, config: Dict[str, Any]): """ self.config_file.parent.mkdir(parents=True, exist_ok=True) self.config_file.write_text( - yaml.dump(config, default_flow_style=False, sort_keys=False) + yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), + encoding="utf-8", ) def register_hooks(self, manifest: ExtensionManifest): @@ -1660,21 +2529,27 @@ def format_hook_message( for hook in hooks: extension = hook.get("extension") command = hook.get("command") + invocation = self._render_hook_invocation(command) + command_text = command if isinstance(command, str) and command.strip() else "" + display_invocation = invocation or ( + f"/{command_text}" if command_text != "" else "/" + ) optional = hook.get("optional", True) prompt = hook.get("prompt", "") description = hook.get("description", "") if optional: lines.append(f"\n**Optional Hook**: {extension}") - lines.append(f"Command: `/{command}`") + lines.append(f"Command: `{display_invocation}`") if description: lines.append(f"Description: {description}") lines.append(f"\nPrompt: {prompt}") - lines.append(f"To execute: `/{command}`") + lines.append(f"To execute: `{display_invocation}`") else: lines.append(f"\n**Automatic Hook**: {extension}") - lines.append(f"Executing: `/{command}`") - lines.append(f"EXECUTE_COMMAND: {command}") + lines.append(f"Executing: `{display_invocation}`") + lines.append(f"EXECUTE_COMMAND: {command_text}") + lines.append(f"EXECUTE_COMMAND_INVOCATION: {display_invocation}") return "\n".join(lines) @@ -1738,6 +2613,7 @@ def execute_hook(self, hook: Dict[str, Any]) -> Dict[str, Any]: """ return { "command": hook.get("command"), + "invocation": self._render_hook_invocation(hook.get("command")), "extension": hook.get("extension"), "optional": hook.get("optional", True), "description": hook.get("description", ""), @@ -1781,5 +2657,3 @@ def disable_hooks(self, extension_id: str): hook["enabled"] = False self.save_project_config(config) - - diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py new file mode 100644 index 0000000000..a5fb3833dc --- /dev/null +++ b/src/specify_cli/integrations/__init__.py @@ -0,0 +1,110 @@ +"""Integration registry for AI coding assistants. + +Each integration is a self-contained subpackage that handles setup/teardown +for a specific AI assistant (Copilot, Claude, Gemini, etc.). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .base import IntegrationBase + +# Maps integration key → IntegrationBase instance. +# Populated by later stages as integrations are migrated. +INTEGRATION_REGISTRY: dict[str, IntegrationBase] = {} + + +def _register(integration: IntegrationBase) -> None: + """Register an integration instance in the global registry. + + Raises ``ValueError`` for falsy keys and ``KeyError`` for duplicates. + """ + key = integration.key + if not key: + raise ValueError("Cannot register integration with an empty key.") + if key in INTEGRATION_REGISTRY: + raise KeyError(f"Integration with key {key!r} is already registered.") + INTEGRATION_REGISTRY[key] = integration + + +def get_integration(key: str) -> IntegrationBase | None: + """Return the integration for *key*, or ``None`` if not registered.""" + return INTEGRATION_REGISTRY.get(key) + + +# -- Register built-in integrations -------------------------------------- + + +def _register_builtins() -> None: + """Register all built-in integrations. + + Package directories use Python-safe identifiers (e.g. ``kiro_cli``, + ``cursor_agent``). The user-facing integration key stored in + ``IntegrationBase.key`` stays hyphenated (``"kiro-cli"``, + ``"cursor-agent"``) to match the actual CLI tool / binary name that + users install and invoke. + """ + # -- Imports (alphabetical) ------------------------------------------- + from .agy import AgyIntegration + from .amp import AmpIntegration + from .auggie import AuggieIntegration + from .bob import BobIntegration + from .claude import ClaudeIntegration + from .codebuddy import CodebuddyIntegration + from .codex import CodexIntegration + from .copilot import CopilotIntegration + from .cursor_agent import CursorAgentIntegration + from .forge import ForgeIntegration + from .gemini import GeminiIntegration + from .generic import GenericIntegration + from .goose import GooseIntegration + from .iflow import IflowIntegration + from .junie import JunieIntegration + from .kilocode import KilocodeIntegration + from .kimi import KimiIntegration + from .kiro_cli import KiroCliIntegration + from .opencode import OpencodeIntegration + from .pi import PiIntegration + from .qodercli import QodercliIntegration + from .qwen import QwenIntegration + from .roo import RooIntegration + from .shai import ShaiIntegration + from .tabnine import TabnineIntegration + from .trae import TraeIntegration + from .vibe import VibeIntegration + from .windsurf import WindsurfIntegration + + # -- Registration (alphabetical) -------------------------------------- + _register(AgyIntegration()) + _register(AmpIntegration()) + _register(AuggieIntegration()) + _register(BobIntegration()) + _register(ClaudeIntegration()) + _register(CodebuddyIntegration()) + _register(CodexIntegration()) + _register(CopilotIntegration()) + _register(CursorAgentIntegration()) + _register(ForgeIntegration()) + _register(GeminiIntegration()) + _register(GenericIntegration()) + _register(GooseIntegration()) + _register(IflowIntegration()) + _register(JunieIntegration()) + _register(KilocodeIntegration()) + _register(KimiIntegration()) + _register(KiroCliIntegration()) + _register(OpencodeIntegration()) + _register(PiIntegration()) + _register(QodercliIntegration()) + _register(QwenIntegration()) + _register(RooIntegration()) + _register(ShaiIntegration()) + _register(TabnineIntegration()) + _register(TraeIntegration()) + _register(VibeIntegration()) + _register(WindsurfIntegration()) + + +_register_builtins() diff --git a/src/specify_cli/integrations/agy/__init__.py b/src/specify_cli/integrations/agy/__init__.py new file mode 100644 index 0000000000..d62bafad40 --- /dev/null +++ b/src/specify_cli/integrations/agy/__init__.py @@ -0,0 +1,52 @@ +"""Antigravity (agy) integration — skills-based agent. + +Antigravity uses ``.agents/skills/speckit-/SKILL.md`` layout (enforced since v1.20.5). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from ..base import SkillsIntegration + +if TYPE_CHECKING: + from ..manifest import IntegrationManifest + + + +class AgyIntegration(SkillsIntegration): + """Integration for Antigravity IDE.""" + + key = "agy" + config = { + "name": "Antigravity", + "folder": ".agents/", + "commands_subdir": "skills", + "install_url": None, + "requires_cli": False, + } + registrar_config = { + "dir": ".agents/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", + } + context_file = "AGENTS.md" + + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + import click + + click.secho( + "Warning: The .agents/ layout requires Antigravity v1.20.5 or newer. " + "Please ensure your agy installation is up to date.", + fg="yellow", + err=True, + ) + return super().setup(project_root, manifest, parsed_options=parsed_options, **opts) diff --git a/src/specify_cli/integrations/amp/__init__.py b/src/specify_cli/integrations/amp/__init__.py new file mode 100644 index 0000000000..39df0a9bbf --- /dev/null +++ b/src/specify_cli/integrations/amp/__init__.py @@ -0,0 +1,21 @@ +"""Amp CLI integration.""" + +from ..base import MarkdownIntegration + + +class AmpIntegration(MarkdownIntegration): + key = "amp" + config = { + "name": "Amp", + "folder": ".agents/", + "commands_subdir": "commands", + "install_url": "https://ampcode.com/manual#install", + "requires_cli": True, + } + registrar_config = { + "dir": ".agents/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = "AGENTS.md" diff --git a/src/specify_cli/integrations/auggie/__init__.py b/src/specify_cli/integrations/auggie/__init__.py new file mode 100644 index 0000000000..9715e936ef --- /dev/null +++ b/src/specify_cli/integrations/auggie/__init__.py @@ -0,0 +1,21 @@ +"""Auggie CLI integration.""" + +from ..base import MarkdownIntegration + + +class AuggieIntegration(MarkdownIntegration): + key = "auggie" + config = { + "name": "Auggie CLI", + "folder": ".augment/", + "commands_subdir": "commands", + "install_url": "https://docs.augmentcode.com/cli/setup-auggie/install-auggie-cli", + "requires_cli": True, + } + registrar_config = { + "dir": ".augment/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = ".augment/rules/specify-rules.md" diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py new file mode 100644 index 0000000000..f3b74b0c05 --- /dev/null +++ b/src/specify_cli/integrations/base.py @@ -0,0 +1,1486 @@ +"""Base classes for AI-assistant integrations. + +Provides: +- ``IntegrationOption`` — declares a CLI option an integration accepts. +- ``IntegrationBase`` — abstract base every integration must implement. +- ``MarkdownIntegration`` — concrete base for standard Markdown-format + integrations (the common case — subclass, set three class attrs, done). +- ``TomlIntegration`` — concrete base for TOML-format integrations + (Gemini, Tabnine — subclass, set three class attrs, done). +- ``SkillsIntegration`` — concrete base for integrations that install + commands as agent skills (``speckit-/SKILL.md`` layout). +""" + +from __future__ import annotations + +import re +import shutil +from abc import ABC +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from .manifest import IntegrationManifest + + +# --------------------------------------------------------------------------- +# IntegrationOption +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class IntegrationOption: + """Declares an option that an integration accepts via ``--integration-options``. + + Attributes: + name: The flag name (e.g. ``"--commands-dir"``). + is_flag: ``True`` for boolean flags (``--skills``). + required: ``True`` if the option must be supplied. + default: Default value when not supplied (``None`` → no default). + help: One-line description shown in ``specify integrate info``. + """ + + name: str + is_flag: bool = False + required: bool = False + default: Any = None + help: str = "" + + +# --------------------------------------------------------------------------- +# IntegrationBase — abstract base class +# --------------------------------------------------------------------------- + + +class IntegrationBase(ABC): + """Abstract base class every integration must implement. + + Subclasses must set the following class-level attributes: + + * ``key`` — unique identifier, matches actual CLI tool name + * ``config`` — dict compatible with ``AGENT_CONFIG`` entries + * ``registrar_config`` — dict compatible with ``CommandRegistrar.AGENT_CONFIGS`` + + And may optionally set: + + * ``context_file`` — path (relative to project root) of the agent + context/instructions file (e.g. ``"CLAUDE.md"``) + """ + + # -- Must be set by every subclass ------------------------------------ + + key: str = "" + """Unique integration key — should match the actual CLI tool name.""" + + config: dict[str, Any] | None = None + """Metadata dict matching the ``AGENT_CONFIG`` shape.""" + + registrar_config: dict[str, Any] | None = None + """Registration dict matching ``CommandRegistrar.AGENT_CONFIGS`` shape.""" + + # -- Optional --------------------------------------------------------- + + context_file: str | None = None + """Relative path to the agent context file (e.g. ``CLAUDE.md``).""" + + invoke_separator: str = "." + """Separator used in slash-command invocations (``"."`` → ``/speckit.plan``).""" + + # -- Markers for managed context section ------------------------------ + + CONTEXT_MARKER_START = "" + CONTEXT_MARKER_END = "" + + # -- Public API ------------------------------------------------------- + + @classmethod + def options(cls) -> list[IntegrationOption]: + """Return options this integration accepts. Default: none.""" + return [] + + def effective_invoke_separator( + self, parsed_options: dict[str, Any] | None = None + ) -> str: + """Return the invoke separator for the given options. + + Subclasses whose separator depends on runtime options (e.g. + Copilot in ``--skills`` mode) should override this method. + The default implementation ignores *parsed_options* and returns + the class-level ``invoke_separator``. + """ + return self.invoke_separator + + def build_exec_args( + self, + prompt: str, + *, + model: str | None = None, + output_json: bool = True, + ) -> list[str] | None: + """Build CLI arguments for non-interactive execution. + + Returns a list of command-line tokens that will execute *prompt* + non-interactively using this integration's CLI tool, or ``None`` + if the integration does not support CLI dispatch. + + Subclasses for CLI-based integrations should override this. + """ + return None + + def build_command_invocation(self, command_name: str, args: str = "") -> str: + """Build the native slash-command invocation for a Spec Kit command. + + The CLI tools discover and execute commands from installed files + on disk. This method builds the invocation string the CLI + expects — e.g. ``"/speckit.specify my-feature"`` for markdown + agents or ``"/speckit-specify my-feature"`` for skills agents. + + *command_name* may be a full dotted name like + ``"speckit.specify"``, an extension command like + ``"speckit.git.commit"``, or a bare stem like ``"specify"``. + """ + stem = command_name + if stem.startswith("speckit."): + stem = stem[len("speckit."):] + + invocation = f"/speckit.{stem}" + if args: + invocation = f"{invocation} {args}" + return invocation + + def dispatch_command( + self, + command_name: str, + args: str = "", + *, + project_root: Path | None = None, + model: str | None = None, + timeout: int = 600, + stream: bool = True, + ) -> dict[str, Any]: + """Dispatch a Spec Kit command through this integration's CLI. + + By default this builds a slash-command invocation with + ``build_command_invocation()`` and passes that prompt to + ``build_exec_args()`` to construct the CLI command line. + Integrations with custom dispatch behavior can override + ``build_command_invocation()``, ``build_exec_args()``, or + ``dispatch_command()`` directly. + + When *stream* is ``True`` (the default), stdout and stderr are + piped directly to the terminal so the user sees live output. + When ``False``, output is captured and returned in the dict. + + Returns a dict with ``exit_code``, ``stdout``, and ``stderr``. + Raises ``NotImplementedError`` if the integration does not + support CLI dispatch. + """ + import subprocess + + prompt = self.build_command_invocation(command_name, args) + # When streaming to the terminal, request text output so the + # user sees readable output instead of raw JSONL events. + exec_args = self.build_exec_args( + prompt, model=model, output_json=not stream + ) + + if exec_args is None: + msg = ( + f"Integration {self.key!r} does not support CLI dispatch. " + f"Override build_exec_args() to enable it." + ) + raise NotImplementedError(msg) + + cwd = str(project_root) if project_root else None + + if stream: + # No timeout when streaming — the user sees live output and + # can Ctrl+C at any time. The timeout parameter is only + # applied in the captured (non-streaming) branch below. + try: + result = subprocess.run( + exec_args, + text=True, + cwd=cwd, + ) + except KeyboardInterrupt: + return { + "exit_code": 130, + "stdout": "", + "stderr": "Interrupted by user", + } + return { + "exit_code": result.returncode, + "stdout": "", + "stderr": "", + } + + result = subprocess.run( + exec_args, + capture_output=True, + text=True, + cwd=cwd, + timeout=timeout, + ) + return { + "exit_code": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + } + + # -- Primitives — building blocks for setup() ------------------------- + + def shared_commands_dir(self) -> Path | None: + """Return path to the shared command templates directory. + + Checks ``core_pack/commands/`` (wheel install) first, then + ``templates/commands/`` (source checkout). Returns ``None`` + if neither exists. + """ + import inspect + + pkg_dir = Path(inspect.getfile(IntegrationBase)).resolve().parent.parent + for candidate in [ + pkg_dir / "core_pack" / "commands", + pkg_dir.parent.parent / "templates" / "commands", + ]: + if candidate.is_dir(): + return candidate + return None + + def shared_templates_dir(self) -> Path | None: + """Return path to the shared page templates directory. + + Contains ``vscode-settings.json``, ``spec-template.md``, etc. + Checks ``core_pack/templates/`` then ``templates/``. + """ + import inspect + + pkg_dir = Path(inspect.getfile(IntegrationBase)).resolve().parent.parent + for candidate in [ + pkg_dir / "core_pack" / "templates", + pkg_dir.parent.parent / "templates", + ]: + if candidate.is_dir(): + return candidate + return None + + def list_command_templates(self) -> list[Path]: + """Return sorted list of command template files from the shared directory.""" + cmd_dir = self.shared_commands_dir() + if not cmd_dir or not cmd_dir.is_dir(): + return [] + return sorted(f for f in cmd_dir.iterdir() if f.is_file() and f.suffix == ".md") + + def command_filename(self, template_name: str) -> str: + """Return the destination filename for a command template. + + *template_name* is the stem of the source file (e.g. ``"plan"``). + Default: ``speckit.{template_name}.md``. Subclasses override + to change the extension or naming convention. + """ + return f"speckit.{template_name}.md" + + def commands_dest(self, project_root: Path) -> Path: + """Return the absolute path to the commands output directory. + + Derived from ``config["folder"]`` and ``config["commands_subdir"]``. + Raises ``ValueError`` if ``config`` or ``folder`` is missing. + """ + if not self.config: + raise ValueError( + f"{type(self).__name__}.config is not set; integration " + "subclasses must define a non-empty 'config' mapping." + ) + folder = self.config.get("folder") + if not folder: + raise ValueError( + f"{type(self).__name__}.config is missing required 'folder' entry." + ) + subdir = self.config.get("commands_subdir", "commands") + return project_root / folder / subdir + + # -- File operations — granular primitives for setup() ---------------- + + @staticmethod + def copy_command_to_directory( + src: Path, + dest_dir: Path, + filename: str, + ) -> Path: + """Copy a command template to *dest_dir* with the given *filename*. + + Creates *dest_dir* if needed. Returns the absolute path of the + written file. The caller can post-process the file before + recording it in the manifest. + """ + dest_dir.mkdir(parents=True, exist_ok=True) + dst = dest_dir / filename + shutil.copy2(src, dst) + return dst + + @staticmethod + def record_file_in_manifest( + file_path: Path, + project_root: Path, + manifest: IntegrationManifest, + ) -> None: + """Hash *file_path* and record it in *manifest*. + + *file_path* must be inside *project_root*. + """ + rel = file_path.resolve().relative_to(project_root.resolve()) + manifest.record_existing(rel) + + @staticmethod + def write_file_and_record( + content: str, + dest: Path, + project_root: Path, + manifest: IntegrationManifest, + ) -> Path: + """Write *content* to *dest*, hash it, and record in *manifest*. + + Creates parent directories as needed. Writes bytes directly to + avoid platform newline translation (CRLF on Windows). Any + ``\r\n`` sequences in *content* are normalised to ``\n`` before + writing. Returns *dest*. + """ + dest.parent.mkdir(parents=True, exist_ok=True) + normalized = content.replace("\r\n", "\n") + dest.write_bytes(normalized.encode("utf-8")) + rel = dest.resolve().relative_to(project_root.resolve()) + manifest.record_existing(rel) + return dest + + def integration_scripts_dir(self) -> Path | None: + """Return path to this integration's bundled ``scripts/`` directory. + + Looks for a ``scripts/`` sibling of the module that defines the + concrete subclass (not ``IntegrationBase`` itself). + Returns ``None`` if the directory doesn't exist. + """ + import inspect + + cls_file = inspect.getfile(type(self)) + scripts = Path(cls_file).resolve().parent / "scripts" + return scripts if scripts.is_dir() else None + + def install_scripts( + self, + project_root: Path, + manifest: IntegrationManifest, + ) -> list[Path]: + """Copy integration-specific scripts into the project. + + Copies files from this integration's ``scripts/`` directory to + ``.specify/integrations//scripts/`` in the project. Shell + scripts are made executable. All copied files are recorded in + *manifest*. + + Returns the list of files created. + """ + scripts_src = self.integration_scripts_dir() + if not scripts_src: + return [] + + created: list[Path] = [] + scripts_dest = project_root / ".specify" / "integrations" / self.key / "scripts" + scripts_dest.mkdir(parents=True, exist_ok=True) + + for src_script in sorted(scripts_src.iterdir()): + if not src_script.is_file(): + continue + dst_script = scripts_dest / src_script.name + shutil.copy2(src_script, dst_script) + if dst_script.suffix == ".sh": + dst_script.chmod(dst_script.stat().st_mode | 0o111) + self.record_file_in_manifest(dst_script, project_root, manifest) + created.append(dst_script) + + return created + + # -- Agent context file management ------------------------------------ + + @staticmethod + def _ensure_mdc_frontmatter(content: str) -> str: + """Ensure ``.mdc`` content has YAML frontmatter with ``alwaysApply: true``. + + If frontmatter is missing, prepend it. If frontmatter exists but + ``alwaysApply`` is absent or not ``true``, inject/fix it. + + Uses string/regex manipulation to preserve comments and formatting + in existing frontmatter. + """ + import re as _re + + leading_ws = len(content) - len(content.lstrip()) + leading = content[:leading_ws] + stripped = content[leading_ws:] + + if not stripped.startswith("---"): + return "---\nalwaysApply: true\n---\n\n" + content + + # Match frontmatter block: ---\n...\n--- + match = _re.match( + r"^(---[ \t]*\r?\n)(.*?)(\r?\n---[ \t]*)(\r?\n|$)(.*)", + stripped, + _re.DOTALL, + ) + if not match: + return "---\nalwaysApply: true\n---\n\n" + content + + opening, fm_text, closing, sep, rest = match.groups() + newline = "\r\n" if "\r\n" in opening else "\n" + + # Already correct? + if _re.search( + r"(?m)^[ \t]*alwaysApply[ \t]*:[ \t]*true[ \t]*(?:#.*)?$", fm_text + ): + return content + + # alwaysApply exists but wrong value — fix in place while preserving + # indentation and any trailing inline comment. + if _re.search(r"(?m)^[ \t]*alwaysApply[ \t]*:", fm_text): + fm_text = _re.sub( + r"(?m)^([ \t]*)alwaysApply[ \t]*:.*?([ \t]*(?:#.*)?)$", + r"\1alwaysApply: true\2", + fm_text, + count=1, + ) + elif fm_text.strip(): + fm_text = fm_text + newline + "alwaysApply: true" + else: + fm_text = "alwaysApply: true" + + return f"{leading}{opening}{fm_text}{closing}{sep}{rest}" + + @staticmethod + def _build_context_section(plan_path: str = "") -> str: + """Build the content for the managed section between markers. + + *plan_path* is the project-relative path to the current plan + (e.g. ``"specs//plan.md"``). When empty, the section + contains only the generic directive without a concrete path. + """ + lines = [ + "For additional context about technologies to be used, project structure,", + "shell commands, and other important information, read the current plan", + ] + if plan_path: + lines.append(f"at {plan_path}") + return "\n".join(lines) + + def upsert_context_section( + self, + project_root: Path, + plan_path: str = "", + ) -> Path | None: + """Create or update the managed section in the agent context file. + + If the context file does not exist it is created with just the + managed section. If it exists, the content between + ```` and ```` markers + is replaced (or appended when no markers are found). + + Returns the path to the context file, or ``None`` when + ``context_file`` is not set. + """ + if not self.context_file: + return None + + ctx_path = project_root / self.context_file + section = ( + f"{self.CONTEXT_MARKER_START}\n" + f"{self._build_context_section(plan_path)}\n" + f"{self.CONTEXT_MARKER_END}\n" + ) + + if ctx_path.exists(): + content = ctx_path.read_text(encoding="utf-8-sig") + start_idx = content.find(self.CONTEXT_MARKER_START) + end_idx = content.find( + self.CONTEXT_MARKER_END, + start_idx if start_idx != -1 else 0, + ) + + if start_idx != -1 and end_idx != -1 and end_idx > start_idx: + # Replace existing section (include the end marker + newline) + end_of_marker = end_idx + len(self.CONTEXT_MARKER_END) + # Consume trailing line ending (CRLF or LF) + if end_of_marker < len(content) and content[end_of_marker] == "\r": + end_of_marker += 1 + if end_of_marker < len(content) and content[end_of_marker] == "\n": + end_of_marker += 1 + new_content = content[:start_idx] + section + content[end_of_marker:] + elif start_idx != -1: + # Corrupted: start marker without end — replace from start through EOF + new_content = content[:start_idx] + section + elif end_idx != -1: + # Corrupted: end marker without start — replace BOF through end marker + end_of_marker = end_idx + len(self.CONTEXT_MARKER_END) + if end_of_marker < len(content) and content[end_of_marker] == "\r": + end_of_marker += 1 + if end_of_marker < len(content) and content[end_of_marker] == "\n": + end_of_marker += 1 + new_content = section + content[end_of_marker:] + else: + # No markers found — append + if content: + if not content.endswith("\n"): + content += "\n" + new_content = content + "\n" + section + else: + new_content = section + + # Ensure .mdc files have required YAML frontmatter + if ctx_path.suffix == ".mdc": + new_content = self._ensure_mdc_frontmatter(new_content) + else: + ctx_path.parent.mkdir(parents=True, exist_ok=True) + # Cursor .mdc files require YAML frontmatter to be loaded + if ctx_path.suffix == ".mdc": + new_content = self._ensure_mdc_frontmatter(section) + else: + new_content = section + + normalized = new_content.replace("\r\n", "\n").replace("\r", "\n") + ctx_path.write_bytes(normalized.encode("utf-8")) + return ctx_path + + def remove_context_section(self, project_root: Path) -> bool: + """Remove the managed section from the agent context file. + + Returns ``True`` if the section was found and removed. If the + file becomes empty (or whitespace-only) after removal it is + deleted. + """ + if not self.context_file: + return False + + ctx_path = project_root / self.context_file + if not ctx_path.exists(): + return False + + content = ctx_path.read_text(encoding="utf-8-sig") + start_idx = content.find(self.CONTEXT_MARKER_START) + end_idx = content.find( + self.CONTEXT_MARKER_END, + start_idx if start_idx != -1 else 0, + ) + + # Only remove a complete, well-ordered managed section. If either + # marker is missing, leave the file unchanged to avoid deleting + # unrelated user-authored content. + if start_idx == -1 or end_idx == -1 or end_idx <= start_idx: + return False + + removal_start = start_idx + removal_end = end_idx + len(self.CONTEXT_MARKER_END) + + # Consume trailing line ending (CRLF or LF) + if removal_end < len(content) and content[removal_end] == "\r": + removal_end += 1 + if removal_end < len(content) and content[removal_end] == "\n": + removal_end += 1 + + # Also strip a blank line before the section if present + if removal_start > 0 and content[removal_start - 1] == "\n": + if removal_start > 1 and content[removal_start - 2] == "\n": + removal_start -= 1 + + new_content = content[:removal_start] + content[removal_end:] + + # Normalize line endings before comparisons + normalized = new_content.replace("\r\n", "\n").replace("\r", "\n") + + # For .mdc files, treat Speckit-generated frontmatter-only content as empty + if ctx_path.suffix == ".mdc": + import re + # Delete the file if only YAML frontmatter remains (no body content) + frontmatter_only = re.match( + r"^---\n.*?\n---\s*$", normalized, re.DOTALL + ) + if not normalized.strip() or frontmatter_only: + ctx_path.unlink() + return True + + if not normalized.strip(): + ctx_path.unlink() + else: + ctx_path.write_bytes(normalized.encode("utf-8")) + + return True + + @staticmethod + def resolve_command_refs(content: str, separator: str = ".") -> str: + """Replace ``__SPECKIT_COMMAND___`` placeholders with invocations. + + Each placeholder encodes a command name in upper-case with + underscores (e.g. ``__SPECKIT_COMMAND_PLAN__``, + ``__SPECKIT_COMMAND_GIT_COMMIT__``). The replacement uses + *separator* to join the segments: + + * ``separator="."`` → ``/speckit.plan``, ``/speckit.git.commit`` + * ``separator="-"`` → ``/speckit-plan``, ``/speckit-git-commit`` + """ + return re.sub( + r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__", + lambda m: "/speckit" + separator + m.group(1).lower().replace("_", separator), + content, + ) + + @staticmethod + def process_template( + content: str, + agent_name: str, + script_type: str, + arg_placeholder: str = "$ARGUMENTS", + context_file: str = "", + invoke_separator: str = ".", + ) -> str: + """Process a raw command template into agent-ready content. + + Performs the same transformations as the release script: + 1. Extract ``scripts.`` value from YAML frontmatter + 2. Replace ``{SCRIPT}`` with the extracted script command + 3. Strip ``scripts:`` section from frontmatter + 4. Replace ``{ARGS}`` and ``$ARGUMENTS`` with *arg_placeholder* + 5. Replace ``__AGENT__`` with *agent_name* + 6. Replace ``__CONTEXT_FILE__`` with *context_file* + 7. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc. + 8. Replace ``__SPECKIT_COMMAND___`` with invocation strings + """ + # 1. Extract script command from frontmatter + script_command = "" + script_pattern = re.compile( + rf"^\s*{re.escape(script_type)}:\s*(.+)$", re.MULTILINE + ) + # Find the scripts: block + in_scripts = False + for line in content.splitlines(): + if line.strip() == "scripts:": + in_scripts = True + continue + if in_scripts and line and not line[0].isspace(): + in_scripts = False + if in_scripts: + m = script_pattern.match(line) + if m: + script_command = m.group(1).strip() + break + + # 2. Replace {SCRIPT} + if script_command: + content = content.replace("{SCRIPT}", script_command) + + # 3. Strip scripts: section from frontmatter + lines = content.splitlines(keepends=True) + output_lines: list[str] = [] + in_frontmatter = False + skip_section = False + dash_count = 0 + for line in lines: + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + if dash_count == 1: + in_frontmatter = True + else: + in_frontmatter = False + skip_section = False + output_lines.append(line) + continue + if in_frontmatter: + if stripped == "scripts:": + skip_section = True + continue + if skip_section: + if line[0:1].isspace(): + continue # skip indented content under scripts + skip_section = False + output_lines.append(line) + content = "".join(output_lines) + + # 4. Replace {ARGS} and $ARGUMENTS + content = content.replace("{ARGS}", arg_placeholder) + content = content.replace("$ARGUMENTS", arg_placeholder) + + # 5. Replace __AGENT__ + content = content.replace("__AGENT__", agent_name) + + # 6. Replace __CONTEXT_FILE__ + content = content.replace("__CONTEXT_FILE__", context_file) + + # 7. Rewrite paths — delegate to the shared implementation in + # CommandRegistrar so extension-local paths are preserved and + # boundary rules stay consistent across the codebase. + from specify_cli.agents import CommandRegistrar + + content = CommandRegistrar.rewrite_project_relative_paths(content) + + # 8. Replace __SPECKIT_COMMAND___ with invocation strings + content = IntegrationBase.resolve_command_refs(content, invoke_separator) + + return content + + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Install integration command files into *project_root*. + + Returns the list of files created. Copies raw templates without + processing. Integrations that need placeholder replacement + (e.g. ``{SCRIPT}``, ``__AGENT__``) should override ``setup()`` + and call ``process_template()`` in their own loop — see + ``CopilotIntegration`` for an example. + """ + templates = self.list_command_templates() + if not templates: + return [] + + project_root_resolved = project_root.resolve() + if manifest.project_root != project_root_resolved: + raise ValueError( + f"manifest.project_root ({manifest.project_root}) does not match " + f"project_root ({project_root_resolved})" + ) + + dest = self.commands_dest(project_root).resolve() + try: + dest.relative_to(project_root_resolved) + except ValueError as exc: + raise ValueError( + f"Integration destination {dest} escapes " + f"project root {project_root_resolved}" + ) from exc + + created: list[Path] = [] + + for src_file in templates: + dst_name = self.command_filename(src_file.stem) + dst_file = self.copy_command_to_directory(src_file, dest, dst_name) + self.record_file_in_manifest(dst_file, project_root, manifest) + created.append(dst_file) + + # Upsert managed context section into the agent context file + self.upsert_context_section(project_root) + + return created + + def teardown( + self, + project_root: Path, + manifest: IntegrationManifest, + *, + force: bool = False, + ) -> tuple[list[Path], list[Path]]: + """Uninstall integration files from *project_root*. + + Delegates to ``manifest.uninstall()`` which only removes files + whose hash still matches the recorded value (unless *force*). + Also removes the managed context section from the agent file. + + Returns ``(removed, skipped)`` file lists. + """ + self.remove_context_section(project_root) + return manifest.uninstall(project_root, force=force) + + # -- Convenience helpers for subclasses ------------------------------- + + def install( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """High-level install — calls ``setup()`` and returns created files.""" + return self.setup(project_root, manifest, parsed_options=parsed_options, **opts) + + def uninstall( + self, + project_root: Path, + manifest: IntegrationManifest, + *, + force: bool = False, + ) -> tuple[list[Path], list[Path]]: + """High-level uninstall — calls ``teardown()``.""" + return self.teardown(project_root, manifest, force=force) + + +# --------------------------------------------------------------------------- +# MarkdownIntegration — covers ~20 standard agents +# --------------------------------------------------------------------------- + + +class MarkdownIntegration(IntegrationBase): + """Concrete base for integrations that use standard Markdown commands. + + Subclasses only need to set ``key``, ``config``, ``registrar_config`` + (and optionally ``context_file``). Everything else is inherited. + + ``setup()`` processes command templates (replacing ``{SCRIPT}``, + ``{ARGS}``, ``__AGENT__``, rewriting paths) and upserts the + managed context section into the agent context file. + """ + + def build_exec_args( + self, + prompt: str, + *, + model: str | None = None, + output_json: bool = True, + ) -> list[str] | None: + if not self.config or not self.config.get("requires_cli"): + return None + args = [self.key, "-p", prompt] + if model: + args.extend(["--model", model]) + if output_json: + args.extend(["--output-format", "json"]) + return args + + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + templates = self.list_command_templates() + if not templates: + return [] + + project_root_resolved = project_root.resolve() + if manifest.project_root != project_root_resolved: + raise ValueError( + f"manifest.project_root ({manifest.project_root}) does not match " + f"project_root ({project_root_resolved})" + ) + + dest = self.commands_dest(project_root).resolve() + try: + dest.relative_to(project_root_resolved) + except ValueError as exc: + raise ValueError( + f"Integration destination {dest} escapes " + f"project root {project_root_resolved}" + ) from exc + dest.mkdir(parents=True, exist_ok=True) + + script_type = opts.get("script_type", "sh") + arg_placeholder = ( + self.registrar_config.get("args", "$ARGUMENTS") + if self.registrar_config + else "$ARGUMENTS" + ) + created: list[Path] = [] + + for src_file in templates: + raw = src_file.read_text(encoding="utf-8") + processed = self.process_template( + raw, self.key, script_type, arg_placeholder, + context_file=self.context_file or "", + ) + dst_name = self.command_filename(src_file.stem) + dst_file = self.write_file_and_record( + processed, dest / dst_name, project_root, manifest + ) + created.append(dst_file) + + # Upsert managed context section into the agent context file + self.upsert_context_section(project_root) + + return created + + +# --------------------------------------------------------------------------- +# TomlIntegration — TOML-format agents (Gemini, Tabnine) +# --------------------------------------------------------------------------- + + +class TomlIntegration(IntegrationBase): + """Concrete base for integrations that use TOML command format. + + Mirrors ``MarkdownIntegration`` closely: subclasses only need to set + ``key``, ``config``, ``registrar_config`` (and optionally + ``context_file``). Everything else is inherited. + + ``setup()`` processes command templates through the same placeholder + pipeline as ``MarkdownIntegration``, then converts the result to + TOML format (``description`` key + ``prompt`` multiline string). + """ + + def build_exec_args( + self, + prompt: str, + *, + model: str | None = None, + output_json: bool = True, + ) -> list[str] | None: + if not self.config or not self.config.get("requires_cli"): + return None + args = [self.key, "-p", prompt] + if model: + args.extend(["-m", model]) + if output_json: + args.extend(["--output-format", "json"]) + return args + + def command_filename(self, template_name: str) -> str: + """TOML commands use ``.toml`` extension.""" + return f"speckit.{template_name}.toml" + + @staticmethod + def _extract_description(content: str) -> str: + """Extract the ``description`` value from YAML frontmatter. + + Parses the YAML frontmatter so block scalar descriptions (``|`` + and ``>``) keep their YAML semantics instead of being treated as + raw text. + """ + import yaml + + frontmatter_text, _ = TomlIntegration._split_frontmatter(content) + if not frontmatter_text: + return "" + try: + frontmatter = yaml.safe_load(frontmatter_text) or {} + except yaml.YAMLError: + return "" + + if not isinstance(frontmatter, dict): + return "" + + description = frontmatter.get("description", "") + if isinstance(description, str): + return description + return "" + + @staticmethod + def _split_frontmatter(content: str) -> tuple[str, str]: + """Split YAML frontmatter from the remaining content. + + Returns ``("", content)`` when no complete frontmatter block is + present. The body is preserved exactly as written so prompt text + keeps its intended formatting. + """ + if not content.startswith("---"): + return "", content + + lines = content.splitlines(keepends=True) + if not lines or lines[0].rstrip("\r\n") != "---": + return "", content + + frontmatter_end = -1 + for i, line in enumerate(lines[1:], start=1): + if line.rstrip("\r\n") == "---": + frontmatter_end = i + break + + if frontmatter_end == -1: + return "", content + + frontmatter = "".join(lines[1:frontmatter_end]) + body = "".join(lines[frontmatter_end + 1 :]) + return frontmatter, body + + @staticmethod + def _render_toml_string(value: str) -> str: + """Render *value* as a TOML string literal. + + Uses a basic string for single-line values, multiline basic + strings for values containing newlines, and falls back to a + literal string or escaped basic string when delimiters appear in + the content. + """ + if "\n" not in value and "\r" not in value: + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + + escaped = value.replace("\\", "\\\\") + if '"""' not in escaped: + if escaped.endswith('"'): + return '"""\n' + escaped + '\\\n"""' + return '"""\n' + escaped + '"""' + if "'''" not in value and not value.endswith("'"): + return "'''\n" + value + "'''" + + return ( + '"' + + ( + value.replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + ) + + '"' + ) + + @staticmethod + def _render_toml(description: str, body: str) -> str: + """Render a TOML command file from description and body. + + Uses multiline basic strings (``\"\"\"``) with backslashes + escaped, matching the output of the release script. Falls back + to multiline literal strings (``'''``) if the body contains + ``\"\"\"``, then to an escaped basic string as a last resort. + + The body is ``rstrip("\\n")``'d before rendering, so the TOML + value preserves content without forcing a trailing newline. As a + result, multiline delimiters appear on their own line only when + the rendered value itself ends with a newline. + """ + toml_lines: list[str] = [] + + if description: + toml_lines.append( + f"description = {TomlIntegration._render_toml_string(description)}" + ) + toml_lines.append("") + + body = body.rstrip("\n") + toml_lines.append(f"prompt = {TomlIntegration._render_toml_string(body)}") + + return "\n".join(toml_lines) + "\n" + + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + templates = self.list_command_templates() + if not templates: + return [] + + project_root_resolved = project_root.resolve() + if manifest.project_root != project_root_resolved: + raise ValueError( + f"manifest.project_root ({manifest.project_root}) does not match " + f"project_root ({project_root_resolved})" + ) + + dest = self.commands_dest(project_root).resolve() + try: + dest.relative_to(project_root_resolved) + except ValueError as exc: + raise ValueError( + f"Integration destination {dest} escapes " + f"project root {project_root_resolved}" + ) from exc + dest.mkdir(parents=True, exist_ok=True) + + script_type = opts.get("script_type", "sh") + arg_placeholder = ( + self.registrar_config.get("args", "{{args}}") + if self.registrar_config + else "{{args}}" + ) + created: list[Path] = [] + + for src_file in templates: + raw = src_file.read_text(encoding="utf-8") + description = self._extract_description(raw) + processed = self.process_template( + raw, self.key, script_type, arg_placeholder, + context_file=self.context_file or "", + ) + _, body = self._split_frontmatter(processed) + toml_content = self._render_toml(description, body) + dst_name = self.command_filename(src_file.stem) + dst_file = self.write_file_and_record( + toml_content, dest / dst_name, project_root, manifest + ) + created.append(dst_file) + + # Upsert managed context section into the agent context file + self.upsert_context_section(project_root) + + return created + + +# --------------------------------------------------------------------------- +# YamlIntegration — YAML-format agents (Goose) +# --------------------------------------------------------------------------- + + +class YamlIntegration(IntegrationBase): + """Concrete base for integrations that use YAML recipe format. + + Mirrors ``TomlIntegration`` closely: subclasses only need to set + ``key``, ``config``, ``registrar_config`` (and optionally + ``context_file``). Everything else is inherited. + + ``setup()`` processes command templates through the same placeholder + pipeline as ``MarkdownIntegration``, then converts the result to + YAML recipe format (version, title, description, prompt block scalar). + """ + + def command_filename(self, template_name: str) -> str: + """YAML commands use ``.yaml`` extension.""" + return f"speckit.{template_name}.yaml" + + @staticmethod + def _extract_frontmatter(content: str) -> dict[str, Any]: + """Extract frontmatter as a dict from YAML frontmatter block.""" + import yaml + + if not content.startswith("---"): + return {} + + lines = content.splitlines(keepends=True) + if not lines or lines[0].rstrip("\r\n") != "---": + return {} + + frontmatter_end = -1 + for i, line in enumerate(lines[1:], start=1): + if line.rstrip("\r\n") == "---": + frontmatter_end = i + break + + if frontmatter_end == -1: + return {} + + frontmatter_text = "".join(lines[1:frontmatter_end]) + try: + fm = yaml.safe_load(frontmatter_text) or {} + except yaml.YAMLError: + return {} + + return fm if isinstance(fm, dict) else {} + + @staticmethod + def _split_frontmatter(content: str) -> tuple[str, str]: + """Split YAML frontmatter from the remaining body content.""" + if not content.startswith("---"): + return "", content + + lines = content.splitlines(keepends=True) + if not lines or lines[0].rstrip("\r\n") != "---": + return "", content + + frontmatter_end = -1 + for i, line in enumerate(lines[1:], start=1): + if line.rstrip("\r\n") == "---": + frontmatter_end = i + break + + if frontmatter_end == -1: + return "", content + + frontmatter = "".join(lines[1:frontmatter_end]) + body = "".join(lines[frontmatter_end + 1 :]) + return frontmatter, body + + @staticmethod + def _human_title(identifier: str) -> str: + """Convert an identifier to a human-readable title. + + Strips a leading ``speckit.`` prefix and replaces ``.``, ``-``, + and ``_`` with spaces before title-casing. + """ + text = identifier + if text.startswith("speckit."): + text = text[len("speckit.") :] + return text.replace(".", " ").replace("-", " ").replace("_", " ").title() + + @staticmethod + def _render_yaml(title: str, description: str, body: str, source_id: str) -> str: + """Render a YAML recipe file from title, description, and body. + + Produces a Goose-compatible recipe with a literal block scalar + for the prompt content. Uses ``yaml.safe_dump()`` for the + header fields to ensure proper escaping. + """ + import yaml + + header = { + "version": "1.0.0", + "title": title, + "description": description, + "author": {"contact": "spec-kit"}, + "extensions": [{"type": "builtin", "name": "developer"}], + "activities": ["Spec-Driven Development"], + } + + header_yaml = yaml.safe_dump( + header, + sort_keys=False, + allow_unicode=True, + default_flow_style=False, + ).strip() + + # Indent each line for YAML block scalar + indented = "\n".join(f" {line}" for line in body.split("\n")) + + lines = [header_yaml, "prompt: |", indented, "", f"# Source: {source_id}"] + return "\n".join(lines) + "\n" + + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + templates = self.list_command_templates() + if not templates: + return [] + + project_root_resolved = project_root.resolve() + if manifest.project_root != project_root_resolved: + raise ValueError( + f"manifest.project_root ({manifest.project_root}) does not match " + f"project_root ({project_root_resolved})" + ) + + dest = self.commands_dest(project_root).resolve() + try: + dest.relative_to(project_root_resolved) + except ValueError as exc: + raise ValueError( + f"Integration destination {dest} escapes " + f"project root {project_root_resolved}" + ) from exc + dest.mkdir(parents=True, exist_ok=True) + + script_type = opts.get("script_type", "sh") + arg_placeholder = ( + self.registrar_config.get("args", "{{args}}") + if self.registrar_config + else "{{args}}" + ) + created: list[Path] = [] + + for src_file in templates: + raw = src_file.read_text(encoding="utf-8") + fm = self._extract_frontmatter(raw) + description = fm.get("description", "") + if not isinstance(description, str): + description = str(description) if description is not None else "" + title = fm.get("title", "") or fm.get("name", "") + if not isinstance(title, str): + title = str(title) if title is not None else "" + if not title: + title = self._human_title(src_file.stem) + + processed = self.process_template( + raw, self.key, script_type, arg_placeholder, + context_file=self.context_file or "", + ) + _, body = self._split_frontmatter(processed) + yaml_content = self._render_yaml( + title, description, body, f"templates/commands/{src_file.name}" + ) + dst_name = self.command_filename(src_file.stem) + dst_file = self.write_file_and_record( + yaml_content, dest / dst_name, project_root, manifest + ) + created.append(dst_file) + + # Upsert managed context section into the agent context file + self.upsert_context_section(project_root) + + return created + + +# --------------------------------------------------------------------------- +# SkillsIntegration — skills-format agents (Codex, Kimi, Agy) +# --------------------------------------------------------------------------- + + +class SkillsIntegration(IntegrationBase): + """Concrete base for integrations that install commands as agent skills. + + Skills use the ``speckit-/SKILL.md`` directory layout following + the `agentskills.io `_ spec. + + Subclasses set ``key``, ``config``, ``registrar_config`` (and + optionally ``context_file``) like any integration. They may also + override ``options()`` to declare additional CLI flags (e.g. + ``--skills``, ``--migrate-legacy``). + + ``setup()`` processes each shared command template into a + ``speckit-/SKILL.md`` file with skills-oriented frontmatter. + """ + + invoke_separator = "-" + + def build_exec_args( + self, + prompt: str, + *, + model: str | None = None, + output_json: bool = True, + ) -> list[str] | None: + if not self.config or not self.config.get("requires_cli"): + return None + args = [self.key, "-p", prompt] + if model: + args.extend(["--model", model]) + if output_json: + args.extend(["--output-format", "json"]) + return args + + def skills_dest(self, project_root: Path) -> Path: + """Return the absolute path to the skills output directory. + + Derived from ``config["folder"]`` and the configured + ``commands_subdir`` (defaults to ``"skills"``). + + Raises ``ValueError`` when ``config`` or ``folder`` is missing. + """ + if not self.config: + raise ValueError(f"{type(self).__name__}.config is not set.") + folder = self.config.get("folder") + if not folder: + raise ValueError( + f"{type(self).__name__}.config is missing required 'folder' entry." + ) + subdir = self.config.get("commands_subdir", "skills") + return project_root / folder / subdir + + def build_command_invocation(self, command_name: str, args: str = "") -> str: + """Skills use ``/speckit-`` (hyphenated directory name).""" + stem = command_name + if stem.startswith("speckit."): + stem = stem[len("speckit."):] + + invocation = "/speckit-" + stem.replace(".", "-") + if args: + invocation = f"{invocation} {args}" + return invocation + + def post_process_skill_content(self, content: str) -> str: + """Post-process a SKILL.md file's content after generation. + + Called by external skill generators (presets, extensions) to let + the integration inject agent-specific frontmatter or body + transformations. The default implementation returns *content* + unchanged. Subclasses may override — see ``ClaudeIntegration``. + """ + return content + + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Install command templates as agent skills. + + Creates ``speckit-/SKILL.md`` for each shared command + template. Each SKILL.md has normalised frontmatter containing + ``name``, ``description``, ``compatibility``, and ``metadata``. + """ + import yaml + + templates = self.list_command_templates() + if not templates: + return [] + + project_root_resolved = project_root.resolve() + if manifest.project_root != project_root_resolved: + raise ValueError( + f"manifest.project_root ({manifest.project_root}) does not match " + f"project_root ({project_root_resolved})" + ) + + skills_dir = self.skills_dest(project_root).resolve() + try: + skills_dir.relative_to(project_root_resolved) + except ValueError as exc: + raise ValueError( + f"Skills destination {skills_dir} escapes " + f"project root {project_root_resolved}" + ) from exc + + script_type = opts.get("script_type", "sh") + arg_placeholder = ( + self.registrar_config.get("args", "$ARGUMENTS") + if self.registrar_config + else "$ARGUMENTS" + ) + created: list[Path] = [] + + for src_file in templates: + raw = src_file.read_text(encoding="utf-8") + + # Derive the skill name from the template stem + command_name = src_file.stem # e.g. "plan" + skill_name = f"speckit-{command_name.replace('.', '-')}" + + # Parse frontmatter for description + frontmatter: dict[str, Any] = {} + if raw.startswith("---"): + parts = raw.split("---", 2) + if len(parts) >= 3: + try: + fm = yaml.safe_load(parts[1]) + if isinstance(fm, dict): + frontmatter = fm + except yaml.YAMLError: + pass + + # Process body through the standard template pipeline + processed_body = self.process_template( + raw, self.key, script_type, arg_placeholder, + context_file=self.context_file or "", + invoke_separator=self.invoke_separator, + ) + # Strip the processed frontmatter — we rebuild it for skills. + # Preserve leading whitespace in the body to match release ZIP + # output byte-for-byte (the template body starts with \n after + # the closing ---). + if processed_body.startswith("---"): + parts = processed_body.split("---", 2) + if len(parts) >= 3: + processed_body = parts[2] + + # Select description — use the original template description + # to stay byte-for-byte identical with release ZIP output. + description = frontmatter.get("description", "") + if not description: + description = f"Spec Kit: {command_name} workflow" + + # Build SKILL.md with manually formatted frontmatter to match + # the release packaging script output exactly (double-quoted + # values, no yaml.safe_dump quoting differences). + def _quote(v: str) -> str: + escaped = v.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + + skill_content = ( + f"---\n" + f"name: {_quote(skill_name)}\n" + f"description: {_quote(description)}\n" + f"compatibility: {_quote('Requires spec-kit project structure with .specify/ directory')}\n" + f"metadata:\n" + f" author: {_quote('github-spec-kit')}\n" + f" source: {_quote('templates/commands/' + src_file.name)}\n" + f"---\n" + f"{processed_body}" + ) + + # Write speckit-/SKILL.md + skill_dir = skills_dir / skill_name + skill_file = skill_dir / "SKILL.md" + dst = self.write_file_and_record( + skill_content, skill_file, project_root, manifest + ) + created.append(dst) + + # Upsert managed context section into the agent context file + self.upsert_context_section(project_root) + + return created diff --git a/src/specify_cli/integrations/bob/__init__.py b/src/specify_cli/integrations/bob/__init__.py new file mode 100644 index 0000000000..78f2df0379 --- /dev/null +++ b/src/specify_cli/integrations/bob/__init__.py @@ -0,0 +1,21 @@ +"""IBM Bob integration.""" + +from ..base import MarkdownIntegration + + +class BobIntegration(MarkdownIntegration): + key = "bob" + config = { + "name": "IBM Bob", + "folder": ".bob/", + "commands_subdir": "commands", + "install_url": None, + "requires_cli": False, + } + registrar_config = { + "dir": ".bob/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = "AGENTS.md" diff --git a/src/specify_cli/integrations/catalog.py b/src/specify_cli/integrations/catalog.py new file mode 100644 index 0000000000..2faa69ae96 --- /dev/null +++ b/src/specify_cli/integrations/catalog.py @@ -0,0 +1,626 @@ +"""Integration catalog — discovery, validation, and upgrade support. + +Provides: +- ``IntegrationCatalogEntry`` — single catalog source metadata. +- ``IntegrationCatalog`` — fetches, caches, and searches integration + catalogs (built-in + community). +- ``IntegrationDescriptor`` — loads and validates ``integration.yml``. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional + +import yaml +from packaging import version as pkg_version + + +# --------------------------------------------------------------------------- +# Errors +# --------------------------------------------------------------------------- + +class IntegrationCatalogError(Exception): + """Raised when a catalog operation fails.""" + + +class IntegrationDescriptorError(Exception): + """Raised when an integration.yml descriptor is invalid.""" + + +# --------------------------------------------------------------------------- +# IntegrationCatalogEntry +# --------------------------------------------------------------------------- + +@dataclass +class IntegrationCatalogEntry: + """Represents a single catalog source in the catalog stack.""" + + url: str + name: str + priority: int + install_allowed: bool + description: str = "" + + +# --------------------------------------------------------------------------- +# IntegrationCatalog +# --------------------------------------------------------------------------- + +class IntegrationCatalog: + """Manages integration catalog fetching, caching, and searching.""" + + DEFAULT_CATALOG_URL = ( + "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json" + ) + COMMUNITY_CATALOG_URL = ( + "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.community.json" + ) + CACHE_DURATION = 3600 # 1 hour + + def __init__(self, project_root: Path) -> None: + self.project_root = project_root + self.cache_dir = project_root / ".specify" / "integrations" / ".cache" + + # -- URL validation --------------------------------------------------- + + @staticmethod + def _validate_catalog_url(url: str) -> None: + from urllib.parse import urlparse + + parsed = urlparse(url) + is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1") + if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost): + raise IntegrationCatalogError( + f"Catalog URL must use HTTPS (got {parsed.scheme}://). " + "HTTP is only allowed for localhost." + ) + if not parsed.netloc: + raise IntegrationCatalogError( + "Catalog URL must be a valid URL with a host." + ) + + # -- Catalog stack ---------------------------------------------------- + + def _load_catalog_config( + self, config_path: Path + ) -> Optional[List[IntegrationCatalogEntry]]: + """Load catalog stack from a YAML file. + + Returns None when the file does not exist. + + Raises: + IntegrationCatalogError: on invalid content + """ + if not config_path.exists(): + return None + try: + data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + except (yaml.YAMLError, OSError, UnicodeError) as exc: + raise IntegrationCatalogError( + f"Failed to read catalog config {config_path}: {exc}" + ) + if not isinstance(data, dict): + raise IntegrationCatalogError( + f"Invalid catalog config {config_path}: expected a YAML mapping at the root" + ) + catalogs_data = data.get("catalogs", []) + if not isinstance(catalogs_data, list): + raise IntegrationCatalogError( + f"Invalid catalog config: 'catalogs' must be a list, " + f"got {type(catalogs_data).__name__}" + ) + if not catalogs_data: + raise IntegrationCatalogError( + f"Catalog config {config_path} exists but contains no 'catalogs' entries. " + f"Remove the file to use built-in defaults, or add valid catalog entries." + ) + entries: List[IntegrationCatalogEntry] = [] + skipped: List[int] = [] + for idx, item in enumerate(catalogs_data): + if not isinstance(item, dict): + raise IntegrationCatalogError( + f"Invalid catalog entry at index {idx}: " + f"expected a mapping, got {type(item).__name__}" + ) + url = str(item.get("url", "")).strip() + if not url: + skipped.append(idx) + continue + self._validate_catalog_url(url) + try: + priority = int(item.get("priority", idx + 1)) + except (TypeError, ValueError): + raise IntegrationCatalogError( + f"Invalid priority for catalog '{item.get('name', idx + 1)}': " + f"expected integer, got {item.get('priority')!r}" + ) + raw_install = item.get("install_allowed", False) + if isinstance(raw_install, str): + install_allowed = raw_install.strip().lower() in ("true", "yes", "1") + else: + install_allowed = bool(raw_install) + entries.append( + IntegrationCatalogEntry( + url=url, + name=str(item.get("name", f"catalog-{idx + 1}")), + priority=priority, + install_allowed=install_allowed, + description=str(item.get("description", "")), + ) + ) + entries.sort(key=lambda e: e.priority) + if not entries: + raise IntegrationCatalogError( + f"Catalog config {config_path} contains {len(catalogs_data)} " + f"entries but none have valid URLs (entries at indices {skipped} " + f"were skipped). Each catalog entry must have a 'url' field." + ) + return entries + + def get_active_catalogs(self) -> List[IntegrationCatalogEntry]: + """Return the ordered list of active integration catalogs. + + Resolution: + 1. ``SPECKIT_INTEGRATION_CATALOG_URL`` env var + 2. Project ``.specify/integration-catalogs.yml`` + 3. User ``~/.specify/integration-catalogs.yml`` + 4. Built-in defaults (built-in + community) + """ + import sys + + env_value = os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip() + if env_value: + self._validate_catalog_url(env_value) + if env_value != self.DEFAULT_CATALOG_URL: + if not getattr(self, "_non_default_catalog_warning_shown", False): + print( + "Warning: Using non-default integration catalog. " + "Only use catalogs from sources you trust.", + file=sys.stderr, + ) + self._non_default_catalog_warning_shown = True + return [ + IntegrationCatalogEntry( + url=env_value, + name="custom", + priority=1, + install_allowed=True, + description="Custom catalog via SPECKIT_INTEGRATION_CATALOG_URL", + ) + ] + + project_cfg = self.project_root / ".specify" / "integration-catalogs.yml" + catalogs = self._load_catalog_config(project_cfg) + if catalogs is not None: + return catalogs + + user_cfg = Path.home() / ".specify" / "integration-catalogs.yml" + catalogs = self._load_catalog_config(user_cfg) + if catalogs is not None: + return catalogs + + return [ + IntegrationCatalogEntry( + url=self.DEFAULT_CATALOG_URL, + name="default", + priority=1, + install_allowed=True, + description="Built-in catalog of installable integrations", + ), + IntegrationCatalogEntry( + url=self.COMMUNITY_CATALOG_URL, + name="community", + priority=2, + install_allowed=False, + description="Community-contributed integrations (discovery only)", + ), + ] + + # -- Fetching --------------------------------------------------------- + + def _fetch_single_catalog( + self, + entry: IntegrationCatalogEntry, + force_refresh: bool = False, + ) -> Dict[str, Any]: + """Fetch one catalog, with per-URL caching.""" + import urllib.error + import urllib.request + + url_hash = hashlib.sha256(entry.url.encode()).hexdigest()[:16] + cache_file = self.cache_dir / f"catalog-{url_hash}.json" + cache_meta = self.cache_dir / f"catalog-{url_hash}-metadata.json" + + if not force_refresh and cache_file.exists() and cache_meta.exists(): + try: + meta = json.loads(cache_meta.read_text(encoding="utf-8")) + cached_at = datetime.fromisoformat(meta.get("cached_at", "")) + if cached_at.tzinfo is None: + cached_at = cached_at.replace(tzinfo=timezone.utc) + age = (datetime.now(timezone.utc) - cached_at).total_seconds() + if age < self.CACHE_DURATION: + return json.loads(cache_file.read_text(encoding="utf-8")) + except (json.JSONDecodeError, ValueError, KeyError, TypeError, AttributeError, OSError, UnicodeError): + # Cache is invalid or stale metadata; delete and refetch from source. + try: + cache_file.unlink(missing_ok=True) + cache_meta.unlink(missing_ok=True) + except OSError: + pass # Cache cleanup is best-effort; ignore deletion failures. + + try: + with urllib.request.urlopen(entry.url, timeout=10) as resp: + # Validate final URL after redirects + final_url = resp.geturl() + if final_url != entry.url: + self._validate_catalog_url(final_url) + catalog_data = json.loads(resp.read()) + + if not isinstance(catalog_data, dict): + raise IntegrationCatalogError( + f"Invalid catalog format from {entry.url}: expected a JSON object" + ) + if ( + "schema_version" not in catalog_data + or "integrations" not in catalog_data + ): + raise IntegrationCatalogError( + f"Invalid catalog format from {entry.url}" + ) + if not isinstance(catalog_data.get("integrations"), dict): + raise IntegrationCatalogError( + f"Invalid catalog format from {entry.url}: 'integrations' must be a JSON object" + ) + + try: + self.cache_dir.mkdir(parents=True, exist_ok=True) + cache_file.write_text(json.dumps(catalog_data, indent=2), encoding="utf-8") + cache_meta.write_text( + json.dumps( + { + "cached_at": datetime.now(timezone.utc).isoformat(), + "catalog_url": entry.url, + }, + indent=2, + ), + encoding="utf-8", + ) + except OSError: + pass # Cache is best-effort; proceed with fetched data + return catalog_data + + except urllib.error.URLError as exc: + raise IntegrationCatalogError( + f"Failed to fetch catalog from {entry.url}: {exc}" + ) + except json.JSONDecodeError as exc: + raise IntegrationCatalogError( + f"Invalid JSON in catalog from {entry.url}: {exc}" + ) + + def _get_merged_integrations( + self, force_refresh: bool = False + ) -> List[Dict[str, Any]]: + """Fetch and merge integrations from all active catalogs. + + Catalogs are processed in the order returned by + :meth:`get_active_catalogs`. On conflicts, the first catalog in that + order wins (lower numeric priority = higher precedence). Each dict is + annotated with ``_catalog_name`` and ``_install_allowed``. + """ + import sys + + active = self.get_active_catalogs() + merged: Dict[str, Dict[str, Any]] = {} + any_success = False + + for entry in active: + try: + data = self._fetch_single_catalog(entry, force_refresh) + any_success = True + except IntegrationCatalogError as exc: + print( + f"Warning: Could not fetch catalog '{entry.name}': {exc}", + file=sys.stderr, + ) + continue + + for integ_id, integ_data in data.get("integrations", {}).items(): + if not isinstance(integ_data, dict): + continue + if integ_id not in merged: + merged[integ_id] = { + **integ_data, + "id": integ_id, + "_catalog_name": entry.name, + "_install_allowed": entry.install_allowed, + } + + if not any_success and active: + raise IntegrationCatalogError( + "Failed to fetch any integration catalog" + ) + + return list(merged.values()) + + # -- Search / info ---------------------------------------------------- + + def search( + self, + query: Optional[str] = None, + tag: Optional[str] = None, + author: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """Search catalogs for integrations matching the given filters.""" + results: List[Dict[str, Any]] = [] + for item in self._get_merged_integrations(): + author_val = item.get("author", "") + if not isinstance(author_val, str): + author_val = str(author_val) if author_val is not None else "" + if author and author_val.lower() != author.lower(): + continue + if tag: + raw_tags = item.get("tags", []) + tags_list = raw_tags if isinstance(raw_tags, list) else [] + if tag.lower() not in [t.lower() for t in tags_list if isinstance(t, str)]: + continue + if query: + raw_tags = item.get("tags", []) + tags_list = raw_tags if isinstance(raw_tags, list) else [] + name_val = item.get("name", "") + desc_val = item.get("description", "") + id_val = item.get("id", "") + haystack = " ".join( + [ + str(name_val) if name_val else "", + str(desc_val) if desc_val else "", + str(id_val) if id_val else "", + ] + + [t for t in tags_list if isinstance(t, str)] + ).lower() + if query.lower() not in haystack: + continue + results.append(item) + return results + + def get_integration_info( + self, integration_id: str + ) -> Optional[Dict[str, Any]]: + """Return catalog metadata for a single integration, or None.""" + for item in self._get_merged_integrations(): + if item["id"] == integration_id: + return item + return None + + # -- Cache management ------------------------------------------------- + + def clear_cache(self) -> None: + """Remove all cached catalog files.""" + if self.cache_dir.exists(): + for pattern in ("catalog-*.json", "catalog-*-metadata.json"): + for f in self.cache_dir.glob(pattern): + f.unlink(missing_ok=True) + + +# --------------------------------------------------------------------------- +# IntegrationDescriptor (integration.yml) +# --------------------------------------------------------------------------- + +class IntegrationDescriptor: + """Loads and validates an ``integration.yml`` descriptor. + + The descriptor mirrors ``extension.yml`` and ``preset.yml``:: + + schema_version: "1.0" + integration: + id: "my-agent" + name: "My Agent" + version: "1.0.0" + description: "Integration for My Agent" + author: "my-org" + requires: + speckit_version: ">=0.6.0" + tools: [...] + provides: + commands: [...] + scripts: [...] + """ + + SCHEMA_VERSION = "1.0" + REQUIRED_TOP_LEVEL = ["schema_version", "integration", "requires", "provides"] + + def __init__(self, descriptor_path: Path) -> None: + self.path = descriptor_path + self.data = self._load(descriptor_path) + self._validate() + + # -- Loading ---------------------------------------------------------- + + @staticmethod + def _load(path: Path) -> dict: + try: + with open(path, "r", encoding="utf-8") as fh: + return yaml.safe_load(fh) or {} + except yaml.YAMLError as exc: + raise IntegrationDescriptorError(f"Invalid YAML in {path}: {exc}") + except FileNotFoundError: + raise IntegrationDescriptorError(f"Descriptor not found: {path}") + except (OSError, UnicodeError) as exc: + raise IntegrationDescriptorError( + f"Unable to read descriptor {path}: {exc}" + ) + + # -- Validation ------------------------------------------------------- + + def _validate(self) -> None: + if not isinstance(self.data, dict): + raise IntegrationDescriptorError( + f"Descriptor root must be a YAML mapping, got {type(self.data).__name__}" + ) + for field in self.REQUIRED_TOP_LEVEL: + if field not in self.data: + raise IntegrationDescriptorError( + f"Missing required field: {field}" + ) + + if self.data["schema_version"] != self.SCHEMA_VERSION: + raise IntegrationDescriptorError( + f"Unsupported schema version: {self.data['schema_version']} " + f"(expected {self.SCHEMA_VERSION})" + ) + + integ = self.data["integration"] + if not isinstance(integ, dict): + raise IntegrationDescriptorError( + "'integration' must be a mapping" + ) + for field in ("id", "name", "version", "description"): + if field not in integ: + raise IntegrationDescriptorError( + f"Missing integration.{field}" + ) + if not isinstance(integ[field], str): + raise IntegrationDescriptorError( + f"integration.{field} must be a string, got {type(integ[field]).__name__}" + ) + + if not re.match(r"^[a-z0-9-]+$", integ["id"]): + raise IntegrationDescriptorError( + f"Invalid integration ID '{integ['id']}': " + "must be lowercase alphanumeric with hyphens only" + ) + + try: + pkg_version.Version(integ["version"]) + except (pkg_version.InvalidVersion, TypeError): + raise IntegrationDescriptorError( + f"Invalid version '{integ['version']}'" + ) + + requires = self.data["requires"] + if not isinstance(requires, dict): + raise IntegrationDescriptorError( + "'requires' must be a mapping" + ) + if "speckit_version" not in requires: + raise IntegrationDescriptorError( + "Missing requires.speckit_version" + ) + if not isinstance(requires["speckit_version"], str) or not requires["speckit_version"].strip(): + raise IntegrationDescriptorError( + "requires.speckit_version must be a non-empty string" + ) + tools = requires.get("tools") + if tools is not None: + if not isinstance(tools, list): + raise IntegrationDescriptorError( + "requires.tools must be a list" + ) + for tool in tools: + if not isinstance(tool, dict): + raise IntegrationDescriptorError( + "Each requires.tools entry must be a mapping" + ) + tool_name = tool.get("name") + if not isinstance(tool_name, str) or not tool_name.strip(): + raise IntegrationDescriptorError( + "requires.tools entry 'name' must be a non-empty string" + ) + + provides = self.data["provides"] + if not isinstance(provides, dict): + raise IntegrationDescriptorError( + "'provides' must be a mapping" + ) + commands = provides.get("commands", []) + scripts = provides.get("scripts", []) + if "commands" in provides and not isinstance(commands, list): + raise IntegrationDescriptorError( + "Invalid provides.commands: expected a list" + ) + if "scripts" in provides and not isinstance(scripts, list): + raise IntegrationDescriptorError( + "Invalid provides.scripts: expected a list" + ) + if not commands and not scripts: + raise IntegrationDescriptorError( + "Integration must provide at least one command or script" + ) + for cmd in commands: + if not isinstance(cmd, dict): + raise IntegrationDescriptorError( + "Each command entry must be a mapping" + ) + if "name" not in cmd or "file" not in cmd: + raise IntegrationDescriptorError( + "Command entry missing 'name' or 'file'" + ) + cmd_name = cmd["name"] + cmd_file = cmd["file"] + if not isinstance(cmd_name, str) or not cmd_name.strip(): + raise IntegrationDescriptorError( + "Command entry 'name' must be a non-empty string" + ) + if not isinstance(cmd_file, str) or not cmd_file.strip(): + raise IntegrationDescriptorError( + "Command entry 'file' must be a non-empty string" + ) + if os.path.isabs(cmd_file) or ".." in Path(cmd_file).parts or Path(cmd_file).drive or Path(cmd_file).anchor: + raise IntegrationDescriptorError( + f"Command entry 'file' must be a relative path without '..': {cmd_file}" + ) + for script_entry in scripts: + if not isinstance(script_entry, str) or not script_entry.strip(): + raise IntegrationDescriptorError( + "Script entry must be a non-empty string" + ) + if os.path.isabs(script_entry) or ".." in Path(script_entry).parts or Path(script_entry).drive or Path(script_entry).anchor: + raise IntegrationDescriptorError( + f"Script entry must be a relative path without '..': {script_entry}" + ) + + # -- Property accessors ----------------------------------------------- + + @property + def id(self) -> str: + return self.data["integration"]["id"] + + @property + def name(self) -> str: + return self.data["integration"]["name"] + + @property + def version(self) -> str: + return self.data["integration"]["version"] + + @property + def description(self) -> str: + return self.data["integration"]["description"] + + @property + def requires_speckit_version(self) -> str: + return self.data["requires"]["speckit_version"] + + @property + def commands(self) -> List[Dict[str, Any]]: + return self.data.get("provides", {}).get("commands", []) + + @property + def scripts(self) -> List[str]: + return self.data.get("provides", {}).get("scripts", []) + + @property + def tools(self) -> List[Dict[str, Any]]: + return self.data.get("requires", {}).get("tools") or [] + + def get_hash(self) -> str: + """SHA-256 hash of the descriptor file.""" + with open(self.path, "rb") as fh: + return f"sha256:{hashlib.sha256(fh.read()).hexdigest()}" diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py new file mode 100644 index 0000000000..3e39db717e --- /dev/null +++ b/src/specify_cli/integrations/claude/__init__.py @@ -0,0 +1,238 @@ +"""Claude Code integration.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import re + +import yaml + +from ..base import SkillsIntegration +from ..manifest import IntegrationManifest + +# Note injected into hook sections so Claude maps dot-notation command +# names (from extensions.yml) to the hyphenated skill names it uses. +_HOOK_COMMAND_NOTE = ( + "- When constructing slash commands from hook command names, " + "replace dots (`.`) with hyphens (`-`). " + "For example, `speckit.git.commit` → `/speckit-git-commit`.\n" +) + +# Mapping of command template stem → argument-hint text shown inline +# when a user invokes the slash command in Claude Code. +ARGUMENT_HINTS: dict[str, str] = { + "specify": "Describe the feature you want to specify", + "plan": "Optional guidance for the planning phase", + "tasks": "Optional task generation constraints", + "implement": "Optional implementation guidance or task filter", + "analyze": "Optional focus areas for analysis", + "clarify": "Optional areas to clarify in the spec", + "constitution": "Principles or values for the project constitution", + "checklist": "Domain or focus area for the checklist", + "taskstoissues": "Optional filter or label for GitHub issues", +} + + +class ClaudeIntegration(SkillsIntegration): + """Integration for Claude Code skills.""" + + key = "claude" + config = { + "name": "Claude Code", + "folder": ".claude/", + "commands_subdir": "skills", + "install_url": "https://docs.anthropic.com/en/docs/claude-code/setup", + "requires_cli": True, + } + registrar_config = { + "dir": ".claude/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", + } + context_file = "CLAUDE.md" + + @staticmethod + def inject_argument_hint(content: str, hint: str) -> str: + """Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter. + + Skips injection if ``argument-hint:`` already exists in the + frontmatter to avoid duplicate keys. + """ + lines = content.splitlines(keepends=True) + + # Pre-scan: bail out if argument-hint already present in frontmatter + dash_count = 0 + for line in lines: + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + if dash_count == 2: + break + continue + if dash_count == 1 and stripped.startswith("argument-hint:"): + return content # already present + + out: list[str] = [] + in_fm = False + dash_count = 0 + injected = False + for line in lines: + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + in_fm = dash_count == 1 + out.append(line) + continue + if in_fm and not injected and stripped.startswith("description:"): + out.append(line) + # Preserve the exact line-ending style (\r\n vs \n) + if line.endswith("\r\n"): + eol = "\r\n" + elif line.endswith("\n"): + eol = "\n" + else: + eol = "" + escaped = hint.replace("\\", "\\\\").replace('"', '\\"') + out.append(f'argument-hint: "{escaped}"{eol}') + injected = True + continue + out.append(line) + return "".join(out) + + def _render_skill(self, template_name: str, frontmatter: dict[str, Any], body: str) -> str: + """Render a processed command template as a Claude skill.""" + skill_name = f"speckit-{template_name.replace('.', '-')}" + description = frontmatter.get( + "description", + f"Spec-kit workflow command: {template_name}", + ) + skill_frontmatter = self._build_skill_fm( + skill_name, description, f"templates/commands/{template_name}.md" + ) + frontmatter_text = yaml.safe_dump(skill_frontmatter, sort_keys=False).strip() + return f"---\n{frontmatter_text}\n---\n\n{body.strip()}\n" + + def _build_skill_fm(self, name: str, description: str, source: str) -> dict: + from specify_cli.agents import CommandRegistrar + return CommandRegistrar.build_skill_frontmatter( + self.key, name, description, source + ) + + @staticmethod + def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str: + """Insert ``key: value`` before the closing ``---`` if not already present.""" + lines = content.splitlines(keepends=True) + + # Pre-scan: bail out if already present in frontmatter + dash_count = 0 + for line in lines: + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + if dash_count == 2: + break + continue + if dash_count == 1 and stripped.startswith(f"{key}:"): + return content + + # Inject before the closing --- of frontmatter + out: list[str] = [] + dash_count = 0 + injected = False + for line in lines: + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + if dash_count == 2 and not injected: + if line.endswith("\r\n"): + eol = "\r\n" + elif line.endswith("\n"): + eol = "\n" + else: + eol = "" + out.append(f"{key}: {value}{eol}") + injected = True + out.append(line) + return "".join(out) + + @staticmethod + def _inject_hook_command_note(content: str) -> str: + """Insert a dot-to-hyphen note before each hook output instruction. + + Targets the line ``- For each executable hook, output the following`` + and inserts the note on the line before it, matching its indentation. + Skips if the note is already present. + """ + if "replace dots" in content: + return content + + def repl(m: re.Match[str]) -> str: + indent = m.group(1) + instruction = m.group(2) + eol = m.group(3) + return ( + indent + + _HOOK_COMMAND_NOTE.rstrip("\n") + + eol + + indent + + instruction + + eol + ) + + return re.sub( + r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)", + repl, + content, + ) + + def post_process_skill_content(self, content: str) -> str: + """Inject Claude-specific frontmatter flags and hook notes.""" + updated = self._inject_frontmatter_flag(content, "user-invocable") + updated = self._inject_frontmatter_flag(updated, "disable-model-invocation", "false") + updated = self._inject_hook_command_note(updated) + return updated + + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Install Claude skills, then inject Claude-specific flags and argument-hints.""" + created = super().setup(project_root, manifest, parsed_options, **opts) + + # Post-process generated skill files + skills_dir = self.skills_dest(project_root).resolve() + + for path in created: + # Only touch SKILL.md files under the skills directory + try: + path.resolve().relative_to(skills_dir) + except ValueError: + continue + if path.name != "SKILL.md": + continue + + content_bytes = path.read_bytes() + content = content_bytes.decode("utf-8") + + updated = self.post_process_skill_content(content) + + # Inject argument-hint if available for this skill + skill_dir_name = path.parent.name # e.g. "speckit-plan" + stem = skill_dir_name + if stem.startswith("speckit-"): + stem = stem[len("speckit-"):] + hint = ARGUMENT_HINTS.get(stem, "") + if hint: + updated = self.inject_argument_hint(updated, hint) + + if updated != content: + path.write_bytes(updated.encode("utf-8")) + self.record_file_in_manifest(path, project_root, manifest) + + return created diff --git a/src/specify_cli/integrations/codebuddy/__init__.py b/src/specify_cli/integrations/codebuddy/__init__.py new file mode 100644 index 0000000000..061ac7641f --- /dev/null +++ b/src/specify_cli/integrations/codebuddy/__init__.py @@ -0,0 +1,21 @@ +"""CodeBuddy CLI integration.""" + +from ..base import MarkdownIntegration + + +class CodebuddyIntegration(MarkdownIntegration): + key = "codebuddy" + config = { + "name": "CodeBuddy", + "folder": ".codebuddy/", + "commands_subdir": "commands", + "install_url": "https://www.codebuddy.ai/cli", + "requires_cli": True, + } + registrar_config = { + "dir": ".codebuddy/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = "CODEBUDDY.md" diff --git a/src/specify_cli/integrations/codex/__init__.py b/src/specify_cli/integrations/codex/__init__.py new file mode 100644 index 0000000000..b3b509b654 --- /dev/null +++ b/src/specify_cli/integrations/codex/__init__.py @@ -0,0 +1,55 @@ +"""Codex CLI integration — skills-based agent. + +Codex uses the ``.agents/skills/speckit-/SKILL.md`` layout. +Commands are deprecated; ``--skills`` defaults to ``True``. +""" + +from __future__ import annotations + +from ..base import IntegrationOption, SkillsIntegration + + +class CodexIntegration(SkillsIntegration): + """Integration for OpenAI Codex CLI.""" + + key = "codex" + config = { + "name": "Codex CLI", + "folder": ".agents/", + "commands_subdir": "skills", + "install_url": "https://github.com/openai/codex", + "requires_cli": True, + } + registrar_config = { + "dir": ".agents/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", + } + context_file = "AGENTS.md" + + def build_exec_args( + self, + prompt: str, + *, + model: str | None = None, + output_json: bool = True, + ) -> list[str] | None: + # Codex uses ``codex exec "prompt"`` for non-interactive mode. + args: list[str] = ["codex", "exec", prompt] + if model: + args.extend(["--model", model]) + if output_json: + args.append("--json") + return args + + @classmethod + def options(cls) -> list[IntegrationOption]: + return [ + IntegrationOption( + "--skills", + is_flag=True, + default=True, + help="Install as agent skills (default for Codex)", + ), + ] diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py new file mode 100644 index 0000000000..c7456ce7f0 --- /dev/null +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -0,0 +1,499 @@ +"""Copilot integration — GitHub Copilot in VS Code. + +Copilot has several unique behaviors compared to standard markdown agents: +- Commands use ``.agent.md`` extension (not ``.md``) +- Each command gets a companion ``.prompt.md`` file in ``.github/prompts/`` +- Installs ``.vscode/settings.json`` with prompt file recommendations +- Context file lives at ``.github/copilot-instructions.md`` + +When ``--skills`` is passed via ``--integration-options``, Copilot scaffolds +commands as ``speckit-/SKILL.md`` directories under ``.github/skills/`` +instead. The two modes are mutually exclusive. +""" + +from __future__ import annotations + +import json +import os +import shutil +import warnings +from pathlib import Path +from typing import Any + +from ..base import IntegrationBase, IntegrationOption, SkillsIntegration +from ..manifest import IntegrationManifest + + +def _allow_all() -> bool: + """Return True if the Copilot CLI should run with full permissions. + + Checks ``SPECKIT_COPILOT_ALLOW_ALL_TOOLS`` first (new canonical name). + Falls back to the deprecated ``SPECKIT_ALLOW_ALL_TOOLS`` if set, + emitting a deprecation warning. Default when neither is set: enabled. + """ + new_var = os.environ.get("SPECKIT_COPILOT_ALLOW_ALL_TOOLS") + if new_var is not None: + return new_var != "0" + + old_var = os.environ.get("SPECKIT_ALLOW_ALL_TOOLS") + if old_var is not None: + warnings.warn( + "SPECKIT_ALLOW_ALL_TOOLS is deprecated; " + "use SPECKIT_COPILOT_ALLOW_ALL_TOOLS instead.", + UserWarning, + stacklevel=2, + ) + return old_var != "0" + + return True + + +class _CopilotSkillsHelper(SkillsIntegration): + """Internal helper used when Copilot is scaffolded in skills mode. + + Not registered in the integration registry — only used as a delegate + by ``CopilotIntegration`` when ``--skills`` is passed. + """ + + key = "copilot" + config = { + "name": "GitHub Copilot", + "folder": ".github/", + "commands_subdir": "skills", + "install_url": "https://docs.github.com/en/copilot/concepts/agents/copilot-cli/about-copilot-cli", + "requires_cli": False, + } + registrar_config = { + "dir": ".github/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", + } + context_file = ".github/copilot-instructions.md" + + +class CopilotIntegration(IntegrationBase): + """Integration for GitHub Copilot (VS Code IDE + CLI). + + The IDE integration (``requires_cli: False``) installs ``.agent.md`` + command files. Workflow dispatch additionally requires the + ``copilot`` CLI to be installed separately. + + When ``--skills`` is passed via ``--integration-options``, commands + are scaffolded as ``speckit-/SKILL.md`` under ``.github/skills/`` + instead of the default ``.agent.md`` + ``.prompt.md`` layout. + """ + + key = "copilot" + config = { + "name": "GitHub Copilot", + "folder": ".github/", + "commands_subdir": "agents", + "install_url": "https://docs.github.com/en/copilot/concepts/agents/copilot-cli/about-copilot-cli", + "requires_cli": False, + } + registrar_config = { + "dir": ".github/agents", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".agent.md", + } + context_file = ".github/copilot-instructions.md" + + # Mutable flag set by setup() — indicates the active scaffolding mode. + _skills_mode: bool = False + + def effective_invoke_separator( + self, parsed_options: dict[str, Any] | None = None + ) -> str: + """Return ``"-"`` when skills mode is requested, ``"."`` otherwise.""" + if parsed_options and parsed_options.get("skills"): + return "-" + if self._skills_mode: + return "-" + return self.invoke_separator + + @classmethod + def options(cls) -> list[IntegrationOption]: + return [ + IntegrationOption( + "--skills", + is_flag=True, + default=False, + help="Scaffold commands as agent skills (speckit-/SKILL.md) instead of .agent.md files", + ), + ] + + def build_exec_args( + self, + prompt: str, + *, + model: str | None = None, + output_json: bool = True, + ) -> list[str] | None: + # GitHub Copilot CLI uses ``copilot -p "prompt"`` for + # non-interactive mode. --yolo enables all permissions + # (tools, paths, and URLs) so the agent can perform file + # edits and shell commands without interactive prompts. + # Controlled by SPECKIT_COPILOT_ALLOW_ALL_TOOLS env var + # (default: enabled). The deprecated SPECKIT_ALLOW_ALL_TOOLS + # is also honoured as a fallback. + args = ["copilot", "-p", prompt] + if _allow_all(): + args.append("--yolo") + if model: + args.extend(["--model", model]) + if output_json: + args.extend(["--output-format", "json"]) + return args + + def build_command_invocation(self, command_name: str, args: str = "") -> str: + """Build the native invocation for a Copilot command. + + Default mode: agents are not slash-commands — return args as prompt. + Skills mode: ``/speckit-`` slash-command dispatch. + """ + if self._skills_mode: + stem = command_name + if stem.startswith("speckit."): + stem = stem[len("speckit."):] + invocation = "/speckit-" + stem.replace(".", "-") + if args: + invocation = f"{invocation} {args}" + return invocation + return args or "" + + def dispatch_command( + self, + command_name: str, + args: str = "", + *, + project_root: Path | None = None, + model: str | None = None, + timeout: int = 600, + stream: bool = True, + ) -> dict[str, Any]: + """Dispatch via ``--agent speckit.`` instead of slash-commands. + + Copilot ``.agent.md`` files are agents, not skills. The CLI + selects them with ``--agent `` and the prompt is just + the user's arguments. + + In skills mode, the prompt includes the skill invocation + (``/speckit-``). + """ + import subprocess + + stem = command_name + if stem.startswith("speckit."): + stem = stem[len("speckit."):] + + # Detect skills mode from project layout when not set via setup() + skills_mode = self._skills_mode + if not skills_mode and project_root: + skills_dir = project_root / ".github" / "skills" + if skills_dir.is_dir(): + skills_mode = any( + d.is_dir() and (d / "SKILL.md").is_file() + for d in skills_dir.glob("speckit-*") + ) + + if skills_mode: + prompt = "/speckit-" + stem.replace(".", "-") + if args: + prompt = f"{prompt} {args}" + else: + agent_name = f"speckit.{stem}" + prompt = args or "" + + cli_args = ["copilot", "-p", prompt] + if not skills_mode: + cli_args.extend(["--agent", agent_name]) + if _allow_all(): + cli_args.append("--yolo") + if model: + cli_args.extend(["--model", model]) + if not stream: + cli_args.extend(["--output-format", "json"]) + + cwd = str(project_root) if project_root else None + + if stream: + try: + result = subprocess.run( + cli_args, + text=True, + cwd=cwd, + ) + except KeyboardInterrupt: + return { + "exit_code": 130, + "stdout": "", + "stderr": "Interrupted by user", + } + return { + "exit_code": result.returncode, + "stdout": "", + "stderr": "", + } + + result = subprocess.run( + cli_args, + capture_output=True, + text=True, + cwd=cwd, + timeout=timeout, + ) + return { + "exit_code": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + } + + def command_filename(self, template_name: str) -> str: + """Copilot commands use ``.agent.md`` extension.""" + return f"speckit.{template_name}.agent.md" + + def post_process_skill_content(self, content: str) -> str: + """Inject Copilot-specific ``mode:`` field into SKILL.md frontmatter. + + Inserts ``mode: speckit.`` before the closing ``---`` so + Copilot can associate the skill with its agent mode. + """ + lines = content.splitlines(keepends=True) + + # Extract skill name from frontmatter to derive the mode value + dash_count = 0 + skill_name = "" + for line in lines: + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + if dash_count == 2: + break + continue + if dash_count == 1: + if stripped.startswith("mode:"): + return content # already present + if stripped.startswith("name:"): + # Parse: name: "speckit-plan" → speckit.plan + val = stripped.split(":", 1)[1].strip().strip('"').strip("'") + # Convert speckit-plan → speckit.plan + if val.startswith("speckit-"): + skill_name = "speckit." + val[len("speckit-"):] + else: + skill_name = val + + if not skill_name: + return content + + # Inject mode: before the closing --- of frontmatter + out: list[str] = [] + dash_count = 0 + injected = False + for line in lines: + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + if dash_count == 2 and not injected: + if line.endswith("\r\n"): + eol = "\r\n" + elif line.endswith("\n"): + eol = "\n" + else: + eol = "" + out.append(f"mode: {skill_name}{eol}") + injected = True + out.append(line) + return "".join(out) + + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Install copilot commands, companion prompts, and VS Code settings. + + When ``parsed_options["skills"]`` is truthy, delegates to skills + scaffolding (``speckit-/SKILL.md`` under ``.github/skills/``). + Otherwise uses the default ``.agent.md`` + ``.prompt.md`` layout. + """ + parsed_options = parsed_options or {} + self._skills_mode = bool(parsed_options.get("skills")) + if self._skills_mode: + return self._setup_skills(project_root, manifest, parsed_options, **opts) + return self._setup_default(project_root, manifest, parsed_options, **opts) + + def _setup_default( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Default mode: .agent.md + .prompt.md + VS Code settings merge.""" + project_root_resolved = project_root.resolve() + if manifest.project_root != project_root_resolved: + raise ValueError( + f"manifest.project_root ({manifest.project_root}) does not match " + f"project_root ({project_root_resolved})" + ) + + templates = self.list_command_templates() + if not templates: + return [] + + dest = self.commands_dest(project_root) + dest_resolved = dest.resolve() + try: + dest_resolved.relative_to(project_root_resolved) + except ValueError as exc: + raise ValueError( + f"Integration destination {dest_resolved} escapes " + f"project root {project_root_resolved}" + ) from exc + dest.mkdir(parents=True, exist_ok=True) + created: list[Path] = [] + + script_type = opts.get("script_type", "sh") + arg_placeholder = self.registrar_config.get("args", "$ARGUMENTS") + + # 1. Process and write command files as .agent.md + for src_file in templates: + raw = src_file.read_text(encoding="utf-8") + processed = self.process_template( + raw, self.key, script_type, arg_placeholder, + context_file=self.context_file or "", + ) + dst_name = self.command_filename(src_file.stem) + dst_file = self.write_file_and_record( + processed, dest / dst_name, project_root, manifest + ) + created.append(dst_file) + + # 2. Generate companion .prompt.md files from the templates we just wrote + prompts_dir = project_root / ".github" / "prompts" + for src_file in templates: + cmd_name = f"speckit.{src_file.stem}" + prompt_content = f"---\nagent: {cmd_name}\n---\n" + prompt_file = self.write_file_and_record( + prompt_content, + prompts_dir / f"{cmd_name}.prompt.md", + project_root, + manifest, + ) + created.append(prompt_file) + + # Write .vscode/settings.json + settings_src = self._vscode_settings_path() + if settings_src and settings_src.is_file(): + dst_settings = project_root / ".vscode" / "settings.json" + dst_settings.parent.mkdir(parents=True, exist_ok=True) + if dst_settings.exists(): + # Merge into existing — don't track since we can't safely + # remove the user's settings file on uninstall. + self._merge_vscode_settings(settings_src, dst_settings) + else: + shutil.copy2(settings_src, dst_settings) + self.record_file_in_manifest(dst_settings, project_root, manifest) + created.append(dst_settings) + + # 4. Upsert managed context section into the agent context file + self.upsert_context_section(project_root) + + return created + + def _setup_skills( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Skills mode: delegate to ``_CopilotSkillsHelper`` then post-process.""" + helper = _CopilotSkillsHelper() + created = SkillsIntegration.setup( + helper, project_root, manifest, parsed_options, **opts + ) + + # Post-process generated skill files with Copilot-specific frontmatter + skills_dir = helper.skills_dest(project_root).resolve() + for path in created: + try: + path.resolve().relative_to(skills_dir) + except ValueError: + continue + if path.name != "SKILL.md": + continue + + content = path.read_text(encoding="utf-8") + updated = self.post_process_skill_content(content) + if updated != content: + path.write_bytes(updated.encode("utf-8")) + self.record_file_in_manifest(path, project_root, manifest) + + return created + + def _vscode_settings_path(self) -> Path | None: + """Return path to the bundled vscode-settings.json template.""" + tpl_dir = self.shared_templates_dir() + if tpl_dir: + candidate = tpl_dir / "vscode-settings.json" + if candidate.is_file(): + return candidate + return None + + @staticmethod + def _merge_vscode_settings(src: Path, dst: Path) -> None: + """Merge settings from *src* into existing *dst* JSON file. + + Top-level keys from *src* are added only if missing in *dst*. + For dict-valued keys, sub-keys are merged the same way. + + If *dst* cannot be parsed (e.g. JSONC with comments), the merge + is skipped to avoid overwriting user settings. + """ + try: + existing = json.loads(dst.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + # Cannot parse existing file (likely JSONC with comments). + # Skip merge to preserve the user's settings, but show + # what they should add manually. + import logging + template_content = src.read_text(encoding="utf-8") + logging.getLogger(__name__).warning( + "Could not parse %s (may contain JSONC comments). " + "Skipping settings merge to preserve existing file.\n" + "Please add the following settings manually:\n%s", + dst, template_content, + ) + return + + new_settings = json.loads(src.read_text(encoding="utf-8")) + + if not isinstance(existing, dict) or not isinstance(new_settings, dict): + import logging + logging.getLogger(__name__).warning( + "Skipping settings merge: %s or template is not a JSON object.", dst + ) + return + + changed = False + for key, value in new_settings.items(): + if key not in existing: + existing[key] = value + changed = True + elif isinstance(existing[key], dict) and isinstance(value, dict): + for sub_key, sub_value in value.items(): + if sub_key not in existing[key]: + existing[key][sub_key] = sub_value + changed = True + + if not changed: + return + + dst.write_text( + json.dumps(existing, indent=4) + "\n", encoding="utf-8" + ) diff --git a/src/specify_cli/integrations/cursor_agent/__init__.py b/src/specify_cli/integrations/cursor_agent/__init__.py new file mode 100644 index 0000000000..a5472654fa --- /dev/null +++ b/src/specify_cli/integrations/cursor_agent/__init__.py @@ -0,0 +1,39 @@ +"""Cursor IDE integration. + +Cursor Agent uses the ``.cursor/skills/speckit-/SKILL.md`` layout. +Commands are deprecated; ``--skills`` defaults to ``True``. +""" + +from __future__ import annotations + +from ..base import IntegrationOption, SkillsIntegration + + +class CursorAgentIntegration(SkillsIntegration): + key = "cursor-agent" + config = { + "name": "Cursor", + "folder": ".cursor/", + "commands_subdir": "skills", + "install_url": None, + "requires_cli": False, + } + registrar_config = { + "dir": ".cursor/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", + } + + context_file = ".cursor/rules/specify-rules.mdc" + + @classmethod + def options(cls) -> list[IntegrationOption]: + return [ + IntegrationOption( + "--skills", + is_flag=True, + default=True, + help="Install as agent skills (recommended for Cursor)", + ), + ] diff --git a/src/specify_cli/integrations/forge/__init__.py b/src/specify_cli/integrations/forge/__init__.py new file mode 100644 index 0000000000..a941d4c331 --- /dev/null +++ b/src/specify_cli/integrations/forge/__init__.py @@ -0,0 +1,206 @@ +"""Forge integration — forgecode.dev AI coding agent. + +Forge has several unique behaviors compared to standard markdown agents: +- Uses `{{parameters}}` instead of `$ARGUMENTS` for argument passing +- Strips `handoffs` frontmatter key (Claude Code feature that causes Forge to hang) +- Injects `name` field into frontmatter when missing +- Uses a hyphenated frontmatter `name` value (e.g., `speckit-foo-bar`) for shell compatibility, especially with ZSH +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from ..base import MarkdownIntegration +from ..manifest import IntegrationManifest + + +def format_forge_command_name(cmd_name: str) -> str: + """Convert command name to Forge-compatible hyphenated format. + + Forge requires command names to use hyphens instead of dots for + compatibility with ZSH and other shells. This function converts + dot-notation command names to hyphenated format. + + The function is idempotent: already-formatted names are returned unchanged. + + Examples: + >>> format_forge_command_name("plan") + 'speckit-plan' + >>> format_forge_command_name("speckit.plan") + 'speckit-plan' + >>> format_forge_command_name("speckit-plan") + 'speckit-plan' + >>> format_forge_command_name("speckit.my-extension.example") + 'speckit-my-extension-example' + >>> format_forge_command_name("speckit-my-extension-example") + 'speckit-my-extension-example' + >>> format_forge_command_name("speckit.jira.sync-status") + 'speckit-jira-sync-status' + + Args: + cmd_name: Command name in dot notation (speckit.foo.bar), + hyphenated format (speckit-foo-bar), or plain name (foo) + + Returns: + Hyphenated command name with 'speckit-' prefix + """ + # Already in hyphenated format - return as-is (idempotent) + if cmd_name.startswith("speckit-"): + return cmd_name + + # Strip 'speckit.' prefix if present + short_name = cmd_name + if short_name.startswith("speckit."): + short_name = short_name[len("speckit."):] + + # Replace all dots with hyphens + short_name = short_name.replace(".", "-") + + # Return with 'speckit-' prefix + return f"speckit-{short_name}" + + +class ForgeIntegration(MarkdownIntegration): + """Integration for Forge (forgecode.dev). + + Extends MarkdownIntegration to add Forge-specific processing: + - Replaces $ARGUMENTS with {{parameters}} + - Strips 'handoffs' frontmatter key (incompatible with Forge) + - Injects 'name' field into frontmatter when missing + """ + + key = "forge" + config = { + "name": "Forge", + "folder": ".forge/", + "commands_subdir": "commands", + "install_url": "https://forgecode.dev/docs/", + "requires_cli": True, + } + registrar_config = { + "dir": ".forge/commands", + "format": "markdown", + "args": "{{parameters}}", + "extension": ".md", + "strip_frontmatter_keys": ["handoffs"], + "inject_name": True, + "format_name": format_forge_command_name, # Custom name formatter + } + context_file = "AGENTS.md" + + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Install Forge commands with custom processing. + + Extends MarkdownIntegration.setup() to inject Forge-specific transformations + after standard template processing. + """ + templates = self.list_command_templates() + if not templates: + return [] + + project_root_resolved = project_root.resolve() + if manifest.project_root != project_root_resolved: + raise ValueError( + f"manifest.project_root ({manifest.project_root}) does not match " + f"project_root ({project_root_resolved})" + ) + + dest = self.commands_dest(project_root).resolve() + try: + dest.relative_to(project_root_resolved) + except ValueError as exc: + raise ValueError( + f"Integration destination {dest} escapes " + f"project root {project_root_resolved}" + ) from exc + dest.mkdir(parents=True, exist_ok=True) + + script_type = opts.get("script_type", "sh") + arg_placeholder = self.registrar_config.get("args", "{{parameters}}") + created: list[Path] = [] + + for src_file in templates: + raw = src_file.read_text(encoding="utf-8") + # Process template with standard MarkdownIntegration logic + processed = self.process_template( + raw, self.key, script_type, arg_placeholder, + context_file=self.context_file or "", + ) + + # FORGE-SPECIFIC: Ensure any remaining $ARGUMENTS placeholders are + # converted to {{parameters}} + processed = processed.replace("$ARGUMENTS", arg_placeholder) + + # FORGE-SPECIFIC: Apply frontmatter transformations + processed = self._apply_forge_transformations(processed, src_file.stem) + + dst_name = self.command_filename(src_file.stem) + dst_file = self.write_file_and_record( + processed, dest / dst_name, project_root, manifest + ) + created.append(dst_file) + + # Upsert managed context section into the agent context file + self.upsert_context_section(project_root) + + return created + + def _apply_forge_transformations(self, content: str, template_name: str) -> str: + """Apply Forge-specific transformations to processed content. + + 1. Strip 'handoffs' frontmatter key (from Claude Code templates; incompatible with Forge) + 2. Inject 'name' field if missing (using hyphenated format) + """ + # Parse frontmatter + lines = content.split('\n') + if not lines or lines[0].strip() != '---': + return content + + # Find end of frontmatter + frontmatter_end = -1 + for i in range(1, len(lines)): + if lines[i].strip() == '---': + frontmatter_end = i + break + + if frontmatter_end == -1: + return content + + frontmatter_lines = lines[1:frontmatter_end] + body_lines = lines[frontmatter_end + 1:] + + # 1. Strip 'handoffs' key + filtered_frontmatter = [] + skip_until_outdent = False + for line in frontmatter_lines: + if skip_until_outdent: + # Skip indented lines under handoffs: + if line and (line[0] == ' ' or line[0] == '\t'): + continue + else: + skip_until_outdent = False + + if line.strip().startswith('handoffs:'): + skip_until_outdent = True + continue + + filtered_frontmatter.append(line) + + # 2. Inject 'name' field if missing (using centralized formatter) + has_name = any(line.strip().startswith('name:') for line in filtered_frontmatter) + if not has_name: + # Use centralized formatter to ensure consistent hyphenated format + cmd_name = format_forge_command_name(template_name) + filtered_frontmatter.insert(0, f'name: {cmd_name}') + + # Reconstruct content + result = ['---'] + filtered_frontmatter + ['---'] + body_lines + return '\n'.join(result) diff --git a/src/specify_cli/integrations/gemini/__init__.py b/src/specify_cli/integrations/gemini/__init__.py new file mode 100644 index 0000000000..d66f0b80bc --- /dev/null +++ b/src/specify_cli/integrations/gemini/__init__.py @@ -0,0 +1,21 @@ +"""Gemini CLI integration.""" + +from ..base import TomlIntegration + + +class GeminiIntegration(TomlIntegration): + key = "gemini" + config = { + "name": "Gemini CLI", + "folder": ".gemini/", + "commands_subdir": "commands", + "install_url": "https://github.com/google-gemini/gemini-cli", + "requires_cli": True, + } + registrar_config = { + "dir": ".gemini/commands", + "format": "toml", + "args": "{{args}}", + "extension": ".toml", + } + context_file = "GEMINI.md" diff --git a/src/specify_cli/integrations/generic/__init__.py b/src/specify_cli/integrations/generic/__init__.py new file mode 100644 index 0000000000..fdaee4ed04 --- /dev/null +++ b/src/specify_cli/integrations/generic/__init__.py @@ -0,0 +1,138 @@ +"""Generic integration — bring your own agent. + +Requires ``--commands-dir`` to specify the output directory for command +files. No longer special-cased in the core CLI — just another +integration with its own required option. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from ..base import IntegrationOption, MarkdownIntegration +from ..manifest import IntegrationManifest + + +class GenericIntegration(MarkdownIntegration): + """Integration for user-specified (generic) agents.""" + + key = "generic" + config = { + "name": "Generic (bring your own agent)", + "folder": None, # Set dynamically from --commands-dir + "commands_subdir": "commands", + "install_url": None, + "requires_cli": False, + } + registrar_config = { + "dir": "", # Set dynamically from --commands-dir + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = "AGENTS.md" + + @classmethod + def options(cls) -> list[IntegrationOption]: + return [ + IntegrationOption( + "--commands-dir", + required=True, + help="Directory for command files (e.g. .myagent/commands/)", + ), + ] + + @staticmethod + def _resolve_commands_dir( + parsed_options: dict[str, Any] | None, + opts: dict[str, Any], + ) -> str: + """Extract ``--commands-dir`` from parsed options or raw_options. + + Returns the directory string or raises ``ValueError``. + """ + parsed_options = parsed_options or {} + + commands_dir = parsed_options.get("commands_dir") + if commands_dir: + return commands_dir + + # Fall back to raw_options (--integration-options="--commands-dir ...") + raw = opts.get("raw_options") + if raw: + import shlex + tokens = shlex.split(raw) + for i, token in enumerate(tokens): + if token == "--commands-dir" and i + 1 < len(tokens): + return tokens[i + 1] + if token.startswith("--commands-dir="): + return token.split("=", 1)[1] + + raise ValueError( + "--commands-dir is required for the generic integration" + ) + + def commands_dest(self, project_root: Path) -> Path: + """Not supported for GenericIntegration — use setup() directly. + + GenericIntegration is stateless; the output directory comes from + ``parsed_options`` or ``raw_options`` at call time, not from + instance state. + """ + raise ValueError( + "GenericIntegration.commands_dest() cannot be called directly; " + "the output directory is resolved from parsed_options in setup()" + ) + + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Install commands to the user-provided commands directory.""" + commands_dir = self._resolve_commands_dir(parsed_options, opts) + + templates = self.list_command_templates() + if not templates: + return [] + + project_root_resolved = project_root.resolve() + if manifest.project_root != project_root_resolved: + raise ValueError( + f"manifest.project_root ({manifest.project_root}) does not match " + f"project_root ({project_root_resolved})" + ) + + dest = (project_root / commands_dir).resolve() + try: + dest.relative_to(project_root_resolved) + except ValueError as exc: + raise ValueError( + f"Integration destination {dest} escapes " + f"project root {project_root_resolved}" + ) from exc + dest.mkdir(parents=True, exist_ok=True) + + script_type = opts.get("script_type", "sh") + arg_placeholder = "$ARGUMENTS" + created: list[Path] = [] + + for src_file in templates: + raw = src_file.read_text(encoding="utf-8") + processed = self.process_template( + raw, self.key, script_type, arg_placeholder, + context_file=self.context_file or "", + ) + dst_name = self.command_filename(src_file.stem) + dst_file = self.write_file_and_record( + processed, dest / dst_name, project_root, manifest + ) + created.append(dst_file) + + # Upsert managed context section into the agent context file + self.upsert_context_section(project_root) + + return created diff --git a/src/specify_cli/integrations/goose/__init__.py b/src/specify_cli/integrations/goose/__init__.py new file mode 100644 index 0000000000..0fc4d9d57a --- /dev/null +++ b/src/specify_cli/integrations/goose/__init__.py @@ -0,0 +1,21 @@ +"""Goose integration — Block's open source AI agent.""" + +from ..base import YamlIntegration + + +class GooseIntegration(YamlIntegration): + key = "goose" + config = { + "name": "Goose", + "folder": ".goose/", + "commands_subdir": "recipes", + "install_url": "https://block.github.io/goose/docs/getting-started/installation", + "requires_cli": True, + } + registrar_config = { + "dir": ".goose/recipes", + "format": "yaml", + "args": "{{args}}", + "extension": ".yaml", + } + context_file = "AGENTS.md" diff --git a/src/specify_cli/integrations/iflow/__init__.py b/src/specify_cli/integrations/iflow/__init__.py new file mode 100644 index 0000000000..4acc2cf372 --- /dev/null +++ b/src/specify_cli/integrations/iflow/__init__.py @@ -0,0 +1,21 @@ +"""iFlow CLI integration.""" + +from ..base import MarkdownIntegration + + +class IflowIntegration(MarkdownIntegration): + key = "iflow" + config = { + "name": "iFlow CLI", + "folder": ".iflow/", + "commands_subdir": "commands", + "install_url": "https://docs.iflow.cn/en/cli/quickstart", + "requires_cli": True, + } + registrar_config = { + "dir": ".iflow/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = "IFLOW.md" diff --git a/src/specify_cli/integrations/junie/__init__.py b/src/specify_cli/integrations/junie/__init__.py new file mode 100644 index 0000000000..0cc3b3f0ff --- /dev/null +++ b/src/specify_cli/integrations/junie/__init__.py @@ -0,0 +1,21 @@ +"""Junie integration (JetBrains).""" + +from ..base import MarkdownIntegration + + +class JunieIntegration(MarkdownIntegration): + key = "junie" + config = { + "name": "Junie", + "folder": ".junie/", + "commands_subdir": "commands", + "install_url": "https://junie.jetbrains.com/", + "requires_cli": True, + } + registrar_config = { + "dir": ".junie/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = ".junie/AGENTS.md" diff --git a/src/specify_cli/integrations/kilocode/__init__.py b/src/specify_cli/integrations/kilocode/__init__.py new file mode 100644 index 0000000000..ffd38f741a --- /dev/null +++ b/src/specify_cli/integrations/kilocode/__init__.py @@ -0,0 +1,21 @@ +"""Kilo Code integration.""" + +from ..base import MarkdownIntegration + + +class KilocodeIntegration(MarkdownIntegration): + key = "kilocode" + config = { + "name": "Kilo Code", + "folder": ".kilocode/", + "commands_subdir": "workflows", + "install_url": None, + "requires_cli": False, + } + registrar_config = { + "dir": ".kilocode/workflows", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = ".kilocode/rules/specify-rules.md" diff --git a/src/specify_cli/integrations/kimi/__init__.py b/src/specify_cli/integrations/kimi/__init__.py new file mode 100644 index 0000000000..5421d48012 --- /dev/null +++ b/src/specify_cli/integrations/kimi/__init__.py @@ -0,0 +1,124 @@ +"""Kimi Code integration — skills-based agent (Moonshot AI). + +Kimi uses the ``.kimi/skills/speckit-/SKILL.md`` layout with +``/skill:speckit-`` invocation syntax. + +Includes legacy migration logic for projects initialised before Kimi +moved from dotted skill directories (``speckit.xxx``) to hyphenated +(``speckit-xxx``). +""" + +from __future__ import annotations + +import shutil +from pathlib import Path +from typing import Any + +from ..base import IntegrationOption, SkillsIntegration +from ..manifest import IntegrationManifest + + +class KimiIntegration(SkillsIntegration): + """Integration for Kimi Code CLI (Moonshot AI).""" + + key = "kimi" + config = { + "name": "Kimi Code", + "folder": ".kimi/", + "commands_subdir": "skills", + "install_url": "https://code.kimi.com/", + "requires_cli": True, + } + registrar_config = { + "dir": ".kimi/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", + } + context_file = "KIMI.md" + + @classmethod + def options(cls) -> list[IntegrationOption]: + return [ + IntegrationOption( + "--skills", + is_flag=True, + default=True, + help="Install as agent skills (default for Kimi)", + ), + IntegrationOption( + "--migrate-legacy", + is_flag=True, + default=False, + help="Migrate legacy dotted skill dirs (speckit.xxx → speckit-xxx)", + ), + ] + + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Install skills with optional legacy dotted-name migration.""" + parsed_options = parsed_options or {} + + # Run base setup first so hyphenated targets (speckit-*) exist, + # then migrate/clean legacy dotted dirs without risking user content loss. + created = super().setup( + project_root, manifest, parsed_options=parsed_options, **opts + ) + + if parsed_options.get("migrate_legacy", False): + skills_dir = self.skills_dest(project_root) + if skills_dir.is_dir(): + _migrate_legacy_kimi_dotted_skills(skills_dir) + + return created + + +def _migrate_legacy_kimi_dotted_skills(skills_dir: Path) -> tuple[int, int]: + """Migrate legacy Kimi dotted skill dirs (speckit.xxx) to hyphenated format. + + Returns ``(migrated_count, removed_count)``. + """ + if not skills_dir.is_dir(): + return (0, 0) + + migrated_count = 0 + removed_count = 0 + + for legacy_dir in sorted(skills_dir.glob("speckit.*")): + if not legacy_dir.is_dir(): + continue + if not (legacy_dir / "SKILL.md").exists(): + continue + + suffix = legacy_dir.name[len("speckit."):] + if not suffix: + continue + + target_dir = skills_dir / f"speckit-{suffix.replace('.', '-')}" + + if not target_dir.exists(): + shutil.move(str(legacy_dir), str(target_dir)) + migrated_count += 1 + continue + + # Target exists — only remove legacy if SKILL.md is identical + target_skill = target_dir / "SKILL.md" + legacy_skill = legacy_dir / "SKILL.md" + if target_skill.is_file(): + try: + if target_skill.read_bytes() == legacy_skill.read_bytes(): + has_extra = any( + child.name != "SKILL.md" for child in legacy_dir.iterdir() + ) + if not has_extra: + shutil.rmtree(legacy_dir) + removed_count += 1 + except OSError: + pass + + return (migrated_count, removed_count) diff --git a/src/specify_cli/integrations/kiro_cli/__init__.py b/src/specify_cli/integrations/kiro_cli/__init__.py new file mode 100644 index 0000000000..b316cb4bd2 --- /dev/null +++ b/src/specify_cli/integrations/kiro_cli/__init__.py @@ -0,0 +1,21 @@ +"""Kiro CLI integration.""" + +from ..base import MarkdownIntegration + + +class KiroCliIntegration(MarkdownIntegration): + key = "kiro-cli" + config = { + "name": "Kiro CLI", + "folder": ".kiro/", + "commands_subdir": "prompts", + "install_url": "https://kiro.dev/docs/cli/", + "requires_cli": True, + } + registrar_config = { + "dir": ".kiro/prompts", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = "AGENTS.md" diff --git a/src/specify_cli/integrations/manifest.py b/src/specify_cli/integrations/manifest.py new file mode 100644 index 0000000000..50ac08ea3d --- /dev/null +++ b/src/specify_cli/integrations/manifest.py @@ -0,0 +1,265 @@ +"""Hash-tracked installation manifest for integrations. + +Each installed integration records the files it created together with +their SHA-256 hashes. On uninstall only files whose hash still matches +the recorded value are removed — modified files are left in place and +reported to the caller. +""" + +from __future__ import annotations + +import hashlib +import json +import os +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +def _sha256(path: Path) -> str: + """Return the hex SHA-256 digest of *path*.""" + h = hashlib.sha256() + with open(path, "rb") as fh: + for chunk in iter(lambda: fh.read(8192), b""): + h.update(chunk) + return h.hexdigest() + + +def _validate_rel_path(rel: Path, root: Path) -> Path: + """Resolve *rel* against *root* and verify it stays within *root*. + + Raises ``ValueError`` if *rel* is absolute, contains ``..`` segments + that escape *root*, or otherwise resolves outside the project root. + """ + if rel.is_absolute(): + raise ValueError( + f"Absolute paths are not allowed in manifests: {rel}" + ) + resolved = (root / rel).resolve() + root_resolved = root.resolve() + try: + resolved.relative_to(root_resolved) + except ValueError: + raise ValueError( + f"Path {rel} resolves to {resolved} which is outside " + f"the project root {root_resolved}" + ) from None + return resolved + + +class IntegrationManifest: + """Tracks files installed by a single integration. + + Parameters: + key: Integration identifier (e.g. ``"copilot"``). + project_root: Absolute path to the project directory. + version: CLI version string recorded in the manifest. + """ + + def __init__(self, key: str, project_root: Path, version: str = "") -> None: + self.key = key + self.project_root = project_root.resolve() + self.version = version + self._files: dict[str, str] = {} # rel_path → sha256 hex + self._installed_at: str = "" + + # -- Manifest file location ------------------------------------------- + + @property + def manifest_path(self) -> Path: + """Path to the on-disk manifest JSON.""" + return self.project_root / ".specify" / "integrations" / f"{self.key}.manifest.json" + + # -- Recording files -------------------------------------------------- + + def record_file(self, rel_path: str | Path, content: bytes | str) -> Path: + """Write *content* to *rel_path* (relative to project root) and record its hash. + + Creates parent directories as needed. Returns the absolute path + of the written file. + + Raises ``ValueError`` if *rel_path* resolves outside the project root. + """ + rel = Path(rel_path) + abs_path = _validate_rel_path(rel, self.project_root) + abs_path.parent.mkdir(parents=True, exist_ok=True) + + if isinstance(content, str): + content = content.encode("utf-8") + abs_path.write_bytes(content) + + normalized = abs_path.relative_to(self.project_root).as_posix() + self._files[normalized] = hashlib.sha256(content).hexdigest() + return abs_path + + def record_existing(self, rel_path: str | Path) -> None: + """Record the hash of an already-existing file at *rel_path*. + + Raises ``ValueError`` if *rel_path* resolves outside the project root. + """ + rel = Path(rel_path) + abs_path = _validate_rel_path(rel, self.project_root) + normalized = abs_path.relative_to(self.project_root).as_posix() + self._files[normalized] = _sha256(abs_path) + + # -- Querying --------------------------------------------------------- + + @property + def files(self) -> dict[str, str]: + """Return a copy of the ``{rel_path: sha256}`` mapping.""" + return dict(self._files) + + def check_modified(self) -> list[str]: + """Return relative paths of tracked files whose content changed on disk.""" + modified: list[str] = [] + for rel, expected_hash in self._files.items(): + rel_path = Path(rel) + # Skip paths that are absolute or attempt to escape the project root + if rel_path.is_absolute() or ".." in rel_path.parts: + continue + abs_path = self.project_root / rel_path + if not abs_path.exists() and not abs_path.is_symlink(): + continue + # Treat symlinks and non-regular-files as modified + if abs_path.is_symlink() or not abs_path.is_file(): + modified.append(rel) + continue + if _sha256(abs_path) != expected_hash: + modified.append(rel) + return modified + + # -- Uninstall -------------------------------------------------------- + + def uninstall( + self, + project_root: Path | None = None, + *, + force: bool = False, + ) -> tuple[list[Path], list[Path]]: + """Remove tracked files whose hash still matches. + + Parameters: + project_root: Override for the project root. + force: If ``True``, remove files even if modified. + + Returns: + ``(removed, skipped)`` — absolute paths. + """ + root = (project_root or self.project_root).resolve() + removed: list[Path] = [] + skipped: list[Path] = [] + + for rel, expected_hash in self._files.items(): + # Use non-resolved path for deletion so symlinks themselves + # are removed, not their targets. + path = root / rel + # Validate containment lexically (without following symlinks) + # by collapsing .. segments via Path resolution on the string parts. + try: + normed = Path(os.path.normpath(path)) + normed.relative_to(root) + except (ValueError, OSError): + continue + if not path.exists() and not path.is_symlink(): + continue + # Skip directories — manifest only tracks files + if not path.is_file() and not path.is_symlink(): + skipped.append(path) + continue + # Never follow symlinks when comparing hashes. Only remove + # symlinks when forced, to avoid acting on tampered entries. + if path.is_symlink(): + if not force: + skipped.append(path) + continue + else: + if not force and _sha256(path) != expected_hash: + skipped.append(path) + continue + try: + path.unlink() + except OSError: + skipped.append(path) + continue + removed.append(path) + # Clean up empty parent directories up to project root + parent = path.parent + while parent != root: + try: + parent.rmdir() # only succeeds if empty + except OSError: + break + parent = parent.parent + + # Remove the manifest file itself + manifest = root / ".specify" / "integrations" / f"{self.key}.manifest.json" + if manifest.exists(): + manifest.unlink() + parent = manifest.parent + while parent != root: + try: + parent.rmdir() + except OSError: + break + parent = parent.parent + + return removed, skipped + + # -- Persistence ------------------------------------------------------ + + def save(self) -> Path: + """Write the manifest to disk. Returns the manifest path.""" + self._installed_at = self._installed_at or datetime.now(timezone.utc).isoformat() + data: dict[str, Any] = { + "integration": self.key, + "version": self.version, + "installed_at": self._installed_at, + "files": self._files, + } + path = self.manifest_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + return path + + @classmethod + def load(cls, key: str, project_root: Path) -> IntegrationManifest: + """Load an existing manifest from disk. + + Raises ``FileNotFoundError`` if the manifest does not exist. + """ + inst = cls(key, project_root) + path = inst.manifest_path + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise ValueError( + f"Integration manifest at {path} contains invalid JSON" + ) from exc + + if not isinstance(data, dict): + raise ValueError( + f"Integration manifest at {path} must be a JSON object, " + f"got {type(data).__name__}" + ) + + files = data.get("files", {}) + if not isinstance(files, dict) or not all( + isinstance(k, str) and isinstance(v, str) for k, v in files.items() + ): + raise ValueError( + f"Integration manifest 'files' at {path} must be a " + "mapping of string paths to string hashes" + ) + + inst.version = data.get("version", "") + inst._installed_at = data.get("installed_at", "") + inst._files = files + + stored_key = data.get("integration", "") + if stored_key and stored_key != key: + raise ValueError( + f"Manifest at {path} belongs to integration {stored_key!r}, " + f"not {key!r}" + ) + + return inst diff --git a/src/specify_cli/integrations/opencode/__init__.py b/src/specify_cli/integrations/opencode/__init__.py new file mode 100644 index 0000000000..be4dcc3094 --- /dev/null +++ b/src/specify_cli/integrations/opencode/__init__.py @@ -0,0 +1,21 @@ +"""opencode integration.""" + +from ..base import MarkdownIntegration + + +class OpencodeIntegration(MarkdownIntegration): + key = "opencode" + config = { + "name": "opencode", + "folder": ".opencode/", + "commands_subdir": "command", + "install_url": "https://opencode.ai", + "requires_cli": True, + } + registrar_config = { + "dir": ".opencode/command", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = "AGENTS.md" diff --git a/src/specify_cli/integrations/pi/__init__.py b/src/specify_cli/integrations/pi/__init__.py new file mode 100644 index 0000000000..8a25f326ba --- /dev/null +++ b/src/specify_cli/integrations/pi/__init__.py @@ -0,0 +1,21 @@ +"""Pi Coding Agent integration.""" + +from ..base import MarkdownIntegration + + +class PiIntegration(MarkdownIntegration): + key = "pi" + config = { + "name": "Pi Coding Agent", + "folder": ".pi/", + "commands_subdir": "prompts", + "install_url": "https://www.npmjs.com/package/@mariozechner/pi-coding-agent", + "requires_cli": True, + } + registrar_config = { + "dir": ".pi/prompts", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = "AGENTS.md" diff --git a/src/specify_cli/integrations/qodercli/__init__.py b/src/specify_cli/integrations/qodercli/__init__.py new file mode 100644 index 0000000000..541001be17 --- /dev/null +++ b/src/specify_cli/integrations/qodercli/__init__.py @@ -0,0 +1,21 @@ +"""Qoder CLI integration.""" + +from ..base import MarkdownIntegration + + +class QodercliIntegration(MarkdownIntegration): + key = "qodercli" + config = { + "name": "Qoder CLI", + "folder": ".qoder/", + "commands_subdir": "commands", + "install_url": "https://qoder.com/cli", + "requires_cli": True, + } + registrar_config = { + "dir": ".qoder/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = "QODER.md" diff --git a/src/specify_cli/integrations/qwen/__init__.py b/src/specify_cli/integrations/qwen/__init__.py new file mode 100644 index 0000000000..d9d930152c --- /dev/null +++ b/src/specify_cli/integrations/qwen/__init__.py @@ -0,0 +1,21 @@ +"""Qwen Code integration.""" + +from ..base import MarkdownIntegration + + +class QwenIntegration(MarkdownIntegration): + key = "qwen" + config = { + "name": "Qwen Code", + "folder": ".qwen/", + "commands_subdir": "commands", + "install_url": "https://github.com/QwenLM/qwen-code", + "requires_cli": True, + } + registrar_config = { + "dir": ".qwen/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = "QWEN.md" diff --git a/src/specify_cli/integrations/roo/__init__.py b/src/specify_cli/integrations/roo/__init__.py new file mode 100644 index 0000000000..3c680e7e35 --- /dev/null +++ b/src/specify_cli/integrations/roo/__init__.py @@ -0,0 +1,21 @@ +"""Roo Code integration.""" + +from ..base import MarkdownIntegration + + +class RooIntegration(MarkdownIntegration): + key = "roo" + config = { + "name": "Roo Code", + "folder": ".roo/", + "commands_subdir": "commands", + "install_url": None, + "requires_cli": False, + } + registrar_config = { + "dir": ".roo/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = ".roo/rules/specify-rules.md" diff --git a/src/specify_cli/integrations/shai/__init__.py b/src/specify_cli/integrations/shai/__init__.py new file mode 100644 index 0000000000..7a9d1deb02 --- /dev/null +++ b/src/specify_cli/integrations/shai/__init__.py @@ -0,0 +1,21 @@ +"""SHAI CLI integration.""" + +from ..base import MarkdownIntegration + + +class ShaiIntegration(MarkdownIntegration): + key = "shai" + config = { + "name": "SHAI", + "folder": ".shai/", + "commands_subdir": "commands", + "install_url": "https://github.com/ovh/shai", + "requires_cli": True, + } + registrar_config = { + "dir": ".shai/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = "SHAI.md" diff --git a/src/specify_cli/integrations/tabnine/__init__.py b/src/specify_cli/integrations/tabnine/__init__.py new file mode 100644 index 0000000000..2928a214a7 --- /dev/null +++ b/src/specify_cli/integrations/tabnine/__init__.py @@ -0,0 +1,21 @@ +"""Tabnine CLI integration.""" + +from ..base import TomlIntegration + + +class TabnineIntegration(TomlIntegration): + key = "tabnine" + config = { + "name": "Tabnine CLI", + "folder": ".tabnine/agent/", + "commands_subdir": "commands", + "install_url": "https://docs.tabnine.com/main/getting-started/tabnine-cli", + "requires_cli": True, + } + registrar_config = { + "dir": ".tabnine/agent/commands", + "format": "toml", + "args": "{{args}}", + "extension": ".toml", + } + context_file = "TABNINE.md" diff --git a/src/specify_cli/integrations/trae/__init__.py b/src/specify_cli/integrations/trae/__init__.py new file mode 100644 index 0000000000..343a7527f8 --- /dev/null +++ b/src/specify_cli/integrations/trae/__init__.py @@ -0,0 +1,40 @@ +"""Trae IDE integration. — skills-based agent. + +Trae IDE uses ``.trae/skills/speckit-/SKILL.md`` layout. +In the Specify CLI Trae integration, explicit command support was deprecated +since v0.5.1; ``--skills`` defaults to ``True``. +""" + +from __future__ import annotations +from ..base import IntegrationOption, SkillsIntegration + + +class TraeIntegration(SkillsIntegration): + """Integration for Trae IDE.""" + + key = "trae" + config = { + "name": "Trae", + "folder": ".trae/", + "commands_subdir": "skills", + "install_url": None, + "requires_cli": False, + } + registrar_config = { + "dir": ".trae/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", + } + context_file = ".trae/rules/project_rules.md" + + @classmethod + def options(cls) -> list[IntegrationOption]: + return [ + IntegrationOption( + "--skills", + is_flag=True, + default=True, + help="Install as agent skills (default for trae since v0.5.1)", + ), + ] diff --git a/src/specify_cli/integrations/vibe/__init__.py b/src/specify_cli/integrations/vibe/__init__.py new file mode 100644 index 0000000000..f5ad63bdc2 --- /dev/null +++ b/src/specify_cli/integrations/vibe/__init__.py @@ -0,0 +1,133 @@ +""" +Mistral Vibe CLI integration — skills-based agent. + +Vibe uses ``.vibe/skills/speckit-/SKILL.md`` layout (enforced since v2.0.0). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from ..base import IntegrationOption, SkillsIntegration +from ..manifest import IntegrationManifest + + +class VibeIntegration(SkillsIntegration): + key = "vibe" + config = { + "name": "Mistral Vibe", + "folder": ".vibe/", + "commands_subdir": "skills", + "install_url": "https://github.com/mistralai/mistral-vibe", + "requires_cli": True, + } + registrar_config = { + "dir": ".vibe/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", + } + context_file = "AGENTS.md" + + @classmethod + def options(cls) -> list[IntegrationOption]: + return [ + IntegrationOption( + "--skills", + is_flag=True, + default=True, + help="Install as agent skills", + ), + ] + + @staticmethod + def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str: + """ + Insert ``key: value`` before the closing ``---`` if not already present. + Value: true by default + """ + lines = content.splitlines(keepends=True) + + # Pre-scan: bail out if already present in frontmatter + dash_count = 0 + for line in lines: + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + if dash_count == 2: + break + continue + if dash_count == 1 and stripped.startswith(f"{key}:"): + return content + + # Inject before the closing --- of frontmatter + out: list[str] = [] + dash_count = 0 + injected = False + for line in lines: + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + if dash_count == 2 and not injected: + if line.endswith("\r\n"): + eol = "\r\n" + elif line.endswith("\n"): + eol = "\n" + else: + eol = "" + out.append(f"{key}: {value}{eol}") + injected = True + out.append(line) + return "".join(out) + + + def post_process_skill_content(self, content: str) -> str: + """ + Inject Vibe-specific frontmatter flags: + - user-invocable: allows the skill to be invoked by the user (not just other agents) + """ + updated = self._inject_frontmatter_flag(content, "user-invocable") + return updated + + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Install Vibe skills then inject Vibe-specific flags""" + import click + + click.secho( + "Warning: The .vibe/skills layout requires Mistral Vibe v2.0.0 or newer. " + "Please ensure your installation is up to date.", + fg="yellow", + err=True, + ) + + created = super().setup(project_root, manifest, parsed_options=parsed_options, **opts) + + # Post-process generated skill files + skills_dir = self.skills_dest(project_root).resolve() + + for path in created: + # Only touch SKILL.md files under the skills directory + try: + path.resolve().relative_to(skills_dir) + except ValueError: + continue + if path.name != "SKILL.md": + continue + + content_bytes = path.read_bytes() + content = content_bytes.decode("utf-8") + + updated = self.post_process_skill_content(content) + + if updated != content: + path.write_bytes(updated.encode("utf-8")) + self.record_file_in_manifest(path, project_root, manifest) + + return created diff --git a/src/specify_cli/integrations/windsurf/__init__.py b/src/specify_cli/integrations/windsurf/__init__.py new file mode 100644 index 0000000000..f0f77d318e --- /dev/null +++ b/src/specify_cli/integrations/windsurf/__init__.py @@ -0,0 +1,21 @@ +"""Windsurf IDE integration.""" + +from ..base import MarkdownIntegration + + +class WindsurfIntegration(MarkdownIntegration): + key = "windsurf" + config = { + "name": "Windsurf", + "folder": ".windsurf/", + "commands_subdir": "workflows", + "install_url": None, + "requires_cli": False, + } + registrar_config = { + "dir": ".windsurf/workflows", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = ".windsurf/rules/specify-rules.md" diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py new file mode 100644 index 0000000000..ed33f992c3 --- /dev/null +++ b/src/specify_cli/presets.py @@ -0,0 +1,3075 @@ +""" +Preset Manager for Spec Kit + +Handles installation, removal, and management of Spec Kit presets. +Presets are self-contained, versioned collections of templates +(artifact, command, and script templates) that can be installed to +customize the Spec-Driven Development workflow. +""" + +import copy +import json +import hashlib +import os +import tempfile +import zipfile +import shutil +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Optional, Dict, List, Any + +if TYPE_CHECKING: + from .agents import CommandRegistrar +from datetime import datetime, timezone +import re + +import yaml +from packaging import version as pkg_version +from packaging.specifiers import SpecifierSet, InvalidSpecifier + +from .extensions import ExtensionRegistry, normalize_priority + + +def _substitute_core_template( + body: str, + cmd_name: str, + project_root: "Path", + registrar: "CommandRegistrar", +) -> "tuple[str, dict]": + """Substitute {CORE_TEMPLATE} with the body of the installed core command template. + + Args: + body: Preset command body (may contain {CORE_TEMPLATE} placeholder). + cmd_name: Full command name (e.g. "speckit.git.feature" or "speckit.specify"). + project_root: Project root path. + registrar: CommandRegistrar instance for parse_frontmatter. + + Returns: + A tuple of (body, core_frontmatter) where body has {CORE_TEMPLATE} replaced + by the core template body and core_frontmatter holds the core template's parsed + frontmatter (so callers can inherit scripts/agent_scripts from it). Both are + unchanged / empty when the placeholder is absent or the core template file does + not exist. + """ + if "{CORE_TEMPLATE}" not in body: + return body, {} + + # Derive the short name (strip "speckit." prefix) used by core command templates. + short_name = cmd_name + if short_name.startswith("speckit."): + short_name = short_name[len("speckit."):] + + resolver = PresetResolver(project_root) + # Resolution order for the core template: + # 1. resolve_core(cmd_name) — covers tier-1 project overrides and tier-3/4 + # name-based lookup (file named .md). Checked first so that a + # local override always wins, even for extension commands. + # 2. resolve_extension_command_via_manifest(cmd_name) — manifest-based tier-3 + # fallback for extension commands whose file is named differently from the + # command name (e.g. speckit.selftest.extension → commands/selftest.md). + # 3. resolve_core(short_name) — core template fallback using the unprefixed + # name (e.g. specify → templates/commands/specify.md). + # resolve_core() skips installed presets (tier 2) to prevent accidental nesting + # where another preset's wrap output is mistaken for the real core. + core_file = ( + resolver.resolve_core(cmd_name, "command") + or resolver.resolve_extension_command_via_manifest(cmd_name) + or resolver.resolve_core(short_name, "command") + ) + if core_file is None: + return body, {} + + core_frontmatter, core_body = registrar.parse_frontmatter(core_file.read_text(encoding="utf-8")) + return body.replace("{CORE_TEMPLATE}", core_body), core_frontmatter + + +@dataclass +class PresetCatalogEntry: + """Represents a single entry in the preset catalog stack.""" + url: str + name: str + priority: int + install_allowed: bool + description: str = "" + + +class PresetError(Exception): + """Base exception for preset-related errors.""" + pass + + +class PresetValidationError(PresetError): + """Raised when preset manifest validation fails.""" + pass + + +class PresetCompatibilityError(PresetError): + """Raised when preset is incompatible with current environment.""" + pass + + +VALID_PRESET_TEMPLATE_TYPES = {"template", "command", "script"} +VALID_PRESET_STRATEGIES = {"replace", "prepend", "append", "wrap"} +# Scripts only support replace and wrap (prepend/append don't make semantic sense for executable code) +VALID_SCRIPT_STRATEGIES = {"replace", "wrap"} + + +class PresetManifest: + """Represents and validates a preset manifest (preset.yml).""" + + SCHEMA_VERSION = "1.0" + REQUIRED_FIELDS = ["schema_version", "preset", "requires", "provides"] + + def __init__(self, manifest_path: Path): + """Load and validate preset manifest. + + Args: + manifest_path: Path to preset.yml file + + Raises: + PresetValidationError: If manifest is invalid + """ + self.path = manifest_path + self.data = self._load_yaml(manifest_path) + self._validate() + + def _load_yaml(self, path: Path) -> dict: + """Load YAML file safely.""" + try: + with open(path, 'r') as f: + return yaml.safe_load(f) or {} + except yaml.YAMLError as e: + raise PresetValidationError(f"Invalid YAML in {path}: {e}") + except FileNotFoundError: + raise PresetValidationError(f"Manifest not found: {path}") + + def _validate(self): + """Validate manifest structure and required fields.""" + # Check required top-level fields + for field in self.REQUIRED_FIELDS: + if field not in self.data: + raise PresetValidationError(f"Missing required field: {field}") + + # Validate schema version + if self.data["schema_version"] != self.SCHEMA_VERSION: + raise PresetValidationError( + f"Unsupported schema version: {self.data['schema_version']} " + f"(expected {self.SCHEMA_VERSION})" + ) + + # Validate preset metadata + pack = self.data["preset"] + for field in ["id", "name", "version", "description"]: + if field not in pack: + raise PresetValidationError(f"Missing preset.{field}") + + # Validate pack ID format + if not re.match(r'^[a-z0-9-]+$', pack["id"]): + raise PresetValidationError( + f"Invalid preset ID '{pack['id']}': " + "must be lowercase alphanumeric with hyphens only" + ) + + # Validate semantic version + try: + pkg_version.Version(pack["version"]) + except pkg_version.InvalidVersion: + raise PresetValidationError(f"Invalid version: {pack['version']}") + + # Validate requires section + requires = self.data["requires"] + if "speckit_version" not in requires: + raise PresetValidationError("Missing requires.speckit_version") + + # Validate provides section + provides = self.data["provides"] + if "templates" not in provides or not provides["templates"]: + raise PresetValidationError( + "Preset must provide at least one template" + ) + + # Validate templates + for tmpl in provides["templates"]: + if "type" not in tmpl or "name" not in tmpl or "file" not in tmpl: + raise PresetValidationError( + "Template missing 'type', 'name', or 'file'" + ) + + if tmpl["type"] not in VALID_PRESET_TEMPLATE_TYPES: + raise PresetValidationError( + f"Invalid template type '{tmpl['type']}': " + f"must be one of {sorted(VALID_PRESET_TEMPLATE_TYPES)}" + ) + + # Validate file path safety: must be relative, no parent traversal + file_path = tmpl["file"] + normalized = os.path.normpath(file_path) + if os.path.isabs(normalized) or normalized.startswith(".."): + raise PresetValidationError( + f"Invalid template file path '{file_path}': " + "must be a relative path within the preset directory" + ) + + # Validate strategy field (optional, defaults to "replace") + strategy = tmpl.get("strategy", "replace") + if not isinstance(strategy, str): + raise PresetValidationError( + f"Invalid strategy value: must be a string, " + f"got {type(strategy).__name__}" + ) + strategy = strategy.lower() + # Persist normalized value so downstream code sees lowercase + if "strategy" in tmpl: + tmpl["strategy"] = strategy + if strategy not in VALID_PRESET_STRATEGIES: + raise PresetValidationError( + f"Invalid strategy '{strategy}': " + f"must be one of {sorted(VALID_PRESET_STRATEGIES)}" + ) + if tmpl["type"] == "script" and strategy not in VALID_SCRIPT_STRATEGIES: + raise PresetValidationError( + f"Invalid strategy '{strategy}' for script: " + f"scripts only support {sorted(VALID_SCRIPT_STRATEGIES)}" + ) + + # Validate template name format + if tmpl["type"] == "command": + # Commands use dot notation (e.g. speckit.specify) + if not re.match(r'^[a-z0-9.-]+$', tmpl["name"]): + raise PresetValidationError( + f"Invalid command name '{tmpl['name']}': " + "must be lowercase alphanumeric with hyphens and dots only" + ) + else: + if not re.match(r'^[a-z0-9-]+$', tmpl["name"]): + raise PresetValidationError( + f"Invalid template name '{tmpl['name']}': " + "must be lowercase alphanumeric with hyphens only" + ) + + @property + def id(self) -> str: + """Get preset ID.""" + return self.data["preset"]["id"] + + @property + def name(self) -> str: + """Get preset name.""" + return self.data["preset"]["name"] + + @property + def version(self) -> str: + """Get preset version.""" + return self.data["preset"]["version"] + + @property + def description(self) -> str: + """Get preset description.""" + return self.data["preset"]["description"] + + @property + def author(self) -> str: + """Get preset author.""" + return self.data["preset"].get("author", "") + + @property + def requires_speckit_version(self) -> str: + """Get required spec-kit version range.""" + return self.data["requires"]["speckit_version"] + + @property + def templates(self) -> List[Dict[str, Any]]: + """Get list of provided templates.""" + return self.data["provides"]["templates"] + + @property + def tags(self) -> List[str]: + """Get preset tags.""" + return self.data.get("tags", []) + + def get_hash(self) -> str: + """Calculate SHA256 hash of manifest file.""" + with open(self.path, 'rb') as f: + return f"sha256:{hashlib.sha256(f.read()).hexdigest()}" + + +class PresetRegistry: + """Manages the registry of installed presets.""" + + REGISTRY_FILE = ".registry" + SCHEMA_VERSION = "1.0" + + def __init__(self, packs_dir: Path): + """Initialize registry. + + Args: + packs_dir: Path to .specify/presets/ directory + """ + self.packs_dir = packs_dir + self.registry_path = packs_dir / self.REGISTRY_FILE + self.data = self._load() + + def _load(self) -> dict: + """Load registry from disk.""" + if not self.registry_path.exists(): + return { + "schema_version": self.SCHEMA_VERSION, + "presets": {} + } + + try: + with open(self.registry_path, 'r') as f: + data = json.load(f) + # Validate loaded data is a dict (handles corrupted registry files) + if not isinstance(data, dict): + return { + "schema_version": self.SCHEMA_VERSION, + "presets": {} + } + # Normalize presets field (handles corrupted presets value) + if not isinstance(data.get("presets"), dict): + data["presets"] = {} + return data + except (json.JSONDecodeError, FileNotFoundError): + return { + "schema_version": self.SCHEMA_VERSION, + "presets": {} + } + + def _save(self): + """Save registry to disk.""" + self.packs_dir.mkdir(parents=True, exist_ok=True) + with open(self.registry_path, 'w') as f: + json.dump(self.data, f, indent=2) + + def add(self, pack_id: str, metadata: dict): + """Add preset to registry. + + Args: + pack_id: Preset ID + metadata: Pack metadata (version, source, etc.) + """ + self.data["presets"][pack_id] = { + **copy.deepcopy(metadata), + "installed_at": datetime.now(timezone.utc).isoformat() + } + self._save() + + def remove(self, pack_id: str): + """Remove preset from registry. + + Args: + pack_id: Preset ID + """ + packs = self.data.get("presets") + if not isinstance(packs, dict): + return + if pack_id in packs: + del packs[pack_id] + self._save() + + def update(self, pack_id: str, updates: dict): + """Update preset metadata in registry. + + Merges the provided updates with the existing entry, preserving any + fields not specified. The installed_at timestamp is always preserved + from the original entry. + + Args: + pack_id: Preset ID + updates: Partial metadata to merge into existing metadata + + Raises: + KeyError: If preset is not installed + """ + packs = self.data.get("presets") + if not isinstance(packs, dict) or pack_id not in packs: + raise KeyError(f"Preset '{pack_id}' not found in registry") + existing = packs[pack_id] + # Handle corrupted registry entries (e.g., string/list instead of dict) + if not isinstance(existing, dict): + existing = {} + # Merge: existing fields preserved, new fields override (deep copy to prevent caller mutation) + merged = {**existing, **copy.deepcopy(updates)} + # Always preserve original installed_at based on key existence, not truthiness, + # to handle cases where the field exists but may be falsy (legacy/corruption) + if "installed_at" in existing: + merged["installed_at"] = existing["installed_at"] + else: + # If not present in existing, explicitly remove from merged if caller provided it + merged.pop("installed_at", None) + packs[pack_id] = merged + self._save() + + def restore(self, pack_id: str, metadata: dict): + """Restore preset metadata to registry without modifying timestamps. + + Use this method for rollback scenarios where you have a complete backup + of the registry entry (including installed_at) and want to restore it + exactly as it was. + + Args: + pack_id: Preset ID + metadata: Complete preset metadata including installed_at + + Raises: + ValueError: If metadata is None or not a dict + """ + if metadata is None or not isinstance(metadata, dict): + raise ValueError(f"Cannot restore '{pack_id}': metadata must be a dict") + # Ensure presets dict exists (handle corrupted registry) + if not isinstance(self.data.get("presets"), dict): + self.data["presets"] = {} + self.data["presets"][pack_id] = copy.deepcopy(metadata) + self._save() + + def get(self, pack_id: str) -> Optional[dict]: + """Get preset metadata from registry. + + Returns a deep copy to prevent callers from accidentally mutating + nested internal registry state without going through the write path. + + Args: + pack_id: Preset ID + + Returns: + Deep copy of preset metadata, or None if not found or corrupted + """ + packs = self.data.get("presets") + if not isinstance(packs, dict): + return None + entry = packs.get(pack_id) + # Return None for missing or corrupted (non-dict) entries + if entry is None or not isinstance(entry, dict): + return None + return copy.deepcopy(entry) + + def list(self) -> Dict[str, dict]: + """Get all installed presets with valid metadata. + + Returns a deep copy of presets with dict metadata only. + Corrupted entries (non-dict values) are filtered out. + + Returns: + Dictionary of pack_id -> metadata (deep copies), empty dict if corrupted + """ + packs = self.data.get("presets", {}) or {} + if not isinstance(packs, dict): + return {} + # Filter to only valid dict entries to match type contract + return { + pack_id: copy.deepcopy(meta) + for pack_id, meta in packs.items() + if isinstance(meta, dict) + } + + def keys(self) -> set: + """Get all preset IDs including corrupted entries. + + Lightweight method that returns IDs without deep-copying metadata. + Use this when you only need to check which presets are tracked. + + Returns: + Set of preset IDs (includes corrupted entries) + """ + packs = self.data.get("presets", {}) or {} + if not isinstance(packs, dict): + return set() + return set(packs.keys()) + + def list_by_priority(self, include_disabled: bool = False) -> List[tuple]: + """Get all installed presets sorted by priority. + + Lower priority number = higher precedence (checked first). + Presets with equal priority are sorted alphabetically by ID + for deterministic ordering. + + Args: + include_disabled: If True, include disabled presets. Default False. + + Returns: + List of (pack_id, metadata_copy) tuples sorted by priority. + Metadata is deep-copied to prevent accidental mutation. + """ + packs = self.data.get("presets", {}) or {} + if not isinstance(packs, dict): + packs = {} + sortable_packs = [] + for pack_id, meta in packs.items(): + if not isinstance(meta, dict): + continue + # Skip disabled presets unless explicitly requested + if not include_disabled and not meta.get("enabled", True): + continue + metadata_copy = copy.deepcopy(meta) + metadata_copy["priority"] = normalize_priority(metadata_copy.get("priority", 10)) + sortable_packs.append((pack_id, metadata_copy)) + return sorted( + sortable_packs, + key=lambda item: (item[1]["priority"], item[0]), + ) + + def is_installed(self, pack_id: str) -> bool: + """Check if preset is installed. + + Args: + pack_id: Preset ID + + Returns: + True if pack is installed, False if not or registry corrupted + """ + packs = self.data.get("presets") + if not isinstance(packs, dict): + return False + return pack_id in packs + + +class PresetManager: + """Manages preset lifecycle: installation, removal, updates.""" + + def __init__(self, project_root: Path): + """Initialize preset manager. + + Args: + project_root: Path to project root directory + """ + self.project_root = project_root + self.presets_dir = project_root / ".specify" / "presets" + self.registry = PresetRegistry(self.presets_dir) + + def check_compatibility( + self, + manifest: PresetManifest, + speckit_version: str + ) -> bool: + """Check if preset is compatible with current spec-kit version. + + Args: + manifest: Preset manifest + speckit_version: Current spec-kit version + + Returns: + True if compatible + + Raises: + PresetCompatibilityError: If pack is incompatible + """ + required = manifest.requires_speckit_version + current = pkg_version.Version(speckit_version) + + try: + specifier = SpecifierSet(required) + if current not in specifier: + raise PresetCompatibilityError( + f"Preset requires spec-kit {required}, " + f"but {speckit_version} is installed.\n" + f"Upgrade spec-kit with: uv tool install specify-cli --force" + ) + except InvalidSpecifier: + raise PresetCompatibilityError( + f"Invalid version specifier: {required}" + ) + + return True + + def _register_commands( + self, + manifest: PresetManifest, + preset_dir: Path + ) -> Dict[str, List[str]]: + """Register preset command overrides with all detected AI agents. + + Scans the preset's templates for type "command", reads each command + file, and writes it to every detected agent directory using the + CommandRegistrar from the agents module. + + When a command uses a composition strategy (prepend, append, wrap), + the content is composed with the lower-priority command before + registration. + + Args: + manifest: Preset manifest + preset_dir: Installed preset directory + + Returns: + Dictionary mapping agent names to lists of registered command names + """ + command_templates = [ + t for t in manifest.templates if t.get("type") == "command" + ] + if not command_templates: + return {} + + # Filter out extension command overrides if the extension isn't installed. + # Command names follow the pattern: speckit.. + # Core commands (e.g. speckit.specify) have only one dot — always register. + extensions_dir = self.project_root / ".specify" / "extensions" + filtered = [] + for cmd in command_templates: + parts = cmd["name"].split(".") + if len(parts) >= 3 and parts[0] == "speckit": + ext_id = parts[1] + if not (extensions_dir / ext_id).is_dir(): + continue + filtered.append(cmd) + + if not filtered: + return {} + + # Handle composition strategies: resolve composed content for non-replace commands + resolver = PresetResolver(self.project_root) + composed_dir = None + commands_to_register = [] + for cmd in filtered: + strategy = cmd.get("strategy", "replace") + if strategy != "replace": + # Only pre-compose if this preset is the top composing layer. + # If a higher-priority replace already wins, skip composition + # here — reconciliation will write the correct content. + layers = resolver.collect_all_layers(cmd["name"], "command") + top_layer_is_ours = ( + layers and layers[0]["path"].is_relative_to(preset_dir) + ) + if top_layer_is_ours: + composed = resolver.resolve_content(cmd["name"], "command") + if composed is not None: + if composed_dir is None: + composed_dir = preset_dir / ".composed" + composed_dir.mkdir(parents=True, exist_ok=True) + composed_file = composed_dir / f"{cmd['name']}.md" + composed_file.write_text(composed, encoding="utf-8") + commands_to_register.append({ + **cmd, + "file": f".composed/{cmd['name']}.md", + }) + else: + raise PresetValidationError( + f"Command '{cmd['name']}' uses '{strategy}' strategy " + f"but no base command layer exists to compose onto. " + f"Ensure a lower-priority preset, extension, or core " + f"command provides this command before using " + f"composition strategies." + ) + else: + # Not the top layer — register raw file; reconciliation + # will overwrite with the correct composed/winning content. + # Note: CommandRegistrar may process frontmatter strategy: wrap + # from the raw file (legacy compat), but reconciliation runs + # immediately after install and corrects the final output. + commands_to_register.append(cmd) + else: + commands_to_register.append(cmd) + + try: + from .agents import CommandRegistrar + except ImportError: + return {} + + registrar = CommandRegistrar() + return registrar.register_commands_for_all_agents( + commands_to_register, manifest.id, preset_dir, self.project_root + ) + + def _unregister_commands(self, registered_commands: Dict[str, List[str]]) -> None: + """Remove previously registered command files from agent directories. + + Args: + registered_commands: Dict mapping agent names to command name lists + """ + try: + from .agents import CommandRegistrar + except ImportError: + return + + registrar = CommandRegistrar() + registrar.unregister_commands(registered_commands, self.project_root) + + def _reconcile_composed_commands(self, command_names: List[str]) -> None: + """Re-resolve and re-register composed commands from the full stack. + + After install or remove, recompute the effective content for each + command name that participates in composition, and write the winning + content to the agent directories. This ensures command files always + reflect the current priority stack rather than depending on + install/remove order. + + Args: + command_names: List of command names to reconcile + """ + if not command_names: + return + + try: + from .agents import CommandRegistrar + except ImportError: + return + + resolver = PresetResolver(self.project_root) + registrar = CommandRegistrar() + + # Cache registry and manifests outside the loop to avoid + # repeated filesystem reads for each command name. + presets_by_priority = list(self.registry.list_by_priority()) + + for cmd_name in command_names: + layers = resolver.collect_all_layers(cmd_name, "command") + if not layers: + continue + + # If the top layer is replace, it wins entirely — lower layers + # are irrelevant regardless of their strategies. + top_is_replace = layers[0]["strategy"] == "replace" + has_composition = not top_is_replace and any( + layer["strategy"] != "replace" for layer in layers + ) + if not has_composition: + # Pure replace — the top layer wins. + top_layer = layers[0] + top_path = top_layer["path"] + # Try to find which preset owns this layer + registered = False + for pack_id, _meta in presets_by_priority: + pack_dir = self.presets_dir / pack_id + if top_path.is_relative_to(pack_dir): + manifest = resolver._get_manifest(pack_dir) + if manifest: + for tmpl in manifest.templates: + if tmpl.get("name") == cmd_name and tmpl.get("type") == "command": + self._register_for_non_skill_agents( + registrar, [tmpl], manifest.id, pack_dir + ) + registered = True + break + break + if not registered: + # Top layer is a non-preset source (extension, core, or + # project override). Register directly from the layer path. + source = layers[0]["source"] + if source.startswith("extension:"): + # Use extension's own registration to preserve context formatting + ext_id = source.split(":", 1)[1].split(" ", 1)[0] + ext_dir = self.project_root / ".specify" / "extensions" / ext_id + ext_manifest_path = ext_dir / "extension.yml" + if ext_manifest_path.exists(): + try: + from .extensions import ExtensionManifest + ext_manifest = ExtensionManifest(ext_manifest_path) + # Filter to only the command being reconciled + matching_cmds = [ + c for c in ext_manifest.commands + if c.get("name") == cmd_name + ] + if matching_cmds: + registrar.register_commands_for_non_skill_agents( + matching_cmds, ext_id, ext_dir, + self.project_root, + context_note=f"\n\n\n", + ) + registered = True + except Exception: + # Extension registration failed; fall back to + # generic path-based registration below. + pass + if not registered: + source_id = source.split(":", 1)[1].split(" ", 1)[0] if source.startswith("extension:") else source + self._register_command_from_path( + registrar, cmd_name, top_path, + source_id=source_id, + ) + else: + # Composed command — resolve from full stack + composed = resolver.resolve_content(cmd_name, "command") + if composed is None: + # Composition no longer possible (e.g. base layer removed). + # Unregister any stale command file from non-skill agents. + import warnings + warnings.warn( + f"Cannot compose command '{cmd_name}': no base layer. " + f"Stale command files may remain.", + stacklevel=2, + ) + registrar._ensure_configs() + # Include aliases from the top layer's manifest + cmd_names_to_unregister = [cmd_name] + for _pid, _meta in presets_by_priority: + _pd = self.presets_dir / _pid + _m = resolver._get_manifest(_pd) + if _m: + for _t in _m.templates: + if _t.get("name") == cmd_name and _t.get("type") == "command": + for alias in _t.get("aliases", []): + if isinstance(alias, str): + cmd_names_to_unregister.append(alias) + break + registrar.unregister_commands( + {agent: cmd_names_to_unregister for agent in registrar.AGENT_CONFIGS + if registrar.AGENT_CONFIGS[agent].get("extension") != "/SKILL.md"}, + self.project_root, + ) + continue + + # Write to the highest-priority preset's .composed dir + registered = False + for pack_id, _meta in presets_by_priority: + pack_dir = self.presets_dir / pack_id + manifest = resolver._get_manifest(pack_dir) + if not manifest: + continue + for tmpl in manifest.templates: + if tmpl.get("name") == cmd_name and tmpl.get("type") == "command": + composed_dir = pack_dir / ".composed" + composed_dir.mkdir(parents=True, exist_ok=True) + composed_file = composed_dir / f"{cmd_name}.md" + composed_file.write_text(composed, encoding="utf-8") + self._register_for_non_skill_agents( + registrar, + [{**tmpl, "file": f".composed/{cmd_name}.md"}], + manifest.id, pack_dir, + ) + registered = True + break + else: + continue + break + if not registered: + # No preset owns this composed command — write to a + # shared .composed dir and register from the top layer. + shared_composed = self.presets_dir / ".composed" + shared_composed.mkdir(parents=True, exist_ok=True) + composed_file = shared_composed / f"{cmd_name}.md" + composed_file.write_text(composed, encoding="utf-8") + source = layers[0]["source"] + if source.startswith("extension:"): + source_id = source.split(":", 1)[1].split(" ", 1)[0] + else: + source_id = source + self._register_command_from_path( + registrar, cmd_name, composed_file, + source_id=source_id, + ) + + def _register_command_from_path( + self, + registrar: Any, + cmd_name: str, + cmd_path: Path, + source_id: str = "reconciled", + ) -> None: + """Register a single command from a file path (non-preset source). + + Used by reconciliation when the winning layer is an extension, + core template, or project override rather than a preset. + + Args: + registrar: CommandRegistrar instance + cmd_name: Command name + cmd_path: Path to the command file + source_id: Source attribution for rendered output + """ + if not cmd_path.exists(): + return + cmd_tmpl: Dict[str, Any] = { + "name": cmd_name, + "type": "command", + "file": cmd_path.name, + } + # Load aliases from extension manifest when the winning layer is an extension + if source_id and not source_id.startswith("preset:"): + try: + from .extensions import ExtensionManifest + for ext_dir in (self.project_root / ".specify" / "extensions").iterdir(): + if not ext_dir.is_dir(): + continue + if cmd_path.is_relative_to(ext_dir): + manifest_path = ext_dir / "extension.yml" + if manifest_path.exists(): + ext_manifest = ExtensionManifest(manifest_path) + for cmd in ext_manifest.commands: + if cmd.get("name") == cmd_name: + aliases = cmd.get("aliases", []) + if isinstance(aliases, list) and aliases: + cmd_tmpl["aliases"] = aliases + break + break + except Exception: + pass # best-effort alias loading + self._register_for_non_skill_agents( + registrar, [cmd_tmpl], source_id, cmd_path.parent + ) + + def _register_for_non_skill_agents( + self, + registrar: Any, + commands: List[Dict[str, Any]], + source_id: str, + source_dir: Path, + ) -> None: + """Register commands for non-skill agents during reconciliation. + + Skill-based agents (``/SKILL.md`` layout) are handled separately: + - On removal: ``_unregister_skills()`` restores from core/extension, + then ``_reconcile_skills()`` re-runs ``_register_skills()`` for the + next winning preset so SKILL.md files get proper frontmatter and + descriptions. + - On install: ``_register_skills()`` writes formatted SKILL.md, then + ``_reconcile_skills()`` ensures the actual priority winner is used. + + Writing raw command content to skill agents would produce invalid + SKILL.md files (missing skill frontmatter, descriptions, etc.). + """ + registrar.register_commands_for_non_skill_agents( + commands, source_id, source_dir, self.project_root + ) + + class _FilteredManifest: + """Wrapper that exposes only selected command templates from a manifest. + + Used by _reconcile_skills to avoid overwriting skills for commands + that aren't being reconciled. + """ + + def __init__(self, manifest: "PresetManifest", cmd_names: set): + self._manifest = manifest + self._cmd_names = cmd_names + + def __getattr__(self, name: str): + return getattr(self._manifest, name) + + @property + def templates(self) -> List[Dict[str, Any]]: + return [ + t for t in self._manifest.templates + if t.get("name") in self._cmd_names + ] + + def _reconcile_skills(self, command_names: List[str]) -> None: + """Re-register skills for commands whose winning layer changed. + + After a preset is removed, finds the next preset in the priority + stack that provides each command and re-runs skill registration + for that preset so SKILL.md files reflect the current winner. + + Args: + command_names: List of command names to reconcile skills for + """ + if not command_names: + return + + resolver = PresetResolver(self.project_root) + skills_dir = self._get_skills_dir() + + # Cache registry once to avoid repeated filesystem reads + presets_by_priority = list(self.registry.list_by_priority()) + + # Group command names by winning preset to batch _register_skills calls + # while only registering skills for the specific commands being reconciled. + preset_cmds: Dict[str, List[str]] = {} + non_preset_skills: List[tuple] = [] + + for cmd_name in command_names: + layers = resolver.collect_all_layers(cmd_name, "command") + if not layers: + continue + + # Re-create the skill directory only if it was previously managed + # (i.e., listed in some preset's registered_skills). This avoids + # creating new skill dirs that _register_skills would normally skip. + if skills_dir: + skill_name, _ = self._skill_names_for_command(cmd_name) + skill_subdir = skills_dir / skill_name + if not skill_subdir.exists(): + # Check if any preset previously registered this skill + was_managed = False + for _pid, meta in presets_by_priority: + if not isinstance(meta, dict): + continue + if skill_name in meta.get("registered_skills", []): + was_managed = True + break + if was_managed: + skill_subdir.mkdir(parents=True, exist_ok=True) + + top_path = layers[0]["path"] + # Find the preset that owns the winning layer + found_preset = False + for pack_id, _meta in presets_by_priority: + pack_dir = self.presets_dir / pack_id + if top_path.is_relative_to(pack_dir): + preset_cmds.setdefault(pack_id, []).append(cmd_name) + found_preset = True + break + if not found_preset: + # Winner is a non-preset source (core/extension/override). + # Track the winning layer path for skill restoration. + skill_name, _ = self._skill_names_for_command(cmd_name) + non_preset_skills.append((skill_name, cmd_name, layers[0])) + + # Restore skills for commands whose winner is non-preset. + if non_preset_skills and skills_dir: + # Separate override-backed skills from core/extension-backed ones. + # _unregister_skills can rmtree the skill dir, so overrides must + # be handled directly (create dir + write) without that call. + core_ext_skills = [] + override_skills = [] + for item in non_preset_skills: + if item[2]["source"] == "project override": + override_skills.append(item) + else: + core_ext_skills.append(item) + + if core_ext_skills: + self._unregister_skills( + [s[0] for s in core_ext_skills], self.presets_dir + ) + + for skill_name, cmd_name, top_layer in override_skills: + skill_subdir = skills_dir / skill_name + skill_subdir.mkdir(parents=True, exist_ok=True) + skill_file = skill_subdir / "SKILL.md" + try: + from .agents import CommandRegistrar + from . import SKILL_DESCRIPTIONS, load_init_options + registrar = CommandRegistrar() + content = top_layer["path"].read_text(encoding="utf-8") + fm, body = registrar.parse_frontmatter(content) + short_name = cmd_name + if short_name.startswith("speckit."): + short_name = short_name[len("speckit."):] + desc = SKILL_DESCRIPTIONS.get( + short_name.replace(".", "-"), + fm.get("description", f"Command: {short_name}"), + ) + init_opts = load_init_options(self.project_root) + selected_ai = init_opts.get("ai") if isinstance(init_opts, dict) else "" + if isinstance(selected_ai, str): + body = registrar.resolve_skill_placeholders( + selected_ai, fm, body, self.project_root + ) + fm_data = registrar.build_skill_frontmatter( + selected_ai if isinstance(selected_ai, str) else "", + skill_name, desc, + f"override:{cmd_name}", + ) + fm_text = yaml.safe_dump(fm_data, sort_keys=False).strip() + skill_title = self._skill_title_from_command(cmd_name) + skill_content = ( + f"---\n{fm_text}\n---\n\n" + f"# Speckit {skill_title} Skill\n\n{body}\n" + ) + # Apply integration post-processing (e.g. Claude flags) + from .integrations import get_integration + integration = get_integration(selected_ai) if isinstance(selected_ai, str) else None + if integration is not None and hasattr(integration, "post_process_skill_content"): + skill_content = integration.post_process_skill_content(skill_content) + skill_file.write_text(skill_content, encoding="utf-8") + except Exception: + pass # best-effort override skill restoration + + # Register skills only for the specific commands being reconciled, + # not all commands in each winning preset's manifest. + for pack_id, cmds in preset_cmds.items(): + pack_dir = self.presets_dir / pack_id + manifest_path = pack_dir / "preset.yml" + if not manifest_path.exists(): + continue + try: + manifest = PresetManifest(manifest_path) + except PresetValidationError: + continue + # Filter manifest to only the commands being reconciled + cmds_set = set(cmds) + filtered_manifest = self._FilteredManifest(manifest, cmds_set) + self._register_skills(filtered_manifest, pack_dir) + + def _get_skills_dir(self) -> Optional[Path]: + """Return the active skills directory for preset skill overrides. + + Reads ``.specify/init-options.json`` to determine whether skills + are enabled and which agent was selected, then delegates to + the module-level ``_get_skills_dir()`` helper for the concrete path. + + Kimi is treated as a native-skills agent: if ``ai == "kimi"`` and + ``.kimi/skills`` exists, presets should still propagate command + overrides to skills even when ``ai_skills`` is false. + + Returns: + The skills directory ``Path``, or ``None`` if skills were not + enabled and no native-skills fallback applies. + """ + from . import load_init_options, _get_skills_dir + + opts = load_init_options(self.project_root) + if not isinstance(opts, dict): + opts = {} + agent = opts.get("ai") + if not isinstance(agent, str) or not agent: + return None + + ai_skills_enabled = bool(opts.get("ai_skills")) + if not ai_skills_enabled and agent != "kimi": + return None + + skills_dir = _get_skills_dir(self.project_root, agent) + if not skills_dir.is_dir(): + return None + + return skills_dir + + @staticmethod + def _skill_names_for_command(cmd_name: str) -> tuple[str, str]: + """Return the modern and legacy skill directory names for a command.""" + raw_short_name = cmd_name + if raw_short_name.startswith("speckit."): + raw_short_name = raw_short_name[len("speckit."):] + + modern_skill_name = f"speckit-{raw_short_name.replace('.', '-')}" + legacy_skill_name = f"speckit.{raw_short_name}" + return modern_skill_name, legacy_skill_name + + @staticmethod + def _skill_title_from_command(cmd_name: str) -> str: + """Return a human-friendly title for a skill command name.""" + title_name = cmd_name + if title_name.startswith("speckit."): + title_name = title_name[len("speckit."):] + return title_name.replace(".", " ").replace("-", " ").title() + + def _build_extension_skill_restore_index(self) -> Dict[str, Dict[str, Any]]: + """Index extension-backed skill restore data by skill directory name.""" + from .extensions import ExtensionManifest, ValidationError + + resolver = PresetResolver(self.project_root) + extensions_dir = self.project_root / ".specify" / "extensions" + restore_index: Dict[str, Dict[str, Any]] = {} + + for _priority, ext_id, _metadata in resolver._get_all_extensions_by_priority(): + ext_dir = extensions_dir / ext_id + manifest_path = ext_dir / "extension.yml" + if not manifest_path.is_file(): + continue + + try: + manifest = ExtensionManifest(manifest_path) + except (ValidationError, TypeError, AttributeError): + continue + + ext_root = ext_dir.resolve() + for cmd_info in manifest.commands: + cmd_name = cmd_info.get("name") + cmd_file_rel = cmd_info.get("file") + if not isinstance(cmd_name, str) or not isinstance(cmd_file_rel, str): + continue + + cmd_path = Path(cmd_file_rel) + if cmd_path.is_absolute(): + continue + + try: + source_file = (ext_root / cmd_path).resolve() + source_file.relative_to(ext_root) + except (OSError, ValueError): + continue + + if not source_file.is_file(): + continue + + restore_info = { + "command_name": cmd_name, + "source_file": source_file, + "source": f"extension:{manifest.id}", + } + modern_skill_name, legacy_skill_name = self._skill_names_for_command(cmd_name) + restore_index.setdefault(modern_skill_name, restore_info) + if legacy_skill_name != modern_skill_name: + restore_index.setdefault(legacy_skill_name, restore_info) + + return restore_index + + def _register_skills( + self, + manifest: "PresetManifest", + preset_dir: Path, + ) -> List[str]: + """Generate SKILL.md files for preset command overrides. + + For every command template in the preset, checks whether a + corresponding skill already exists in any detected skills + directory. If so, the skill is overwritten with content derived + from the preset's command file. This ensures that presets that + override commands also propagate to the agentskills.io skill + layer when ``--ai-skills`` was used during project initialisation. + + Args: + manifest: Preset manifest. + preset_dir: Installed preset directory. + + Returns: + List of skill names that were written (for registry storage). + """ + command_templates = [ + t for t in manifest.templates if t.get("type") == "command" + ] + if not command_templates: + return [] + + # Filter out extension command overrides if the extension isn't installed, + # matching the same logic used by _register_commands(). + extensions_dir = self.project_root / ".specify" / "extensions" + filtered = [] + for cmd in command_templates: + parts = cmd["name"].split(".") + if len(parts) >= 3 and parts[0] == "speckit": + ext_id = parts[1] + if not (extensions_dir / ext_id).is_dir(): + continue + filtered.append(cmd) + + if not filtered: + return [] + + skills_dir = self._get_skills_dir() + if not skills_dir: + return [] + + from . import SKILL_DESCRIPTIONS, load_init_options + from .agents import CommandRegistrar + from .integrations import get_integration + + init_opts = load_init_options(self.project_root) + if not isinstance(init_opts, dict): + init_opts = {} + selected_ai = init_opts.get("ai") + if not isinstance(selected_ai, str): + return [] + ai_skills_enabled = bool(init_opts.get("ai_skills")) + registrar = CommandRegistrar() + integration = get_integration(selected_ai) + agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {}) + # Native skill agents (e.g. codex/kimi/agy/trae) materialize brand-new + # preset skills in _register_commands() because their detected agent + # directory is already the skills directory. This flag is only for + # command-backed agents that also mirror commands into skills. + create_missing_skills = ai_skills_enabled and agent_config.get("extension") != "/SKILL.md" + + written: List[str] = [] + + for cmd_tmpl in filtered: + cmd_name = cmd_tmpl["name"] + cmd_file_rel = cmd_tmpl["file"] + source_file = preset_dir / cmd_file_rel + if not source_file.exists(): + continue + + # Use composed content if available (written by _register_commands + # for commands with non-replace strategies), otherwise the original. + composed_file = preset_dir / ".composed" / f"{cmd_name}.md" + if composed_file.exists(): + source_file = composed_file + + # Derive the short command name (e.g. "specify" from "speckit.specify") + raw_short_name = cmd_name + if raw_short_name.startswith("speckit."): + raw_short_name = raw_short_name[len("speckit."):] + short_name = raw_short_name.replace(".", "-") + skill_name, legacy_skill_name = self._skill_names_for_command(cmd_name) + skill_title = self._skill_title_from_command(cmd_name) + + # Only overwrite skills that already exist under skills_dir, + # including Kimi native skills when ai_skills is false. + # If both modern and legacy directories exist, update both. + target_skill_names: List[str] = [] + if (skills_dir / skill_name).is_dir(): + target_skill_names.append(skill_name) + if legacy_skill_name != skill_name and (skills_dir / legacy_skill_name).is_dir(): + target_skill_names.append(legacy_skill_name) + if not target_skill_names and create_missing_skills: + missing_skill_dir = skills_dir / skill_name + if not missing_skill_dir.exists(): + target_skill_names.append(skill_name) + if not target_skill_names: + continue + + # Parse the command file + content = source_file.read_text(encoding="utf-8") + frontmatter, body = registrar.parse_frontmatter(content) + + if frontmatter.get("strategy") == "wrap": + body, core_frontmatter = _substitute_core_template(body, cmd_name, self.project_root, registrar) + frontmatter = dict(frontmatter) + for key in ("scripts", "agent_scripts"): + if key not in frontmatter and key in core_frontmatter: + frontmatter[key] = core_frontmatter[key] + + original_desc = frontmatter.get("description", "") + enhanced_desc = SKILL_DESCRIPTIONS.get( + short_name, + original_desc or f"Spec-kit workflow command: {short_name}", + ) + frontmatter = dict(frontmatter) + frontmatter["description"] = enhanced_desc + body = registrar.resolve_skill_placeholders( + selected_ai, frontmatter, body, self.project_root + ) + + for target_skill_name in target_skill_names: + skill_subdir = skills_dir / target_skill_name + if skill_subdir.exists() and not skill_subdir.is_dir(): + continue + skill_subdir.mkdir(parents=True, exist_ok=True) + frontmatter_data = registrar.build_skill_frontmatter( + selected_ai, + target_skill_name, + enhanced_desc, + f"preset:{manifest.id}", + ) + frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip() + skill_content = ( + f"---\n" + f"{frontmatter_text}\n" + f"---\n\n" + f"# Speckit {skill_title} Skill\n\n" + f"{body}\n" + ) + if integration is not None and hasattr(integration, "post_process_skill_content"): + skill_content = integration.post_process_skill_content( + skill_content + ) + + skill_file = skill_subdir / "SKILL.md" + skill_file.write_text(skill_content, encoding="utf-8") + written.append(target_skill_name) + + return written + + def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None: + """Restore original SKILL.md files after a preset is removed. + + For each skill that was overridden by the preset, attempts to + regenerate the skill from the core command template. If no core + template exists, the skill directory is removed. + + Args: + skill_names: List of skill names written by the preset. + preset_dir: The preset's installed directory (may already be deleted). + """ + if not skill_names: + return + + skills_dir = self._get_skills_dir() + if not skills_dir: + return + + from . import SKILL_DESCRIPTIONS, load_init_options + from .agents import CommandRegistrar + from .integrations import get_integration + + # Locate core command templates from the project's installed templates + core_templates_dir = self.project_root / ".specify" / "templates" / "commands" + init_opts = load_init_options(self.project_root) + if not isinstance(init_opts, dict): + init_opts = {} + selected_ai = init_opts.get("ai") + registrar = CommandRegistrar() + integration = get_integration(selected_ai) if isinstance(selected_ai, str) else None + extension_restore_index = self._build_extension_skill_restore_index() + + for skill_name in skill_names: + # Derive command name from skill name (speckit-specify -> specify) + short_name = skill_name + if short_name.startswith("speckit-"): + short_name = short_name[len("speckit-"):] + elif short_name.startswith("speckit."): + short_name = short_name[len("speckit."):] + + skill_subdir = skills_dir / skill_name + skill_file = skill_subdir / "SKILL.md" + if not skill_subdir.is_dir(): + continue + if not skill_file.is_file(): + # Only manage directories that contain the expected skill entrypoint. + continue + + # Try to find the core command template + core_file = core_templates_dir / f"{short_name}.md" if core_templates_dir.exists() else None + if core_file and not core_file.exists(): + core_file = None + + if core_file: + # Restore from core template + content = core_file.read_text(encoding="utf-8") + frontmatter, body = registrar.parse_frontmatter(content) + if isinstance(selected_ai, str): + body = registrar.resolve_skill_placeholders( + selected_ai, frontmatter, body, self.project_root + ) + + original_desc = frontmatter.get("description", "") + enhanced_desc = SKILL_DESCRIPTIONS.get( + short_name, + original_desc or f"Spec-kit workflow command: {short_name}", + ) + + frontmatter_data = registrar.build_skill_frontmatter( + selected_ai if isinstance(selected_ai, str) else "", + skill_name, + enhanced_desc, + f"templates/commands/{short_name}.md", + ) + frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip() + skill_title = self._skill_title_from_command(short_name) + skill_content = ( + f"---\n" + f"{frontmatter_text}\n" + f"---\n\n" + f"# Speckit {skill_title} Skill\n\n" + f"{body}\n" + ) + if integration is not None and hasattr(integration, "post_process_skill_content"): + skill_content = integration.post_process_skill_content( + skill_content + ) + skill_file.write_text(skill_content, encoding="utf-8") + continue + + extension_restore = extension_restore_index.get(skill_name) + if extension_restore: + content = extension_restore["source_file"].read_text(encoding="utf-8") + frontmatter, body = registrar.parse_frontmatter(content) + if isinstance(selected_ai, str): + body = registrar.resolve_skill_placeholders( + selected_ai, frontmatter, body, self.project_root + ) + + command_name = extension_restore["command_name"] + title_name = self._skill_title_from_command(command_name) + + frontmatter_data = registrar.build_skill_frontmatter( + selected_ai if isinstance(selected_ai, str) else "", + skill_name, + frontmatter.get("description", f"Extension command: {command_name}"), + extension_restore["source"], + ) + frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip() + skill_content = ( + f"---\n" + f"{frontmatter_text}\n" + f"---\n\n" + f"# {title_name} Skill\n\n" + f"{body}\n" + ) + if integration is not None and hasattr(integration, "post_process_skill_content"): + skill_content = integration.post_process_skill_content( + skill_content + ) + skill_file.write_text(skill_content, encoding="utf-8") + else: + # No core or extension template — remove the skill entirely + shutil.rmtree(skill_subdir) + + def install_from_directory( + self, + source_dir: Path, + speckit_version: str, + priority: int = 10, + ) -> PresetManifest: + """Install preset from a local directory. + + Args: + source_dir: Path to preset directory + speckit_version: Current spec-kit version + priority: Resolution priority (lower = higher precedence, default 10) + + Returns: + Installed preset manifest + + Raises: + PresetValidationError: If manifest is invalid or priority is invalid + PresetCompatibilityError: If pack is incompatible + """ + # Validate priority + if priority < 1: + raise PresetValidationError("Priority must be a positive integer (1 or higher)") + + manifest_path = source_dir / "preset.yml" + manifest = PresetManifest(manifest_path) + + self.check_compatibility(manifest, speckit_version) + + if self.registry.is_installed(manifest.id): + raise PresetError( + f"Preset '{manifest.id}' is already installed. " + f"Use 'specify preset remove {manifest.id}' first." + ) + + dest_dir = self.presets_dir / manifest.id + if dest_dir.exists(): + shutil.rmtree(dest_dir) + + shutil.copytree(source_dir, dest_dir) + + # Pre-register the preset so that composition resolution can see it + # in the priority stack when resolving composed command content. + self.registry.add(manifest.id, { + "version": manifest.version, + "source": "local", + "manifest_hash": manifest.get_hash(), + "enabled": True, + "priority": priority, + "registered_commands": {}, + "registered_skills": [], + }) + + registered_commands: Dict[str, List[str]] = {} + registered_skills: List[str] = [] + try: + # Register command overrides with AI agents and persist the result + # immediately so cleanup can recover even if installation stops + # before later phases complete. + registered_commands = self._register_commands(manifest, dest_dir) + self.registry.update(manifest.id, { + "registered_commands": registered_commands, + }) + + # Update corresponding skills when --ai-skills was previously used + # and persist that result as well. + registered_skills = self._register_skills(manifest, dest_dir) + self.registry.update(manifest.id, { + "registered_skills": registered_skills, + }) + except Exception: + # Roll back all side effects. Note: if _register_commands or + # _register_skills raised mid-way (e.g. I/O error after writing + # some files), registered_commands/registered_skills may be empty + # and some agent command files could be orphaned. Removing dest_dir + # (which contains .composed/) and the registry entry ensures the + # preset system is consistent even if orphaned files remain. + if registered_commands: + self._unregister_commands(registered_commands) + if registered_skills: + self._unregister_skills(registered_skills, dest_dir) + try: + if dest_dir.exists(): + shutil.rmtree(dest_dir) + except OSError: + pass # best-effort cleanup; don't mask the original error + self.registry.remove(manifest.id) + raise + + # Reconcile all affected commands from the full priority stack so that + # install order doesn't determine the winning command file. + # Apply the same extension-installed filter as _register_commands to + # avoid reconciling extension commands when the extension isn't installed. + extensions_dir = self.project_root / ".specify" / "extensions" + cmd_names = [] + for t in manifest.templates: + if t.get("type") != "command": + continue + name = t["name"] + parts = name.split(".") + if len(parts) >= 3 and parts[0] == "speckit": + ext_id = parts[1] + if not (extensions_dir / ext_id).is_dir(): + continue + cmd_names.append(name) + if cmd_names: + try: + self._reconcile_composed_commands(cmd_names) + self._reconcile_skills(cmd_names) + except Exception as exc: + import warnings + warnings.warn( + f"Post-install reconciliation failed for {manifest.id}: {exc}. " + f"Agent command files may not reflect the current priority stack.", + stacklevel=2, + ) + + return manifest + + def install_from_zip( + self, + zip_path: Path, + speckit_version: str, + priority: int = 10, + ) -> PresetManifest: + """Install preset from ZIP file. + + Args: + zip_path: Path to preset ZIP file + speckit_version: Current spec-kit version + priority: Resolution priority (lower = higher precedence, default 10) + + Returns: + Installed preset manifest + + Raises: + PresetValidationError: If manifest is invalid or priority is invalid + PresetCompatibilityError: If pack is incompatible + """ + # Validate priority early + if priority < 1: + raise PresetValidationError("Priority must be a positive integer (1 or higher)") + + with tempfile.TemporaryDirectory() as tmpdir: + temp_path = Path(tmpdir) + + with zipfile.ZipFile(zip_path, 'r') as zf: + temp_path_resolved = temp_path.resolve() + for member in zf.namelist(): + member_path = (temp_path / member).resolve() + try: + member_path.relative_to(temp_path_resolved) + except ValueError: + raise PresetValidationError( + f"Unsafe path in ZIP archive: {member} " + "(potential path traversal)" + ) + zf.extractall(temp_path) + + pack_dir = temp_path + manifest_path = pack_dir / "preset.yml" + + if not manifest_path.exists(): + subdirs = [d for d in temp_path.iterdir() if d.is_dir()] + if len(subdirs) == 1: + pack_dir = subdirs[0] + manifest_path = pack_dir / "preset.yml" + + if not manifest_path.exists(): + raise PresetValidationError( + "No preset.yml found in ZIP file" + ) + + return self.install_from_directory(pack_dir, speckit_version, priority) + + def remove(self, pack_id: str) -> bool: + """Remove an installed preset. + + Args: + pack_id: Preset ID + + Returns: + True if pack was removed + """ + if not self.registry.is_installed(pack_id): + return False + + metadata = self.registry.get(pack_id) + # Restore original skills when preset is removed + registered_skills = metadata.get("registered_skills", []) if metadata else [] + registered_commands = metadata.get("registered_commands", {}) if metadata else {} + pack_dir = self.presets_dir / pack_id + + # Collect ALL command names before filtering for reconciliation, + # so commands registered only for skill-based agents are also reconciled. + # Also include aliases from the manifest as a safety net for registries + # populated by older versions that may not track aliases. + removed_cmd_names = set() + for cmd_names in registered_commands.values(): + removed_cmd_names.update(cmd_names) + manifest_path = pack_dir / "preset.yml" + if manifest_path.exists(): + try: + manifest = PresetManifest(manifest_path) + for tmpl in manifest.templates: + if tmpl.get("type") == "command": + for alias in tmpl.get("aliases", []): + if isinstance(alias, str): + removed_cmd_names.add(alias) + except PresetValidationError: + # Invalid manifest — skip alias extraction; primary command + # names from registered_commands are still unregistered. + pass + + if registered_skills: + self._unregister_skills(registered_skills, pack_dir) + try: + from .agents import CommandRegistrar + except ImportError: + CommandRegistrar = None + if CommandRegistrar is not None: + registered_commands = { + agent_name: cmd_names + for agent_name, cmd_names in registered_commands.items() + if CommandRegistrar.AGENT_CONFIGS.get(agent_name, {}).get("extension") != "/SKILL.md" + } + + # Unregister non-skill command files from AI agents. + if registered_commands: + self._unregister_commands(registered_commands) + + if pack_dir.exists(): + shutil.rmtree(pack_dir) + + self.registry.remove(pack_id) + + # Reconcile: if other presets still provide these commands, + # re-resolve from the remaining stack so the next layer takes effect. + if removed_cmd_names: + try: + self._reconcile_composed_commands(list(removed_cmd_names)) + self._reconcile_skills(list(removed_cmd_names)) + except Exception as exc: + import warnings + warnings.warn( + f"Post-removal reconciliation failed for {pack_id}: {exc}. " + f"Agent command files may be stale; reinstall affected presets " + f"or run 'specify preset add' to refresh.", + stacklevel=2, + ) + + return True + + def list_installed(self) -> List[Dict[str, Any]]: + """List all installed presets with metadata. + + Returns: + List of preset metadata dictionaries + """ + result = [] + + for pack_id, metadata in self.registry.list().items(): + # Ensure metadata is a dictionary to avoid AttributeError when using .get() + if not isinstance(metadata, dict): + metadata = {} + pack_dir = self.presets_dir / pack_id + manifest_path = pack_dir / "preset.yml" + + try: + manifest = PresetManifest(manifest_path) + result.append({ + "id": pack_id, + "name": manifest.name, + "version": metadata.get("version", manifest.version), + "description": manifest.description, + "enabled": metadata.get("enabled", True), + "installed_at": metadata.get("installed_at"), + "template_count": len(manifest.templates), + "tags": manifest.tags, + "priority": normalize_priority(metadata.get("priority")), + }) + except PresetValidationError: + result.append({ + "id": pack_id, + "name": pack_id, + "version": metadata.get("version", "unknown"), + "description": "āš ļø Corrupted preset", + "enabled": False, + "installed_at": metadata.get("installed_at"), + "template_count": 0, + "tags": [], + "priority": normalize_priority(metadata.get("priority")), + }) + + return result + + def get_pack(self, pack_id: str) -> Optional[PresetManifest]: + """Get manifest for an installed preset. + + Args: + pack_id: Preset ID + + Returns: + Preset manifest or None if not installed + """ + if not self.registry.is_installed(pack_id): + return None + + pack_dir = self.presets_dir / pack_id + manifest_path = pack_dir / "preset.yml" + + try: + return PresetManifest(manifest_path) + except PresetValidationError: + return None + + +class PresetCatalog: + """Manages preset catalog fetching, caching, and searching. + + Supports multi-catalog stacks with priority-based resolution, + mirroring the extension catalog system. + """ + + DEFAULT_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.json" + COMMUNITY_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json" + CACHE_DURATION = 3600 # 1 hour in seconds + + def __init__(self, project_root: Path): + """Initialize preset catalog manager. + + Args: + project_root: Root directory of the spec-kit project + """ + self.project_root = project_root + self.presets_dir = project_root / ".specify" / "presets" + self.cache_dir = self.presets_dir / ".cache" + self.cache_file = self.cache_dir / "catalog.json" + self.cache_metadata_file = self.cache_dir / "catalog-metadata.json" + + def _validate_catalog_url(self, url: str) -> None: + """Validate that a catalog URL uses HTTPS (localhost HTTP allowed). + + Args: + url: URL to validate + + Raises: + PresetValidationError: If URL is invalid or uses non-HTTPS scheme + """ + from urllib.parse import urlparse + + parsed = urlparse(url) + is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1") + if parsed.scheme != "https" and not ( + parsed.scheme == "http" and is_localhost + ): + raise PresetValidationError( + f"Catalog URL must use HTTPS (got {parsed.scheme}://). " + "HTTP is only allowed for localhost." + ) + if not parsed.netloc: + raise PresetValidationError( + "Catalog URL must be a valid URL with a host." + ) + + def _load_catalog_config(self, config_path: Path) -> Optional[List[PresetCatalogEntry]]: + """Load catalog stack configuration from a YAML file. + + Args: + config_path: Path to preset-catalogs.yml + + Returns: + Ordered list of PresetCatalogEntry objects, or None if file + doesn't exist or contains no valid catalog entries. + + Raises: + PresetValidationError: If any catalog entry has an invalid URL, + the file cannot be parsed, or a priority value is invalid. + """ + if not config_path.exists(): + return None + try: + data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + except (yaml.YAMLError, OSError, UnicodeError) as e: + raise PresetValidationError( + f"Failed to read catalog config {config_path}: {e}" + ) + if not isinstance(data, dict): + raise PresetValidationError( + f"Invalid catalog config {config_path}: expected a mapping at root, got {type(data).__name__}" + ) + catalogs_data = data.get("catalogs", []) + if not catalogs_data: + return None + if not isinstance(catalogs_data, list): + raise PresetValidationError( + f"Invalid catalog config: 'catalogs' must be a list, got {type(catalogs_data).__name__}" + ) + entries: List[PresetCatalogEntry] = [] + for idx, item in enumerate(catalogs_data): + if not isinstance(item, dict): + raise PresetValidationError( + f"Invalid catalog entry at index {idx}: expected a mapping, got {type(item).__name__}" + ) + url = str(item.get("url", "")).strip() + if not url: + continue + self._validate_catalog_url(url) + try: + priority = int(item.get("priority", idx + 1)) + except (TypeError, ValueError): + raise PresetValidationError( + f"Invalid priority for catalog '{item.get('name', idx + 1)}': " + f"expected integer, got {item.get('priority')!r}" + ) + raw_install = item.get("install_allowed", False) + if isinstance(raw_install, str): + install_allowed = raw_install.strip().lower() in ("true", "yes", "1") + else: + install_allowed = bool(raw_install) + entries.append(PresetCatalogEntry( + url=url, + name=str(item.get("name", f"catalog-{idx + 1}")), + priority=priority, + install_allowed=install_allowed, + description=str(item.get("description", "")), + )) + entries.sort(key=lambda e: e.priority) + return entries if entries else None + + def get_active_catalogs(self) -> List[PresetCatalogEntry]: + """Get the ordered list of active preset catalogs. + + Resolution order: + 1. SPECKIT_PRESET_CATALOG_URL env var — single catalog replacing all defaults + 2. Project-level .specify/preset-catalogs.yml + 3. User-level ~/.specify/preset-catalogs.yml + 4. Built-in default stack (default + community) + + Returns: + List of PresetCatalogEntry objects sorted by priority (ascending) + + Raises: + PresetValidationError: If a catalog URL is invalid + """ + import sys + + # 1. SPECKIT_PRESET_CATALOG_URL env var replaces all defaults + if env_value := os.environ.get("SPECKIT_PRESET_CATALOG_URL"): + catalog_url = env_value.strip() + self._validate_catalog_url(catalog_url) + if catalog_url != self.DEFAULT_CATALOG_URL: + if not getattr(self, "_non_default_catalog_warning_shown", False): + print( + "Warning: Using non-default preset catalog. " + "Only use catalogs from sources you trust.", + file=sys.stderr, + ) + self._non_default_catalog_warning_shown = True + return [PresetCatalogEntry(url=catalog_url, name="custom", priority=1, install_allowed=True, description="Custom catalog via SPECKIT_PRESET_CATALOG_URL")] + + # 2. Project-level config overrides all defaults + project_config_path = self.project_root / ".specify" / "preset-catalogs.yml" + catalogs = self._load_catalog_config(project_config_path) + if catalogs is not None: + return catalogs + + # 3. User-level config + user_config_path = Path.home() / ".specify" / "preset-catalogs.yml" + catalogs = self._load_catalog_config(user_config_path) + if catalogs is not None: + return catalogs + + # 4. Built-in default stack + return [ + PresetCatalogEntry(url=self.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True, description="Built-in catalog of installable presets"), + PresetCatalogEntry(url=self.COMMUNITY_CATALOG_URL, name="community", priority=2, install_allowed=False, description="Community-contributed presets (discovery only)"), + ] + + def get_catalog_url(self) -> str: + """Get the primary catalog URL. + + Returns the URL of the highest-priority catalog. Kept for backward + compatibility. Use get_active_catalogs() for full multi-catalog support. + + Returns: + URL of the primary catalog + """ + active = self.get_active_catalogs() + return active[0].url if active else self.DEFAULT_CATALOG_URL + + def _get_cache_paths(self, url: str): + """Get cache file paths for a given catalog URL. + + For the DEFAULT_CATALOG_URL, uses legacy cache files for backward + compatibility. For all other URLs, uses URL-hash-based cache files. + + Returns: + Tuple of (cache_file_path, cache_metadata_path) + """ + if url == self.DEFAULT_CATALOG_URL: + return self.cache_file, self.cache_metadata_file + url_hash = hashlib.sha256(url.encode()).hexdigest()[:16] + return ( + self.cache_dir / f"catalog-{url_hash}.json", + self.cache_dir / f"catalog-{url_hash}-metadata.json", + ) + + def _is_url_cache_valid(self, url: str) -> bool: + """Check if cached catalog for a specific URL is still valid.""" + cache_file, metadata_file = self._get_cache_paths(url) + if not cache_file.exists() or not metadata_file.exists(): + return False + try: + metadata = json.loads(metadata_file.read_text()) + cached_at = datetime.fromisoformat(metadata.get("cached_at", "")) + if cached_at.tzinfo is None: + cached_at = cached_at.replace(tzinfo=timezone.utc) + age_seconds = ( + datetime.now(timezone.utc) - cached_at + ).total_seconds() + return age_seconds < self.CACHE_DURATION + except (json.JSONDecodeError, ValueError, KeyError, TypeError): + return False + + def _fetch_single_catalog(self, entry: PresetCatalogEntry, force_refresh: bool = False) -> Dict[str, Any]: + """Fetch a single catalog with per-URL caching. + + Args: + entry: PresetCatalogEntry describing the catalog to fetch + force_refresh: If True, bypass cache + + Returns: + Catalog data dictionary + + Raises: + PresetError: If catalog cannot be fetched + """ + cache_file, metadata_file = self._get_cache_paths(entry.url) + + if not force_refresh and self._is_url_cache_valid(entry.url): + try: + return json.loads(cache_file.read_text()) + except json.JSONDecodeError: + pass + + try: + import urllib.request + import urllib.error + + with urllib.request.urlopen(entry.url, timeout=10) as response: + catalog_data = json.loads(response.read()) + + if ( + "schema_version" not in catalog_data + or "presets" not in catalog_data + ): + raise PresetError("Invalid preset catalog format") + + self.cache_dir.mkdir(parents=True, exist_ok=True) + cache_file.write_text(json.dumps(catalog_data, indent=2)) + metadata = { + "cached_at": datetime.now(timezone.utc).isoformat(), + "catalog_url": entry.url, + } + metadata_file.write_text(json.dumps(metadata, indent=2)) + + return catalog_data + + except (ImportError, Exception) as e: + if isinstance(e, PresetError): + raise + raise PresetError( + f"Failed to fetch preset catalog from {entry.url}: {e}" + ) + + def _get_merged_packs(self, force_refresh: bool = False) -> Dict[str, Dict[str, Any]]: + """Fetch and merge presets from all active catalogs. + + Higher-priority catalogs (lower priority number) win on ID conflicts. + + Returns: + Merged dictionary of pack_id -> pack_data + """ + active_catalogs = self.get_active_catalogs() + merged: Dict[str, Dict[str, Any]] = {} + + for entry in reversed(active_catalogs): + try: + data = self._fetch_single_catalog(entry, force_refresh) + for pack_id, pack_data in data.get("presets", {}).items(): + pack_data_with_catalog = {**pack_data, "_catalog_name": entry.name, "_install_allowed": entry.install_allowed} + merged[pack_id] = pack_data_with_catalog + except PresetError: + continue + + return merged + + def is_cache_valid(self) -> bool: + """Check if cached catalog is still valid. + + Returns: + True if cache exists and is within cache duration + """ + if not self.cache_file.exists() or not self.cache_metadata_file.exists(): + return False + + try: + metadata = json.loads(self.cache_metadata_file.read_text()) + cached_at = datetime.fromisoformat(metadata.get("cached_at", "")) + if cached_at.tzinfo is None: + cached_at = cached_at.replace(tzinfo=timezone.utc) + age_seconds = ( + datetime.now(timezone.utc) - cached_at + ).total_seconds() + return age_seconds < self.CACHE_DURATION + except (json.JSONDecodeError, ValueError, KeyError, TypeError): + return False + + def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]: + """Fetch preset catalog from URL or cache. + + Args: + force_refresh: If True, bypass cache and fetch from network + + Returns: + Catalog data dictionary + + Raises: + PresetError: If catalog cannot be fetched + """ + catalog_url = self.get_catalog_url() + + if not force_refresh and self.is_cache_valid(): + try: + metadata = json.loads(self.cache_metadata_file.read_text()) + if metadata.get("catalog_url") == catalog_url: + return json.loads(self.cache_file.read_text()) + except (json.JSONDecodeError, OSError): + # Cache is corrupt or unreadable; fall through to network fetch + pass + + try: + import urllib.request + import urllib.error + + with urllib.request.urlopen(catalog_url, timeout=10) as response: + catalog_data = json.loads(response.read()) + + if ( + "schema_version" not in catalog_data + or "presets" not in catalog_data + ): + raise PresetError("Invalid preset catalog format") + + self.cache_dir.mkdir(parents=True, exist_ok=True) + self.cache_file.write_text(json.dumps(catalog_data, indent=2)) + + metadata = { + "cached_at": datetime.now(timezone.utc).isoformat(), + "catalog_url": catalog_url, + } + self.cache_metadata_file.write_text( + json.dumps(metadata, indent=2) + ) + + return catalog_data + + except (ImportError, Exception) as e: + if isinstance(e, PresetError): + raise + raise PresetError( + f"Failed to fetch preset catalog from {catalog_url}: {e}" + ) + + def search( + self, + query: Optional[str] = None, + tag: Optional[str] = None, + author: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """Search catalog for presets. + + Searches across all active catalogs (merged by priority) so that + community and custom catalogs are included in results. + + Args: + query: Search query (searches name, description, tags) + tag: Filter by specific tag + author: Filter by author name + + Returns: + List of matching preset metadata + """ + try: + packs = self._get_merged_packs() + except PresetError: + return [] + + results = [] + + for pack_id, pack_data in packs.items(): + if author and pack_data.get("author", "").lower() != author.lower(): + continue + + if tag and tag.lower() not in [ + t.lower() for t in pack_data.get("tags", []) + ]: + continue + + if query: + query_lower = query.lower() + searchable_text = " ".join( + [ + pack_data.get("name", ""), + pack_data.get("description", ""), + pack_id, + ] + + pack_data.get("tags", []) + ).lower() + + if query_lower not in searchable_text: + continue + + results.append({**pack_data, "id": pack_id}) + + return results + + def get_pack_info( + self, pack_id: str + ) -> Optional[Dict[str, Any]]: + """Get detailed information about a specific preset. + + Searches across all active catalogs (merged by priority). + + Args: + pack_id: ID of the preset + + Returns: + Pack metadata or None if not found + """ + try: + packs = self._get_merged_packs() + except PresetError: + return None + + if pack_id in packs: + return {**packs[pack_id], "id": pack_id} + return None + + def download_pack( + self, pack_id: str, target_dir: Optional[Path] = None + ) -> Path: + """Download preset ZIP from catalog. + + Args: + pack_id: ID of the preset to download + target_dir: Directory to save ZIP file (defaults to cache directory) + + Returns: + Path to downloaded ZIP file + + Raises: + PresetError: If pack not found or download fails + """ + import urllib.request + import urllib.error + + pack_info = self.get_pack_info(pack_id) + if not pack_info: + raise PresetError( + f"Preset '{pack_id}' not found in catalog" + ) + + # Bundled presets without a download URL must be installed locally + if pack_info.get("bundled") and not pack_info.get("download_url"): + from .extensions import REINSTALL_COMMAND + raise PresetError( + f"Preset '{pack_id}' is bundled with spec-kit and has no download URL. " + f"It should be installed from the local package. " + f"Use 'specify preset add {pack_id}' to install from the bundled package, " + f"or reinstall spec-kit if the bundled files are missing: {REINSTALL_COMMAND}" + ) + + if not pack_info.get("_install_allowed", True): + catalog_name = pack_info.get("_catalog_name", "unknown") + raise PresetError( + f"Preset '{pack_id}' is from the '{catalog_name}' catalog which does not allow installation. " + f"Use --from with the preset's repository URL instead." + ) + + download_url = pack_info.get("download_url") + if not download_url: + raise PresetError( + f"Preset '{pack_id}' has no download URL" + ) + + from urllib.parse import urlparse + + parsed = urlparse(download_url) + is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1") + if parsed.scheme != "https" and not ( + parsed.scheme == "http" and is_localhost + ): + raise PresetError( + f"Preset download URL must use HTTPS: {download_url}" + ) + + if target_dir is None: + target_dir = self.cache_dir / "downloads" + target_dir.mkdir(parents=True, exist_ok=True) + + version = pack_info.get("version", "unknown") + zip_filename = f"{pack_id}-{version}.zip" + zip_path = target_dir / zip_filename + + try: + with urllib.request.urlopen(download_url, timeout=60) as response: + zip_data = response.read() + + zip_path.write_bytes(zip_data) + return zip_path + + except urllib.error.URLError as e: + raise PresetError( + f"Failed to download preset from {download_url}: {e}" + ) + except IOError as e: + raise PresetError(f"Failed to save preset ZIP: {e}") + + def clear_cache(self): + """Clear all catalog cache files, including per-URL hashed caches.""" + if self.cache_dir.exists(): + for f in self.cache_dir.iterdir(): + if f.is_file() and f.name.startswith("catalog"): + f.unlink(missing_ok=True) + + +class PresetResolver: + """Resolves template names to file paths using a priority stack. + + Resolution order: + 1. .specify/templates/overrides/ - Project-local overrides + 2. .specify/presets// - Installed presets + 3. .specify/extensions//templates/ - Extension-provided templates + 4. .specify/templates/ - Core templates (shipped with Spec Kit) + """ + + def __init__(self, project_root: Path): + """Initialize preset resolver. + + Args: + project_root: Path to project root directory + """ + self.project_root = project_root + self.templates_dir = project_root / ".specify" / "templates" + self.presets_dir = project_root / ".specify" / "presets" + self.overrides_dir = self.templates_dir / "overrides" + self.extensions_dir = project_root / ".specify" / "extensions" + self._manifest_cache: Dict[str, Optional["PresetManifest"]] = {} + + def _get_manifest(self, pack_dir: Path) -> Optional["PresetManifest"]: + """Get a cached preset manifest, parsing it on first access.""" + key = str(pack_dir) + if key not in self._manifest_cache: + manifest_path = pack_dir / "preset.yml" + if manifest_path.exists(): + try: + self._manifest_cache[key] = PresetManifest(manifest_path) + except PresetValidationError: + self._manifest_cache[key] = None + else: + self._manifest_cache[key] = None + return self._manifest_cache[key] + + def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]: + """Build unified list of registered and unregistered extensions sorted by priority. + + Registered extensions use their stored priority; unregistered directories + get implicit priority=10. Results are sorted by (priority, ext_id) for + deterministic ordering. + + Returns: + List of (priority, ext_id, metadata_or_none) tuples sorted by priority. + """ + if not self.extensions_dir.exists(): + return [] + + registry = ExtensionRegistry(self.extensions_dir) + # Use keys() to track ALL extensions (including corrupted entries) without deep copy + # This prevents corrupted entries from being picked up as "unregistered" dirs + registered_extension_ids = registry.keys() + + # Get all registered extensions including disabled; we filter disabled manually below + all_registered = registry.list_by_priority(include_disabled=True) + + all_extensions: list[tuple[int, str, dict | None]] = [] + + # Only include enabled extensions in the result + for ext_id, metadata in all_registered: + # Skip disabled extensions + if not metadata.get("enabled", True): + continue + priority = normalize_priority(metadata.get("priority") if metadata else None) + all_extensions.append((priority, ext_id, metadata)) + + # Add unregistered directories with implicit priority=10 + for ext_dir in self.extensions_dir.iterdir(): + if not ext_dir.is_dir() or ext_dir.name.startswith("."): + continue + if ext_dir.name not in registered_extension_ids: + all_extensions.append((10, ext_dir.name, None)) + + # Sort by (priority, ext_id) for deterministic ordering + all_extensions.sort(key=lambda x: (x[0], x[1])) + return all_extensions + + @staticmethod + def _core_stem(template_name: str) -> Optional[str]: + """Extract the stem for core command lookup. + + Commands use dot notation (e.g. ``speckit.specify``), but core + command files are named by stem (e.g. ``specify.md``). Returns + the stem if *template_name* follows the ``speckit.`` pattern, + or ``None`` otherwise. + """ + if template_name.startswith("speckit."): + return template_name[len("speckit."):] + return None + + def resolve( + self, + template_name: str, + template_type: str = "template", + skip_presets: bool = False, + ) -> Optional[Path]: + """Resolve a template name to its file path. + + Walks the priority stack and returns the first match. + + Args: + template_name: Template name (e.g., "spec-template") + template_type: Template type ("template", "command", or "script") + skip_presets: When True, skip tier 2 (installed presets). Use + resolve_core() as the preferred caller-facing API for this. + + Returns: + Path to the resolved template file, or None if not found + """ + # Determine subdirectory based on template type + if template_type == "template": + subdirs = ["templates", ""] + elif template_type == "command": + subdirs = ["commands"] + elif template_type == "script": + subdirs = ["scripts"] + else: + subdirs = [""] + + # Determine file extension based on template type + ext = ".md" + if template_type == "script": + ext = ".sh" # scripts use .sh; callers can also check .ps1 + + # Priority 1: Project-local overrides + if template_type == "script": + override = self.overrides_dir / "scripts" / f"{template_name}{ext}" + else: + override = self.overrides_dir / f"{template_name}{ext}" + if override.exists(): + return override + + # Priority 2: Installed presets (sorted by priority — lower number wins) + if not skip_presets and self.presets_dir.exists(): + registry = PresetRegistry(self.presets_dir) + for pack_id, _metadata in registry.list_by_priority(): + pack_dir = self.presets_dir / pack_id + for subdir in subdirs: + if subdir: + candidate = pack_dir / subdir / f"{template_name}{ext}" + else: + candidate = pack_dir / f"{template_name}{ext}" + if candidate.exists(): + return candidate + + # Priority 3: Extension-provided templates (sorted by priority — lower number wins) + for _priority, ext_id, _metadata in self._get_all_extensions_by_priority(): + ext_dir = self.extensions_dir / ext_id + if not ext_dir.is_dir(): + continue + for subdir in subdirs: + if subdir: + candidate = ext_dir / subdir / f"{template_name}{ext}" + else: + candidate = ext_dir / f"{template_name}{ext}" + if candidate.exists(): + return candidate + + # Priority 4: Core templates + if template_type == "template": + core = self.templates_dir / f"{template_name}.md" + if core.exists(): + return core + elif template_type == "command": + core = self.templates_dir / "commands" / f"{template_name}.md" + if core.exists(): + return core + # Fallback: speckit. → .md + stem = self._core_stem(template_name) + if stem: + core = self.templates_dir / "commands" / f"{stem}.md" + if core.exists(): + return core + elif template_type == "script": + core = self.templates_dir / "scripts" / f"{template_name}{ext}" + if core.exists(): + return core + + # Priority 5: Bundled core_pack (wheel install) or repo-root templates + # (source-checkout / editable install). This is the canonical home for + # speckit's built-in command/template files and must always be checked + # so that strategy:wrap presets can locate {CORE_TEMPLATE}. + from specify_cli import _locate_core_pack # local import to avoid cycles + _core_pack = _locate_core_pack() + if _core_pack is not None: + # Wheel install path + if template_type == "template": + candidate = _core_pack / "templates" / f"{template_name}.md" + elif template_type == "command": + candidate = _core_pack / "commands" / f"{template_name}.md" + if not candidate.exists(): + stem = self._core_stem(template_name) + if stem: + candidate = _core_pack / "commands" / f"{stem}.md" + elif template_type == "script": + candidate = _core_pack / "scripts" / f"{template_name}{ext}" + else: + candidate = _core_pack / f"{template_name}.md" + if candidate.exists(): + return candidate + else: + # Source-checkout / editable install: templates live at repo root + repo_root = Path(__file__).parent.parent.parent + if template_type == "template": + candidate = repo_root / "templates" / f"{template_name}.md" + elif template_type == "command": + candidate = repo_root / "templates" / "commands" / f"{template_name}.md" + if not candidate.exists(): + stem = self._core_stem(template_name) + if stem: + candidate = repo_root / "templates" / "commands" / f"{stem}.md" + elif template_type == "script": + candidate = repo_root / "scripts" / f"{template_name}{ext}" + else: + candidate = repo_root / f"{template_name}.md" + if candidate.exists(): + return candidate + + return None + + def resolve_core( + self, + template_name: str, + template_type: str = "template", + ) -> Optional[Path]: + """Resolve while skipping installed presets (tier 2). + + Searches tiers 1, 3, 4, and 5 (bundled core_pack / repo-root fallback). + Use when resolving {CORE_TEMPLATE} to guarantee the result is actual + base content, never another preset's wrap output. + """ + return self.resolve(template_name, template_type, skip_presets=True) + + def resolve_extension_command_via_manifest(self, cmd_name: str) -> Optional[Path]: + """Resolve an extension command by consulting installed extension manifests. + + Walks installed extension directories in priority order, loads each + extension.yml via ExtensionManifest, and looks up the command by its + declared name to find the actual file path. This is necessary because + the manifest's ``provides.commands[].file`` field is authoritative and + may differ from the command name + (e.g. ``speckit.selftest.extension`` → ``commands/selftest.md``). + + Returns None if no manifest maps the given command name, so the caller + can fall back to the name-based lookup. + """ + if not self.extensions_dir.exists(): + return None + + from .extensions import ExtensionManifest, ValidationError + + for _priority, ext_id, _metadata in self._get_all_extensions_by_priority(): + ext_dir = self.extensions_dir / ext_id + manifest_path = ext_dir / "extension.yml" + if not manifest_path.is_file(): + continue + try: + manifest = ExtensionManifest(manifest_path) + except (ValidationError, OSError, TypeError, AttributeError): + continue + for cmd_info in manifest.commands: + if cmd_info.get("name") != cmd_name: + continue + file_rel = cmd_info.get("file") + if not file_rel: + continue + # Mirror the containment check in ExtensionManager to guard against + # path traversal via a malformed manifest (e.g. file: ../../AGENTS.md). + cmd_path = Path(file_rel) + if cmd_path.is_absolute(): + continue + try: + ext_root = ext_dir.resolve() + candidate = (ext_root / cmd_path).resolve() + candidate.relative_to(ext_root) # raises ValueError if outside + except (OSError, ValueError): + continue + if candidate.is_file(): + return candidate + return None + + def resolve_with_source( + self, + template_name: str, + template_type: str = "template", + ) -> Optional[Dict[str, str]]: + """Resolve a template name and return source attribution. + + Args: + template_name: Template name (e.g., "spec-template") + template_type: Template type ("template", "command", or "script") + + Returns: + Dictionary with 'path' and 'source' keys, or None if not found + """ + # Delegate to resolve() for the actual lookup, then determine source + resolved = self.resolve(template_name, template_type) + if resolved is None: + return None + + resolved_str = str(resolved) + + # Determine source attribution + if str(self.overrides_dir) in resolved_str: + return {"path": resolved_str, "source": "project override"} + + if str(self.presets_dir) in resolved_str and self.presets_dir.exists(): + registry = PresetRegistry(self.presets_dir) + for pack_id, _metadata in registry.list_by_priority(): + pack_dir = self.presets_dir / pack_id + try: + resolved.relative_to(pack_dir) + meta = registry.get(pack_id) + version = meta.get("version", "?") if meta else "?" + return { + "path": resolved_str, + "source": f"{pack_id} v{version}", + } + except ValueError: + continue + + for _priority, ext_id, ext_meta in self._get_all_extensions_by_priority(): + ext_dir = self.extensions_dir / ext_id + if not ext_dir.is_dir(): + continue + try: + resolved.relative_to(ext_dir) + if ext_meta: + version = ext_meta.get("version", "?") + return { + "path": resolved_str, + "source": f"extension:{ext_id} v{version}", + } + else: + return { + "path": resolved_str, + "source": f"extension:{ext_id} (unregistered)", + } + except ValueError: + continue + + return {"path": resolved_str, "source": "core"} + + def collect_all_layers( + self, + template_name: str, + template_type: str = "template", + ) -> List[Dict[str, Any]]: + """Collect all layers in the priority stack for a template. + + Returns layers from highest priority (checked first) to lowest priority. + Each layer is a dict with 'path', 'source', and 'strategy' keys. + + Args: + template_name: Template name (e.g., "spec-template") + template_type: Template type ("template", "command", or "script") + + Returns: + List of layer dicts ordered highest-to-lowest priority. + """ + if template_type == "template": + subdirs = ["templates", ""] + elif template_type == "command": + subdirs = ["commands"] + elif template_type == "script": + subdirs = ["scripts"] + else: + subdirs = [""] + + ext = ".md" + if template_type == "script": + ext = ".sh" + + layers: List[Dict[str, Any]] = [] + + def _find_in_subdirs(base_dir: Path) -> Optional[Path]: + for subdir in subdirs: + if subdir: + candidate = base_dir / subdir / f"{template_name}{ext}" + else: + candidate = base_dir / f"{template_name}{ext}" + if candidate.exists(): + return candidate + return None + + # Priority 1: Project-local overrides (always "replace" strategy) + if template_type == "script": + override = self.overrides_dir / "scripts" / f"{template_name}{ext}" + else: + override = self.overrides_dir / f"{template_name}{ext}" + if override.exists(): + layers.append({ + "path": override, + "source": "project override", + "strategy": "replace", + }) + + # Priority 2: Installed presets (sorted by priority — lower number = higher precedence) + if self.presets_dir.exists(): + registry = PresetRegistry(self.presets_dir) + for pack_id, metadata in registry.list_by_priority(): + pack_dir = self.presets_dir / pack_id + # Read strategy and manifest file path from preset manifest + strategy = "replace" + manifest_file_path = None + manifest_has_strategy = False + manifest_found_entry = False + manifest = self._get_manifest(pack_dir) + if manifest: + for tmpl in manifest.templates: + if (tmpl.get("name") == template_name + and tmpl.get("type") == template_type): + strategy = tmpl.get("strategy", "replace") + manifest_has_strategy = "strategy" in tmpl + manifest_file_path = tmpl.get("file") + manifest_found_entry = True + break + # Use manifest file path if specified, otherwise convention-based + # lookup — but only when the manifest doesn't exist or doesn't + # list this template, so preset.yml stays authoritative. + candidate = None + if manifest_file_path: + manifest_candidate = pack_dir / manifest_file_path + if manifest_candidate.exists(): + candidate = manifest_candidate + # Explicit file path that doesn't exist: skip convention + # fallback to avoid masking typos or picking up unintended files. + elif not manifest_found_entry: + # Manifest doesn't list this template — check convention paths + candidate = _find_in_subdirs(pack_dir) + if candidate: + # Legacy fallback: if manifest doesn't explicitly declare a + # strategy, check the command file's frontmatter for any valid + # strategy. Skip when the manifest entry includes strategy key + # (even if it's "replace") to avoid overriding explicit declarations. + if not manifest_has_strategy and strategy == "replace" and template_type == "command": + try: + cmd_content = candidate.read_text(encoding="utf-8") + lines = cmd_content.splitlines(keepends=True) + if lines and lines[0].rstrip("\r\n") == "---": + fence_end = -1 + for fi, fline in enumerate(lines[1:], start=1): + if fline.rstrip("\r\n") == "---": + fence_end = fi + break + if fence_end > 0: + fm_text = "".join(lines[1:fence_end]) + fm_data = yaml.safe_load(fm_text) + if isinstance(fm_data, dict): + fm_strategy = fm_data.get("strategy") + if isinstance(fm_strategy, str) and fm_strategy.lower() in VALID_PRESET_STRATEGIES: + strategy = fm_strategy.lower() + except (yaml.YAMLError, OSError): + # Best-effort legacy frontmatter parsing: keep default + # strategy ("replace") when content is unreadable/invalid. + pass + version = metadata.get("version", "?") if metadata else "?" + layers.append({ + "path": candidate, + "source": f"{pack_id} v{version}", + "strategy": strategy, + }) + + # Priority 3: Extension-provided templates (always "replace") + for _priority, ext_id, ext_meta in self._get_all_extensions_by_priority(): + ext_dir = self.extensions_dir / ext_id + if not ext_dir.is_dir(): + continue + # Try convention-based lookup first + candidate = _find_in_subdirs(ext_dir) + # If not found and this is a command, check extension manifest + if candidate is None and template_type == "command": + ext_manifest_path = ext_dir / "extension.yml" + if ext_manifest_path.exists(): + try: + from .extensions import ExtensionManifest, ValidationError as ExtValidationError + ext_manifest = ExtensionManifest(ext_manifest_path) + for cmd in ext_manifest.commands: + if cmd.get("name") == template_name: + cmd_file = cmd.get("file") + if cmd_file: + c = ext_dir / cmd_file + if c.exists(): + candidate = c + break + except (ExtValidationError, yaml.YAMLError): + # Invalid extension manifest — fall back to + # convention-based lookup (already attempted above). + pass + if candidate: + if ext_meta: + version = ext_meta.get("version", "?") + source = f"extension:{ext_id} v{version}" + else: + source = f"extension:{ext_id} (unregistered)" + layers.append({ + "path": candidate, + "source": source, + "strategy": "replace", + }) + + # Priority 4: Core templates (always "replace") + core = None + if template_type == "template": + c = self.templates_dir / f"{template_name}.md" + if c.exists(): + core = c + elif template_type == "command": + c = self.templates_dir / "commands" / f"{template_name}.md" + if c.exists(): + core = c + else: + # Fallback: speckit. → .md + stem = self._core_stem(template_name) + if stem: + c = self.templates_dir / "commands" / f"{stem}.md" + if c.exists(): + core = c + elif template_type == "script": + c = self.templates_dir / "scripts" / f"{template_name}{ext}" + if c.exists(): + core = c + if core: + layers.append({ + "path": core, + "source": "core", + "strategy": "replace", + }) + else: + # Priority 5: Bundled core_pack (wheel install) or repo-root + # templates (source-checkout), matching resolve()'s tier-5 fallback. + bundled = self._find_bundled_core(template_name, template_type, ext) + if bundled: + layers.append({ + "path": bundled, + "source": "core (bundled)", + "strategy": "replace", + }) + + return layers + + def _find_bundled_core( + self, + template_name: str, + template_type: str, + ext: str, + ) -> Optional[Path]: + """Find a core template from the bundled pack or source checkout. + + Mirrors the tier-5 fallback logic in ``resolve()`` so that + ``collect_all_layers()`` can locate base layers even when + ``.specify/templates/`` doesn't contain the core file. + """ + try: + from specify_cli import _locate_core_pack + except ImportError: + return None + + stem = self._core_stem(template_name) + names = [template_name] + if stem and stem != template_name: + names.append(stem) + + core_pack = _locate_core_pack() + if core_pack is not None: + for name in names: + if template_type == "template": + c = core_pack / "templates" / f"{name}.md" + elif template_type == "command": + c = core_pack / "commands" / f"{name}.md" + elif template_type == "script": + c = core_pack / "scripts" / f"{name}{ext}" + else: + c = core_pack / f"{name}.md" + if c.exists(): + return c + else: + repo_root = Path(__file__).parent.parent.parent + for name in names: + if template_type == "template": + c = repo_root / "templates" / f"{name}.md" + elif template_type == "command": + c = repo_root / "templates" / "commands" / f"{name}.md" + elif template_type == "script": + c = repo_root / "scripts" / f"{name}{ext}" + else: + c = repo_root / f"{name}.md" + if c.exists(): + return c + return None + + def resolve_content( + self, + template_name: str, + template_type: str = "template", + ) -> Optional[str]: + """Resolve a template name and return composed content. + + Walks the priority stack and composes content using strategies: + - replace (default): highest-priority content wins entirely + - prepend: content is placed before lower-priority content + - append: content is placed after lower-priority content + - wrap: content contains {CORE_TEMPLATE} placeholder replaced + with lower-priority content (or $CORE_SCRIPT for scripts) + + Composition is recursive — multiple composing presets chain. + + Args: + template_name: Template name (e.g., "spec-template") + template_type: Template type ("template", "command", or "script") + + Returns: + Composed content string, or None if not found + """ + layers = self.collect_all_layers(template_name, template_type) + if not layers: + return None + + # If the top (highest-priority) layer is replace, it wins entirely — + # lower layers are irrelevant regardless of their strategies. + if layers[0]["strategy"] == "replace": + return layers[0]["path"].read_text(encoding="utf-8") + + # Composition: build content bottom-up from the effective base. + # The base is the nearest replace layer scanning from highest priority + # downward. Only layers above the base contribute to composition. + # + # layers is ordered highest-priority first. We process in reverse. + reversed_layers = list(reversed(layers)) + + # Find the effective base: scan from highest priority (layers[0]) downward + # to find the nearest replace layer. Only compose layers above that base. + # layers is highest-priority first; reversed_layers is lowest first. + base_layer_idx = None # index in layers[] (highest-priority first) + for idx, layer in enumerate(layers): + if layer["strategy"] == "replace": + base_layer_idx = idx + break + + if base_layer_idx is None: + return None # no replace base found + + # Convert to reversed_layers index + base_reversed_idx = len(layers) - 1 - base_layer_idx + content = layers[base_layer_idx]["path"].read_text(encoding="utf-8") + # Compose only the layers above the base (higher priority = lower index in layers, + # higher index in reversed_layers). Process bottom-up from base+1. + start_idx = base_reversed_idx + 1 + + # For command composition, strip frontmatter from each layer to avoid + # leaking YAML metadata into the composed body. The highest-priority + # layer's frontmatter will be reattached at the end. + is_command = template_type == "command" + top_frontmatter_text = None + base_frontmatter_text = None + + def _split_frontmatter(text: str) -> tuple: + """Return (frontmatter_block_with_fences, body) or (None, text). + + Uses line-based fence detection (fence must be ``---`` on its + own line) to avoid false matches on ``---`` inside YAML values. + """ + lines = text.splitlines(keepends=True) + if not lines or lines[0].rstrip("\r\n") != "---": + return None, text + + fence_end = -1 + for i, line in enumerate(lines[1:], start=1): + if line.rstrip("\r\n") == "---": + fence_end = i + break + + if fence_end == -1: + return None, text + + fm_block = "".join(lines[:fence_end + 1]).rstrip("\r\n") + body = "".join(lines[fence_end + 1:]) + return fm_block, body + + if is_command: + fm, body = _split_frontmatter(content) + if fm: + top_frontmatter_text = fm + base_frontmatter_text = fm + content = body + + # Apply composition layers from bottom to top + for layer in reversed_layers[start_idx:]: + layer_content = layer["path"].read_text(encoding="utf-8") + strategy = layer["strategy"] + + if is_command: + fm, layer_body = _split_frontmatter(layer_content) + layer_content = layer_body + # Track the highest-priority frontmatter seen; + # replace layers reset both top and base frontmatter since + # they replace the entire command including metadata. + if strategy == "replace": + top_frontmatter_text = fm + base_frontmatter_text = fm + elif fm: + top_frontmatter_text = fm + + if strategy == "replace": + content = layer_content + elif strategy == "prepend": + content = layer_content + "\n\n" + content + elif strategy == "append": + content = content + "\n\n" + layer_content + elif strategy == "wrap": + if template_type == "script": + placeholder = "$CORE_SCRIPT" + else: + placeholder = "{CORE_TEMPLATE}" + if placeholder not in layer_content: + raise PresetValidationError( + f"Wrap strategy in '{layer['source']}' is missing " + f"the {placeholder} placeholder. The wrapper must " + f"contain {placeholder} to indicate where the " + f"lower-priority content should be inserted." + ) + content = layer_content.replace(placeholder, content) + + # Reattach the highest-priority frontmatter for commands, + # inheriting scripts/agent_scripts from the base if missing + # and stripping the strategy key (internal-only, not for agent output). + if is_command and top_frontmatter_text: + def _parse_fm_yaml(fm_block: str) -> dict: + """Parse YAML from a frontmatter block (with --- fences).""" + lines = fm_block.splitlines() + # Parse only interior lines (between --- fences) + if len(lines) >= 2: + yaml_lines = lines[1:-1] + else: + yaml_lines = [] + try: + return yaml.safe_load("\n".join(yaml_lines)) or {} + except yaml.YAMLError: + return {} + + top_fm = _parse_fm_yaml(top_frontmatter_text) + + # Inherit scripts/agent_scripts from base frontmatter if missing + if base_frontmatter_text and base_frontmatter_text != top_frontmatter_text: + base_fm = _parse_fm_yaml(base_frontmatter_text) + for key in ("scripts", "agent_scripts"): + if key not in top_fm and key in base_fm: + top_fm[key] = base_fm[key] + + # Strip strategy key — it's an internal composition directive, + # not meant for rendered agent command files + top_fm.pop("strategy", None) + + if top_fm: + top_frontmatter_text = ( + "---\n" + + yaml.safe_dump(top_fm, sort_keys=False).strip() + + "\n---" + ) + else: + # Empty frontmatter — omit rather than emitting {} + top_frontmatter_text = None + + if top_frontmatter_text: + content = top_frontmatter_text + "\n\n" + content + + return content diff --git a/src/specify_cli/workflows/__init__.py b/src/specify_cli/workflows/__init__.py new file mode 100644 index 0000000000..13782f620b --- /dev/null +++ b/src/specify_cli/workflows/__init__.py @@ -0,0 +1,68 @@ +"""Workflow engine for multi-step, resumable automation workflows. + +Provides: +- ``StepBase`` — abstract base every step type must implement. +- ``StepContext`` — execution context passed to each step. +- ``StepResult`` — return value from step execution. +- ``STEP_REGISTRY`` — maps ``type_key`` to ``StepBase`` subclass instances. +- ``WorkflowEngine`` — orchestrator that loads, validates, and executes + workflow YAML definitions. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .base import StepBase + +# Maps step type_key → StepBase instance. +STEP_REGISTRY: dict[str, StepBase] = {} + + +def _register_step(step: StepBase) -> None: + """Register a step type instance in the global registry. + + Raises ``ValueError`` for falsy keys and ``KeyError`` for duplicates. + """ + key = step.type_key + if not key: + raise ValueError("Cannot register step type with an empty type_key.") + if key in STEP_REGISTRY: + raise KeyError(f"Step type with key {key!r} is already registered.") + STEP_REGISTRY[key] = step + + +def get_step_type(type_key: str) -> StepBase | None: + """Return the step type for *type_key*, or ``None`` if not registered.""" + return STEP_REGISTRY.get(type_key) + + +# -- Register built-in step types ---------------------------------------- + +def _register_builtin_steps() -> None: + """Register all built-in step types.""" + from .steps.command import CommandStep + from .steps.do_while import DoWhileStep + from .steps.fan_in import FanInStep + from .steps.fan_out import FanOutStep + from .steps.gate import GateStep + from .steps.if_then import IfThenStep + from .steps.prompt import PromptStep + from .steps.shell import ShellStep + from .steps.switch import SwitchStep + from .steps.while_loop import WhileStep + + _register_step(CommandStep()) + _register_step(DoWhileStep()) + _register_step(FanInStep()) + _register_step(FanOutStep()) + _register_step(GateStep()) + _register_step(IfThenStep()) + _register_step(PromptStep()) + _register_step(ShellStep()) + _register_step(SwitchStep()) + _register_step(WhileStep()) + + +_register_builtin_steps() diff --git a/src/specify_cli/workflows/base.py b/src/specify_cli/workflows/base.py new file mode 100644 index 0000000000..b144ca903d --- /dev/null +++ b/src/specify_cli/workflows/base.py @@ -0,0 +1,132 @@ +"""Base classes for workflow step types. + +Provides: +- ``StepBase`` — abstract base every step type must implement. +- ``StepContext`` — execution context passed to each step. +- ``StepResult`` — return value from step execution. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + + +class StepStatus(str, Enum): + """Status of a step execution.""" + + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + SKIPPED = "skipped" + PAUSED = "paused" + + +class RunStatus(str, Enum): + """Status of a workflow run.""" + + CREATED = "created" + RUNNING = "running" + PAUSED = "paused" + COMPLETED = "completed" + FAILED = "failed" + ABORTED = "aborted" + + +@dataclass +class StepContext: + """Execution context passed to each step. + + Contains everything the step needs to resolve expressions, dispatch + commands, and record results. + """ + + #: Resolved workflow inputs (from user prompts / defaults). + inputs: dict[str, Any] = field(default_factory=dict) + + #: Accumulated step results keyed by step ID. + #: Each entry is ``{"integration": ..., "model": ..., "options": ..., + #: "input": ..., "output": ...}``. + steps: dict[str, dict[str, Any]] = field(default_factory=dict) + + #: Current fan-out item (set only inside fan-out iterations). + item: Any = None + + #: Fan-in aggregated results (set only for fan-in steps). + fan_in: dict[str, Any] = field(default_factory=dict) + + #: Workflow-level default integration key. + default_integration: str | None = None + + #: Workflow-level default model. + default_model: str | None = None + + #: Workflow-level default options. + default_options: dict[str, Any] = field(default_factory=dict) + + #: Project root path. + project_root: str | None = None + + #: Current run ID. + run_id: str | None = None + + +@dataclass +class StepResult: + """Return value from a step execution.""" + + #: Step status. + status: StepStatus = StepStatus.COMPLETED + + #: Output data (stored as ``steps..output``). + output: dict[str, Any] = field(default_factory=dict) + + #: Nested steps to execute (for control-flow steps like if/then). + next_steps: list[dict[str, Any]] = field(default_factory=list) + + #: Error message if step failed. + error: str | None = None + + +class StepBase(ABC): + """Abstract base class for workflow step types. + + Every step type — built-in or extension-provided — implements this + interface and registers in ``STEP_REGISTRY``. + """ + + #: Matches the ``type:`` value in workflow YAML. + type_key: str = "" + + @abstractmethod + def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: + """Execute the step with the given config and context. + + Parameters + ---------- + config: + The step configuration from workflow YAML. + context: + The execution context with inputs, accumulated step results, etc. + + Returns + ------- + StepResult with status, output data, and optional nested steps. + """ + + def validate(self, config: dict[str, Any]) -> list[str]: + """Validate step configuration and return a list of error messages. + + An empty list means the configuration is valid. + """ + errors: list[str] = [] + if "id" not in config: + errors.append("Step is missing required 'id' field.") + return errors + + def can_resume(self, state: dict[str, Any]) -> bool: + """Return whether this step can be resumed from the given state.""" + return True diff --git a/src/specify_cli/workflows/catalog.py b/src/specify_cli/workflows/catalog.py new file mode 100644 index 0000000000..da5c60b5c8 --- /dev/null +++ b/src/specify_cli/workflows/catalog.py @@ -0,0 +1,540 @@ +"""Workflow catalog — discovery, install, and management of workflows. + +Mirrors the existing extension/preset catalog pattern with: +- Multi-catalog stack (env var → project → user → built-in) +- SHA256-hashed per-URL caching with 1-hour TTL +- Workflow registry for installed workflow tracking +- Search across all configured catalog sources +""" + +from __future__ import annotations + +import hashlib +import json +import os +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + + +# --------------------------------------------------------------------------- +# Errors +# --------------------------------------------------------------------------- + + +class WorkflowCatalogError(Exception): + """Base error for workflow catalog operations.""" + + +class WorkflowValidationError(WorkflowCatalogError): + """Validation error for catalog config or workflow data.""" + + +# --------------------------------------------------------------------------- +# CatalogEntry +# --------------------------------------------------------------------------- + + +@dataclass +class WorkflowCatalogEntry: + """Represents a single catalog source in the catalog stack.""" + + url: str + name: str + priority: int + install_allowed: bool + description: str = "" + + +# --------------------------------------------------------------------------- +# WorkflowRegistry +# --------------------------------------------------------------------------- + + +class WorkflowRegistry: + """Manages the registry of installed workflows. + + Tracks installed workflows and their metadata in + ``.specify/workflows/workflow-registry.json``. + """ + + REGISTRY_FILE = "workflow-registry.json" + SCHEMA_VERSION = "1.0" + + def __init__(self, project_root: Path) -> None: + self.project_root = project_root + self.workflows_dir = project_root / ".specify" / "workflows" + self.registry_path = self.workflows_dir / self.REGISTRY_FILE + self.data = self._load() + + def _load(self) -> dict[str, Any]: + """Load registry from disk or create default.""" + if self.registry_path.exists(): + try: + with open(self.registry_path, encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, ValueError): + # Corrupted registry file — reset to default + return {"schema_version": self.SCHEMA_VERSION, "workflows": {}} + return {"schema_version": self.SCHEMA_VERSION, "workflows": {}} + + def save(self) -> None: + """Persist registry to disk.""" + self.workflows_dir.mkdir(parents=True, exist_ok=True) + with open(self.registry_path, "w", encoding="utf-8") as f: + json.dump(self.data, f, indent=2) + + def add(self, workflow_id: str, metadata: dict[str, Any]) -> None: + """Add or update an installed workflow entry.""" + from datetime import datetime, timezone + + existing = self.data["workflows"].get(workflow_id, {}) + metadata["installed_at"] = existing.get( + "installed_at", datetime.now(timezone.utc).isoformat() + ) + metadata["updated_at"] = datetime.now(timezone.utc).isoformat() + self.data["workflows"][workflow_id] = metadata + self.save() + + def remove(self, workflow_id: str) -> bool: + """Remove an installed workflow entry. Returns True if found.""" + if workflow_id in self.data["workflows"]: + del self.data["workflows"][workflow_id] + self.save() + return True + return False + + def get(self, workflow_id: str) -> dict[str, Any] | None: + """Get metadata for an installed workflow.""" + return self.data["workflows"].get(workflow_id) + + def list(self) -> dict[str, dict[str, Any]]: + """Return all installed workflows.""" + return dict(self.data["workflows"]) + + def is_installed(self, workflow_id: str) -> bool: + """Check if a workflow is installed.""" + return workflow_id in self.data["workflows"] + + +# --------------------------------------------------------------------------- +# WorkflowCatalog +# --------------------------------------------------------------------------- + + +class WorkflowCatalog: + """Manages workflow catalog fetching, caching, and searching. + + Resolution order for catalog sources: + 1. ``SPECKIT_WORKFLOW_CATALOG_URL`` env var (overrides all) + 2. Project-level ``.specify/workflow-catalogs.yml`` + 3. User-level ``~/.specify/workflow-catalogs.yml`` + 4. Built-in defaults (official + community) + """ + + DEFAULT_CATALOG_URL = ( + "https://raw.githubusercontent.com/github/spec-kit/main/" + "workflows/catalog.json" + ) + COMMUNITY_CATALOG_URL = ( + "https://raw.githubusercontent.com/github/spec-kit/main/" + "workflows/catalog.community.json" + ) + CACHE_DURATION = 3600 # 1 hour + + def __init__(self, project_root: Path) -> None: + self.project_root = project_root + self.workflows_dir = project_root / ".specify" / "workflows" + self.cache_dir = self.workflows_dir / ".cache" + + # -- Catalog resolution ----------------------------------------------- + + def _validate_catalog_url(self, url: str) -> None: + """Validate that a catalog URL uses HTTPS (localhost HTTP allowed).""" + from urllib.parse import urlparse + + parsed = urlparse(url) + is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1") + if parsed.scheme != "https" and not ( + parsed.scheme == "http" and is_localhost + ): + raise WorkflowValidationError( + f"Catalog URL must use HTTPS (got {parsed.scheme}://). " + "HTTP is only allowed for localhost." + ) + if not parsed.netloc: + raise WorkflowValidationError( + "Catalog URL must be a valid URL with a host." + ) + + def _load_catalog_config( + self, config_path: Path + ) -> list[WorkflowCatalogEntry] | None: + """Load catalog stack configuration from a YAML file.""" + if not config_path.exists(): + return None + try: + data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + except (yaml.YAMLError, OSError, UnicodeError) as exc: + raise WorkflowValidationError( + f"Failed to read catalog config {config_path}: {exc}" + ) + catalogs_data = data.get("catalogs", []) + if not catalogs_data: + # Empty catalogs list (e.g. after removing last entry) + # is valid — fall back to built-in defaults. + return None + if not isinstance(catalogs_data, list): + raise WorkflowValidationError( + f"Invalid catalog config: 'catalogs' must be a list, " + f"got {type(catalogs_data).__name__}" + ) + + entries: list[WorkflowCatalogEntry] = [] + for idx, item in enumerate(catalogs_data): + if not isinstance(item, dict): + raise WorkflowValidationError( + f"Invalid catalog entry at index {idx}: " + f"expected a mapping, got {type(item).__name__}" + ) + url = str(item.get("url", "")).strip() + if not url: + continue + self._validate_catalog_url(url) + try: + priority = int(item.get("priority", idx + 1)) + except (TypeError, ValueError): + raise WorkflowValidationError( + f"Invalid priority for catalog " + f"'{item.get('name', idx + 1)}': " + f"expected integer, got {item.get('priority')!r}" + ) + raw_install = item.get("install_allowed", False) + if isinstance(raw_install, str): + install_allowed = raw_install.strip().lower() in ( + "true", + "yes", + "1", + ) + else: + install_allowed = bool(raw_install) + entries.append( + WorkflowCatalogEntry( + url=url, + name=str(item.get("name", f"catalog-{idx + 1}")), + priority=priority, + install_allowed=install_allowed, + description=str(item.get("description", "")), + ) + ) + entries.sort(key=lambda e: e.priority) + if not entries: + raise WorkflowValidationError( + f"Catalog config {config_path} contains {len(catalogs_data)} " + f"entries but none have valid URLs." + ) + return entries + + def get_active_catalogs(self) -> list[WorkflowCatalogEntry]: + """Get the ordered list of active catalogs.""" + # 1. Environment variable override + env_url = os.environ.get("SPECKIT_WORKFLOW_CATALOG_URL", "").strip() + if env_url: + self._validate_catalog_url(env_url) + return [ + WorkflowCatalogEntry( + url=env_url, + name="env-override", + priority=1, + install_allowed=True, + description="From SPECKIT_WORKFLOW_CATALOG_URL", + ) + ] + + # 2. Project-level config + project_config = self.project_root / ".specify" / "workflow-catalogs.yml" + project_entries = self._load_catalog_config(project_config) + if project_entries is not None: + return project_entries + + # 3. User-level config + home = Path.home() + user_config = home / ".specify" / "workflow-catalogs.yml" + user_entries = self._load_catalog_config(user_config) + if user_entries is not None: + return user_entries + + # 4. Built-in defaults + return [ + WorkflowCatalogEntry( + url=self.DEFAULT_CATALOG_URL, + name="default", + priority=1, + install_allowed=True, + description="Official workflows", + ), + WorkflowCatalogEntry( + url=self.COMMUNITY_CATALOG_URL, + name="community", + priority=2, + install_allowed=False, + description="Community-contributed workflows (discovery only)", + ), + ] + + # -- Caching ---------------------------------------------------------- + + def _get_cache_paths(self, url: str) -> tuple[Path, Path]: + """Get cache file paths for a URL (hash-based).""" + url_hash = hashlib.sha256(url.encode()).hexdigest()[:16] + cache_file = self.cache_dir / f"workflow-catalog-{url_hash}.json" + meta_file = self.cache_dir / f"workflow-catalog-{url_hash}-meta.json" + return cache_file, meta_file + + def _is_url_cache_valid(self, url: str) -> bool: + """Check if cached data for a URL is still fresh.""" + _, meta_file = self._get_cache_paths(url) + if not meta_file.exists(): + return False + try: + with open(meta_file, encoding="utf-8") as f: + meta = json.load(f) + fetched_at = meta.get("fetched_at", 0) + return (time.time() - fetched_at) < self.CACHE_DURATION + except (json.JSONDecodeError, OSError): + return False + + def _fetch_single_catalog( + self, entry: WorkflowCatalogEntry, force_refresh: bool = False + ) -> dict[str, Any]: + """Fetch a single catalog, using cache when possible.""" + cache_file, meta_file = self._get_cache_paths(entry.url) + + if not force_refresh and self._is_url_cache_valid(entry.url): + try: + with open(cache_file, encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, OSError): + pass + + # Fetch from URL — validate scheme before opening and after redirects + from urllib.parse import urlparse + from urllib.request import urlopen + + def _validate_catalog_url(url: str) -> None: + parsed = urlparse(url) + is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1") + if parsed.scheme != "https" and not ( + parsed.scheme == "http" and is_localhost + ): + raise WorkflowCatalogError( + f"Refusing to fetch catalog from non-HTTPS URL: {url}" + ) + + _validate_catalog_url(entry.url) + + try: + with urlopen(entry.url, timeout=30) as resp: # noqa: S310 + _validate_catalog_url(resp.geturl()) + data = json.loads(resp.read().decode("utf-8")) + except Exception as exc: + # Fall back to cache if available + if cache_file.exists(): + try: + with open(cache_file, encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, ValueError, OSError): + pass + raise WorkflowCatalogError( + f"Failed to fetch catalog from {entry.url}: {exc}" + ) from exc + + if not isinstance(data, dict): + raise WorkflowCatalogError( + f"Catalog from {entry.url} is not a valid JSON object." + ) + + # Write cache + self.cache_dir.mkdir(parents=True, exist_ok=True) + with open(cache_file, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + with open(meta_file, "w", encoding="utf-8") as f: + json.dump({"url": entry.url, "fetched_at": time.time()}, f) + + return data + + def _get_merged_workflows( + self, force_refresh: bool = False + ) -> dict[str, dict[str, Any]]: + """Merge workflows from all active catalogs (lower priority number wins).""" + catalogs = self.get_active_catalogs() + merged: dict[str, dict[str, Any]] = {} + fetch_errors = 0 + + # Process later/higher-numbered entries first so earlier/lower-numbered + # entries overwrite them on workflow ID conflicts. + for entry in reversed(catalogs): + try: + data = self._fetch_single_catalog(entry, force_refresh) + except WorkflowCatalogError: + fetch_errors += 1 + continue + workflows = data.get("workflows", {}) + # Handle both dict and list formats + if isinstance(workflows, dict): + for wf_id, wf_data in workflows.items(): + if not isinstance(wf_data, dict): + continue + wf_data["_catalog_name"] = entry.name + wf_data["_install_allowed"] = entry.install_allowed + merged[wf_id] = wf_data + elif isinstance(workflows, list): + for wf_data in workflows: + if not isinstance(wf_data, dict): + continue + wf_id = wf_data.get("id", "") + if wf_id: + wf_data["_catalog_name"] = entry.name + wf_data["_install_allowed"] = entry.install_allowed + merged[wf_id] = wf_data + if fetch_errors == len(catalogs) and catalogs: + raise WorkflowCatalogError( + "All configured catalogs failed to fetch." + ) + return merged + + # -- Public API ------------------------------------------------------- + + def search( + self, + query: str | None = None, + tag: str | None = None, + ) -> list[dict[str, Any]]: + """Search workflows across all configured catalogs.""" + merged = self._get_merged_workflows() + results: list[dict[str, Any]] = [] + + for wf_id, wf_data in merged.items(): + wf_data.setdefault("id", wf_id) + if query: + q = query.lower() + searchable = " ".join( + [ + wf_data.get("name", ""), + wf_data.get("description", ""), + wf_data.get("id", ""), + ] + ).lower() + if q not in searchable: + continue + if tag: + raw_tags = wf_data.get("tags", []) + tags = raw_tags if isinstance(raw_tags, list) else [] + normalized_tags = [t.lower() for t in tags if isinstance(t, str)] + if tag.lower() not in normalized_tags: + continue + results.append(wf_data) + return results + + def get_workflow_info(self, workflow_id: str) -> dict[str, Any] | None: + """Get details for a specific workflow from the catalog.""" + merged = self._get_merged_workflows() + wf = merged.get(workflow_id) + if wf: + wf.setdefault("id", workflow_id) + return wf + + def get_catalog_configs(self) -> list[dict[str, Any]]: + """Return current catalog configuration as a list of dicts.""" + entries = self.get_active_catalogs() + return [ + { + "name": e.name, + "url": e.url, + "priority": e.priority, + "install_allowed": e.install_allowed, + "description": e.description, + } + for e in entries + ] + + def add_catalog(self, url: str, name: str | None = None) -> None: + """Add a catalog source to the project-level config.""" + self._validate_catalog_url(url) + config_path = self.project_root / ".specify" / "workflow-catalogs.yml" + + data: dict[str, Any] = {"catalogs": []} + if config_path.exists(): + raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raise WorkflowValidationError( + "Catalog config file is corrupted (expected a mapping)." + ) + data = raw + + catalogs = data.get("catalogs", []) + if not isinstance(catalogs, list): + raise WorkflowValidationError( + "Catalog config 'catalogs' must be a list." + ) + # Check for duplicate URL (guard against non-dict entries) + for cat in catalogs: + if isinstance(cat, dict) and cat.get("url") == url: + raise WorkflowValidationError( + f"Catalog URL already configured: {url}" + ) + + # Derive priority from the highest existing priority + 1 + max_priority = max( + (cat.get("priority", 0) for cat in catalogs if isinstance(cat, dict)), + default=0, + ) + catalogs.append( + { + "name": name or f"catalog-{len(catalogs) + 1}", + "url": url, + "priority": max_priority + 1, + "install_allowed": True, + "description": "", + } + ) + data["catalogs"] = catalogs + + config_path.parent.mkdir(parents=True, exist_ok=True) + with open(config_path, "w", encoding="utf-8") as f: + yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True) + + def remove_catalog(self, index: int) -> str: + """Remove a catalog source by index (0-based). Returns the removed name.""" + config_path = self.project_root / ".specify" / "workflow-catalogs.yml" + if not config_path.exists(): + raise WorkflowValidationError("No catalog config file found.") + + data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + if not isinstance(data, dict): + raise WorkflowValidationError( + "Catalog config file is corrupted (expected a mapping)." + ) + catalogs = data.get("catalogs", []) + if not isinstance(catalogs, list): + raise WorkflowValidationError( + "Catalog config 'catalogs' must be a list." + ) + + if index < 0 or index >= len(catalogs): + raise WorkflowValidationError( + f"Catalog index {index} out of range (0-{len(catalogs) - 1})." + ) + + removed = catalogs.pop(index) + data["catalogs"] = catalogs + + with open(config_path, "w", encoding="utf-8") as f: + yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True) + + if isinstance(removed, dict): + return removed.get("name", f"catalog-{index + 1}") + return f"catalog-{index + 1}" diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py new file mode 100644 index 0000000000..d6a73bbeb0 --- /dev/null +++ b/src/specify_cli/workflows/engine.py @@ -0,0 +1,778 @@ +"""Workflow engine — loads, validates, and executes workflow YAML definitions. + +The engine is the orchestrator that: +- Parses workflow YAML definitions +- Validates step configurations and requirements +- Executes steps sequentially, dispatching to the correct step type +- Manages state persistence for resume capability +- Handles control flow (branching, loops, fan-out/fan-in) +""" + +from __future__ import annotations + +import json +import re +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import yaml + +from .base import RunStatus, StepContext, StepResult, StepStatus + + +# -- Workflow Definition -------------------------------------------------- + + +class WorkflowDefinition: + """Parsed and validated workflow YAML definition.""" + + def __init__(self, data: dict[str, Any], source_path: Path | None = None) -> None: + self.data = data + self.source_path = source_path + + workflow = data.get("workflow", {}) + self.id: str = workflow.get("id", "") + self.name: str = workflow.get("name", "") + self.version: str = workflow.get("version", "0.0.0") + self.author: str = workflow.get("author", "") + self.description: str = workflow.get("description", "") + self.schema_version: str = data.get("schema_version", "1.0") + + # Defaults + self.default_integration: str | None = workflow.get("integration") + self.default_model: str | None = workflow.get("model") + self.default_options: dict[str, Any] = workflow.get("options") or {} + if not isinstance(self.default_options, dict): + self.default_options = {} + + # Requirements (declared but not yet enforced at runtime; + # enforcement is a planned enhancement) + self.requires: dict[str, Any] = data.get("requires", {}) + + # Inputs + self.inputs: dict[str, Any] = data.get("inputs", {}) + + # Steps + self.steps: list[dict[str, Any]] = data.get("steps", []) + + @classmethod + def from_yaml(cls, path: Path) -> WorkflowDefinition: + """Load a workflow definition from a YAML file.""" + with open(path, encoding="utf-8") as f: + data = yaml.safe_load(f) + if not isinstance(data, dict): + msg = f"Workflow YAML must be a mapping, got {type(data).__name__}." + raise ValueError(msg) + return cls(data, source_path=path) + + @classmethod + def from_string(cls, content: str) -> WorkflowDefinition: + """Load a workflow definition from a YAML string.""" + data = yaml.safe_load(content) + if not isinstance(data, dict): + msg = f"Workflow YAML must be a mapping, got {type(data).__name__}." + raise ValueError(msg) + return cls(data) + + +# -- Workflow Validation -------------------------------------------------- + +# ID format: lowercase alphanumeric with hyphens +_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$") + +# Valid step types (matching STEP_REGISTRY keys) +def _get_valid_step_types() -> set[str]: + """Return valid step types from the registry, with a built-in fallback.""" + from . import STEP_REGISTRY + if STEP_REGISTRY: + return set(STEP_REGISTRY.keys()) + return { + "command", "shell", "prompt", "gate", "if", + "switch", "while", "do-while", "fan-out", "fan-in", + } + + +def validate_workflow(definition: WorkflowDefinition) -> list[str]: + """Validate a workflow definition and return a list of error messages. + + An empty list means the workflow is valid. + """ + errors: list[str] = [] + + # -- Schema version --------------------------------------------------- + if definition.schema_version not in ("1.0", "1"): + errors.append( + f"Unsupported schema_version {definition.schema_version!r}. " + f"Expected '1.0'." + ) + + # -- Top-level fields ------------------------------------------------- + if not definition.id: + errors.append("Workflow is missing 'workflow.id'.") + elif not _ID_PATTERN.match(definition.id): + errors.append( + f"Workflow ID {definition.id!r} must be lowercase alphanumeric " + f"with hyphens." + ) + + if not definition.name: + errors.append("Workflow is missing 'workflow.name'.") + + if not definition.version: + errors.append("Workflow is missing 'workflow.version'.") + elif not re.match(r"^\d+\.\d+\.\d+$", definition.version): + errors.append( + f"Workflow version {definition.version!r} is not valid " + f"semantic versioning (expected X.Y.Z)." + ) + + # -- Inputs ----------------------------------------------------------- + if not isinstance(definition.inputs, dict): + errors.append("'inputs' must be a mapping (or omitted).") + else: + for input_name, input_def in definition.inputs.items(): + if not isinstance(input_def, dict): + errors.append(f"Input {input_name!r} must be a mapping.") + continue + input_type = input_def.get("type") + if input_type and input_type not in ("string", "number", "boolean"): + errors.append( + f"Input {input_name!r} has invalid type {input_type!r}. " + f"Must be 'string', 'number', or 'boolean'." + ) + + # -- Steps ------------------------------------------------------------ + if not isinstance(definition.steps, list): + errors.append("'steps' must be a list.") + return errors + if not definition.steps: + errors.append("Workflow has no steps defined.") + + seen_ids: set[str] = set() + _validate_steps(definition.steps, seen_ids, errors) + + return errors + + +def _validate_steps( + steps: list[dict[str, Any]], + seen_ids: set[str], + errors: list[str], +) -> None: + """Recursively validate a list of steps.""" + from . import STEP_REGISTRY + + for step_config in steps: + if not isinstance(step_config, dict): + errors.append(f"Step must be a mapping, got {type(step_config).__name__}.") + continue + + step_id = step_config.get("id") + if not step_id: + errors.append("Step is missing 'id' field.") + continue + + if ":" in step_id: + errors.append( + f"Step ID {step_id!r} contains ':' which is reserved " + f"for engine-generated nested IDs (parentId:childId)." + ) + + if step_id in seen_ids: + errors.append(f"Duplicate step ID {step_id!r}.") + seen_ids.add(step_id) + + # Determine step type + step_type = step_config.get("type", "command") + if step_type not in _get_valid_step_types(): + errors.append( + f"Step {step_id!r} has invalid type {step_type!r}." + ) + continue + + # Delegate to step-specific validation + step_impl = STEP_REGISTRY.get(step_type) + if step_impl: + step_errors = step_impl.validate(step_config) + errors.extend(step_errors) + + # Recursively validate nested steps + for nested_key in ("then", "else", "steps"): + nested = step_config.get(nested_key) + if isinstance(nested, list): + _validate_steps(nested, seen_ids, errors) + + # Validate switch cases + cases = step_config.get("cases") + if isinstance(cases, dict): + for _case_key, case_steps in cases.items(): + if isinstance(case_steps, list): + _validate_steps(case_steps, seen_ids, errors) + + # Validate switch default + default = step_config.get("default") + if isinstance(default, list): + _validate_steps(default, seen_ids, errors) + + # Validate fan-out nested step (template — not added to seen_ids + # since the engine generates parentId:templateId:index at runtime) + fan_step = step_config.get("step") + if isinstance(fan_step, dict): + fan_errors: list[str] = [] + _validate_steps([fan_step], set(), fan_errors) + errors.extend(fan_errors) + + +# -- Run State Persistence ------------------------------------------------ + + +class RunState: + """Manages workflow run state for persistence and resume.""" + + def __init__( + self, + run_id: str | None = None, + workflow_id: str = "", + project_root: Path | None = None, + ) -> None: + self.run_id = run_id or str(uuid.uuid4())[:8] + if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_-]*$', self.run_id): + msg = f"Invalid run_id {self.run_id!r}: must be alphanumeric with hyphens/underscores only." + raise ValueError(msg) + self.workflow_id = workflow_id + self.project_root = project_root or Path(".") + self.status = RunStatus.CREATED + self.current_step_index = 0 + self.current_step_id: str | None = None + self.step_results: dict[str, dict[str, Any]] = {} + self.inputs: dict[str, Any] = {} + self.created_at = datetime.now(timezone.utc).isoformat() + self.updated_at = self.created_at + self.log_entries: list[dict[str, Any]] = [] + + @property + def runs_dir(self) -> Path: + return self.project_root / ".specify" / "workflows" / "runs" / self.run_id + + def save(self) -> None: + """Persist current state to disk.""" + self.updated_at = datetime.now(timezone.utc).isoformat() + runs_dir = self.runs_dir + runs_dir.mkdir(parents=True, exist_ok=True) + + state_data = { + "run_id": self.run_id, + "workflow_id": self.workflow_id, + "status": self.status.value, + "current_step_index": self.current_step_index, + "current_step_id": self.current_step_id, + "step_results": self.step_results, + "created_at": self.created_at, + "updated_at": self.updated_at, + } + with open(runs_dir / "state.json", "w", encoding="utf-8") as f: + json.dump(state_data, f, indent=2) + + inputs_data = {"inputs": self.inputs} + with open(runs_dir / "inputs.json", "w", encoding="utf-8") as f: + json.dump(inputs_data, f, indent=2) + + @classmethod + def load(cls, run_id: str, project_root: Path) -> RunState: + """Load a run state from disk.""" + runs_dir = project_root / ".specify" / "workflows" / "runs" / run_id + state_path = runs_dir / "state.json" + if not state_path.exists(): + msg = f"Run state not found: {state_path}" + raise FileNotFoundError(msg) + + with open(state_path, encoding="utf-8") as f: + state_data = json.load(f) + + state = cls( + run_id=state_data["run_id"], + workflow_id=state_data["workflow_id"], + project_root=project_root, + ) + state.status = RunStatus(state_data["status"]) + state.current_step_index = state_data.get("current_step_index", 0) + state.current_step_id = state_data.get("current_step_id") + state.step_results = state_data.get("step_results", {}) + state.created_at = state_data.get("created_at", "") + state.updated_at = state_data.get("updated_at", "") + + inputs_path = runs_dir / "inputs.json" + if inputs_path.exists(): + with open(inputs_path, encoding="utf-8") as f: + inputs_data = json.load(f) + state.inputs = inputs_data.get("inputs", {}) + + return state + + def append_log(self, entry: dict[str, Any]) -> None: + """Append a log entry to the run log.""" + entry["timestamp"] = datetime.now(timezone.utc).isoformat() + self.log_entries.append(entry) + + runs_dir = self.runs_dir + runs_dir.mkdir(parents=True, exist_ok=True) + with open(runs_dir / "log.jsonl", "a", encoding="utf-8") as f: + f.write(json.dumps(entry) + "\n") + + +# -- Workflow Engine ------------------------------------------------------ + + +class WorkflowEngine: + """Orchestrator that loads, validates, and executes workflow definitions.""" + + def __init__(self, project_root: Path | None = None) -> None: + self.project_root = project_root or Path(".") + self.on_step_start: Any = None # Callable[[str, str], None] | None + + def load_workflow(self, source: str | Path) -> WorkflowDefinition: + """Load a workflow from an installed ID or a local YAML path. + + Parameters + ---------- + source: + Either a workflow ID (looked up in the installed workflows + directory) or a path to a YAML file. + + Returns + ------- + A parsed ``WorkflowDefinition`` (not yet validated; call + ``validate_workflow()`` or ``engine.validate()`` separately). + + Raises + ------ + FileNotFoundError: + If the workflow file cannot be found. + ValueError: + If the workflow YAML is invalid. + """ + path = Path(source) + + # Try as a direct file path first + if path.suffix in (".yml", ".yaml") and path.exists(): + return WorkflowDefinition.from_yaml(path) + + # Try as an installed workflow ID + installed_path = ( + self.project_root + / ".specify" + / "workflows" + / str(source) + / "workflow.yml" + ) + if installed_path.exists(): + return WorkflowDefinition.from_yaml(installed_path) + + msg = f"Workflow not found: {source}" + raise FileNotFoundError(msg) + + def validate(self, definition: WorkflowDefinition) -> list[str]: + """Validate a workflow definition.""" + return validate_workflow(definition) + + def execute( + self, + definition: WorkflowDefinition, + inputs: dict[str, Any] | None = None, + run_id: str | None = None, + ) -> RunState: + """Execute a workflow definition. + + Parameters + ---------- + definition: + The validated workflow definition. + inputs: + User-provided input values. + run_id: + Optional run ID (auto-generated if not provided). + + Returns + ------- + The final ``RunState`` after execution completes (or pauses). + """ + from . import STEP_REGISTRY + + state = RunState( + run_id=run_id, + workflow_id=definition.id, + project_root=self.project_root, + ) + + # Persist a copy of the workflow definition so resume can + # reload it even if the original source is no longer available + # (e.g. a local YAML path that was moved or deleted). + run_dir = self.project_root / ".specify" / "workflows" / "runs" / state.run_id + run_dir.mkdir(parents=True, exist_ok=True) + workflow_copy = run_dir / "workflow.yml" + import yaml + with open(workflow_copy, "w", encoding="utf-8") as f: + yaml.safe_dump(definition.data, f, sort_keys=False) + + # Resolve inputs + resolved_inputs = self._resolve_inputs(definition, inputs or {}) + state.inputs = resolved_inputs + state.status = RunStatus.RUNNING + state.save() + + context = StepContext( + inputs=resolved_inputs, + default_integration=definition.default_integration, + default_model=definition.default_model, + default_options=definition.default_options, + project_root=str(self.project_root), + run_id=state.run_id, + ) + + # Execute steps + try: + self._execute_steps(definition.steps, context, state, STEP_REGISTRY) + except KeyboardInterrupt: + state.status = RunStatus.PAUSED + state.append_log({"event": "workflow_interrupted"}) + state.save() + return state + except Exception as exc: + state.status = RunStatus.FAILED + state.append_log({"event": "workflow_failed", "error": str(exc)}) + state.save() + raise + + if state.status == RunStatus.RUNNING: + state.status = RunStatus.COMPLETED + state.append_log({"event": "workflow_finished", "status": state.status.value}) + state.save() + return state + + def resume(self, run_id: str) -> RunState: + """Resume a paused or failed workflow run.""" + state = RunState.load(run_id, self.project_root) + if state.status not in (RunStatus.PAUSED, RunStatus.FAILED): + msg = f"Cannot resume run {run_id!r} with status {state.status.value!r}." + raise ValueError(msg) + + # Load the workflow definition — try the persisted copy in the + # run directory first so resume works even if the original + # source (e.g. a local YAML path) is no longer available. + run_dir = self.project_root / ".specify" / "workflows" / "runs" / run_id + run_copy = run_dir / "workflow.yml" + if run_copy.exists(): + definition = WorkflowDefinition.from_yaml(run_copy) + else: + definition = self.load_workflow(state.workflow_id) + + # Restore context + context = StepContext( + inputs=state.inputs, + steps=state.step_results, + default_integration=definition.default_integration, + default_model=definition.default_model, + default_options=definition.default_options, + project_root=str(self.project_root), + run_id=state.run_id, + ) + + from . import STEP_REGISTRY + + state.status = RunStatus.RUNNING + state.save() + + # Resume from the current step — re-execute it so gates + # can prompt interactively again. + remaining_steps = definition.steps[state.current_step_index :] + step_offset = state.current_step_index + + try: + self._execute_steps( + remaining_steps, context, state, STEP_REGISTRY, + step_offset=step_offset, + ) + except KeyboardInterrupt: + state.status = RunStatus.PAUSED + state.append_log({"event": "workflow_interrupted"}) + state.save() + return state + except Exception as exc: + state.status = RunStatus.FAILED + state.append_log({"event": "resume_failed", "error": str(exc)}) + state.save() + raise + + if state.status == RunStatus.RUNNING: + state.status = RunStatus.COMPLETED + state.append_log({"event": "workflow_finished", "status": state.status.value}) + state.save() + return state + + def _execute_steps( + self, + steps: list[dict[str, Any]], + context: StepContext, + state: RunState, + registry: dict[str, Any], + *, + step_offset: int = 0, + ) -> None: + """Execute a list of steps sequentially.""" + for i, step_config in enumerate(steps): + step_id = step_config.get("id", f"step-{i}") + step_type = step_config.get("type", "command") + + state.current_step_id = step_id + if step_offset >= 0: + state.current_step_index = step_offset + i + state.save() + + state.append_log( + {"event": "step_started", "step_id": step_id, "type": step_type} + ) + + # Log progress — use the engine's on_step_start callback if set, + # otherwise stay silent (library-safe default). + label = step_config.get("command", "") or step_type + if self.on_step_start is not None: + self.on_step_start(step_id, label) + + step_impl = registry.get(step_type) + if not step_impl: + state.status = RunStatus.FAILED + state.append_log( + { + "event": "step_failed", + "step_id": step_id, + "error": f"Unknown step type: {step_type!r}", + } + ) + state.save() + return + + result: StepResult = step_impl.execute(step_config, context) + + # Record step results — prefer resolved values from step output + step_data = { + "integration": result.output.get("integration") + or step_config.get("integration") + or context.default_integration, + "model": result.output.get("model") + or step_config.get("model") + or context.default_model, + "options": result.output.get("options") + or step_config.get("options", {}), + "input": result.output.get("input") + or step_config.get("input", {}), + "output": result.output, + "status": result.status.value, + } + context.steps[step_id] = step_data + state.step_results[step_id] = step_data + + state.append_log( + { + "event": "step_completed", + "step_id": step_id, + "status": result.status.value, + } + ) + + # Handle gate pauses + if result.status == StepStatus.PAUSED: + state.status = RunStatus.PAUSED + state.save() + return + + # Handle failures + if result.status == StepStatus.FAILED: + # Gate abort (output.aborted) maps to ABORTED status + if result.output.get("aborted"): + state.status = RunStatus.ABORTED + state.append_log( + { + "event": "workflow_aborted", + "step_id": step_id, + } + ) + else: + state.status = RunStatus.FAILED + state.append_log( + { + "event": "step_failed", + "step_id": step_id, + "error": result.error, + } + ) + state.save() + return + + # Execute nested steps (from control flow) + # NOTE: Nested steps run with step_offset=-1 so they don't + # update current_step_index. If a nested step pauses, + # resume will re-run the parent step and its nested body. + # A step-path stack for exact nested resume is a future + # enhancement. + if result.next_steps: + self._execute_steps( + result.next_steps, context, state, registry, + step_offset=-1, + ) + if state.status in ( + RunStatus.PAUSED, + RunStatus.FAILED, + RunStatus.ABORTED, + ): + return + + # Loop iteration: while/do-while re-evaluate after body + if step_type in ("while", "do-while"): + from .expressions import evaluate_condition + + max_iters = step_config.get("max_iterations") + if not isinstance(max_iters, int) or max_iters < 1: + max_iters = 10 + condition = step_config.get("condition", False) + for _loop_iter in range(max_iters - 1): + if not evaluate_condition(condition, context): + break + # Namespace nested step IDs per iteration + iter_steps = [] + for ns in result.next_steps: + ns_copy = dict(ns) + if "id" in ns_copy: + ns_copy["id"] = f"{step_id}:{ns_copy['id']}:{_loop_iter + 1}" + iter_steps.append(ns_copy) + self._execute_steps( + iter_steps, context, state, registry, + step_offset=-1, + ) + if state.status in ( + RunStatus.PAUSED, + RunStatus.FAILED, + RunStatus.ABORTED, + ): + return + + # Fan-out: execute nested step template per item with unique IDs + if step_type == "fan-out": + items = result.output.get("items", []) + template = result.output.get("step_template", {}) + if template and items: + fan_out_results = [] + for item_idx, item_val in enumerate(result.output["items"]): + context.item = item_val + # Per-item ID: parentId:templateId:index + item_step = dict(template) + base_id = item_step.get("id", "item") + item_step["id"] = f"{step_id}:{base_id}:{item_idx}" + self._execute_steps( + [item_step], context, state, registry, + step_offset=-1, + ) + # Collect per-item result for fan-in + item_result = context.steps.get(item_step["id"], {}) + fan_out_results.append(item_result.get("output", {})) + if state.status in ( + RunStatus.PAUSED, + RunStatus.FAILED, + RunStatus.ABORTED, + ): + break + context.item = None + # Preserve original output and add collected results + fan_out_output = dict(result.output) + fan_out_output["results"] = fan_out_results + context.steps[step_id]["output"] = fan_out_output + state.step_results[step_id]["output"] = fan_out_output + if state.status in ( + RunStatus.PAUSED, + RunStatus.FAILED, + RunStatus.ABORTED, + ): + return + else: + # Empty items or no template — normalize output + result.output["results"] = [] + context.steps[step_id]["output"] = result.output + state.step_results[step_id]["output"] = result.output + + def _resolve_inputs( + self, + definition: WorkflowDefinition, + provided: dict[str, Any], + ) -> dict[str, Any]: + """Resolve workflow inputs against definitions and provided values.""" + resolved: dict[str, Any] = {} + for name, input_def in definition.inputs.items(): + if not isinstance(input_def, dict): + continue + if name in provided: + resolved[name] = self._coerce_input( + name, provided[name], input_def + ) + elif "default" in input_def: + resolved[name] = input_def["default"] + elif input_def.get("required", False): + msg = f"Required input {name!r} not provided." + raise ValueError(msg) + return resolved + + @staticmethod + def _coerce_input( + name: str, value: Any, input_def: dict[str, Any] + ) -> Any: + """Coerce a provided input value to the declared type.""" + input_type = input_def.get("type", "string") + enum_values = input_def.get("enum") + + if input_type == "number": + try: + value = float(value) + if value == int(value): + value = int(value) + except (ValueError, TypeError): + msg = f"Input {name!r} expected a number, got {value!r}." + raise ValueError(msg) from None + elif input_type == "boolean": + if isinstance(value, str): + if value.lower() in ("true", "1", "yes"): + value = True + elif value.lower() in ("false", "0", "no"): + value = False + else: + msg = f"Input {name!r} expected a boolean, got {value!r}." + raise ValueError(msg) + + if enum_values is not None and value not in enum_values: + msg = ( + f"Input {name!r} value {value!r} not in allowed " + f"values: {enum_values}." + ) + raise ValueError(msg) + + return value + + def list_runs(self) -> list[dict[str, Any]]: + """List all workflow runs in the project.""" + runs_dir = self.project_root / ".specify" / "workflows" / "runs" + if not runs_dir.exists(): + return [] + + runs: list[dict[str, Any]] = [] + for run_dir in sorted(runs_dir.iterdir()): + if not run_dir.is_dir(): + continue + state_path = run_dir / "state.json" + if state_path.exists(): + with open(state_path, encoding="utf-8") as f: + state_data = json.load(f) + runs.append(state_data) + return runs + + +class WorkflowAbortError(Exception): + """Raised when a workflow is aborted (e.g., gate rejection).""" diff --git a/src/specify_cli/workflows/expressions.py b/src/specify_cli/workflows/expressions.py new file mode 100644 index 0000000000..eb39a31e79 --- /dev/null +++ b/src/specify_cli/workflows/expressions.py @@ -0,0 +1,300 @@ +"""Sandboxed expression evaluator for workflow templates. + +Provides a safe Jinja2 subset for evaluating expressions in workflow YAML. +No file I/O, no imports, no arbitrary code execution. +""" + +from __future__ import annotations + +import re +from typing import Any + + +# -- Custom filters ------------------------------------------------------- + +def _filter_default(value: Any, default_value: Any = "") -> Any: + """Return *default_value* when *value* is ``None`` or empty string.""" + if value is None or value == "": + return default_value + return value + + +def _filter_join(value: Any, separator: str = ", ") -> str: + """Join a list into a string with *separator*.""" + if isinstance(value, list): + return separator.join(str(v) for v in value) + return str(value) + + +def _filter_map(value: Any, attr: str) -> list[Any]: + """Map a list of dicts to a specific attribute.""" + if isinstance(value, list): + result = [] + for item in value: + if isinstance(item, dict): + # Support dot notation: "result.status" → item["result"]["status"] + parts = attr.split(".") + v = item + for part in parts: + if isinstance(v, dict): + v = v.get(part) + else: + v = None + break + result.append(v) + else: + result.append(item) + return result + return [] + + +def _filter_contains(value: Any, substring: str) -> bool: + """Check if a string or list contains *substring*.""" + if isinstance(value, str): + return substring in value + if isinstance(value, list): + return substring in value + return False + + +# -- Expression resolution ------------------------------------------------ + +_EXPR_PATTERN = re.compile(r"\{\{(.+?)\}\}") + + +def _resolve_dot_path(obj: Any, path: str) -> Any: + """Resolve a dotted path like ``steps.specify.output.file`` against *obj*. + + Supports dict key access and list indexing (e.g., ``task_list[0]``). + """ + parts = path.split(".") + current = obj + for part in parts: + # Handle list indexing: name[0] + idx_match = re.match(r"^([\w-]+)\[(\d+)\]$", part) + if idx_match: + key, idx = idx_match.group(1), int(idx_match.group(2)) + if isinstance(current, dict): + current = current.get(key) + else: + return None + if isinstance(current, list) and 0 <= idx < len(current): + current = current[idx] + else: + return None + elif isinstance(current, dict): + current = current.get(part) + else: + return None + if current is None: + return None + return current + + +def _build_namespace(context: Any) -> dict[str, Any]: + """Build the variable namespace from a StepContext.""" + ns: dict[str, Any] = {} + if hasattr(context, "inputs"): + ns["inputs"] = context.inputs or {} + if hasattr(context, "steps"): + ns["steps"] = context.steps or {} + if hasattr(context, "item"): + ns["item"] = context.item + if hasattr(context, "fan_in"): + ns["fan_in"] = context.fan_in or {} + return ns + + +def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any: + """Evaluate a simple expression against the namespace. + + Supports: + - Dot-path access: ``steps.specify.output.file`` + - Comparisons: ``==``, ``!=``, ``>``, ``<``, ``>=``, ``<=`` + - Boolean operators: ``and``, ``or``, ``not`` + - ``in``, ``not in`` + - Pipe filters: ``| default('...')``, ``| join(', ')``, ``| contains('...')``, ``| map('...')`` + - String and numeric literals + """ + expr = expr.strip() + + # String literal — check before pipes and operators so quoted strings + # containing | or operator keywords are not mis-parsed. + if (expr.startswith("'") and expr.endswith("'")) or ( + expr.startswith('"') and expr.endswith('"') + ): + return expr[1:-1] + + # Handle pipe filters + if "|" in expr: + parts = expr.split("|", 1) + value = _evaluate_simple_expression(parts[0].strip(), namespace) + filter_expr = parts[1].strip() + + # Parse filter name and argument + filter_match = re.match(r"(\w+)\((.+)\)", filter_expr) + if filter_match: + fname = filter_match.group(1) + farg = _evaluate_simple_expression(filter_match.group(2).strip(), namespace) + if fname == "default": + return _filter_default(value, farg) + if fname == "join": + return _filter_join(value, farg) + if fname == "map": + return _filter_map(value, farg) + if fname == "contains": + return _filter_contains(value, farg) + # Filter without args + filter_name = filter_expr.strip() + if filter_name == "default": + return _filter_default(value) + return value + + # Boolean operators — parse 'or' first (lower precedence) so that + # 'a or b and c' is evaluated as 'a or (b and c)'. + if " or " in expr: + parts = expr.split(" or ", 1) + left = _evaluate_simple_expression(parts[0].strip(), namespace) + right = _evaluate_simple_expression(parts[1].strip(), namespace) + return bool(left) or bool(right) + + if " and " in expr: + parts = expr.split(" and ", 1) + left = _evaluate_simple_expression(parts[0].strip(), namespace) + right = _evaluate_simple_expression(parts[1].strip(), namespace) + return bool(left) and bool(right) + + if expr.startswith("not "): + inner = _evaluate_simple_expression(expr[4:].strip(), namespace) + return not bool(inner) + + # Comparison operators (order matters — check multi-char ops first) + for op in ("!=", "==", ">=", "<=", ">", "<", " not in ", " in "): + if op in expr: + parts = expr.split(op, 1) + left = _evaluate_simple_expression(parts[0].strip(), namespace) + right = _evaluate_simple_expression(parts[1].strip(), namespace) + if op == "==": + return left == right + if op == "!=": + return left != right + if op == ">": + return _safe_compare(left, right, ">") + if op == "<": + return _safe_compare(left, right, "<") + if op == ">=": + return _safe_compare(left, right, ">=") + if op == "<=": + return _safe_compare(left, right, "<=") + if op == " in ": + return left in right if right is not None else False + if op == " not in ": + return left not in right if right is not None else True + + # Numeric literal + try: + if "." in expr: + return float(expr) + return int(expr) + except (ValueError, TypeError): + pass + + # Boolean literal + if expr.lower() == "true": + return True + if expr.lower() == "false": + return False + + # Null + if expr.lower() in ("none", "null"): + return None + + # List literal (simple) + if expr.startswith("[") and expr.endswith("]"): + inner = expr[1:-1].strip() + if not inner: + return [] + items = [_evaluate_simple_expression(i.strip(), namespace) for i in inner.split(",")] + return items + + # Variable reference (dot-path) + return _resolve_dot_path(namespace, expr) + + +def _safe_compare(left: Any, right: Any, op: str) -> bool: + """Safely compare two values, coercing types when possible.""" + try: + if isinstance(left, str): + left = float(left) if "." in left else int(left) + if isinstance(right, str): + right = float(right) if "." in right else int(right) + except (ValueError, TypeError): + return False + try: + if op == ">": + return left > right # type: ignore[operator] + if op == "<": + return left < right # type: ignore[operator] + if op == ">=": + return left >= right # type: ignore[operator] + if op == "<=": + return left <= right # type: ignore[operator] + except TypeError: + return False + return False + + +def evaluate_expression(template: str, context: Any) -> Any: + """Evaluate a template string with ``{{ ... }}`` expressions. + + If the entire string is a single expression, returns the raw value + (preserving type). Otherwise, substitutes each expression inline + and returns a string. + + Parameters + ---------- + template: + The template string (e.g., ``"{{ steps.plan.output.task_count }}"`` + or ``"Processed {{ inputs.spec }}"``. + context: + A ``StepContext`` or compatible object. + + Returns + ------- + The resolved value (any type for single-expression templates, + string for multi-expression or mixed templates). + """ + if not isinstance(template, str): + return template + + namespace = _build_namespace(context) + + # Single expression: return typed value + match = _EXPR_PATTERN.fullmatch(template.strip()) + if match: + return _evaluate_simple_expression(match.group(1).strip(), namespace) + + # Multi-expression: string interpolation + def _replacer(m: re.Match[str]) -> str: + val = _evaluate_simple_expression(m.group(1).strip(), namespace) + return str(val) if val is not None else "" + + return _EXPR_PATTERN.sub(_replacer, template) + + +def evaluate_condition(condition: str, context: Any) -> bool: + """Evaluate a condition expression and return a boolean. + + Convenience wrapper around ``evaluate_expression`` that coerces + the result to bool. + """ + result = evaluate_expression(condition, context) + # Treat plain "false"/"true" strings as booleans so that + # condition: "false" (without {{ }}) behaves as expected. + if isinstance(result, str): + lower = result.lower() + if lower == "false": + return False + if lower == "true": + return True + return bool(result) diff --git a/src/specify_cli/workflows/steps/__init__.py b/src/specify_cli/workflows/steps/__init__.py new file mode 100644 index 0000000000..0aa9182dd0 --- /dev/null +++ b/src/specify_cli/workflows/steps/__init__.py @@ -0,0 +1 @@ +"""Auto-discovery for built-in step types.""" diff --git a/src/specify_cli/workflows/steps/command/__init__.py b/src/specify_cli/workflows/steps/command/__init__.py new file mode 100644 index 0000000000..21fd4837d1 --- /dev/null +++ b/src/specify_cli/workflows/steps/command/__init__.py @@ -0,0 +1,155 @@ +"""Command step — dispatches a Spec Kit command to an integration CLI.""" + +from __future__ import annotations + +import shutil +from pathlib import Path +from typing import Any + +from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus +from specify_cli.workflows.expressions import evaluate_expression + + +class CommandStep(StepBase): + """Default step type — invokes a Spec Kit command via the integration CLI. + + The command files (skills, markdown, TOML) are already installed in + the integration's directory on disk. This step tells the CLI to + execute the command by name (e.g. ``/speckit.specify`` or + ``/speckit-specify``) rather than reading the file contents. + + .. note:: + + CLI output is streamed to the terminal for live progress. + ``output.exit_code`` is always captured and can be referenced + by later steps (e.g. ``{{ steps.specify.output.exit_code }}``). + Full ``stdout``/``stderr`` capture is a planned enhancement. + """ + + type_key = "command" + + def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: + command = config.get("command", "") + input_data = config.get("input", {}) + + # Resolve expressions in input + resolved_input: dict[str, Any] = {} + for key, value in input_data.items(): + resolved_input[key] = evaluate_expression(value, context) + + # Resolve integration (step → workflow default → project default) + integration = config.get("integration") or context.default_integration + if integration and isinstance(integration, str) and "{{" in integration: + integration = evaluate_expression(integration, context) + + # Resolve model + model = config.get("model") or context.default_model + if model and isinstance(model, str) and "{{" in model: + model = evaluate_expression(model, context) + + # Merge options (workflow defaults ← step overrides) + options = dict(context.default_options) + step_options = config.get("options", {}) + if step_options: + options.update(step_options) + + # Attempt CLI dispatch + args_str = str(resolved_input.get("args", "")) + dispatch_result = self._try_dispatch( + command, integration, model, args_str, context + ) + + output: dict[str, Any] = { + "command": command, + "integration": integration, + "model": model, + "options": options, + "input": resolved_input, + } + + if dispatch_result is not None: + output["exit_code"] = dispatch_result["exit_code"] + output["stdout"] = dispatch_result["stdout"] + output["stderr"] = dispatch_result["stderr"] + output["dispatched"] = True + if dispatch_result["exit_code"] != 0: + return StepResult( + status=StepStatus.FAILED, + output=output, + error=dispatch_result["stderr"] or f"Command exited with code {dispatch_result['exit_code']}", + ) + return StepResult( + status=StepStatus.COMPLETED, + output=output, + ) + else: + output["exit_code"] = 1 + output["dispatched"] = False + return StepResult( + status=StepStatus.FAILED, + output=output, + error=( + f"Cannot dispatch command {command!r}: " + f"integration {integration!r} CLI not found or not installed. " + f"Install the CLI tool or check 'specify integration list'." + ), + ) + + @staticmethod + def _try_dispatch( + command: str, + integration_key: str | None, + model: str | None, + args: str, + context: StepContext, + ) -> dict[str, Any] | None: + """Invoke *command* by name through the integration CLI. + + The integration's ``dispatch_command`` builds the native + slash-command invocation (e.g. ``/speckit.specify`` for + markdown agents, ``/speckit-specify`` for skills agents), + then executes the CLI non-interactively. + + Returns the dispatch result dict, or ``None`` if dispatch is + not possible (integration not found, CLI not installed, or + dispatch not supported). + """ + if not integration_key: + return None + + try: + from specify_cli.integrations import get_integration + except ImportError: + return None + + impl = get_integration(integration_key) + if impl is None: + return None + + # Check if the integration supports CLI dispatch + if impl.build_exec_args("test") is None: + return None + + # Check if the CLI tool is actually installed + if not shutil.which(impl.key): + return None + + project_root = Path(context.project_root) if context.project_root else None + + try: + return impl.dispatch_command( + command, + args=args, + project_root=project_root, + model=model, + ) + except (NotImplementedError, OSError): + return None + + def validate(self, config: dict[str, Any]) -> list[str]: + errors = super().validate(config) + if "command" not in config: + errors.append( + f"Command step {config.get('id', '?')!r} is missing 'command' field." + ) + return errors diff --git a/src/specify_cli/workflows/steps/do_while/__init__.py b/src/specify_cli/workflows/steps/do_while/__init__.py new file mode 100644 index 0000000000..47a4d34437 --- /dev/null +++ b/src/specify_cli/workflows/steps/do_while/__init__.py @@ -0,0 +1,61 @@ +"""Do-While loop step — execute at least once, then repeat while condition is truthy.""" + +from __future__ import annotations + +from typing import Any + +from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus + + +class DoWhileStep(StepBase): + """Execute body at least once, then check condition. + + Continues while condition is truthy. ``max_iterations`` is an + optional safety cap (defaults to 10 if omitted). + + The first invocation always returns the nested steps for execution. + The engine re-evaluates ``step_config['condition']`` after each + iteration to decide whether to loop again. + """ + + type_key = "do-while" + + def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: + max_iterations = config.get("max_iterations") + if max_iterations is None: + max_iterations = 10 + nested_steps = config.get("steps", []) + condition = config.get("condition", "false") + + # Always execute body at least once; the engine layer evaluates + # `condition` after each iteration to decide whether to loop. + return StepResult( + status=StepStatus.COMPLETED, + output={ + "condition": condition, + "max_iterations": max_iterations, + "loop_type": "do-while", + }, + next_steps=nested_steps, + ) + + def validate(self, config: dict[str, Any]) -> list[str]: + errors = super().validate(config) + if "condition" not in config: + errors.append( + f"Do-while step {config.get('id', '?')!r} is missing " + f"'condition' field." + ) + max_iter = config.get("max_iterations") + if max_iter is not None: + if not isinstance(max_iter, int) or max_iter < 1: + errors.append( + f"Do-while step {config.get('id', '?')!r}: " + f"'max_iterations' must be an integer >= 1." + ) + nested = config.get("steps", []) + if not isinstance(nested, list): + errors.append( + f"Do-while step {config.get('id', '?')!r}: 'steps' must be a list." + ) + return errors diff --git a/src/specify_cli/workflows/steps/fan_in/__init__.py b/src/specify_cli/workflows/steps/fan_in/__init__.py new file mode 100644 index 0000000000..dec3e3fd4d --- /dev/null +++ b/src/specify_cli/workflows/steps/fan_in/__init__.py @@ -0,0 +1,61 @@ +"""Fan-in step — join point for parallel steps.""" + +from __future__ import annotations + +from typing import Any + +from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus +from specify_cli.workflows.expressions import evaluate_expression + + +class FanInStep(StepBase): + """Join point that aggregates results from ``wait_for:`` steps. + + Reads completed step outputs from ``context.steps`` and collects + them into ``output.results``. Does not block; relies on the + engine executing steps sequentially. + """ + + type_key = "fan-in" + + def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: + wait_for = config.get("wait_for", []) + output_config = config.get("output") or {} + if not isinstance(output_config, dict): + output_config = {} + + # Collect results from referenced steps + results = [] + for step_id in wait_for: + step_data = context.steps.get(step_id, {}) + results.append(step_data.get("output", {})) + + # Resolve output expressions with fan_in in context + prev_fan_in = getattr(context, "fan_in", None) + context.fan_in = {"results": results} + resolved_output: dict[str, Any] = {"results": results} + + try: + for key, expr in output_config.items(): + if isinstance(expr, str) and "{{" in expr: + resolved_output[key] = evaluate_expression(expr, context) + else: + resolved_output[key] = expr + finally: + # Restore previous fan_in state even if evaluation fails + context.fan_in = prev_fan_in + + return StepResult( + status=StepStatus.COMPLETED, + output=resolved_output, + ) + + def validate(self, config: dict[str, Any]) -> list[str]: + errors = super().validate(config) + wait_for = config.get("wait_for", []) + if not isinstance(wait_for, list) or not wait_for: + errors.append( + f"Fan-in step {config.get('id', '?')!r}: " + f"'wait_for' must be a non-empty list of step IDs." + ) + return errors diff --git a/src/specify_cli/workflows/steps/fan_out/__init__.py b/src/specify_cli/workflows/steps/fan_out/__init__.py new file mode 100644 index 0000000000..c2fff1face --- /dev/null +++ b/src/specify_cli/workflows/steps/fan_out/__init__.py @@ -0,0 +1,58 @@ +"""Fan-out step — dispatch a step template over a collection.""" + +from __future__ import annotations + +from typing import Any + +from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus +from specify_cli.workflows.expressions import evaluate_expression + + +class FanOutStep(StepBase): + """Dispatch a step template for each item in a collection. + + The engine executes the nested ``step:`` template once per item, + setting ``context.item`` for each iteration. Execution is + currently sequential; ``max_concurrency`` is accepted but not + enforced. + """ + + type_key = "fan-out" + + def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: + items_expr = config.get("items", "[]") + items = evaluate_expression(items_expr, context) + if not isinstance(items, list): + items = [] + + max_concurrency = config.get("max_concurrency", 1) + step_template = config.get("step", {}) + + return StepResult( + status=StepStatus.COMPLETED, + output={ + "items": items, + "max_concurrency": max_concurrency, + "step_template": step_template, + "item_count": len(items), + }, + ) + + def validate(self, config: dict[str, Any]) -> list[str]: + errors = super().validate(config) + if "items" not in config: + errors.append( + f"Fan-out step {config.get('id', '?')!r} is missing " + f"'items' field." + ) + if "step" not in config: + errors.append( + f"Fan-out step {config.get('id', '?')!r} is missing " + f"'step' field (nested step template)." + ) + step = config.get("step") + if step is not None and not isinstance(step, dict): + errors.append( + f"Fan-out step {config.get('id', '?')!r}: 'step' must be a mapping." + ) + return errors diff --git a/src/specify_cli/workflows/steps/gate/__init__.py b/src/specify_cli/workflows/steps/gate/__init__.py new file mode 100644 index 0000000000..d4d32d763c --- /dev/null +++ b/src/specify_cli/workflows/steps/gate/__init__.py @@ -0,0 +1,121 @@ +"""Gate step — human review gate.""" + +from __future__ import annotations + +import sys +from typing import Any + +from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus +from specify_cli.workflows.expressions import evaluate_expression + + +class GateStep(StepBase): + """Interactive review gate. + + When running in an interactive terminal, prompts the user to choose + an option (e.g. approve / reject). Falls back to ``PAUSED`` when + stdin is not a TTY (CI, piped input) so the run can be resumed + later with ``specify workflow resume``. + + The user's choice is stored in ``output.choice``. ``on_reject`` + controls abort / skip behaviour. + """ + + type_key = "gate" + + def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: + message = config.get("message", "Review required.") + if isinstance(message, str) and "{{" in message: + message = evaluate_expression(message, context) + + options = config.get("options", ["approve", "reject"]) + on_reject = config.get("on_reject", "abort") + + show_file = config.get("show_file") + if show_file and isinstance(show_file, str) and "{{" in show_file: + show_file = evaluate_expression(show_file, context) + + output = { + "message": message, + "options": options, + "on_reject": on_reject, + "show_file": show_file, + "choice": None, + } + + # Non-interactive: pause for later resume + if not sys.stdin.isatty(): + return StepResult(status=StepStatus.PAUSED, output=output) + + # Interactive: prompt the user + choice = self._prompt(message, options) + output["choice"] = choice + + if choice in ("reject", "abort"): + if on_reject == "abort": + output["aborted"] = True + return StepResult( + status=StepStatus.FAILED, + output=output, + error=f"Gate rejected by user at step {config.get('id', '?')!r}", + ) + if on_reject == "retry": + # Pause so the next resume re-executes this gate + return StepResult(status=StepStatus.PAUSED, output=output) + # on_reject == "skip" → completed, downstream steps decide + return StepResult(status=StepStatus.COMPLETED, output=output) + + return StepResult(status=StepStatus.COMPLETED, output=output) + + @staticmethod + def _prompt(message: str, options: list[str]) -> str: + """Display gate message and prompt for a choice.""" + print("\n ā”Œā”€ Gate ─────────────────────────────────────") + print(f" │ {message}") + print(" │") + for i, opt in enumerate(options, 1): + print(f" │ [{i}] {opt}") + print(" └────────────────────────────────────────────") + + while True: + try: + raw = input(f" Choose [1-{len(options)}]: ").strip() + except (EOFError, KeyboardInterrupt): + print() + return options[-1] # default to last (usually reject) + if raw.isdigit() and 1 <= int(raw) <= len(options): + return options[int(raw) - 1] + # Also accept the option name directly + if raw.lower() in [o.lower() for o in options]: + return next(o for o in options if o.lower() == raw.lower()) + print(f" Invalid choice. Enter 1-{len(options)} or an option name.") + + def validate(self, config: dict[str, Any]) -> list[str]: + errors = super().validate(config) + if "message" not in config: + errors.append( + f"Gate step {config.get('id', '?')!r} is missing 'message' field." + ) + options = config.get("options", ["approve", "reject"]) + if not isinstance(options, list) or not options: + errors.append( + f"Gate step {config.get('id', '?')!r}: 'options' must be a non-empty list." + ) + elif not all(isinstance(o, str) for o in options): + errors.append( + f"Gate step {config.get('id', '?')!r}: all options must be strings." + ) + on_reject = config.get("on_reject", "abort") + if on_reject not in ("abort", "skip", "retry"): + errors.append( + f"Gate step {config.get('id', '?')!r}: 'on_reject' must be " + f"'abort', 'skip', or 'retry'." + ) + if on_reject in ("abort", "retry") and isinstance(options, list): + reject_choices = {"reject", "abort"} + if not any(o.lower() in reject_choices for o in options): + errors.append( + f"Gate step {config.get('id', '?')!r}: on_reject={on_reject!r} " + f"but options has no 'reject' or 'abort' choice." + ) + return errors diff --git a/src/specify_cli/workflows/steps/if_then/__init__.py b/src/specify_cli/workflows/steps/if_then/__init__.py new file mode 100644 index 0000000000..5b921a31a5 --- /dev/null +++ b/src/specify_cli/workflows/steps/if_then/__init__.py @@ -0,0 +1,55 @@ +"""If/Then/Else step — conditional branching.""" + +from __future__ import annotations + +from typing import Any + +from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus +from specify_cli.workflows.expressions import evaluate_condition + + +class IfThenStep(StepBase): + """Branch based on a boolean condition expression. + + Both ``then:`` and ``else:`` contain inline step arrays — full step + definitions, not ID references. + """ + + type_key = "if" + + def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: + condition = config.get("condition", False) + result = evaluate_condition(condition, context) + + if result: + branch = config.get("then", []) + else: + branch = config.get("else", []) + + return StepResult( + status=StepStatus.COMPLETED, + output={"condition_result": result}, + next_steps=branch, + ) + + def validate(self, config: dict[str, Any]) -> list[str]: + errors = super().validate(config) + if "condition" not in config: + errors.append( + f"If step {config.get('id', '?')!r} is missing 'condition' field." + ) + if "then" not in config: + errors.append( + f"If step {config.get('id', '?')!r} is missing 'then' field." + ) + then_branch = config.get("then", []) + if not isinstance(then_branch, list): + errors.append( + f"If step {config.get('id', '?')!r}: 'then' must be a list of steps." + ) + else_branch = config.get("else", []) + if else_branch and not isinstance(else_branch, list): + errors.append( + f"If step {config.get('id', '?')!r}: 'else' must be a list of steps." + ) + return errors diff --git a/src/specify_cli/workflows/steps/prompt/__init__.py b/src/specify_cli/workflows/steps/prompt/__init__.py new file mode 100644 index 0000000000..44fa22508b --- /dev/null +++ b/src/specify_cli/workflows/steps/prompt/__init__.py @@ -0,0 +1,156 @@ +"""Prompt step — sends an arbitrary prompt to an integration CLI.""" + +from __future__ import annotations + +import shutil +from pathlib import Path +from typing import Any + +from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus +from specify_cli.workflows.expressions import evaluate_expression + + +class PromptStep(StepBase): + """Send a free-form prompt to an integration CLI. + + Unlike ``CommandStep`` which invokes an installed Spec Kit command + by name (e.g. ``/speckit.specify`` or ``/speckit-specify``), + ``PromptStep`` sends an arbitrary inline ``prompt:`` string + directly to the CLI. This is useful for ad-hoc instructions + that don't map to a registered command. + + .. note:: + + CLI output is streamed to the terminal for live progress. + ``output.exit_code`` is always captured and can be referenced + by later steps. Full response text capture is a planned + enhancement. + + Example YAML:: + + - id: review-security + type: prompt + prompt: "Review {{ inputs.file }} for security vulnerabilities" + integration: claude + """ + + type_key = "prompt" + + def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: + prompt_template = config.get("prompt", "") + prompt = evaluate_expression(prompt_template, context) + if not isinstance(prompt, str): + prompt = str(prompt) + + # Resolve integration (step → workflow default) + integration = config.get("integration") or context.default_integration + if integration and isinstance(integration, str) and "{{" in integration: + integration = evaluate_expression(integration, context) + + # Resolve model + model = config.get("model") or context.default_model + if model and isinstance(model, str) and "{{" in model: + model = evaluate_expression(model, context) + + # Attempt CLI dispatch + dispatch_result = self._try_dispatch( + prompt, integration, model, context + ) + + output: dict[str, Any] = { + "prompt": prompt, + "integration": integration, + "model": model, + } + + if dispatch_result is not None: + output["exit_code"] = dispatch_result["exit_code"] + output["stdout"] = dispatch_result["stdout"] + output["stderr"] = dispatch_result["stderr"] + output["dispatched"] = True + if dispatch_result["exit_code"] != 0: + return StepResult( + status=StepStatus.FAILED, + output=output, + error=( + dispatch_result["stderr"] + or f"Prompt exited with code {dispatch_result['exit_code']}" + ), + ) + return StepResult( + status=StepStatus.COMPLETED, + output=output, + ) + else: + output["exit_code"] = 1 + output["dispatched"] = False + return StepResult( + status=StepStatus.FAILED, + output=output, + error=( + f"Cannot dispatch prompt: " + f"integration {integration!r} " + f"CLI not found or not installed." + ), + ) + + @staticmethod + def _try_dispatch( + prompt: str, + integration_key: str | None, + model: str | None, + context: StepContext, + ) -> dict[str, Any] | None: + """Dispatch *prompt* directly through the integration CLI.""" + if not integration_key or not prompt: + return None + + try: + from specify_cli.integrations import get_integration + except ImportError: + return None + + impl = get_integration(integration_key) + if impl is None: + return None + + exec_args = impl.build_exec_args(prompt, model=model, output_json=False) + if exec_args is None: + return None + + if not shutil.which(impl.key): + return None + + import subprocess + + project_root = ( + Path(context.project_root) if context.project_root else Path.cwd() + ) + + try: + result = subprocess.run( + exec_args, + text=True, + cwd=str(project_root), + ) + return { + "exit_code": result.returncode, + "stdout": "", + "stderr": "", + } + except KeyboardInterrupt: + return { + "exit_code": 130, + "stdout": "", + "stderr": "Interrupted by user", + } + except OSError: + return None + + def validate(self, config: dict[str, Any]) -> list[str]: + errors = super().validate(config) + if "prompt" not in config: + errors.append( + f"Prompt step {config.get('id', '?')!r} is missing 'prompt' field." + ) + return errors diff --git a/src/specify_cli/workflows/steps/shell/__init__.py b/src/specify_cli/workflows/steps/shell/__init__.py new file mode 100644 index 0000000000..73ac99530a --- /dev/null +++ b/src/specify_cli/workflows/steps/shell/__init__.py @@ -0,0 +1,75 @@ +"""Shell step — run a local shell command.""" + +from __future__ import annotations + +import subprocess +from typing import Any + +from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus +from specify_cli.workflows.expressions import evaluate_expression + + +class ShellStep(StepBase): + """Run a local shell command (non-agent). + + Captures exit code and stdout/stderr. + """ + + type_key = "shell" + + def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: + run_cmd = config.get("run", "") + if isinstance(run_cmd, str) and "{{" in run_cmd: + run_cmd = evaluate_expression(run_cmd, context) + run_cmd = str(run_cmd) + + cwd = context.project_root or "." + + # NOTE: shell=True is required to support pipes, redirects, and + # multi-command expressions in workflow YAML. Workflow authors + # control commands; catalog-installed workflows should be reviewed + # before use (see PUBLISHING.md for security guidance). + try: + proc = subprocess.run( + run_cmd, + shell=True, + capture_output=True, + text=True, + cwd=cwd, + timeout=300, + ) + output = { + "exit_code": proc.returncode, + "stdout": proc.stdout, + "stderr": proc.stderr, + } + if proc.returncode != 0: + return StepResult( + status=StepStatus.FAILED, + error=f"Shell command exited with code {proc.returncode}.", + output=output, + ) + return StepResult( + status=StepStatus.COMPLETED, + output=output, + ) + except subprocess.TimeoutExpired: + return StepResult( + status=StepStatus.FAILED, + error="Shell command timed out after 300 seconds.", + output={"exit_code": -1, "stdout": "", "stderr": "timeout"}, + ) + except OSError as exc: + return StepResult( + status=StepStatus.FAILED, + error=f"Shell command failed: {exc}", + output={"exit_code": -1, "stdout": "", "stderr": str(exc)}, + ) + + def validate(self, config: dict[str, Any]) -> list[str]: + errors = super().validate(config) + if "run" not in config: + errors.append( + f"Shell step {config.get('id', '?')!r} is missing 'run' field." + ) + return errors diff --git a/src/specify_cli/workflows/steps/switch/__init__.py b/src/specify_cli/workflows/steps/switch/__init__.py new file mode 100644 index 0000000000..e58d3c23c3 --- /dev/null +++ b/src/specify_cli/workflows/steps/switch/__init__.py @@ -0,0 +1,70 @@ +"""Switch step — multi-branch dispatch.""" + +from __future__ import annotations + +from typing import Any + +from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus +from specify_cli.workflows.expressions import evaluate_expression + + +class SwitchStep(StepBase): + """Multi-branch dispatch on an expression. + + Evaluates ``expression:`` once, matches against ``cases:`` keys + (exact match, string-coerced). Falls through to ``default:`` if + no case matches. + """ + + type_key = "switch" + + def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: + expression = config.get("expression", "") + value = evaluate_expression(expression, context) + + # String-coerce for matching + str_value = str(value) if value is not None else "" + + cases = config.get("cases", {}) + for case_key, case_steps in cases.items(): + if str(case_key) == str_value: + return StepResult( + status=StepStatus.COMPLETED, + output={"matched_case": str(case_key), "expression_value": value}, + next_steps=case_steps, + ) + + # Default fallback + default_steps = config.get("default", []) + return StepResult( + status=StepStatus.COMPLETED, + output={"matched_case": "__default__", "expression_value": value}, + next_steps=default_steps, + ) + + def validate(self, config: dict[str, Any]) -> list[str]: + errors = super().validate(config) + if "expression" not in config: + errors.append( + f"Switch step {config.get('id', '?')!r} is missing " + f"'expression' field." + ) + cases = config.get("cases", {}) + if not isinstance(cases, dict): + errors.append( + f"Switch step {config.get('id', '?')!r}: 'cases' must be a mapping." + ) + else: + for key, val in cases.items(): + if not isinstance(val, list): + errors.append( + f"Switch step {config.get('id', '?')!r}: " + f"case {key!r} must be a list of steps." + ) + default = config.get("default") + if default is not None and not isinstance(default, list): + errors.append( + f"Switch step {config.get('id', '?')!r}: " + f"'default' must be a list of steps." + ) + return errors diff --git a/src/specify_cli/workflows/steps/while_loop/__init__.py b/src/specify_cli/workflows/steps/while_loop/__init__.py new file mode 100644 index 0000000000..18c2f46050 --- /dev/null +++ b/src/specify_cli/workflows/steps/while_loop/__init__.py @@ -0,0 +1,68 @@ +"""While loop step — repeat while condition is truthy.""" + +from __future__ import annotations + +from typing import Any + +from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus +from specify_cli.workflows.expressions import evaluate_condition + + +class WhileStep(StepBase): + """Repeat nested steps while condition is truthy. + + Evaluates condition *before* each iteration. If falsy on first + check, the body never runs. ``max_iterations`` is an optional + safety cap (defaults to 10 if omitted). + """ + + type_key = "while" + + def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: + condition = config.get("condition", False) + max_iterations = config.get("max_iterations") + if max_iterations is None: + max_iterations = 10 + nested_steps = config.get("steps", []) + + result = evaluate_condition(condition, context) + if result: + return StepResult( + status=StepStatus.COMPLETED, + output={ + "condition_result": True, + "max_iterations": max_iterations, + "loop_type": "while", + }, + next_steps=nested_steps, + ) + + return StepResult( + status=StepStatus.COMPLETED, + output={ + "condition_result": False, + "max_iterations": max_iterations, + "loop_type": "while", + }, + ) + + def validate(self, config: dict[str, Any]) -> list[str]: + errors = super().validate(config) + if "condition" not in config: + errors.append( + f"While step {config.get('id', '?')!r} is missing " + f"'condition' field." + ) + max_iter = config.get("max_iterations") + if max_iter is not None: + if not isinstance(max_iter, int) or max_iter < 1: + errors.append( + f"While step {config.get('id', '?')!r}: " + f"'max_iterations' must be an integer >= 1." + ) + nested = config.get("steps", []) + if not isinstance(nested, list): + errors.append( + f"While step {config.get('id', '?')!r}: 'steps' must be a list." + ) + return errors diff --git a/templates/agent-file-template.md b/templates/agent-file-template.md deleted file mode 100644 index 4cc7fd6678..0000000000 --- a/templates/agent-file-template.md +++ /dev/null @@ -1,28 +0,0 @@ -# [PROJECT NAME] Development Guidelines - -Auto-generated from all feature plans. Last updated: [DATE] - -## Active Technologies - -[EXTRACTED FROM ALL PLAN.MD FILES] - -## Project Structure - -```text -[ACTUAL STRUCTURE FROM PLANS] -``` - -## Commands - -[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES] - -## Code Style - -[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE] - -## Recent Changes - -[LAST 3 FEATURES AND WHAT THEY ADDED] - - - diff --git a/templates/checklist-template.md b/templates/checklist-template.md index 806657da09..9752c130ec 100644 --- a/templates/checklist-template.md +++ b/templates/checklist-template.md @@ -4,13 +4,13 @@ **Created**: [DATE] **Feature**: [Link to spec.md or relevant documentation] -**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements. +**Note**: This checklist is generated by the `__SPECKIT_COMMAND_CHECKLIST__` command based on feature context and requirements. ` and `` markers in `__CONTEXT_FILE__` to point to the plan file created in step 1 (the IMPL_PLAN path) + +**Output**: data-model.md, /contracts/*, quickstart.md, updated agent context file + +## Key rules + +- Use absolute paths for filesystem operations; use project-relative paths for references in documentation and agent context files +- ERROR on gate failures or unresolved clarifications diff --git a/templates/commands/specify.md b/templates/commands/specify.md index 785b0976c3..1f3f5c4465 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -1,56 +1,327 @@ --- -description: Create feature specification from GitHub issue +description: Create or update the feature specification from a natural language feature description. +handoffs: + - label: Build Technical Plan + agent: speckit.plan + prompt: Create a plan for the spec. I am building with... + - label: Clarify Spec Requirements + agent: speckit.clarify + prompt: Clarify specification requirements + send: true --- -**INPUT**: `$ARGUMENTS` +## User Input -**OUTPUT CONSTRAINT**: spec.md must be **≤50 lines**. Be terse. No prose. +```text +$ARGUMENTS +``` -## Steps +You **MUST** consider the user input before proceeding (if not empty). -1. **Get issue number**: - - If `$ARGUMENTS` contains a number, use it as issue number - - Otherwise, ask user for GitHub issue number - - Optionally get sub-issue number for child issues (e.g., `1225 sub 1`) +## Pre-Execution Checks -2. **Fetch issue details** (if available): - ```bash - gh issue view --json title,body,labels - ``` +**Check for extension hooks (before specification)**: +- Check if `.specify/extensions.yml` exists in the project root. +- If it exists, read it and look for entries under the `hooks.before_specify` key +- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. +- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks -3. **Generate short name** (2-4 words, kebab-case) from issue title + **Optional Pre-Hook**: {extension} + Command: `/{command}` + Description: {description} -4. **Run script**: - ```bash - .specify/scripts/bash/create-new-feature.sh --json --issue --short-name "" "" - # For sub-issues: - .specify/scripts/bash/create-new-feature.sh --json --issue --sub --short-name "" "" - ``` - Parse JSON for BRANCH_NAME and SPEC_FILE. + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks -5. **Load template**: `.specify/templates/spec-template.md` + **Automatic Pre-Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} -6. **Fill spec.md** with: - - 2-4 user stories (P1, P2, P3) - one sentence each + Given/When/Then - - 3-5 functional requirements - testable, no tech details - - 3-4 success criteria - measurable outcomes - - Max 3 open questions (only if critical) + Wait for the result of the hook command before proceeding to the Outline. + ``` +- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently -7. **Validate**: No implementation details, ≤50 lines +## Outline -8. **Label and assign issue**: - ```bash - gh issue edit --add-label "in progress" --add-assignee @me - ``` +The text the user typed after `__SPECKIT_COMMAND_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. -9. **Report**: Branch name, spec path, confirm label added and issue assigned +Given that feature description, do this: -## Examples +1. **Generate a concise short name** (2-4 words) for the feature: + - Analyze the feature description and extract the most meaningful keywords + - Create a 2-4 word short name that captures the essence of the feature + - Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug") + - Preserve technical terms and acronyms (OAuth2, API, JWT, etc.) + - Keep it concise but descriptive enough to understand the feature at a glance + - Examples: + - "I want to add user authentication" → "user-auth" + - "Implement OAuth2 integration for the API" → "oauth2-api-integration" + - "Create a dashboard for analytics" → "analytics-dashboard" + - "Fix payment processing timeout bug" → "fix-payment-timeout" -```bash -# From issue number -/speckit.specify 1223 +2. **Branch creation** (optional, via hook): -# With sub-issue -/speckit.specify 1225 sub 1 -``` + If a `before_specify` hook ran successfully in the Pre-Execution Checks above, it will have created/switched to a git branch and output JSON containing `BRANCH_NAME` and `FEATURE_NUM`. Note these values for reference, but the branch name does **not** dictate the spec directory name. + + If the user explicitly provided `GIT_BRANCH_NAME`, pass it through to the hook so the branch script uses the exact value as the branch name (bypassing all prefix/suffix generation). + +3. **Create the spec feature directory**: + + Specs live under the default `specs/` directory unless the user explicitly provides `SPECIFY_FEATURE_DIRECTORY`. + + **Resolution order for `SPECIFY_FEATURE_DIRECTORY`**: + 1. If the user explicitly provided `SPECIFY_FEATURE_DIRECTORY` (e.g., via environment variable, argument, or configuration), use it as-is + 2. Otherwise, auto-generate it under `specs/`: + - Check `.specify/init-options.json` for `branch_numbering` + - If `"timestamp"`: prefix is `YYYYMMDD-HHMMSS` (current timestamp) + - If `"sequential"` or absent: prefix is `NNN` (next available 3-digit number after scanning existing directories in `specs/`) + - Construct the directory name: `-` (e.g., `003-user-auth` or `20260319-143022-user-auth`) + - Set `SPECIFY_FEATURE_DIRECTORY` to `specs/` + + **Create the directory and spec file**: + - `mkdir -p SPECIFY_FEATURE_DIRECTORY` + - Copy `templates/spec-template.md` to `SPECIFY_FEATURE_DIRECTORY/spec.md` as the starting point + - Set `SPEC_FILE` to `SPECIFY_FEATURE_DIRECTORY/spec.md` + - Persist the resolved path to `.specify/feature.json`: + ```json + { + "feature_directory": "" + } + ``` + Write the actual resolved directory path value (for example, `specs/003-user-auth`), not the literal string `SPECIFY_FEATURE_DIRECTORY`. + This allows downstream commands (`__SPECKIT_COMMAND_PLAN__`, `__SPECKIT_COMMAND_TASKS__`, etc.) to locate the feature directory without relying on git branch name conventions. + + **IMPORTANT**: + - You must only create one feature per `__SPECKIT_COMMAND_SPECIFY__` invocation + - The spec directory name and the git branch name are independent — they may be the same but that is the user's choice + - The spec directory and file are always created by this command, never by the hook + +4. Load `templates/spec-template.md` to understand required sections. + +5. Follow this execution flow: + 1. Parse user description from arguments + If empty: ERROR "No feature description provided" + 2. Extract key concepts from description + Identify: actors, actions, data, constraints + 3. For unclear aspects: + - Make informed guesses based on context and industry standards + - Only mark with [NEEDS CLARIFICATION: specific question] if: + - The choice significantly impacts feature scope or user experience + - Multiple reasonable interpretations exist with different implications + - No reasonable default exists + - **LIMIT: Maximum 3 [NEEDS CLARIFICATION] markers total** + - Prioritize clarifications by impact: scope > security/privacy > user experience > technical details + 4. Fill User Scenarios & Testing section + If no clear user flow: ERROR "Cannot determine user scenarios" + 5. Generate Functional Requirements + Each requirement must be testable + Use reasonable defaults for unspecified details (document assumptions in Assumptions section) + 6. Define Success Criteria + Create measurable, technology-agnostic outcomes + Include both quantitative metrics (time, performance, volume) and qualitative measures (user satisfaction, task completion) + Each criterion must be verifiable without implementation details + 7. Identify Key Entities (if data involved) + 8. Return: SUCCESS (spec ready for planning) + +6. 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. + +7. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria: + + a. **Create Spec Quality Checklist**: Generate a checklist file at `SPECIFY_FEATURE_DIRECTORY/checklists/requirements.md` using the checklist template structure with these validation items: + + ```markdown + # Specification Quality Checklist: [FEATURE NAME] + + **Purpose**: Validate specification completeness and quality before proceeding to planning + **Created**: [DATE] + **Feature**: [Link to spec.md] + + ## Content Quality + + - [ ] No implementation details (languages, frameworks, APIs) + - [ ] Focused on user value and business needs + - [ ] Written for non-technical stakeholders + - [ ] All mandatory sections completed + + ## Requirement Completeness + + - [ ] No [NEEDS CLARIFICATION] markers remain + - [ ] Requirements are testable and unambiguous + - [ ] Success criteria are measurable + - [ ] Success criteria are technology-agnostic (no implementation details) + - [ ] All acceptance scenarios are defined + - [ ] Edge cases are identified + - [ ] Scope is clearly bounded + - [ ] Dependencies and assumptions identified + + ## Feature Readiness + + - [ ] All functional requirements have clear acceptance criteria + - [ ] User scenarios cover primary flows + - [ ] Feature meets measurable outcomes defined in Success Criteria + - [ ] No implementation details leak into specification + + ## Notes + + - Items marked incomplete require spec updates before `__SPECKIT_COMMAND_CLARIFY__` or `__SPECKIT_COMMAND_PLAN__` + ``` + + b. **Run Validation Check**: Review the spec against each checklist item: + - For each item, determine if it passes or fails + - Document specific issues found (quote relevant spec sections) + + c. **Handle Validation Results**: + + - **If all items pass**: Mark checklist complete and proceed to step 7 + + - **If items fail (excluding [NEEDS CLARIFICATION])**: + 1. List the failing items and specific issues + 2. Update the spec to address each issue + 3. Re-run validation until all items pass (max 3 iterations) + 4. If still failing after 3 iterations, document remaining issues in checklist notes and warn user + + - **If [NEEDS CLARIFICATION] markers remain**: + 1. Extract all [NEEDS CLARIFICATION: ...] markers from the spec + 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 + 3. For each clarification needed (max 3), present options to user in this format: + + ```markdown + ## Question [N]: [Topic] + + **Context**: [Quote relevant spec section] + + **What we need to know**: [Specific question from NEEDS CLARIFICATION marker] + + **Suggested Answers**: + + | Option | Answer | Implications | + |--------|--------|--------------| + | A | [First suggested answer] | [What this means for the feature] | + | B | [Second suggested answer] | [What this means for the feature] | + | C | [Third suggested answer] | [What this means for the feature] | + | Custom | Provide your own answer | [Explain how to provide custom input] | + + **Your choice**: _[Wait for user response]_ + ``` + + 4. **CRITICAL - Table Formatting**: Ensure markdown tables are properly formatted: + - Use consistent spacing with pipes aligned + - Each cell should have spaces around content: `| Content |` not `|Content|` + - Header separator must have at least 3 dashes: `|--------|` + - Test that the table renders correctly in markdown preview + 5. Number questions sequentially (Q1, Q2, Q3 - max 3 total) + 6. Present all questions together before waiting for responses + 7. Wait for user to respond with their choices for all questions (e.g., "Q1: A, Q2: Custom - [details], Q3: B") + 8. Update the spec by replacing each [NEEDS CLARIFICATION] marker with the user's selected or provided answer + 9. Re-run validation after all clarifications are resolved + + d. **Update Checklist**: After each validation iteration, update the checklist file with current pass/fail status + +8. **Report completion** to the user with: + - `SPECIFY_FEATURE_DIRECTORY` — the feature directory path + - `SPEC_FILE` — the spec file path + - Checklist results summary + - Readiness for the next phase (`__SPECKIT_COMMAND_CLARIFY__` or `__SPECKIT_COMMAND_PLAN__`) + +9. **Check for extension hooks**: After reporting completion, check if `.specify/extensions.yml` exists in the project root. + - If it exists, read it and look for entries under the `hooks.after_specify` key + - If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally + - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. + - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation + - For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + ``` + - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently + +**NOTE:** Branch creation is handled by the `before_specify` hook (git extension). Spec directory and file creation are always handled by this core command. + +## Quick Guidelines + +- Focus on **WHAT** users need and **WHY**. +- Avoid HOW to implement (no tech stack, APIs, code structure). +- Written for business stakeholders, not developers. +- DO NOT create any checklists that are embedded in the spec. That will be a separate command. + +### Section Requirements + +- **Mandatory sections**: Must be completed for every feature +- **Optional sections**: Include only when relevant to the feature +- When a section doesn't apply, remove it entirely (don't leave as "N/A") + +### For AI Generation + +When creating this spec from a user prompt: + +1. **Make informed guesses**: Use context, industry standards, and common patterns to fill gaps +2. **Document assumptions**: Record reasonable defaults in the Assumptions section +3. **Limit clarifications**: Maximum 3 [NEEDS CLARIFICATION] markers - use only for critical decisions that: + - Significantly impact feature scope or user experience + - Have multiple reasonable interpretations with different implications + - Lack any reasonable default +4. **Prioritize clarifications**: scope > security/privacy > user experience > technical details +5. **Think like a tester**: Every vague requirement should fail the "testable and unambiguous" checklist item +6. **Common areas needing clarification** (only if no reasonable default exists): + - Feature scope and boundaries (include/exclude specific use cases) + - User types and permissions (if multiple conflicting interpretations possible) + - Security/compliance requirements (when legally/financially significant) + +**Examples of reasonable defaults** (don't ask about these): + +- Data retention: Industry-standard practices for the domain +- Performance targets: Standard web/mobile app expectations unless specified +- Error handling: User-friendly messages with appropriate fallbacks +- Authentication method: Standard session-based or OAuth2 for web apps +- Integration patterns: Use project-appropriate patterns (REST/GraphQL for web services, function calls for libraries, CLI args for tools, etc.) + +### Success Criteria Guidelines + +Success criteria must be: + +1. **Measurable**: Include specific metrics (time, percentage, count, rate) +2. **Technology-agnostic**: No mention of frameworks, languages, databases, or tools +3. **User-focused**: Describe outcomes from user/business perspective, not system internals +4. **Verifiable**: Can be tested/validated without knowing implementation details + +**Good examples**: + +- "Users can complete checkout in under 3 minutes" +- "System supports 10,000 concurrent users" +- "95% of searches return results in under 1 second" +- "Task completion rate improves by 40%" + +**Bad examples** (implementation-focused): + +- "API response time is under 200ms" (too technical, use "Users see results instantly") +- "Database can handle 1000 TPS" (implementation detail, use user-facing metric) +- "React components render efficiently" (framework-specific) +- "Redis cache hit rate above 80%" (technology-specific) diff --git a/templates/commands/tasks.md b/templates/commands/tasks.md index 11484b33b2..4e204abc1b 100644 --- a/templates/commands/tasks.md +++ b/templates/commands/tasks.md @@ -1,41 +1,203 @@ --- -description: Generate actionable task list from plan +description: Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts. +handoffs: + - label: Analyze For Consistency + agent: speckit.analyze + prompt: Run a project analysis for consistency + send: true + - label: Implement Project + agent: speckit.implement + prompt: Start the implementation in phases + send: true +scripts: + sh: scripts/bash/check-prerequisites.sh --json + ps: scripts/powershell/check-prerequisites.ps1 -Json --- -**INPUT**: `$ARGUMENTS` +## User Input -**OUTPUT CONSTRAINT**: tasks.md must be **≤40 lines**. Just checkboxes, no prose. +```text +$ARGUMENTS +``` -## Steps +You **MUST** consider the user input before proceeding (if not empty). -1. **Setup**: Run `.specify/scripts/bash/check-prerequisites.sh --json`, parse FEATURE_DIR +## Pre-Execution Checks -2. **Load context**: - - Read plan.md (tech stack, structure) - - Read spec.md (user stories with priorities) +**Check for extension hooks (before tasks generation)**: +- Check if `.specify/extensions.yml` exists in the project root. +- If it exists, read it and look for entries under the `hooks.before_tasks` key +- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. +- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks -3. **Generate tasks.md**: - - **Phase 1: Setup** - project init, dependencies - - **Phase 2+: One phase per user story** (US1, US2, US3) in priority order - - **Final Phase: Polish** - cleanup, documentation + **Optional Pre-Hook**: {extension} + Command: `/{command}` + Description: {description} -4. **Task format** (strict): - ``` - - [ ] T001 [Description] `path/to/file` - - [ ] T002 [P] [Description] `path/to/file` # [P] = parallelizable - ``` + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks -5. **Validate**: - - Every task has ID, description, file path - - Tasks in dependency order - - Each phase independently testable - - If >40 lines, combine or cut tasks + **Automatic Pre-Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + + Wait for the result of the hook command before proceeding to the Outline. + ``` +- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently -6. **Report**: Tasks path, task count, ready for `/speckit.implement` +## Outline -## Rules +1. **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"). -- NO prose, NO explanations - just checkboxes -- One task = one file (usually) -- [P] marker only if truly parallelizable -- 10-20 tasks typical, 30 max +2. **Load design documents**: Read from FEATURE_DIR: + - **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities) + - **Optional**: data-model.md (entities), contracts/ (interface contracts), research.md (decisions), quickstart.md (test scenarios) + - Note: Not all projects have all documents. Generate tasks based on what's available. + +3. **Execute task generation workflow**: + - Load plan.md and extract tech stack, libraries, project structure + - Load spec.md and extract user stories with their priorities (P1, P2, P3, etc.) + - If data-model.md exists: Extract entities and map to user stories + - If contracts/ exists: Map interface contracts to user stories + - If research.md exists: Extract decisions for setup tasks + - Generate tasks organized by user story (see Task Generation Rules below) + - Generate dependency graph showing user story completion order + - Create parallel execution examples per user story + - Validate task completeness (each user story has all needed tasks, independently testable) + +4. **Generate tasks.md**: Use `templates/tasks-template.md` as structure, fill with: + - Correct feature name from plan.md + - Phase 1: Setup tasks (project initialization) + - Phase 2: Foundational tasks (blocking prerequisites for all user stories) + - Phase 3+: One phase per user story (in priority order from spec.md) + - Each phase includes: story goal, independent test criteria, tests (if requested), implementation tasks + - Final Phase: Polish & cross-cutting concerns + - All tasks must follow the strict checklist format (see Task Generation Rules below) + - Clear file paths for each task + - Dependencies section showing story completion order + - Parallel execution examples per story + - Implementation strategy section (MVP first, incremental delivery) + +5. **Report**: Output path to generated tasks.md and summary: + - Total task count + - Task count per user story + - Parallel opportunities identified + - Independent test criteria for each story + - Suggested MVP scope (typically just User Story 1) + - Format validation: Confirm ALL tasks follow the checklist format (checkbox, ID, labels, file paths) + +6. **Check for extension hooks**: After tasks.md is generated, check if `.specify/extensions.yml` exists in the project root. + - If it exists, read it and look for entries under the `hooks.after_tasks` key + - If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally + - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. + - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation + - For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + ``` + - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently + +Context for task generation: {ARGS} + +The tasks.md should be immediately executable - each task must be specific enough that an LLM can complete it without additional context. + +## Task Generation Rules + +**CRITICAL**: Tasks MUST be organized by user story to enable independent implementation and testing. + +**Tests are OPTIONAL**: Only generate test tasks if explicitly requested in the feature specification or if user requests TDD approach. + +### Checklist Format (REQUIRED) + +Every task MUST strictly follow this format: + +```text +- [ ] [TaskID] [P?] [Story?] Description with file path +``` + +**Format Components**: + +1. **Checkbox**: ALWAYS start with `- [ ]` (markdown checkbox) +2. **Task ID**: Sequential number (T001, T002, T003...) in execution order +3. **[P] marker**: Include ONLY if task is parallelizable (different files, no dependencies on incomplete tasks) +4. **[Story] label**: REQUIRED for user story phase tasks only + - Format: [US1], [US2], [US3], etc. (maps to user stories from spec.md) + - Setup phase: NO story label + - Foundational phase: NO story label + - User Story phases: MUST have story label + - Polish phase: NO story label +5. **Description**: Clear action with exact file path + +**Examples**: + +- āœ… CORRECT: `- [ ] T001 Create project structure per implementation plan` +- āœ… CORRECT: `- [ ] T005 [P] Implement authentication middleware in src/middleware/auth.py` +- āœ… CORRECT: `- [ ] T012 [P] [US1] Create User model in src/models/user.py` +- āœ… CORRECT: `- [ ] T014 [US1] Implement UserService in src/services/user_service.py` +- āŒ WRONG: `- [ ] Create User model` (missing ID and Story label) +- āŒ WRONG: `T001 [US1] Create model` (missing checkbox) +- āŒ WRONG: `- [ ] [US1] Create User model` (missing Task ID) +- āŒ WRONG: `- [ ] T001 [US1] Create model` (missing file path) + +### Task Organization + +1. **From User Stories (spec.md)** - PRIMARY ORGANIZATION: + - Each user story (P1, P2, P3...) gets its own phase + - Map all related components to their story: + - Models needed for that story + - Services needed for that story + - Interfaces/UI needed for that story + - If tests requested: Tests specific to that story + - Mark story dependencies (most stories should be independent) + +2. **From Contracts**: + - Map each interface contract → to the user story it serves + - If tests requested: Each interface contract → contract test task [P] before implementation in that story's phase + +3. **From Data Model**: + - Map each entity to the user story(ies) that need it + - If entity serves multiple stories: Put in earliest story or Setup phase + - Relationships → service layer tasks in appropriate story phase + +4. **From Setup/Infrastructure**: + - Shared infrastructure → Setup phase (Phase 1) + - Foundational/blocking tasks → Foundational phase (Phase 2) + - Story-specific setup → within that story's phase + +### Phase Structure + +- **Phase 1**: Setup (project initialization) +- **Phase 2**: Foundational (blocking prerequisites - MUST complete before user stories) +- **Phase 3+**: User Stories in priority order (P1, P2, P3...) + - Within each story: Tests (if requested) → Models → Services → Endpoints → Integration + - Each phase should be a complete, independently testable increment +- **Final Phase**: Polish & Cross-Cutting Concerns diff --git a/templates/commands/taskstoissues.md b/templates/commands/taskstoissues.md index d6aa3bbf55..77db7be130 100644 --- a/templates/commands/taskstoissues.md +++ b/templates/commands/taskstoissues.md @@ -14,6 +14,40 @@ $ARGUMENTS You **MUST** consider the user input before proceeding (if not empty). +## Pre-Execution Checks + +**Check for extension hooks (before tasks-to-issues conversion)**: +- Check if `.specify/extensions.yml` exists in the project root. +- If it exists, read it and look for entries under the `hooks.before_taskstoissues` key +- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. +- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Pre-Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Pre-Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + + Wait for the result of the hook command before proceeding to the Outline. + ``` +- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently + ## Outline 1. 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"). @@ -31,3 +65,35 @@ git config --get remote.origin.url > [!CAUTION] > UNDER NO CIRCUMSTANCES EVER CREATE ISSUES IN REPOSITORIES THAT DO NOT MATCH THE REMOTE URL + +## Post-Execution Checks + +**Check for extension hooks (after tasks-to-issues conversion)**: +Check if `.specify/extensions.yml` exists in the project root. +- If it exists, read it and look for entries under the `hooks.after_taskstoissues` key +- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally +- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. +- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: + - If the hook has no `condition` field, or it is null/empty, treat the hook as executable + - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- For each executable hook, output the following based on its `optional` flag: + - **Optional hook** (`optional: true`): + ``` + ## Extension Hooks + + **Optional Hook**: {extension} + Command: `/{command}` + Description: {description} + + Prompt: {prompt} + To execute: `/{command}` + ``` + - **Mandatory hook** (`optional: false`): + ``` + ## Extension Hooks + + **Automatic Hook**: {extension} + Executing: `/{command}` + EXECUTE_COMMAND: {command} + ``` +- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently diff --git a/templates/plan-template.md b/templates/plan-template.md index 6a8bfc6c8a..ee57c35656 100644 --- a/templates/plan-template.md +++ b/templates/plan-template.md @@ -3,7 +3,7 @@ **Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link] **Input**: Feature specification from `/specs/[###-feature-name]/spec.md` -**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/commands/plan.md` for the execution workflow. +**Note**: This template is filled in by the `__SPECKIT_COMMAND_PLAN__` command. See `.specify/templates/plan-template.md` for the execution workflow. ## Summary @@ -22,7 +22,7 @@ **Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A] **Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION] **Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION] -**Project Type**: [single/web/mobile - determines source structure] +**Project Type**: [e.g., library/cli/web-service/mobile-app/compiler/desktop-app or NEEDS CLARIFICATION] **Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION] **Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION] **Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION] @@ -39,12 +39,12 @@ ```text specs/[###-feature]/ -ā”œā”€ā”€ plan.md # This file (/speckit.plan command output) -ā”œā”€ā”€ research.md # Phase 0 output (/speckit.plan command) -ā”œā”€ā”€ data-model.md # Phase 1 output (/speckit.plan command) -ā”œā”€ā”€ quickstart.md # Phase 1 output (/speckit.plan command) -ā”œā”€ā”€ contracts/ # Phase 1 output (/speckit.plan command) -└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan) +ā”œā”€ā”€ plan.md # This file (__SPECKIT_COMMAND_PLAN__ command output) +ā”œā”€ā”€ research.md # Phase 0 output (__SPECKIT_COMMAND_PLAN__ command) +ā”œā”€ā”€ data-model.md # Phase 1 output (__SPECKIT_COMMAND_PLAN__ command) +ā”œā”€ā”€ quickstart.md # Phase 1 output (__SPECKIT_COMMAND_PLAN__ command) +ā”œā”€ā”€ contracts/ # Phase 1 output (__SPECKIT_COMMAND_PLAN__ command) +└── tasks.md # Phase 2 output (__SPECKIT_COMMAND_TASKS__ command - NOT created by __SPECKIT_COMMAND_PLAN__) ``` ### Source Code (repository root) diff --git a/templates/spec-template.md b/templates/spec-template.md index c67d914980..4581e40529 100644 --- a/templates/spec-template.md +++ b/templates/spec-template.md @@ -113,3 +113,16 @@ - **SC-002**: [Measurable metric, e.g., "System handles 1000 concurrent users without degradation"] - **SC-003**: [User satisfaction metric, e.g., "90% of users successfully complete primary task on first attempt"] - **SC-004**: [Business metric, e.g., "Reduce support tickets related to [X] by 50%"] + +## Assumptions + + + +- [Assumption about target users, e.g., "Users have stable internet connectivity"] +- [Assumption about scope boundaries, e.g., "Mobile support is out of scope for v1"] +- [Assumption about data/environment, e.g., "Existing authentication system will be reused"] +- [Dependency on existing system/service, e.g., "Requires access to the existing user profile API"] diff --git a/templates/tasks-template.md b/templates/tasks-template.md index 60f9be455d..cc649380b9 100644 --- a/templates/tasks-template.md +++ b/templates/tasks-template.md @@ -29,7 +29,7 @@ description: "Task list template for feature implementation" ============================================================================ IMPORTANT: The tasks below are SAMPLE TASKS for illustration purposes only. - The /speckit.tasks command MUST replace these with actual tasks based on: + The __SPECKIT_COMMAND_TASKS__ command MUST replace these with actual tasks based on: - User stories from spec.md (with their priorities P1, P2, P3...) - Feature requirements from plan.md - Entities from data-model.md diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000000..9e8ffaae59 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,68 @@ +"""Shared test helpers for the Spec Kit test suite.""" + +import os +import re +import shutil +import subprocess +import sys + +import pytest + +_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") + + +def _has_working_bash() -> bool: + """Check whether a functional native bash is available. + + On Windows, ``subprocess.run(["bash", ...])`` uses CreateProcess, + which searches System32 *before* PATH — so it may find the WSL + launcher even when Git-for-Windows bash appears first in PATH via + ``shutil.which``. We therefore probe with bare ``"bash"`` (the + same way test helpers invoke it) to get an accurate result. + + On Windows, only Git-for-Windows bash (MSYS2/MINGW) is accepted. + The WSL launcher is rejected because it runs in a separate Linux + filesystem and cannot handle native Windows paths used by the + test fixtures. + + Set SPECKIT_TEST_BASH=1 to force-enable bash tests regardless. + """ + if os.environ.get("SPECKIT_TEST_BASH") == "1": + return True + if shutil.which("bash") is None: + return False + # Probe with bare "bash" — same as the test helpers — so that + # Windows CreateProcess resolution order is respected. + try: + r = subprocess.run( + ["bash", "-c", "echo ok"], + capture_output=True, text=True, timeout=5, + ) + if r.returncode != 0 or "ok" not in r.stdout: + return False + except (OSError, subprocess.TimeoutExpired): + return False + # On Windows, verify we have MSYS/MINGW bash (Git for Windows), + # not the WSL launcher which can't handle native paths. + if sys.platform == "win32": + try: + u = subprocess.run( + ["bash", "-c", "uname -s"], + capture_output=True, text=True, timeout=5, + ) + kernel = u.stdout.strip().upper() + if not any(k in kernel for k in ("MSYS", "MINGW", "CYGWIN")): + return False + except (OSError, subprocess.TimeoutExpired): + return False + return True + + +requires_bash = pytest.mark.skipif( + not _has_working_bash(), reason="working bash not available" +) + + +def strip_ansi(text: str) -> str: + """Remove ANSI escape codes from Rich-formatted CLI output.""" + return _ANSI_ESCAPE_RE.sub("", text) diff --git a/tests/extensions/__init__.py b/tests/extensions/__init__.py new file mode 100644 index 0000000000..97d2f6978b --- /dev/null +++ b/tests/extensions/__init__.py @@ -0,0 +1 @@ +"""Extensions test package.""" diff --git a/tests/extensions/git/__init__.py b/tests/extensions/git/__init__.py new file mode 100644 index 0000000000..bec5daeccc --- /dev/null +++ b/tests/extensions/git/__init__.py @@ -0,0 +1 @@ +"""Tests for the bundled git extension.""" diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py new file mode 100644 index 0000000000..c4f986d177 --- /dev/null +++ b/tests/extensions/git/test_git_extension.py @@ -0,0 +1,839 @@ +""" +Tests for the bundled git extension (extensions/git/). + +Validates: +- extension.yml manifest +- Bash scripts (create-new-feature.sh, initialize-repo.sh, auto-commit.sh, git-common.sh) +- PowerShell scripts (where pwsh is available) +- Config reading from git-config.yml +- Extension install via ExtensionManager +""" + +import json +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +from tests.conftest import requires_bash + +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent +EXT_DIR = PROJECT_ROOT / "extensions" / "git" +EXT_BASH = EXT_DIR / "scripts" / "bash" +EXT_PS = EXT_DIR / "scripts" / "powershell" +CORE_COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh" +CORE_COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1" + +HAS_PWSH = shutil.which("pwsh") is not None + + +# ── Helpers ────────────────────────────────────────────────────────────────── + + +def _init_git(path: Path) -> None: + """Initialize a git repo with a dummy commit.""" + subprocess.run(["git", "init", "-q"], cwd=path, check=True) + subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=path, check=True) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=path, check=True) + subprocess.run( + ["git", "commit", "--allow-empty", "-m", "seed", "-q"], + cwd=path, + check=True, + ) + + +def _setup_project(tmp_path: Path, *, git: bool = True) -> Path: + """Create a project directory with core scripts and .specify.""" + # Core scripts (needed by extension scripts that source common.sh) + bash_dir = tmp_path / "scripts" / "bash" + bash_dir.mkdir(parents=True) + shutil.copy(CORE_COMMON_SH, bash_dir / "common.sh") + + ps_dir = tmp_path / "scripts" / "powershell" + ps_dir.mkdir(parents=True) + shutil.copy(CORE_COMMON_PS, ps_dir / "common.ps1") + + # .specify structure + (tmp_path / ".specify" / "templates").mkdir(parents=True) + + # Extension scripts (as if installed) + ext_bash = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "bash" + ext_bash.mkdir(parents=True) + for f in EXT_BASH.iterdir(): + dest = ext_bash / f.name + shutil.copy(f, dest) + dest.chmod(0o755) + + ext_ps = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "powershell" + ext_ps.mkdir(parents=True) + for f in EXT_PS.iterdir(): + shutil.copy(f, ext_ps / f.name) + + # Copy extension.yml + shutil.copy(EXT_DIR / "extension.yml", tmp_path / ".specify" / "extensions" / "git" / "extension.yml") + + if git: + _init_git(tmp_path) + + return tmp_path + + +def _write_config(project: Path, content: str) -> Path: + """Write git-config.yml into the extension config directory.""" + config_path = project / ".specify" / "extensions" / "git" / "git-config.yml" + config_path.write_text(content, encoding="utf-8") + return config_path + + +# Git identity env vars for CI runners without global git config +_GIT_ENV = { + "GIT_AUTHOR_NAME": "Test User", + "GIT_AUTHOR_EMAIL": "test@example.com", + "GIT_COMMITTER_NAME": "Test User", + "GIT_COMMITTER_EMAIL": "test@example.com", +} + + +def _run_bash(script_name: str, cwd: Path, *args: str, env_extra: dict | None = None) -> subprocess.CompletedProcess: + """Run an extension bash script.""" + script = cwd / ".specify" / "extensions" / "git" / "scripts" / "bash" / script_name + env = {**os.environ, **_GIT_ENV, **(env_extra or {})} + return subprocess.run( + ["bash", str(script), *args], + cwd=cwd, + capture_output=True, + text=True, + env=env, + ) + + +def _run_pwsh(script_name: str, cwd: Path, *args: str) -> subprocess.CompletedProcess: + """Run an extension PowerShell script.""" + script = cwd / ".specify" / "extensions" / "git" / "scripts" / "powershell" / script_name + env = {**os.environ, **_GIT_ENV} + return subprocess.run( + ["pwsh", "-NoProfile", "-File", str(script), *args], + cwd=cwd, + capture_output=True, + text=True, + env=env, + ) + + +# ── Manifest Tests ─────────────────────────────────────────────────────────── + + +class TestGitExtensionManifest: + def test_manifest_validates(self): + """extension.yml passes manifest validation.""" + from specify_cli.extensions import ExtensionManifest + + m = ExtensionManifest(EXT_DIR / "extension.yml") + assert m.id == "git" + assert m.version == "1.0.0" + + def test_manifest_commands(self): + """Manifest declares expected commands.""" + from specify_cli.extensions import ExtensionManifest + + m = ExtensionManifest(EXT_DIR / "extension.yml") + names = [c["name"] for c in m.commands] + assert "speckit.git.feature" in names + assert "speckit.git.validate" in names + assert "speckit.git.remote" in names + assert "speckit.git.initialize" in names + assert "speckit.git.commit" in names + + def test_manifest_hooks(self): + """Manifest declares expected hooks.""" + from specify_cli.extensions import ExtensionManifest + + m = ExtensionManifest(EXT_DIR / "extension.yml") + assert "before_constitution" in m.hooks + assert "before_specify" in m.hooks + assert "after_specify" in m.hooks + assert "after_implement" in m.hooks + assert m.hooks["before_constitution"]["command"] == "speckit.git.initialize" + assert m.hooks["before_specify"]["command"] == "speckit.git.feature" + + def test_manifest_command_files_exist(self): + """All command files referenced in the manifest exist.""" + from specify_cli.extensions import ExtensionManifest + + m = ExtensionManifest(EXT_DIR / "extension.yml") + for cmd in m.commands: + cmd_path = EXT_DIR / cmd["file"] + assert cmd_path.is_file(), f"Missing command file: {cmd['file']}" + + +# ── Install Tests ──────────────────────────────────────────────────────────── + + +class TestGitExtensionInstall: + def test_install_from_directory(self, tmp_path: Path): + """Extension installs via ExtensionManager.install_from_directory.""" + from specify_cli.extensions import ExtensionManager + + (tmp_path / ".specify").mkdir() + manager = ExtensionManager(tmp_path) + manifest = manager.install_from_directory(EXT_DIR, "0.5.0", register_commands=False) + assert manifest.id == "git" + assert manager.registry.is_installed("git") + + def test_install_copies_scripts(self, tmp_path: Path): + """Extension install copies script files.""" + from specify_cli.extensions import ExtensionManager + + (tmp_path / ".specify").mkdir() + manager = ExtensionManager(tmp_path) + manager.install_from_directory(EXT_DIR, "0.5.0", register_commands=False) + + ext_installed = tmp_path / ".specify" / "extensions" / "git" + assert (ext_installed / "scripts" / "bash" / "create-new-feature.sh").is_file() + assert (ext_installed / "scripts" / "bash" / "initialize-repo.sh").is_file() + assert (ext_installed / "scripts" / "bash" / "auto-commit.sh").is_file() + assert (ext_installed / "scripts" / "bash" / "git-common.sh").is_file() + assert (ext_installed / "scripts" / "powershell" / "create-new-feature.ps1").is_file() + assert (ext_installed / "scripts" / "powershell" / "initialize-repo.ps1").is_file() + assert (ext_installed / "scripts" / "powershell" / "auto-commit.ps1").is_file() + assert (ext_installed / "scripts" / "powershell" / "git-common.ps1").is_file() + + def test_bundled_extension_locator(self): + """_locate_bundled_extension finds the git extension.""" + from specify_cli import _locate_bundled_extension + + path = _locate_bundled_extension("git") + assert path is not None + assert (path / "extension.yml").is_file() + + +# ── initialize-repo.sh Tests ───────────────────────────────────────────────── + + +@requires_bash +class TestInitializeRepoBash: + def test_initializes_git_repo(self, tmp_path: Path): + """initialize-repo.sh creates a git repo with initial commit.""" + project = _setup_project(tmp_path, git=False) + result = _run_bash("initialize-repo.sh", project) + assert result.returncode == 0, result.stderr + + # Verify git repo exists + assert (project / ".git").exists() + + # Verify at least one commit exists + log = subprocess.run( + ["git", "log", "--oneline", "-1"], + cwd=project, capture_output=True, text=True, + ) + assert log.returncode == 0 + + def test_skips_if_already_git_repo(self, tmp_path: Path): + """initialize-repo.sh skips if already a git repo.""" + project = _setup_project(tmp_path, git=True) + result = _run_bash("initialize-repo.sh", project) + assert result.returncode == 0 + assert "already initialized" in result.stderr.lower() + + def test_custom_commit_message(self, tmp_path: Path): + """initialize-repo.sh reads custom commit message from config.""" + project = _setup_project(tmp_path, git=False) + _write_config(project, 'init_commit_message: "Custom init message"\n') + + result = _run_bash("initialize-repo.sh", project) + assert result.returncode == 0 + + log = subprocess.run( + ["git", "log", "--oneline", "-1"], + cwd=project, capture_output=True, text=True, + ) + assert "Custom init message" in log.stdout + + +@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available") +class TestInitializeRepoPowerShell: + def test_initializes_git_repo(self, tmp_path: Path): + """initialize-repo.ps1 creates a git repo with initial commit.""" + project = _setup_project(tmp_path, git=False) + result = _run_pwsh("initialize-repo.ps1", project) + assert result.returncode == 0, result.stderr + assert (project / ".git").exists() + + def test_skips_if_already_git_repo(self, tmp_path: Path): + """initialize-repo.ps1 skips if already a git repo.""" + project = _setup_project(tmp_path, git=True) + result = _run_pwsh("initialize-repo.ps1", project) + assert result.returncode == 0 + + +# ── create-new-feature.sh Tests ────────────────────────────────────────────── + + +@requires_bash +class TestCreateFeatureBash: + def test_creates_branch_sequential(self, tmp_path: Path): + """Extension create-new-feature.sh creates sequential branch.""" + project = _setup_project(tmp_path) + result = _run_bash( + "create-new-feature.sh", project, + "--json", "--short-name", "user-auth", "Add user authentication", + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "001-user-auth" + assert data["FEATURE_NUM"] == "001" + + def test_creates_branch_timestamp(self, tmp_path: Path): + """Extension create-new-feature.sh creates timestamp branch.""" + project = _setup_project(tmp_path) + result = _run_bash( + "create-new-feature.sh", project, + "--json", "--timestamp", "--short-name", "feat", "Feature", + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert re.match(r"^\d{8}-\d{6}-feat$", data["BRANCH_NAME"]) + + def test_increments_from_existing_specs(self, tmp_path: Path): + """Sequential numbering increments past existing spec directories.""" + project = _setup_project(tmp_path) + (project / "specs" / "001-first").mkdir(parents=True) + (project / "specs" / "002-second").mkdir(parents=True) + + result = _run_bash( + "create-new-feature.sh", project, + "--json", "--short-name", "third", "Third feature", + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["FEATURE_NUM"] == "003" + + def test_no_git_graceful_degradation(self, tmp_path: Path): + """create-new-feature.sh works without git (outputs branch name, skips branch creation).""" + project = _setup_project(tmp_path, git=False) + result = _run_bash( + "create-new-feature.sh", project, + "--json", "--short-name", "no-git", "No git feature", + ) + assert result.returncode == 0, result.stderr + assert "Warning" in result.stderr + data = json.loads(result.stdout) + assert "BRANCH_NAME" in data + assert "FEATURE_NUM" in data + + def test_dry_run(self, tmp_path: Path): + """--dry-run computes branch name without creating anything.""" + project = _setup_project(tmp_path) + result = _run_bash( + "create-new-feature.sh", project, + "--json", "--dry-run", "--short-name", "dry", "Dry run test", + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data.get("DRY_RUN") is True + assert not (project / "specs" / data["BRANCH_NAME"]).exists() + + +@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available") +class TestCreateFeaturePowerShell: + def test_creates_branch_sequential(self, tmp_path: Path): + """Extension create-new-feature.ps1 creates sequential branch.""" + project = _setup_project(tmp_path) + result = _run_pwsh( + "create-new-feature.ps1", project, + "-Json", "-ShortName", "user-auth", "Add user authentication", + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "001-user-auth" + + def test_creates_branch_timestamp(self, tmp_path: Path): + """Extension create-new-feature.ps1 creates timestamp branch.""" + project = _setup_project(tmp_path) + result = _run_pwsh( + "create-new-feature.ps1", project, + "-Json", "-Timestamp", "-ShortName", "feat", "Feature", + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert re.match(r"^\d{8}-\d{6}-feat$", data["BRANCH_NAME"]) + + def test_no_git_graceful_degradation(self, tmp_path: Path): + """create-new-feature.ps1 works without git.""" + project = _setup_project(tmp_path, git=False) + result = _run_pwsh( + "create-new-feature.ps1", project, + "-Json", "-ShortName", "no-git", "No git feature", + ) + assert result.returncode == 0, result.stderr + # pwsh may prefix warnings to stdout; find the JSON line + json_line = [l for l in result.stdout.splitlines() if l.strip().startswith("{")] + assert json_line, f"No JSON in output: {result.stdout}" + data = json.loads(json_line[-1]) + assert "BRANCH_NAME" in data + assert "FEATURE_NUM" in data + + +# ── auto-commit.sh Tests ───────────────────────────────────────────────────── + + +@requires_bash +class TestAutoCommitBash: + def test_disabled_by_default(self, tmp_path: Path): + """auto-commit.sh exits silently when config is all false.""" + project = _setup_project(tmp_path) + _write_config(project, "auto_commit:\n default: false\n") + result = _run_bash("auto-commit.sh", project, "after_specify") + assert result.returncode == 0 + # Should not have created any new commits + log = subprocess.run( + ["git", "log", "--oneline"], + cwd=project, capture_output=True, text=True, + ) + assert log.stdout.strip().count("\n") == 0 # only the seed commit + + def test_enabled_per_command(self, tmp_path: Path): + """auto-commit.sh commits when per-command key is enabled.""" + project = _setup_project(tmp_path) + _write_config(project, ( + "auto_commit:\n" + " default: false\n" + " after_specify:\n" + " enabled: true\n" + ' message: "test commit after specify"\n' + )) + # Create a file to commit + (project / "specs" / "001-test" / "spec.md").parent.mkdir(parents=True) + (project / "specs" / "001-test" / "spec.md").write_text("test spec") + + result = _run_bash("auto-commit.sh", project, "after_specify") + assert result.returncode == 0 + + log = subprocess.run( + ["git", "log", "--oneline", "-1"], + cwd=project, capture_output=True, text=True, + ) + assert "test commit after specify" in log.stdout + + def test_custom_message(self, tmp_path: Path): + """auto-commit.sh uses the per-command message.""" + project = _setup_project(tmp_path) + _write_config(project, ( + "auto_commit:\n" + " default: false\n" + " after_plan:\n" + " enabled: true\n" + ' message: "[Project] Plan complete"\n' + )) + (project / "new-file.txt").write_text("content") + + result = _run_bash("auto-commit.sh", project, "after_plan") + assert result.returncode == 0 + + log = subprocess.run( + ["git", "log", "--oneline", "-1"], + cwd=project, capture_output=True, text=True, + ) + assert "[Project] Plan complete" in log.stdout + + def test_default_true_with_no_event_key(self, tmp_path: Path): + """auto-commit.sh uses default: true when event key is absent.""" + project = _setup_project(tmp_path) + _write_config(project, "auto_commit:\n default: true\n") + (project / "new-file.txt").write_text("content") + + result = _run_bash("auto-commit.sh", project, "after_tasks") + assert result.returncode == 0 + + log = subprocess.run( + ["git", "log", "--oneline", "-1"], + cwd=project, capture_output=True, text=True, + ) + assert "Auto-commit after tasks" in log.stdout + + def test_no_changes_skips(self, tmp_path: Path): + """auto-commit.sh skips when there are no changes.""" + project = _setup_project(tmp_path) + _write_config(project, ( + "auto_commit:\n" + " default: false\n" + " after_specify:\n" + " enabled: true\n" + ' message: "should not appear"\n' + )) + # Commit all existing files so nothing is dirty + subprocess.run(["git", "add", "."], cwd=project, check=True) + subprocess.run(["git", "commit", "-m", "setup", "-q"], cwd=project, check=True) + + result = _run_bash("auto-commit.sh", project, "after_specify") + assert result.returncode == 0 + assert "No changes" in result.stderr + + def test_no_config_file_skips(self, tmp_path: Path): + """auto-commit.sh exits silently when no config file exists.""" + project = _setup_project(tmp_path) + # Remove config if it was copied + config = project / ".specify" / "extensions" / "git" / "git-config.yml" + config.unlink(missing_ok=True) + + result = _run_bash("auto-commit.sh", project, "after_specify") + assert result.returncode == 0 + + def test_no_git_repo_skips(self, tmp_path: Path): + """auto-commit.sh skips when not in a git repo.""" + project = _setup_project(tmp_path, git=False) + _write_config(project, "auto_commit:\n default: true\n") + result = _run_bash("auto-commit.sh", project, "after_specify") + assert result.returncode == 0 + assert "not a Git repository" in result.stderr.lower() or "Warning" in result.stderr + + def test_requires_event_name_argument(self, tmp_path: Path): + """auto-commit.sh fails without event name argument.""" + project = _setup_project(tmp_path) + result = _run_bash("auto-commit.sh", project) + assert result.returncode != 0 + + def test_success_message_uses_ok_prefix(self, tmp_path: Path): + """auto-commit.sh success message uses [OK] (not Unicode).""" + project = _setup_project(tmp_path) + _write_config(project, ( + "auto_commit:\n" + " default: false\n" + " after_specify:\n" + " enabled: true\n" + )) + (project / "new-file.txt").write_text("content") + result = _run_bash("auto-commit.sh", project, "after_specify") + assert result.returncode == 0 + assert "[OK] Changes committed" in result.stderr + + def test_success_message_no_unicode_checkmark(self, tmp_path: Path): + """auto-commit.sh must not use Unicode checkmark in output.""" + project = _setup_project(tmp_path) + _write_config(project, ( + "auto_commit:\n" + " default: false\n" + " after_plan:\n" + " enabled: true\n" + )) + (project / "new-file.txt").write_text("content") + result = _run_bash("auto-commit.sh", project, "after_plan") + assert result.returncode == 0 + assert "\u2713" not in result.stderr, "Must not use Unicode checkmark" + + +@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available") +class TestAutoCommitPowerShell: + def test_disabled_by_default(self, tmp_path: Path): + """auto-commit.ps1 exits silently when config is all false.""" + project = _setup_project(tmp_path) + _write_config(project, "auto_commit:\n default: false\n") + result = _run_pwsh("auto-commit.ps1", project, "after_specify") + assert result.returncode == 0 + + def test_enabled_per_command(self, tmp_path: Path): + """auto-commit.ps1 commits when per-command key is enabled.""" + project = _setup_project(tmp_path) + _write_config(project, ( + "auto_commit:\n" + " default: false\n" + " after_specify:\n" + " enabled: true\n" + ' message: "ps commit"\n' + )) + (project / "specs" / "001-test").mkdir(parents=True) + (project / "specs" / "001-test" / "spec.md").write_text("test") + + result = _run_pwsh("auto-commit.ps1", project, "after_specify") + assert result.returncode == 0 + + log = subprocess.run( + ["git", "log", "--oneline", "-1"], + cwd=project, capture_output=True, text=True, + ) + assert "ps commit" in log.stdout + + def test_success_message_uses_ok_prefix(self, tmp_path: Path): + """auto-commit.ps1 success message uses [OK] (not Unicode).""" + project = _setup_project(tmp_path) + _write_config(project, ( + "auto_commit:\n" + " default: false\n" + " after_specify:\n" + " enabled: true\n" + )) + (project / "new-file.txt").write_text("content") + result = _run_pwsh("auto-commit.ps1", project, "after_specify") + assert result.returncode == 0 + assert "[OK] Changes committed" in result.stdout + + def test_success_message_no_unicode_checkmark(self, tmp_path: Path): + """auto-commit.ps1 must not use Unicode checkmark in output.""" + project = _setup_project(tmp_path) + _write_config(project, ( + "auto_commit:\n" + " default: false\n" + " after_plan:\n" + " enabled: true\n" + )) + (project / "new-file.txt").write_text("content") + result = _run_pwsh("auto-commit.ps1", project, "after_plan") + assert result.returncode == 0 + assert "\u2713" not in result.stdout, "Must not use Unicode checkmark" + + +# ── auto-commit.ps1 CRLF warning tests (issue #2253) ──────────────────────── + + +@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available") +class TestAutoCommitPowerShellCRLF: + """Tests for CRLF warning handling in auto-commit.ps1 (issue #2253). + + On Windows, git emits CRLF warnings to stderr when core.autocrlf=true + and files use LF line endings. PowerShell's $ErrorActionPreference='Stop' + converts stderr output into terminating errors, crashing the script. + + These tests use core.autocrlf=true + explicit LF-ending files. On Windows + the CRLF warnings fire and exercise the fix; on other platforms the tests + still run (they just won't produce stderr warnings, so they pass trivially). + """ + + # -- positive tests (fix works) ---------------------------------------- + + def test_commit_succeeds_with_autocrlf(self, tmp_path: Path): + """auto-commit.ps1 creates a commit when core.autocrlf=true (CRLF + warnings on stderr must not crash the script).""" + project = _setup_project(tmp_path) + _write_config(project, ( + "auto_commit:\n" + " default: false\n" + " after_specify:\n" + " enabled: true\n" + ' message: "crlf commit"\n' + )) + # Create and commit a tracked LF-ending file first so the script's + # `git diff --quiet HEAD` checks inspect a tracked modification. + tracked = project / "crlf-test.txt" + tracked.write_bytes(b"line one\nline two\nline three\n") + subprocess.run(["git", "add", "crlf-test.txt"], cwd=project, check=True) + subprocess.run( + ["git", "commit", "-m", "seed tracked file"], + cwd=project, check=True, env={**os.environ, **_GIT_ENV}, + ) + subprocess.run( + ["git", "config", "core.autocrlf", "true"], + cwd=project, check=True, + ) + # Modify the tracked file with explicit LF endings to trigger the + # CRLF warning during diff/status checks on Windows. + tracked.write_bytes(b"line one\nline two changed\nline three\n") + + # On Windows, verify the test setup actually produces a CRLF warning. + if sys.platform == "win32": + probe = subprocess.run( + ["git", "diff", "--quiet", "HEAD"], + cwd=project, capture_output=True, text=True, + ) + assert "LF will be replaced by CRLF" in probe.stderr, ( + "Expected CRLF warning from git on Windows; test setup may be wrong" + ) + + result = _run_pwsh("auto-commit.ps1", project, "after_specify") + + assert result.returncode == 0, ( + f"Script crashed (likely CRLF stderr); stderr:\n{result.stderr}" + ) + assert "[OK] Changes committed" in result.stdout + + log = subprocess.run( + ["git", "log", "--oneline", "-1"], + cwd=project, capture_output=True, text=True, + ) + assert "crlf commit" in log.stdout + + def test_custom_message_not_corrupted_by_crlf(self, tmp_path: Path): + """Commit message is the configured value, not a CRLF warning.""" + project = _setup_project(tmp_path) + _write_config(project, ( + "auto_commit:\n" + " default: false\n" + " after_plan:\n" + " enabled: true\n" + ' message: "[Project] Plan done"\n' + )) + subprocess.run( + ["git", "config", "core.autocrlf", "true"], + cwd=project, check=True, + ) + (project / "plan.txt").write_bytes(b"plan\ncontent\n") + + result = _run_pwsh("auto-commit.ps1", project, "after_plan") + assert result.returncode == 0 + + log = subprocess.run( + ["git", "log", "--format=%s", "-1"], + cwd=project, capture_output=True, text=True, + ) + assert "[Project] Plan done" in log.stdout.strip() + + def test_no_changes_still_skips_with_autocrlf(self, tmp_path: Path): + """Script correctly detects 'no changes' even with core.autocrlf=true.""" + project = _setup_project(tmp_path) + _write_config(project, ( + "auto_commit:\n" + " default: false\n" + " after_specify:\n" + " enabled: true\n" + )) + subprocess.run( + ["git", "config", "core.autocrlf", "true"], + cwd=project, check=True, + ) + # Stage and commit everything so the working tree is clean. + subprocess.run(["git", "add", "."], cwd=project, check=True, + env={**os.environ, **_GIT_ENV}) + subprocess.run(["git", "commit", "-m", "setup", "-q"], cwd=project, + check=True, env={**os.environ, **_GIT_ENV}) + + result = _run_pwsh("auto-commit.ps1", project, "after_specify") + assert result.returncode == 0 + assert "[OK]" not in result.stdout, "Should not have committed anything" + + # -- negative tests (real errors still surface) ------------------------ + + def test_not_a_repo_still_detected_with_autocrlf(self, tmp_path: Path): + """Script still exits gracefully when not in a git repo, even though + ErrorActionPreference is relaxed around the rev-parse call.""" + project = _setup_project(tmp_path, git=False) + _write_config(project, "auto_commit:\n default: true\n") + + result = _run_pwsh("auto-commit.ps1", project, "after_specify") + assert result.returncode == 0 + combined = result.stdout + result.stderr + assert "not a git repository" in combined.lower() or "warning" in combined.lower() + + def test_missing_config_still_exits_cleanly_with_autocrlf(self, tmp_path: Path): + """Script exits 0 when git-config.yml is absent (no over-suppression).""" + project = _setup_project(tmp_path) + subprocess.run( + ["git", "config", "core.autocrlf", "true"], + cwd=project, check=True, + ) + config = project / ".specify" / "extensions" / "git" / "git-config.yml" + config.unlink(missing_ok=True) + + result = _run_pwsh("auto-commit.ps1", project, "after_specify") + assert result.returncode == 0 + # Should not have committed anything — config file missing means disabled. + log = subprocess.run( + ["git", "log", "--oneline"], + cwd=project, capture_output=True, text=True, + ) + assert log.stdout.strip().count("\n") == 0 # only the seed commit + + +# ── git-common.sh Tests ────────────────────────────────────────────────────── + + +@requires_bash +class TestGitCommonBash: + def test_has_git_true(self, tmp_path: Path): + """has_git returns 0 in a git repo.""" + project = _setup_project(tmp_path, git=True) + script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh" + result = subprocess.run( + ["bash", "-c", f'source "{script}" && has_git "{project}"'], + capture_output=True, text=True, + ) + assert result.returncode == 0 + + def test_has_git_false(self, tmp_path: Path): + """has_git returns non-zero outside a git repo.""" + project = _setup_project(tmp_path, git=False) + script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh" + result = subprocess.run( + ["bash", "-c", f'source "{script}" && has_git "{project}"'], + capture_output=True, text=True, + ) + assert result.returncode != 0 + + def test_check_feature_branch_sequential(self, tmp_path: Path): + """check_feature_branch accepts sequential branch names.""" + project = _setup_project(tmp_path) + script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh" + result = subprocess.run( + ["bash", "-c", f'source "{script}" && check_feature_branch "001-my-feature" "true"'], + capture_output=True, text=True, + ) + assert result.returncode == 0 + + def test_check_feature_branch_timestamp(self, tmp_path: Path): + """check_feature_branch accepts timestamp branch names.""" + project = _setup_project(tmp_path) + script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh" + result = subprocess.run( + ["bash", "-c", f'source "{script}" && check_feature_branch "20260319-143022-feat" "true"'], + capture_output=True, text=True, + ) + assert result.returncode == 0 + + def test_check_feature_branch_rejects_main(self, tmp_path: Path): + """check_feature_branch rejects non-feature branch names.""" + project = _setup_project(tmp_path) + script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh" + result = subprocess.run( + ["bash", "-c", f'source "{script}" && check_feature_branch "main" "true"'], + capture_output=True, text=True, + ) + assert result.returncode != 0 + + def test_check_feature_branch_rejects_malformed_timestamp(self, tmp_path: Path): + """check_feature_branch rejects malformed timestamps (7-digit date).""" + project = _setup_project(tmp_path) + script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh" + result = subprocess.run( + ["bash", "-c", f'source "{script}" && check_feature_branch "2026031-143022-feat" "true"'], + capture_output=True, text=True, + ) + assert result.returncode != 0 + + def test_check_feature_branch_accepts_single_prefix(self, tmp_path: Path): + """git-common check_feature_branch matches core: one optional path prefix.""" + project = _setup_project(tmp_path) + script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh" + result = subprocess.run( + ["bash", "-c", f'source "{script}" && check_feature_branch "feat/001-my-feature" "true"'], + capture_output=True, text=True, + ) + assert result.returncode == 0 + + def test_check_feature_branch_rejects_nested_prefix(self, tmp_path: Path): + project = _setup_project(tmp_path) + script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh" + result = subprocess.run( + ["bash", "-c", f'source "{script}" && check_feature_branch "feat/fix/001-x" "true"'], + capture_output=True, text=True, + ) + assert result.returncode != 0 + + +@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available") +class TestGitCommonPowerShell: + def test_test_feature_branch_accepts_single_prefix(self, tmp_path: Path): + project = _setup_project(tmp_path) + script = project / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "git-common.ps1" + result = subprocess.run( + [ + "pwsh", + "-NoProfile", + "-Command", + f'. "{script}"; if (Test-FeatureBranch -Branch "feat/001-x" -HasGit $true) {{ exit 0 }} else {{ exit 1 }}', + ], + capture_output=True, + text=True, + ) + assert result.returncode == 0 diff --git a/tests/hooks/.specify/extensions.yml b/tests/hooks/.specify/extensions.yml new file mode 100644 index 0000000000..0a73b3254d --- /dev/null +++ b/tests/hooks/.specify/extensions.yml @@ -0,0 +1,34 @@ +hooks: + before_implement: + - id: pre_test + enabled: true + optional: false + extension: "test-extension" + command: "pre_implement_test" + description: "Test before implement hook execution" + + after_implement: + - id: post_test + enabled: true + optional: true + extension: "test-extension" + command: "post_implement_test" + description: "Test after implement hook execution" + prompt: "Would you like to run the post-implement test?" + + before_tasks: + - id: pre_tasks_test + enabled: true + optional: false + extension: "test-extension" + command: "pre_tasks_test" + description: "Test before tasks hook execution" + + after_tasks: + - id: post_tasks_test + enabled: true + optional: true + extension: "test-extension" + command: "post_tasks_test" + description: "Test after tasks hook execution" + prompt: "Would you like to run the post-tasks test?" diff --git a/tests/hooks/TESTING.md b/tests/hooks/TESTING.md new file mode 100644 index 0000000000..6ab704442f --- /dev/null +++ b/tests/hooks/TESTING.md @@ -0,0 +1,30 @@ +# Testing Extension Hooks + +This directory contains a mock project to verify that LLM agents correctly identify and execute hook commands defined in `.specify/extensions.yml`. + +## Test 1: Testing `before_tasks` and `after_tasks` + +1. Open a chat with an LLM (like GitHub Copilot) in this project. +2. Ask it to generate tasks for the current directory: + > "Please follow `/speckit.tasks` for the `./tests/hooks` directory." +3. **Expected Behavior**: + - Before doing any generation, the LLM should notice the `AUTOMATIC Pre-Hook` in `.specify/extensions.yml` under `before_tasks`. + - It should state it is executing `EXECUTE_COMMAND: pre_tasks_test`. + - It should then proceed to read the `.md` docs and produce a `tasks.md`. + - After generation, it should output the optional `after_tasks` hook (`post_tasks_test`) block, asking if you want to run it. + +## Test 2: Testing `before_implement` and `after_implement` + +*(Requires `tasks.md` from Test 1 to exist)* + +1. In the same (or new) chat, ask the LLM to implement the tasks: + > "Please follow `/speckit.implement` for the `./tests/hooks` directory." +2. **Expected Behavior**: + - The LLM should first check for `before_implement` hooks. + - It should state it is executing `EXECUTE_COMMAND: pre_implement_test` BEFORE doing any actual task execution. + - It should evaluate the checklists and execute the code writing tasks. + - Upon completion, it should output the optional `after_implement` hook (`post_implement_test`) block. + +## How it works + +The 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. diff --git a/tests/hooks/plan.md b/tests/hooks/plan.md new file mode 100644 index 0000000000..e2694887d1 --- /dev/null +++ b/tests/hooks/plan.md @@ -0,0 +1,3 @@ +# Test Setup for Hooks + +This feature is designed to test if LLMs correctly invoke Spec Kit extensions hooks when generating tasks and implementing code. diff --git a/tests/hooks/spec.md b/tests/hooks/spec.md new file mode 100644 index 0000000000..0285468a67 --- /dev/null +++ b/tests/hooks/spec.md @@ -0,0 +1 @@ +- **User Story 1:** I want a test script that prints "Hello hooks!". diff --git a/tests/hooks/tasks.md b/tests/hooks/tasks.md new file mode 100644 index 0000000000..3c22b0b2ac --- /dev/null +++ b/tests/hooks/tasks.md @@ -0,0 +1 @@ +- [ ] T001 [US1] Create script that prints 'Hello hooks!' in hello.py diff --git a/tests/integrations/__init__.py b/tests/integrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/integrations/conftest.py b/tests/integrations/conftest.py new file mode 100644 index 0000000000..54f59e23a7 --- /dev/null +++ b/tests/integrations/conftest.py @@ -0,0 +1,23 @@ +"""Shared test helpers for integration tests.""" + +from specify_cli.integrations.base import MarkdownIntegration + + +class StubIntegration(MarkdownIntegration): + """Minimal concrete integration for testing.""" + + key = "stub" + config = { + "name": "Stub Agent", + "folder": ".stub/", + "commands_subdir": "commands", + "install_url": None, + "requires_cli": False, + } + registrar_config = { + "dir": ".stub/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = "STUB.md" diff --git a/tests/integrations/test_base.py b/tests/integrations/test_base.py new file mode 100644 index 0000000000..3b244943b4 --- /dev/null +++ b/tests/integrations/test_base.py @@ -0,0 +1,297 @@ +"""Tests for IntegrationOption, IntegrationBase, MarkdownIntegration, and primitives.""" + +import pytest + +from specify_cli.integrations.base import ( + IntegrationBase, + IntegrationOption, + MarkdownIntegration, + SkillsIntegration, +) +from specify_cli.integrations.manifest import IntegrationManifest +from .conftest import StubIntegration + + +class TestIntegrationOption: + def test_defaults(self): + opt = IntegrationOption(name="--flag") + assert opt.name == "--flag" + assert opt.is_flag is False + assert opt.required is False + assert opt.default is None + assert opt.help == "" + + def test_flag_option(self): + opt = IntegrationOption(name="--skills", is_flag=True, default=True, help="Enable skills") + assert opt.is_flag is True + assert opt.default is True + assert opt.help == "Enable skills" + + def test_required_option(self): + opt = IntegrationOption(name="--commands-dir", required=True, help="Dir path") + assert opt.required is True + + def test_frozen(self): + opt = IntegrationOption(name="--x") + with pytest.raises(AttributeError): + opt.name = "--y" # type: ignore[misc] + + +class TestIntegrationBase: + def test_key_and_config(self): + i = StubIntegration() + assert i.key == "stub" + assert i.config["name"] == "Stub Agent" + assert i.registrar_config["format"] == "markdown" + assert i.context_file == "STUB.md" + + def test_options_default_empty(self): + assert StubIntegration.options() == [] + + def test_shared_commands_dir(self): + i = StubIntegration() + cmd_dir = i.shared_commands_dir() + assert cmd_dir is not None + assert cmd_dir.is_dir() + + def test_setup_uses_shared_templates(self, tmp_path): + i = StubIntegration() + manifest = IntegrationManifest("stub", tmp_path) + created = i.setup(tmp_path, manifest) + assert len(created) > 0 + for f in created: + assert f.parent == tmp_path / ".stub" / "commands" + assert f.name.startswith("speckit.") + assert f.name.endswith(".md") + + def test_setup_copies_templates(self, tmp_path, monkeypatch): + tpl = tmp_path / "_templates" + tpl.mkdir() + (tpl / "plan.md").write_text("plan content", encoding="utf-8") + (tpl / "specify.md").write_text("spec content", encoding="utf-8") + + i = StubIntegration() + monkeypatch.setattr(type(i), "list_command_templates", lambda self: sorted(tpl.glob("*.md"))) + + project = tmp_path / "project" + project.mkdir() + created = i.setup(project, IntegrationManifest("stub", project)) + assert len(created) == 2 + assert (project / ".stub" / "commands" / "speckit.plan.md").exists() + assert (project / ".stub" / "commands" / "speckit.specify.md").exists() + + def test_install_delegates_to_setup(self, tmp_path): + i = StubIntegration() + manifest = IntegrationManifest("stub", tmp_path) + result = i.install(tmp_path, manifest) + assert len(result) > 0 + + def test_uninstall_delegates_to_teardown(self, tmp_path): + i = StubIntegration() + manifest = IntegrationManifest("stub", tmp_path) + removed, skipped = i.uninstall(tmp_path, manifest) + assert removed == [] + assert skipped == [] + + +class TestMarkdownIntegration: + def test_is_subclass_of_base(self): + assert issubclass(MarkdownIntegration, IntegrationBase) + + def test_stub_is_markdown(self): + assert isinstance(StubIntegration(), MarkdownIntegration) + + +class TestBasePrimitives: + def test_shared_commands_dir_returns_path(self): + i = StubIntegration() + cmd_dir = i.shared_commands_dir() + assert cmd_dir is not None + assert cmd_dir.is_dir() + + def test_shared_templates_dir_returns_path(self): + i = StubIntegration() + tpl_dir = i.shared_templates_dir() + assert tpl_dir is not None + assert tpl_dir.is_dir() + + def test_list_command_templates_returns_md_files(self): + i = StubIntegration() + templates = i.list_command_templates() + assert len(templates) > 0 + assert all(t.suffix == ".md" for t in templates) + + def test_command_filename_default(self): + i = StubIntegration() + assert i.command_filename("plan") == "speckit.plan.md" + + def test_commands_dest(self, tmp_path): + i = StubIntegration() + dest = i.commands_dest(tmp_path) + assert dest == tmp_path / ".stub" / "commands" + + def test_commands_dest_no_config_raises(self, tmp_path): + class NoConfig(MarkdownIntegration): + key = "noconfig" + with pytest.raises(ValueError, match="config is not set"): + NoConfig().commands_dest(tmp_path) + + def test_copy_command_to_directory(self, tmp_path): + src = tmp_path / "source.md" + src.write_text("content", encoding="utf-8") + dest_dir = tmp_path / "output" + result = IntegrationBase.copy_command_to_directory(src, dest_dir, "speckit.plan.md") + assert result == dest_dir / "speckit.plan.md" + assert result.read_text(encoding="utf-8") == "content" + + def test_record_file_in_manifest(self, tmp_path): + f = tmp_path / "f.txt" + f.write_text("hello", encoding="utf-8") + m = IntegrationManifest("test", tmp_path) + IntegrationBase.record_file_in_manifest(f, tmp_path, m) + assert "f.txt" in m.files + + def test_write_file_and_record(self, tmp_path): + m = IntegrationManifest("test", tmp_path) + dest = tmp_path / "sub" / "f.txt" + result = IntegrationBase.write_file_and_record("content", dest, tmp_path, m) + assert result == dest + assert dest.read_text(encoding="utf-8") == "content" + assert "sub/f.txt" in m.files + + def test_setup_copies_shared_templates(self, tmp_path): + i = StubIntegration() + m = IntegrationManifest("stub", tmp_path) + created = i.setup(tmp_path, m) + assert len(created) > 0 + for f in created: + assert f.parent.name == "commands" + assert f.name.startswith("speckit.") + assert f.name.endswith(".md") + + +class TestBuildCommandInvocation: + """Tests for build_command_invocation across integration types.""" + + def test_base_core_command_dotted(self): + i = StubIntegration() + assert i.build_command_invocation("speckit.plan") == "/speckit.plan" + + def test_base_core_command_bare(self): + i = StubIntegration() + assert i.build_command_invocation("plan") == "/speckit.plan" + + def test_base_core_command_with_args(self): + i = StubIntegration() + assert i.build_command_invocation("plan", "my feature") == "/speckit.plan my feature" + + def test_base_extension_command(self): + i = StubIntegration() + assert i.build_command_invocation("speckit.git.commit") == "/speckit.git.commit" + + def test_base_extension_command_bare(self): + i = StubIntegration() + assert i.build_command_invocation("git.commit") == "/speckit.git.commit" + + def test_skills_core_command(self): + from specify_cli.integrations import get_integration + i = get_integration("codex") + assert i.build_command_invocation("speckit.plan") == "/speckit-plan" + assert i.build_command_invocation("plan") == "/speckit-plan" + + def test_skills_extension_command(self): + from specify_cli.integrations import get_integration + i = get_integration("codex") + assert i.build_command_invocation("speckit.git.commit") == "/speckit-git-commit" + assert i.build_command_invocation("git.commit") == "/speckit-git-commit" + + def test_skills_extension_command_with_args(self): + from specify_cli.integrations import get_integration + i = get_integration("codex") + assert i.build_command_invocation("speckit.git.commit", "fix typo") == "/speckit-git-commit fix typo" + + +class TestResolveCommandRefs: + """Tests for __SPECKIT_COMMAND___ placeholder resolution.""" + + def test_dot_separator_core_command(self): + text = "Run `__SPECKIT_COMMAND_PLAN__` to plan." + result = IntegrationBase.resolve_command_refs(text, ".") + assert result == "Run `/speckit.plan` to plan." + + def test_hyphen_separator_core_command(self): + text = "Run `__SPECKIT_COMMAND_PLAN__` to plan." + result = IntegrationBase.resolve_command_refs(text, "-") + assert result == "Run `/speckit-plan` to plan." + + def test_multiple_placeholders(self): + text = "__SPECKIT_COMMAND_SPECIFY__ then __SPECKIT_COMMAND_PLAN__ then __SPECKIT_COMMAND_TASKS__" + result = IntegrationBase.resolve_command_refs(text, ".") + assert result == "/speckit.specify then /speckit.plan then /speckit.tasks" + + def test_extension_command_dot(self): + text = "Run __SPECKIT_COMMAND_GIT_COMMIT__ to commit." + result = IntegrationBase.resolve_command_refs(text, ".") + assert result == "Run /speckit.git.commit to commit." + + def test_extension_command_hyphen(self): + text = "Run __SPECKIT_COMMAND_GIT_COMMIT__ to commit." + result = IntegrationBase.resolve_command_refs(text, "-") + assert result == "Run /speckit-git-commit to commit." + + def test_no_placeholders_unchanged(self): + text = "No placeholders here." + assert IntegrationBase.resolve_command_refs(text, ".") == text + + def test_default_separator_is_dot(self): + text = "__SPECKIT_COMMAND_PLAN__" + assert IntegrationBase.resolve_command_refs(text) == "/speckit.plan" + + def test_invoke_separator_class_attribute(self): + assert IntegrationBase.invoke_separator == "." + assert SkillsIntegration.invoke_separator == "-" + + def test_effective_invoke_separator_default(self): + """Base classes return invoke_separator regardless of parsed_options.""" + from .conftest import StubIntegration + stub = StubIntegration() + assert stub.effective_invoke_separator() == "." + assert stub.effective_invoke_separator({"skills": True}) == "." + + def test_process_template_resolves_placeholders(self): + content = "---\ndescription: test\n---\nRun __SPECKIT_COMMAND_PLAN__ now." + result = IntegrationBase.process_template( + content, "test-agent", "sh", invoke_separator="." + ) + assert "/speckit.plan" in result + assert "__SPECKIT_COMMAND_" not in result + + def test_process_template_skills_separator(self): + content = "---\ndescription: test\n---\nRun __SPECKIT_COMMAND_PLAN__ now." + result = IntegrationBase.process_template( + content, "test-agent", "sh", invoke_separator="-" + ) + assert "/speckit-plan" in result + assert "__SPECKIT_COMMAND_" not in result + + def test_unclosed_placeholder_unchanged(self): + text = "Run __SPECKIT_COMMAND_PLAN to plan." + assert IntegrationBase.resolve_command_refs(text, ".") == text + + def test_empty_name_not_matched(self): + text = "Run __SPECKIT_COMMAND___ to plan." + assert IntegrationBase.resolve_command_refs(text, ".") == text + + def test_lowercase_placeholder_not_matched(self): + text = "Run __SPECKIT_COMMAND_plan__ to plan." + assert IntegrationBase.resolve_command_refs(text, ".") == text + + def test_placeholder_adjacent_to_text(self): + text = "foo__SPECKIT_COMMAND_PLAN__bar" + result = IntegrationBase.resolve_command_refs(text, ".") + assert result == "foo/speckit.planbar" + + def test_placeholder_with_digits(self): + text = "__SPECKIT_COMMAND_V2_PLAN__" + result = IntegrationBase.resolve_command_refs(text, ".") + assert result == "/speckit.v2.plan" diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py new file mode 100644 index 0000000000..152c56813c --- /dev/null +++ b/tests/integrations/test_cli.py @@ -0,0 +1,603 @@ +"""Tests for --integration flag on specify init (CLI-level).""" + +import json +import os + +import yaml + +from tests.conftest import strip_ansi + + +def _normalize_cli_output(output: str) -> str: + output = strip_ansi(output) + output = " ".join(output.split()) + return output.strip() + + +class TestInitIntegrationFlag: + def test_integration_and_ai_mutually_exclusive(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + result = runner.invoke(app, [ + "init", str(tmp_path / "test-project"), "--ai", "claude", "--integration", "copilot", + ]) + assert result.exit_code != 0 + assert "mutually exclusive" in result.output + + def test_unknown_integration_rejected(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + result = runner.invoke(app, [ + "init", str(tmp_path / "test-project"), "--integration", "nonexistent", + ]) + assert result.exit_code != 0 + assert "Unknown integration" in result.output + + def test_integration_copilot_creates_files(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = tmp_path / "int-test" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "init", "--here", "--integration", "copilot", "--script", "sh", "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists() + assert (project / ".github" / "prompts" / "speckit.plan.prompt.md").exists() + assert (project / ".specify" / "scripts" / "bash" / "common.sh").exists() + + data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert data["integration"] == "copilot" + + opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8")) + assert opts["integration"] == "copilot" + assert opts["context_file"] == ".github/copilot-instructions.md" + + assert (project / ".specify" / "integrations" / "copilot.manifest.json").exists() + + # Context section should be upserted into the copilot instructions file + ctx_file = project / ".github" / "copilot-instructions.md" + assert ctx_file.exists() + ctx_content = ctx_file.read_text(encoding="utf-8") + assert "" in ctx_content + assert "" in ctx_content + + shared_manifest = project / ".specify" / "integrations" / "speckit.manifest.json" + assert shared_manifest.exists() + + def test_ai_copilot_auto_promotes(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + project = tmp_path / "promote-test" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--ai", "copilot", "--script", "sh", "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists() + + def test_ai_emits_deprecation_warning_with_integration_replacement(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "warn-ai" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--ai", "copilot", "--script", "sh", "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + normalized_output = _normalize_cli_output(result.output) + assert result.exit_code == 0, result.output + assert "Deprecation Warning" in normalized_output + assert "--ai" in normalized_output + assert "deprecated" in normalized_output + assert "no longer be available" in normalized_output + assert "1.0.0" in normalized_output + assert "--integration copilot" in normalized_output + assert normalized_output.index("Deprecation Warning") < normalized_output.index("Next Steps") + assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists() + + def test_ai_generic_warning_suggests_integration_options_equivalent(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "warn-generic" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--ai", "generic", "--ai-commands-dir", ".myagent/commands", + "--script", "sh", "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + normalized_output = _normalize_cli_output(result.output) + assert result.exit_code == 0, result.output + assert "Deprecation Warning" in normalized_output + assert "--integration generic" in normalized_output + assert "--integration-options" in normalized_output + assert ".myagent/commands" in normalized_output + assert normalized_output.index("Deprecation Warning") < normalized_output.index("Next Steps") + assert (project / ".myagent" / "commands" / "speckit.plan.md").exists() + + def test_ai_claude_here_preserves_preexisting_commands(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "claude-here-existing" + project.mkdir() + commands_dir = project / ".claude" / "skills" + commands_dir.mkdir(parents=True) + skill_dir = commands_dir / "speckit-specify" + skill_dir.mkdir(parents=True) + command_file = skill_dir / "SKILL.md" + command_file.write_text("# preexisting command\n", encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--force", "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0, result.output + assert command_file.exists() + # init replaces skills (not additive); verify the file has valid skill content + assert command_file.exists() + assert "speckit-specify" in command_file.read_text(encoding="utf-8") + assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() + + def test_shared_infra_skips_existing_files_without_force(self, tmp_path): + """Pre-existing shared files are not overwritten without --force.""" + from specify_cli import _install_shared_infra + + project = tmp_path / "skip-test" + project.mkdir() + (project / ".specify").mkdir() + + # Pre-create a shared script with custom content + scripts_dir = project / ".specify" / "scripts" / "bash" + scripts_dir.mkdir(parents=True) + custom_content = "# user-modified common.sh\n" + (scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8") + + # Pre-create a shared template with custom content + templates_dir = project / ".specify" / "templates" + templates_dir.mkdir(parents=True) + custom_template = "# user-modified spec-template\n" + (templates_dir / "spec-template.md").write_text(custom_template, encoding="utf-8") + + _install_shared_infra(project, "sh", force=False) + + # User's files should be preserved (not overwritten) + assert (scripts_dir / "common.sh").read_text(encoding="utf-8") == custom_content + assert (templates_dir / "spec-template.md").read_text(encoding="utf-8") == custom_template + + # Other shared files should still be installed + assert (scripts_dir / "setup-plan.sh").exists() + assert (templates_dir / "plan-template.md").exists() + + def test_shared_infra_overwrites_existing_files_with_force(self, tmp_path): + """Pre-existing shared files ARE overwritten when force=True.""" + from specify_cli import _install_shared_infra + + project = tmp_path / "force-test" + project.mkdir() + (project / ".specify").mkdir() + + # Pre-create a shared script with custom content + scripts_dir = project / ".specify" / "scripts" / "bash" + scripts_dir.mkdir(parents=True) + custom_content = "# user-modified common.sh\n" + (scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8") + + # Pre-create a shared template with custom content + templates_dir = project / ".specify" / "templates" + templates_dir.mkdir(parents=True) + custom_template = "# user-modified spec-template\n" + (templates_dir / "spec-template.md").write_text(custom_template, encoding="utf-8") + + _install_shared_infra(project, "sh", force=True) + + # Files should be overwritten with bundled versions + assert (scripts_dir / "common.sh").read_text(encoding="utf-8") != custom_content + assert (templates_dir / "spec-template.md").read_text(encoding="utf-8") != custom_template + + # Other shared files should also be installed + assert (scripts_dir / "setup-plan.sh").exists() + assert (templates_dir / "plan-template.md").exists() + + def test_shared_infra_skip_warning_displayed(self, tmp_path, capsys): + """Console warning is displayed when files are skipped.""" + from specify_cli import _install_shared_infra + + project = tmp_path / "warn-test" + project.mkdir() + (project / ".specify").mkdir() + + scripts_dir = project / ".specify" / "scripts" / "bash" + scripts_dir.mkdir(parents=True) + (scripts_dir / "common.sh").write_text("# custom\n", encoding="utf-8") + + _install_shared_infra(project, "sh", force=False) + + captured = capsys.readouterr() + assert "already exist and were not updated" in captured.out + assert "specify init --here --force" in captured.out + # Rich may wrap long lines; normalize whitespace for the second command + normalized = " ".join(captured.out.split()) + assert "specify integration upgrade --force" in normalized + + def test_shared_infra_no_warning_when_forced(self, tmp_path, capsys): + """No skip warning when force=True (all files overwritten).""" + from specify_cli import _install_shared_infra + + project = tmp_path / "no-warn-test" + project.mkdir() + (project / ".specify").mkdir() + + scripts_dir = project / ".specify" / "scripts" / "bash" + scripts_dir.mkdir(parents=True) + (scripts_dir / "common.sh").write_text("# custom\n", encoding="utf-8") + + _install_shared_infra(project, "sh", force=True) + + captured = capsys.readouterr() + assert "already exist and were not updated" not in captured.out + + def test_init_here_force_overwrites_shared_infra(self, tmp_path): + """E2E: specify init --here --force overwrites shared infra files.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "e2e-force" + project.mkdir() + + scripts_dir = project / ".specify" / "scripts" / "bash" + scripts_dir.mkdir(parents=True) + custom_content = "# user-modified common.sh\n" + (scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--force", + "--integration", "copilot", + "--script", "sh", + "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0 + # --force should overwrite the custom file + assert (scripts_dir / "common.sh").read_text(encoding="utf-8") != custom_content + + def test_init_here_without_force_preserves_shared_infra(self, tmp_path): + """E2E: specify init --here (no --force) preserves existing shared infra files.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "e2e-no-force" + project.mkdir() + + scripts_dir = project / ".specify" / "scripts" / "bash" + scripts_dir.mkdir(parents=True) + custom_content = "# user-modified common.sh\n" + (scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", + "--integration", "copilot", + "--script", "sh", + "--no-git", + ], input="y\n", catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0 + # Without --force, custom file should be preserved + assert (scripts_dir / "common.sh").read_text(encoding="utf-8") == custom_content + # Warning about skipped files should appear + assert "not updated" in result.output + + +class TestForceExistingDirectory: + """Tests for --force merging into an existing named directory.""" + + def test_force_merges_into_existing_dir(self, tmp_path): + """specify init --force succeeds when the directory already exists.""" + from typer.testing import CliRunner + from specify_cli import app + + target = tmp_path / "existing-proj" + target.mkdir() + # Place a pre-existing file to verify it survives the merge + marker = target / "user-file.txt" + marker.write_text("keep me", encoding="utf-8") + + runner = CliRunner() + result = runner.invoke(app, [ + "init", str(target), "--integration", "copilot", "--force", + "--no-git", "--script", "sh", + ], catch_exceptions=False) + + assert result.exit_code == 0, f"init --force failed: {result.output}" + + # Pre-existing file should survive + assert marker.read_text(encoding="utf-8") == "keep me" + + # Spec Kit files should be installed + assert (target / ".specify" / "init-options.json").exists() + assert (target / ".specify" / "templates" / "spec-template.md").exists() + + def test_without_force_errors_on_existing_dir(self, tmp_path): + """specify init without --force errors when directory exists.""" + from typer.testing import CliRunner + from specify_cli import app + + target = tmp_path / "existing-proj" + target.mkdir() + + runner = CliRunner() + result = runner.invoke(app, [ + "init", str(target), "--integration", "copilot", + "--no-git", "--script", "sh", + ], catch_exceptions=False) + + assert result.exit_code == 1 + assert "already exists" in _normalize_cli_output(result.output) + + +class TestGitExtensionAutoInstall: + """Tests for auto-installation of the git extension during specify init.""" + + def test_git_extension_auto_installed(self, tmp_path): + """Without --no-git, the git extension is installed during init.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "git-auto" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--ai", "claude", "--script", "sh", + "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0, f"init failed: {result.output}" + + # Check that the tracker didn't report a git error + assert "install failed" not in result.output, f"git extension install failed: {result.output}" + + # Git extension files should be installed + ext_dir = project / ".specify" / "extensions" / "git" + assert ext_dir.exists(), "git extension directory not installed" + assert (ext_dir / "extension.yml").exists() + assert (ext_dir / "scripts" / "bash" / "create-new-feature.sh").exists() + assert (ext_dir / "scripts" / "bash" / "initialize-repo.sh").exists() + + # Hooks should be registered + extensions_yml = project / ".specify" / "extensions.yml" + assert extensions_yml.exists(), "extensions.yml not created" + hooks_data = yaml.safe_load(extensions_yml.read_text(encoding="utf-8")) + assert "hooks" in hooks_data + assert "before_specify" in hooks_data["hooks"] + assert "before_constitution" in hooks_data["hooks"] + + def test_no_git_skips_extension(self, tmp_path): + """With --no-git, the git extension is NOT installed.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "no-git" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--ai", "claude", "--script", "sh", + "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0, f"init failed: {result.output}" + + # Git extension should NOT be installed + ext_dir = project / ".specify" / "extensions" / "git" + assert not ext_dir.exists(), "git extension should not be installed with --no-git" + + def test_git_extension_commands_registered(self, tmp_path): + """Git extension commands are registered with the agent during init.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "git-cmds" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--ai", "claude", "--script", "sh", + "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0, f"init failed: {result.output}" + + # Git extension commands should be registered with the agent + claude_skills = project / ".claude" / "skills" + assert claude_skills.exists(), "Claude skills directory was not created" + git_skills = [f for f in claude_skills.iterdir() if f.name.startswith("speckit-git-")] + assert len(git_skills) > 0, "no git extension commands registered" + + +class TestSharedInfraCommandRefs: + """Verify _install_shared_infra resolves __SPECKIT_COMMAND_*__ in page templates.""" + + def test_dot_separator_in_page_templates(self, tmp_path): + """Markdown agents get /speckit. in page templates.""" + from specify_cli import _install_shared_infra + + project = tmp_path / "dot-test" + project.mkdir() + (project / ".specify").mkdir() + + _install_shared_infra(project, "sh", invoke_separator=".") + + plan = project / ".specify" / "templates" / "plan-template.md" + assert plan.exists() + content = plan.read_text(encoding="utf-8") + assert "__SPECKIT_COMMAND_" not in content, "unresolved placeholder in plan-template.md" + assert "/speckit.plan" in content + + checklist = project / ".specify" / "templates" / "checklist-template.md" + content = checklist.read_text(encoding="utf-8") + assert "__SPECKIT_COMMAND_" not in content + assert "/speckit.checklist" in content + + def test_hyphen_separator_in_page_templates(self, tmp_path): + """Skills agents get /speckit- in page templates.""" + from specify_cli import _install_shared_infra + + project = tmp_path / "hyphen-test" + project.mkdir() + (project / ".specify").mkdir() + + _install_shared_infra(project, "sh", invoke_separator="-") + + plan = project / ".specify" / "templates" / "plan-template.md" + assert plan.exists() + content = plan.read_text(encoding="utf-8") + assert "__SPECKIT_COMMAND_" not in content, "unresolved placeholder in plan-template.md" + assert "/speckit-plan" in content + assert "/speckit.plan" not in content, "dot-notation leaked into skills page template" + + tasks = project / ".specify" / "templates" / "tasks-template.md" + content = tasks.read_text(encoding="utf-8") + assert "__SPECKIT_COMMAND_" not in content + assert "/speckit-tasks" in content + + def test_full_init_claude_resolves_page_templates(self, tmp_path): + """Full CLI init with Claude (skills agent) produces hyphen refs in page templates.""" + from typer.testing import CliRunner + from specify_cli import app + + runner = CliRunner() + project = tmp_path / "init-claude" + old_cwd = os.getcwd() + try: + os.chdir(tmp_path) + result = runner.invoke(app, [ + "init", str(project), + "--integration", "claude", + "--script", "sh", + "--no-git", + "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0, f"init failed: {result.output}" + + plan = project / ".specify" / "templates" / "plan-template.md" + content = plan.read_text(encoding="utf-8") + assert "/speckit-plan" in content, "Claude (skills) should use /speckit-plan" + assert "__SPECKIT_COMMAND_" not in content + + def test_full_init_copilot_resolves_page_templates(self, tmp_path): + """Full CLI init with Copilot (markdown agent) produces dot refs in page templates.""" + from typer.testing import CliRunner + from specify_cli import app + + runner = CliRunner() + project = tmp_path / "init-copilot" + old_cwd = os.getcwd() + try: + os.chdir(tmp_path) + result = runner.invoke(app, [ + "init", str(project), + "--integration", "copilot", + "--script", "sh", + "--no-git", + "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0, f"init failed: {result.output}" + + plan = project / ".specify" / "templates" / "plan-template.md" + content = plan.read_text(encoding="utf-8") + assert "/speckit.plan" in content, "Copilot (markdown) should use /speckit.plan" + assert "__SPECKIT_COMMAND_" not in content + + def test_full_init_copilot_skills_resolves_page_templates(self, tmp_path): + """Full CLI init with Copilot --skills produces hyphen refs in page templates.""" + from typer.testing import CliRunner + from specify_cli import app + + runner = CliRunner() + project = tmp_path / "init-copilot-skills" + old_cwd = os.getcwd() + try: + os.chdir(tmp_path) + result = runner.invoke(app, [ + "init", str(project), + "--integration", "copilot", + "--integration-options", "--skills", + "--script", "sh", + "--no-git", + "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0, f"init failed: {result.output}" + + plan = project / ".specify" / "templates" / "plan-template.md" + content = plan.read_text(encoding="utf-8") + assert "/speckit-plan" in content, "Copilot --skills should use /speckit-plan" + assert "/speckit.plan" not in content, "dot-notation leaked into Copilot skills page template" + assert "__SPECKIT_COMMAND_" not in content diff --git a/tests/integrations/test_integration_agy.py b/tests/integrations/test_integration_agy.py new file mode 100644 index 0000000000..b95caf3bee --- /dev/null +++ b/tests/integrations/test_integration_agy.py @@ -0,0 +1,45 @@ +"""Tests for AgyIntegration (Antigravity).""" + +from .test_integration_base_skills import SkillsIntegrationTests + + +class TestAgyIntegration(SkillsIntegrationTests): + KEY = "agy" + FOLDER = ".agents/" + COMMANDS_SUBDIR = "skills" + REGISTRAR_DIR = ".agents/skills" + CONTEXT_FILE = "AGENTS.md" + + def test_options_include_skills_flag(self): + """Override inherited test: AgyIntegration should not expose a --skills flag because .agents/ is its only layout.""" + from specify_cli.integrations import get_integration + i = get_integration(self.KEY) + skills_opts = [o for o in i.options() if o.name == "--skills"] + assert len(skills_opts) == 0 +class TestAgyAutoPromote: + """--ai agy auto-promotes to integration path.""" + + def test_ai_agy_without_ai_skills_auto_promotes(self, tmp_path): + """--ai agy should work the same as --integration agy.""" + from typer.testing import CliRunner + from specify_cli import app + + runner = CliRunner() + target = tmp_path / "test-proj" + result = runner.invoke(app, ["init", str(target), "--ai", "agy", "--no-git", "--script", "sh"]) + + assert result.exit_code == 0, f"init --ai agy failed: {result.output}" + assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists() + + def test_agy_setup_warning(self, tmp_path): + """Agy integration should print a warning about v1.20.5 requirement during setup.""" + from typer.testing import CliRunner + from specify_cli import app + + # Click >= 8.2 separates stdout and stderr natively, mix_stderr is removed + runner = CliRunner() + target = tmp_path / "test-proj2" + result = runner.invoke(app, ["init", str(target), "--ai", "agy", "--no-git", "--script", "sh"]) + + assert result.exit_code == 0 + assert "Warning: The .agents/ layout requires Antigravity v1.20.5 or newer" in result.stderr diff --git a/tests/integrations/test_integration_amp.py b/tests/integrations/test_integration_amp.py new file mode 100644 index 0000000000..a36dd47136 --- /dev/null +++ b/tests/integrations/test_integration_amp.py @@ -0,0 +1,11 @@ +"""Tests for AmpIntegration.""" + +from .test_integration_base_markdown import MarkdownIntegrationTests + + +class TestAmpIntegration(MarkdownIntegrationTests): + KEY = "amp" + FOLDER = ".agents/" + COMMANDS_SUBDIR = "commands" + REGISTRAR_DIR = ".agents/commands" + CONTEXT_FILE = "AGENTS.md" diff --git a/tests/integrations/test_integration_auggie.py b/tests/integrations/test_integration_auggie.py new file mode 100644 index 0000000000..e4033a23e8 --- /dev/null +++ b/tests/integrations/test_integration_auggie.py @@ -0,0 +1,11 @@ +"""Tests for AuggieIntegration.""" + +from .test_integration_base_markdown import MarkdownIntegrationTests + + +class TestAuggieIntegration(MarkdownIntegrationTests): + KEY = "auggie" + FOLDER = ".augment/" + COMMANDS_SUBDIR = "commands" + REGISTRAR_DIR = ".augment/commands" + CONTEXT_FILE = ".augment/rules/specify-rules.md" diff --git a/tests/integrations/test_integration_base_markdown.py b/tests/integrations/test_integration_base_markdown.py new file mode 100644 index 0000000000..82d7b8cfb3 --- /dev/null +++ b/tests/integrations/test_integration_base_markdown.py @@ -0,0 +1,348 @@ +"""Reusable test mixin for standard MarkdownIntegration subclasses. + +Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``, +``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification +logic from ``MarkdownIntegrationTests``. +""" + +import os + +from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration +from specify_cli.integrations.base import MarkdownIntegration +from specify_cli.integrations.manifest import IntegrationManifest + + +class MarkdownIntegrationTests: + """Mixin — set class-level constants and inherit these tests. + + Required class attrs on subclass:: + + KEY: str — integration registry key + FOLDER: str — e.g. ".claude/" + COMMANDS_SUBDIR: str — e.g. "commands" + REGISTRAR_DIR: str — e.g. ".claude/commands" + CONTEXT_FILE: str — e.g. "CLAUDE.md" + """ + + KEY: str + FOLDER: str + COMMANDS_SUBDIR: str + REGISTRAR_DIR: str + CONTEXT_FILE: str + + # -- Registration ----------------------------------------------------- + + def test_registered(self): + assert self.KEY in INTEGRATION_REGISTRY + assert get_integration(self.KEY) is not None + + def test_is_markdown_integration(self): + assert isinstance(get_integration(self.KEY), MarkdownIntegration) + + # -- Config ----------------------------------------------------------- + + def test_config_folder(self): + i = get_integration(self.KEY) + assert i.config["folder"] == self.FOLDER + + def test_config_commands_subdir(self): + i = get_integration(self.KEY) + assert i.config["commands_subdir"] == self.COMMANDS_SUBDIR + + def test_registrar_config(self): + i = get_integration(self.KEY) + assert i.registrar_config["dir"] == self.REGISTRAR_DIR + assert i.registrar_config["format"] == "markdown" + assert i.registrar_config["args"] == "$ARGUMENTS" + assert i.registrar_config["extension"] == ".md" + + def test_context_file(self): + i = get_integration(self.KEY) + assert i.context_file == self.CONTEXT_FILE + + # -- Setup / teardown ------------------------------------------------- + + def test_setup_creates_files(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + assert len(created) > 0 + cmd_files = [f for f in created if "scripts" not in f.parts] + for f in cmd_files: + assert f.exists() + assert f.name.startswith("speckit.") + assert f.name.endswith(".md") + + def test_setup_writes_to_correct_directory(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + expected_dir = i.commands_dest(tmp_path) + assert expected_dir.exists(), f"Expected directory {expected_dir} was not created" + cmd_files = [f for f in created if "scripts" not in f.parts] + assert len(cmd_files) > 0, "No command files were created" + for f in cmd_files: + assert f.resolve().parent == expected_dir.resolve(), ( + f"{f} is not under {expected_dir}" + ) + + def test_templates_are_processed(self, tmp_path): + """Command files must have placeholders replaced, not raw templates.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + assert len(cmd_files) > 0 + for f in cmd_files: + content = f.read_text(encoding="utf-8") + assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}" + assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__" + assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}" + assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__" + assert "\nscripts:\n" not in content, f"{f.name} has unstripped scripts: block" + + def test_plan_references_correct_context_file(self, tmp_path): + """The generated plan command must reference this integration's context file.""" + i = get_integration(self.KEY) + if not i.context_file: + return + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + plan_file = i.commands_dest(tmp_path) / i.command_filename("plan") + assert plan_file.exists(), f"Plan file {plan_file} not created" + content = plan_file.read_text(encoding="utf-8") + assert i.context_file in content, ( + f"Plan command should reference {i.context_file!r} but it was not found in {plan_file.name}" + ) + assert "__CONTEXT_FILE__" not in content, ( + f"Plan command has unprocessed __CONTEXT_FILE__ placeholder in {plan_file.name}" + ) + + def test_all_files_tracked_in_manifest(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + for f in created: + rel = f.resolve().relative_to(tmp_path.resolve()).as_posix() + assert rel in m.files, f"{rel} not tracked in manifest" + + def test_install_uninstall_roundtrip(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.install(tmp_path, m) + assert len(created) > 0 + m.save() + for f in created: + assert f.exists() + removed, skipped = i.uninstall(tmp_path, m) + assert len(removed) == len(created) + assert skipped == [] + + def test_modified_file_survives_uninstall(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.install(tmp_path, m) + m.save() + modified_file = created[0] + modified_file.write_text("user modified this", encoding="utf-8") + removed, skipped = i.uninstall(tmp_path, m) + assert modified_file.exists() + assert modified_file in skipped + + # -- Context section --------------------------------------------------- + + def test_setup_upserts_context_section(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + if i.context_file: + ctx_path = tmp_path / i.context_file + assert ctx_path.exists(), f"Context file {i.context_file} not created for {self.KEY}" + content = ctx_path.read_text(encoding="utf-8") + assert "" in content + assert "" in content + assert "read the current plan" in content + + def test_teardown_removes_context_section(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + m.save() + if i.context_file: + ctx_path = tmp_path / i.context_file + # Add user content around the section + content = ctx_path.read_text(encoding="utf-8") + ctx_path.write_text("# My Rules\n\n" + content + "\n# Footer\n", encoding="utf-8") + i.teardown(tmp_path, m) + remaining = ctx_path.read_text(encoding="utf-8") + assert "" not in remaining + assert "" not in remaining + assert "# My Rules" in remaining + + # -- CLI auto-promote ------------------------------------------------- + + def test_ai_flag_auto_promotes(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"promote-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--ai", self.KEY, "--script", "sh", "--no-git", + "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}" + i = get_integration(self.KEY) + cmd_dir = i.commands_dest(project) + assert cmd_dir.is_dir(), f"--ai {self.KEY} did not create commands directory" + + def test_integration_flag_creates_files(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"int-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git", + "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}" + i = get_integration(self.KEY) + cmd_dir = i.commands_dest(project) + assert cmd_dir.is_dir(), f"Commands directory {cmd_dir} not created" + commands = sorted(cmd_dir.glob("speckit.*")) + assert len(commands) > 0, f"No command files in {cmd_dir}" + + def test_init_options_includes_context_file(self, tmp_path): + """init-options.json must include context_file for the active integration.""" + import json + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"opts-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", self.KEY, "--script", "sh", + "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + opts = json.loads((project / ".specify" / "init-options.json").read_text()) + i = get_integration(self.KEY) + assert opts.get("context_file") == i.context_file, ( + f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}" + ) + + # -- Complete file inventory ------------------------------------------ + + COMMAND_STEMS = [ + "analyze", "checklist", "clarify", "constitution", + "implement", "plan", "specify", "tasks", "taskstoissues", + ] + + def _expected_files(self, script_variant: str) -> list[str]: + """Build the expected file list for this integration + script variant.""" + i = get_integration(self.KEY) + cmd_dir = i.registrar_config["dir"] + files = [] + + # Command files + for stem in self.COMMAND_STEMS: + files.append(f"{cmd_dir}/speckit.{stem}.md") + + # Framework files + files.append(f".specify/integration.json") + files.append(f".specify/init-options.json") + files.append(f".specify/integrations/{self.KEY}.manifest.json") + files.append(f".specify/integrations/speckit.manifest.json") + + if script_variant == "sh": + for name in ["check-prerequisites.sh", "common.sh", "create-new-feature.sh", + "setup-plan.sh"]: + files.append(f".specify/scripts/bash/{name}") + else: + for name in ["check-prerequisites.ps1", "common.ps1", "create-new-feature.ps1", + "setup-plan.ps1"]: + files.append(f".specify/scripts/powershell/{name}") + + for name in ["checklist-template.md", + "constitution-template.md", "plan-template.md", + "spec-template.md", "tasks-template.md"]: + files.append(f".specify/templates/{name}") + + files.append(".specify/memory/constitution.md") + # Bundled workflow + files.append(".specify/workflows/speckit/workflow.yml") + files.append(".specify/workflows/workflow-registry.json") + + # Agent context file (if set) + if i.context_file: + files.append(i.context_file) + + return sorted(files) + + def test_complete_file_inventory_sh(self, tmp_path): + """Every file produced by specify init --integration --script sh.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"inventory-sh-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", self.KEY, "--script", "sh", + "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + actual = sorted(p.relative_to(project).as_posix() + for p in project.rglob("*") if p.is_file()) + expected = self._expected_files("sh") + assert actual == expected, ( + f"Missing: {sorted(set(expected) - set(actual))}\n" + f"Extra: {sorted(set(actual) - set(expected))}" + ) + + def test_complete_file_inventory_ps(self, tmp_path): + """Every file produced by specify init --integration --script ps.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"inventory-ps-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", self.KEY, "--script", "ps", + "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + actual = sorted(p.relative_to(project).as_posix() + for p in project.rglob("*") if p.is_file()) + expected = self._expected_files("ps") + assert actual == expected, ( + f"Missing: {sorted(set(expected) - set(actual))}\n" + f"Extra: {sorted(set(actual) - set(expected))}" + ) diff --git a/tests/integrations/test_integration_base_skills.py b/tests/integrations/test_integration_base_skills.py new file mode 100644 index 0000000000..98a65fcff4 --- /dev/null +++ b/tests/integrations/test_integration_base_skills.py @@ -0,0 +1,468 @@ +"""Reusable test mixin for standard SkillsIntegration subclasses. + +Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``, +``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification +logic from ``SkillsIntegrationTests``. + +Mirrors ``MarkdownIntegrationTests`` / ``TomlIntegrationTests`` closely, +adapted for the ``speckit-/SKILL.md`` skills layout. +""" + +import os + +import yaml + +from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration +from specify_cli.integrations.base import SkillsIntegration +from specify_cli.integrations.manifest import IntegrationManifest + + +class SkillsIntegrationTests: + """Mixin — set class-level constants and inherit these tests. + + Required class attrs on subclass:: + + KEY: str — integration registry key + FOLDER: str — e.g. ".agents/" + COMMANDS_SUBDIR: str — e.g. "skills" + REGISTRAR_DIR: str — e.g. ".agents/skills" + CONTEXT_FILE: str — e.g. "AGENTS.md" + """ + + KEY: str + FOLDER: str + COMMANDS_SUBDIR: str + REGISTRAR_DIR: str + CONTEXT_FILE: str + + # -- Registration ----------------------------------------------------- + + def test_registered(self): + assert self.KEY in INTEGRATION_REGISTRY + assert get_integration(self.KEY) is not None + + def test_is_skills_integration(self): + assert isinstance(get_integration(self.KEY), SkillsIntegration) + + # -- Config ----------------------------------------------------------- + + def test_config_folder(self): + i = get_integration(self.KEY) + assert i.config["folder"] == self.FOLDER + + def test_config_commands_subdir(self): + i = get_integration(self.KEY) + assert i.config["commands_subdir"] == self.COMMANDS_SUBDIR + + def test_registrar_config(self): + i = get_integration(self.KEY) + assert i.registrar_config["dir"] == self.REGISTRAR_DIR + assert i.registrar_config["format"] == "markdown" + assert i.registrar_config["args"] == "$ARGUMENTS" + assert i.registrar_config["extension"] == "/SKILL.md" + + def test_context_file(self): + i = get_integration(self.KEY) + assert i.context_file == self.CONTEXT_FILE + + # -- Setup / teardown ------------------------------------------------- + + def test_setup_creates_files(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + assert len(created) > 0 + skill_files = [f for f in created if "scripts" not in f.parts] + for f in skill_files: + assert f.exists() + assert f.name == "SKILL.md" + assert f.parent.name.startswith("speckit-") + + def test_setup_writes_to_correct_directory(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + expected_dir = i.skills_dest(tmp_path) + assert expected_dir.exists(), f"Expected directory {expected_dir} was not created" + skill_files = [f for f in created if "scripts" not in f.parts] + assert len(skill_files) > 0, "No skill files were created" + for f in skill_files: + # Each SKILL.md is in speckit-/ under the skills directory + assert f.resolve().parent.parent == expected_dir.resolve(), ( + f"{f} is not under {expected_dir}" + ) + + def test_skill_directory_structure(self, tmp_path): + """Each command produces speckit-/SKILL.md.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + skill_files = [f for f in created if "scripts" not in f.parts] + + expected_commands = { + "analyze", "checklist", "clarify", "constitution", + "implement", "plan", "specify", "tasks", "taskstoissues", + } + + # Derive command names from the skill directory names + actual_commands = set() + for f in skill_files: + skill_dir_name = f.parent.name # e.g. "speckit-plan" + assert skill_dir_name.startswith("speckit-") + actual_commands.add(skill_dir_name.removeprefix("speckit-")) + + assert actual_commands == expected_commands + + def test_skill_frontmatter_structure(self, tmp_path): + """SKILL.md must have name, description, compatibility, metadata.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + skill_files = [f for f in created if "scripts" not in f.parts] + + for f in skill_files: + content = f.read_text(encoding="utf-8") + assert content.startswith("---\n"), f"{f} missing frontmatter" + parts = content.split("---", 2) + fm = yaml.safe_load(parts[1]) + assert "name" in fm, f"{f} frontmatter missing 'name'" + assert "description" in fm, f"{f} frontmatter missing 'description'" + assert "compatibility" in fm, f"{f} frontmatter missing 'compatibility'" + assert "metadata" in fm, f"{f} frontmatter missing 'metadata'" + assert fm["metadata"]["author"] == "github-spec-kit" + assert "source" in fm["metadata"] + + def test_skill_uses_template_descriptions(self, tmp_path): + """SKILL.md should use the original template description for ZIP parity.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + skill_files = [f for f in created if "scripts" not in f.parts] + + for f in skill_files: + content = f.read_text(encoding="utf-8") + parts = content.split("---", 2) + fm = yaml.safe_load(parts[1]) + # Description must be a non-empty string (from the template) + assert isinstance(fm["description"], str) + assert len(fm["description"]) > 0, f"{f} has empty description" + + def test_templates_are_processed(self, tmp_path): + """Skill body must have placeholders replaced, not raw templates.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + skill_files = [f for f in created if "scripts" not in f.parts] + assert len(skill_files) > 0 + for f in skill_files: + content = f.read_text(encoding="utf-8") + assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}" + assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__" + assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}" + assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__" + + def test_command_refs_use_hyphen_separator(self, tmp_path): + """Skills agents must resolve command refs with hyphen separator.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + skill_files = [f for f in created if "scripts" not in f.parts] + assert len(skill_files) > 0 + for f in skill_files: + content = f.read_text(encoding="utf-8") + # Skills agents must use /speckit-, not /speckit. + assert "/speckit." not in content, ( + f"{f.name} contains dot-notation /speckit. reference; " + f"skills agents must use /speckit-" + ) + + def test_skill_body_has_content(self, tmp_path): + """Each SKILL.md body should contain template content after the frontmatter.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + skill_files = [f for f in created if "scripts" not in f.parts] + for f in skill_files: + content = f.read_text(encoding="utf-8") + # Body is everything after the second --- + parts = content.split("---", 2) + body = parts[2].strip() if len(parts) >= 3 else "" + assert len(body) > 0, f"{f} has empty body" + + def test_plan_references_correct_context_file(self, tmp_path): + """The generated plan skill must reference this integration's context file.""" + i = get_integration(self.KEY) + if not i.context_file: + return + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + plan_file = i.skills_dest(tmp_path) / "speckit-plan" / "SKILL.md" + assert plan_file.exists(), f"Plan skill {plan_file} not created" + content = plan_file.read_text(encoding="utf-8") + assert i.context_file in content, ( + f"Plan skill should reference {i.context_file!r} but it was not found" + ) + assert "__CONTEXT_FILE__" not in content, ( + "Plan skill has unprocessed __CONTEXT_FILE__ placeholder" + ) + + def test_all_files_tracked_in_manifest(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + for f in created: + rel = f.resolve().relative_to(tmp_path.resolve()).as_posix() + assert rel in m.files, f"{rel} not tracked in manifest" + + def test_install_uninstall_roundtrip(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.install(tmp_path, m) + assert len(created) > 0 + m.save() + for f in created: + assert f.exists() + removed, skipped = i.uninstall(tmp_path, m) + assert len(removed) == len(created) + assert skipped == [] + + def test_modified_file_survives_uninstall(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.install(tmp_path, m) + m.save() + modified_file = created[0] + modified_file.write_text("user modified this", encoding="utf-8") + removed, skipped = i.uninstall(tmp_path, m) + assert modified_file.exists() + assert modified_file in skipped + + def test_pre_existing_skills_not_removed(self, tmp_path): + """Pre-existing non-speckit skills should be left untouched.""" + i = get_integration(self.KEY) + skills_dir = i.skills_dest(tmp_path) + foreign_dir = skills_dir / "other-tool" + foreign_dir.mkdir(parents=True) + (foreign_dir / "SKILL.md").write_text("# Foreign skill\n") + + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + + assert (foreign_dir / "SKILL.md").exists(), "Foreign skill was removed" + + # -- Context section --------------------------------------------------- + + def test_setup_upserts_context_section(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + if i.context_file: + ctx_path = tmp_path / i.context_file + assert ctx_path.exists(), f"Context file {i.context_file} not created for {self.KEY}" + content = ctx_path.read_text(encoding="utf-8") + assert "" in content + assert "" in content + assert "read the current plan" in content + + def test_teardown_removes_context_section(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + m.save() + if i.context_file: + ctx_path = tmp_path / i.context_file + content = ctx_path.read_text(encoding="utf-8") + ctx_path.write_text("# My Rules\n\n" + content + "\n# Footer\n", encoding="utf-8") + i.teardown(tmp_path, m) + remaining = ctx_path.read_text(encoding="utf-8") + assert "" not in remaining + assert "" not in remaining + assert "# My Rules" in remaining + + # -- CLI auto-promote ------------------------------------------------- + + def test_ai_flag_auto_promotes(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"promote-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--ai", self.KEY, "--script", "sh", "--no-git", + "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}" + i = get_integration(self.KEY) + skills_dir = i.skills_dest(project) + assert skills_dir.is_dir(), f"--ai {self.KEY} did not create skills directory" + + def test_integration_flag_creates_files(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"int-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git", + "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}" + i = get_integration(self.KEY) + skills_dir = i.skills_dest(project) + assert skills_dir.is_dir(), f"Skills directory {skills_dir} not created" + + def test_init_options_includes_context_file(self, tmp_path): + """init-options.json must include context_file for the active integration.""" + import json + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"opts-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", self.KEY, "--script", "sh", + "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + opts = json.loads((project / ".specify" / "init-options.json").read_text()) + i = get_integration(self.KEY) + assert opts.get("context_file") == i.context_file, ( + f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}" + ) + + # -- IntegrationOption ------------------------------------------------ + + def test_options_include_skills_flag(self): + i = get_integration(self.KEY) + opts = i.options() + skills_opts = [o for o in opts if o.name == "--skills"] + assert len(skills_opts) == 1 + assert skills_opts[0].is_flag is True + + # -- Complete file inventory ------------------------------------------ + + _SKILL_COMMANDS = [ + "analyze", "checklist", "clarify", "constitution", + "implement", "plan", "specify", "tasks", "taskstoissues", + ] + + def _expected_files(self, script_variant: str) -> list[str]: + """Build the full expected file list for a given script variant.""" + i = get_integration(self.KEY) + skills_prefix = i.config["folder"].rstrip("/") + "/" + i.config.get("commands_subdir", "skills") + + files = [] + # Skill files + for cmd in self._SKILL_COMMANDS: + files.append(f"{skills_prefix}/speckit-{cmd}/SKILL.md") + # Integration metadata + files += [ + ".specify/init-options.json", + ".specify/integration.json", + f".specify/integrations/{self.KEY}.manifest.json", + ".specify/integrations/speckit.manifest.json", + ".specify/memory/constitution.md", + ] + # Script variant + if script_variant == "sh": + files += [ + ".specify/scripts/bash/check-prerequisites.sh", + ".specify/scripts/bash/common.sh", + ".specify/scripts/bash/create-new-feature.sh", + ".specify/scripts/bash/setup-plan.sh", + ] + else: + files += [ + ".specify/scripts/powershell/check-prerequisites.ps1", + ".specify/scripts/powershell/common.ps1", + ".specify/scripts/powershell/create-new-feature.ps1", + ".specify/scripts/powershell/setup-plan.ps1", + ] + # Templates + files += [ + ".specify/templates/checklist-template.md", + ".specify/templates/constitution-template.md", + ".specify/templates/plan-template.md", + ".specify/templates/spec-template.md", + ".specify/templates/tasks-template.md", + ] + # Bundled workflow + files += [ + ".specify/workflows/speckit/workflow.yml", + ".specify/workflows/workflow-registry.json", + ] + # Agent context file (if set) + if i.context_file: + files.append(i.context_file) + return sorted(files) + + def test_complete_file_inventory_sh(self, tmp_path): + """Every file produced by specify init --integration --script sh.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"inventory-sh-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", self.KEY, + "--script", "sh", "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + actual = sorted( + p.relative_to(project).as_posix() + for p in project.rglob("*") if p.is_file() + ) + expected = self._expected_files("sh") + assert actual == expected, ( + f"Missing: {sorted(set(expected) - set(actual))}\n" + f"Extra: {sorted(set(actual) - set(expected))}" + ) + + def test_complete_file_inventory_ps(self, tmp_path): + """Every file produced by specify init --integration --script ps.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"inventory-ps-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", self.KEY, + "--script", "ps", "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + actual = sorted( + p.relative_to(project).as_posix() + for p in project.rglob("*") if p.is_file() + ) + expected = self._expected_files("ps") + assert actual == expected, ( + f"Missing: {sorted(set(expected) - set(actual))}\n" + f"Extra: {sorted(set(actual) - set(expected))}" + ) diff --git a/tests/integrations/test_integration_base_toml.py b/tests/integrations/test_integration_base_toml.py new file mode 100644 index 0000000000..78273b560e --- /dev/null +++ b/tests/integrations/test_integration_base_toml.py @@ -0,0 +1,620 @@ +"""Reusable test mixin for standard TomlIntegration subclasses. + +Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``, +``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification +logic from ``TomlIntegrationTests``. + +Mirrors ``MarkdownIntegrationTests`` closely — same test structure, +adapted for TOML output format. +""" + +import os +import tomllib + +import pytest + +from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration +from specify_cli.integrations.base import TomlIntegration +from specify_cli.integrations.manifest import IntegrationManifest + + +class TomlIntegrationTests: + """Mixin — set class-level constants and inherit these tests. + + Required class attrs on subclass:: + + KEY: str — integration registry key + FOLDER: str — e.g. ".gemini/" + COMMANDS_SUBDIR: str — e.g. "commands" + REGISTRAR_DIR: str — e.g. ".gemini/commands" + CONTEXT_FILE: str — e.g. "GEMINI.md" + """ + + KEY: str + FOLDER: str + COMMANDS_SUBDIR: str + REGISTRAR_DIR: str + CONTEXT_FILE: str + + # -- Registration ----------------------------------------------------- + + def test_registered(self): + assert self.KEY in INTEGRATION_REGISTRY + assert get_integration(self.KEY) is not None + + def test_is_toml_integration(self): + assert isinstance(get_integration(self.KEY), TomlIntegration) + + # -- Config ----------------------------------------------------------- + + def test_config_folder(self): + i = get_integration(self.KEY) + assert i.config["folder"] == self.FOLDER + + def test_config_commands_subdir(self): + i = get_integration(self.KEY) + assert i.config["commands_subdir"] == self.COMMANDS_SUBDIR + + def test_registrar_config(self): + i = get_integration(self.KEY) + assert i.registrar_config["dir"] == self.REGISTRAR_DIR + assert i.registrar_config["format"] == "toml" + assert i.registrar_config["args"] == "{{args}}" + assert i.registrar_config["extension"] == ".toml" + + def test_context_file(self): + i = get_integration(self.KEY) + assert i.context_file == self.CONTEXT_FILE + + # -- Setup / teardown ------------------------------------------------- + + def test_setup_creates_files(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + assert len(created) > 0 + cmd_files = [f for f in created if "scripts" not in f.parts] + for f in cmd_files: + assert f.exists() + assert f.name.startswith("speckit.") + assert f.name.endswith(".toml") + + def test_setup_writes_to_correct_directory(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + expected_dir = i.commands_dest(tmp_path) + assert expected_dir.exists(), ( + f"Expected directory {expected_dir} was not created" + ) + cmd_files = [f for f in created if "scripts" not in f.parts] + assert len(cmd_files) > 0, "No command files were created" + for f in cmd_files: + assert f.resolve().parent == expected_dir.resolve(), ( + f"{f} is not under {expected_dir}" + ) + + def test_templates_are_processed(self, tmp_path): + """Command files must have placeholders replaced and be valid TOML.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + assert len(cmd_files) > 0 + for f in cmd_files: + content = f.read_text(encoding="utf-8") + assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}" + assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__" + assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}" + assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__" + + def test_toml_has_description(self, tmp_path): + """Every TOML command file should have a description key.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + for f in cmd_files: + content = f.read_text(encoding="utf-8") + assert 'description = "' in content, f"{f.name} missing description key" + + def test_toml_has_prompt(self, tmp_path): + """Every TOML command file should have a prompt key.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + for f in cmd_files: + content = f.read_text(encoding="utf-8") + assert "prompt = " in content, f"{f.name} missing prompt key" + + def test_toml_uses_correct_arg_placeholder(self, tmp_path): + """TOML commands must use {{args}} (from {ARGS} replacement).""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + # At least one file should contain {{args}} from the {ARGS} placeholder + has_args = any("{{args}}" in f.read_text(encoding="utf-8") for f in cmd_files) + assert has_args, "No TOML command file contains {{args}} placeholder" + has_dollar_args = any( + "$ARGUMENTS" in f.read_text(encoding="utf-8") for f in cmd_files + ) + assert not has_dollar_args, ( + "TOML command still contains $ARGUMENTS instead of {{args}}" + ) + + @pytest.mark.parametrize( + ("frontmatter", "expected"), + [ + ( + "---\ndescription: |\n First line\n Second line\n---\nBody\n", + "First line\nSecond line\n", + ), + ( + "---\ndescription: >\n First line\n Second line\n---\nBody\n", + "First line Second line\n", + ), + ( + "---\ndescription: |-\n First line\n Second line\n---\nBody\n", + "First line\nSecond line", + ), + ( + "---\ndescription: >-\n First line\n Second line\n---\nBody\n", + "First line Second line", + ), + ], + ) + def test_toml_extract_description_supports_block_scalars( + self, frontmatter, expected + ): + assert TomlIntegration._extract_description(frontmatter) == expected + + def test_split_frontmatter_ignores_indented_delimiters(self): + content = "---\ndescription: |\n line one\n ---\n line two\n---\nBody\n" + + frontmatter, body = TomlIntegration._split_frontmatter(content) + + assert "line two" in frontmatter + assert body == "Body\n" + + def test_toml_prompt_excludes_frontmatter(self, tmp_path, monkeypatch): + i = get_integration(self.KEY) + template = tmp_path / "sample.md" + template.write_text( + "---\n" + "description: Summary line one\n" + "scripts:\n" + " sh: scripts/bash/example.sh\n" + "---\n" + "Body line one\n" + "Body line two\n", + encoding="utf-8", + ) + monkeypatch.setattr(i, "list_command_templates", lambda: [template]) + + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + assert len(cmd_files) == 1 + + generated = cmd_files[0].read_text(encoding="utf-8") + parsed = tomllib.loads(generated) + + assert parsed["description"] == "Summary line one" + assert parsed["prompt"] == "Body line one\nBody line two" + assert "description:" not in parsed["prompt"] + assert "scripts:" not in parsed["prompt"] + assert "---" not in parsed["prompt"] + + def test_toml_no_ambiguous_closing_quotes(self, tmp_path, monkeypatch): + """Multiline body ending with a double quote must not produce an ambiguous TOML multiline-string closing delimiter (#2113).""" + i = get_integration(self.KEY) + template = tmp_path / "sample.md" + template.write_text( + "---\n" + "description: Test\n" + "scripts:\n" + " sh: echo ok\n" + "---\n" + "Check the following:\n" + '- Correct: "Is X clearly specified?"\n', + encoding="utf-8", + ) + monkeypatch.setattr(i, "list_command_templates", lambda: [template]) + + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + assert len(cmd_files) == 1 + + raw = cmd_files[0].read_text(encoding="utf-8") + assert '""""' not in raw, "closing delimiter must not merge with body quote" + assert '"""\n' in raw, "body must use multiline basic string" + parsed = tomllib.loads(raw) + assert parsed["prompt"].endswith('specified?"') + assert not parsed["prompt"].endswith("\n"), ( + "parsed value must not gain a trailing newline" + ) + + def test_toml_triple_double_and_single_quote_ending(self, tmp_path, monkeypatch): + """Body containing `\"\"\"` and ending with `'` falls back to escaped basic string.""" + i = get_integration(self.KEY) + template = tmp_path / "sample.md" + template.write_text( + "---\n" + "description: Test\n" + "scripts:\n" + " sh: echo ok\n" + "---\n" + 'Use """triple""" quotes\n' + "and end with 'single'\n", + encoding="utf-8", + ) + monkeypatch.setattr(i, "list_command_templates", lambda: [template]) + + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + assert len(cmd_files) == 1 + + raw = cmd_files[0].read_text(encoding="utf-8") + assert "''''" not in raw, ( + "literal string must not produce ambiguous closing quotes" + ) + parsed = tomllib.loads(raw) + assert parsed["prompt"].endswith("'single'") + assert '"""triple"""' in parsed["prompt"] + assert not parsed["prompt"].endswith("\n"), ( + "parsed value must not gain a trailing newline" + ) + + def test_toml_closing_delimiter_inline_when_safe(self, tmp_path, monkeypatch): + """Body NOT ending with `"` keeps closing `\"\"\"` inline (no extra newline).""" + i = get_integration(self.KEY) + template = tmp_path / "sample.md" + template.write_text( + "---\n" + "description: Test\n" + "scripts:\n" + " sh: echo ok\n" + "---\n" + "Line one\n" + "Plain body content\n", + encoding="utf-8", + ) + monkeypatch.setattr(i, "list_command_templates", lambda: [template]) + + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + assert len(cmd_files) == 1 + + raw = cmd_files[0].read_text(encoding="utf-8") + parsed = tomllib.loads(raw) + assert parsed["prompt"] == "Line one\nPlain body content" + assert raw.rstrip().endswith('content"""'), ( + "closing delimiter should be inline when body does not end with a quote" + ) + + def test_toml_is_valid(self, tmp_path): + """Every generated TOML file must parse without errors.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + for f in cmd_files: + raw = f.read_bytes() + try: + parsed = tomllib.loads(raw.decode("utf-8")) + except Exception as exc: + raise AssertionError(f"{f.name} is not valid TOML: {exc}") from exc + assert "prompt" in parsed, f"{f.name} parsed TOML has no 'prompt' key" + + def test_plan_references_correct_context_file(self, tmp_path): + """The generated plan command must reference this integration's context file.""" + i = get_integration(self.KEY) + if not i.context_file: + return + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + plan_file = i.commands_dest(tmp_path) / i.command_filename("plan") + assert plan_file.exists(), f"Plan file {plan_file} not created" + content = plan_file.read_text(encoding="utf-8") + assert i.context_file in content, ( + f"Plan command should reference {i.context_file!r} but it was not found in {plan_file.name}" + ) + assert "__CONTEXT_FILE__" not in content, ( + f"Plan command has unprocessed __CONTEXT_FILE__ placeholder in {plan_file.name}" + ) + + def test_all_files_tracked_in_manifest(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + for f in created: + rel = f.resolve().relative_to(tmp_path.resolve()).as_posix() + assert rel in m.files, f"{rel} not tracked in manifest" + + def test_install_uninstall_roundtrip(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.install(tmp_path, m) + assert len(created) > 0 + m.save() + for f in created: + assert f.exists() + removed, skipped = i.uninstall(tmp_path, m) + assert len(removed) == len(created) + assert skipped == [] + + def test_modified_file_survives_uninstall(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.install(tmp_path, m) + m.save() + modified_file = created[0] + modified_file.write_text("user modified this", encoding="utf-8") + removed, skipped = i.uninstall(tmp_path, m) + assert modified_file.exists() + assert modified_file in skipped + + # -- Context section --------------------------------------------------- + + def test_setup_upserts_context_section(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + if i.context_file: + ctx_path = tmp_path / i.context_file + assert ctx_path.exists(), f"Context file {i.context_file} not created for {self.KEY}" + content = ctx_path.read_text(encoding="utf-8") + assert "" in content + assert "" in content + assert "read the current plan" in content + + def test_teardown_removes_context_section(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + m.save() + if i.context_file: + ctx_path = tmp_path / i.context_file + content = ctx_path.read_text(encoding="utf-8") + ctx_path.write_text("# My Rules\n\n" + content + "\n# Footer\n", encoding="utf-8") + i.teardown(tmp_path, m) + remaining = ctx_path.read_text(encoding="utf-8") + assert "" not in remaining + assert "" not in remaining + assert "# My Rules" in remaining + + # -- CLI auto-promote ------------------------------------------------- + + def test_ai_flag_auto_promotes(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"promote-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke( + app, + [ + "init", + "--here", + "--ai", + self.KEY, + "--script", + "sh", + "--no-git", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}" + i = get_integration(self.KEY) + cmd_dir = i.commands_dest(project) + assert cmd_dir.is_dir(), f"--ai {self.KEY} did not create commands directory" + + def test_integration_flag_creates_files(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"int-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke( + app, + [ + "init", + "--here", + "--integration", + self.KEY, + "--script", + "sh", + "--no-git", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, ( + f"init --integration {self.KEY} failed: {result.output}" + ) + i = get_integration(self.KEY) + cmd_dir = i.commands_dest(project) + assert cmd_dir.is_dir(), f"Commands directory {cmd_dir} not created" + commands = sorted(cmd_dir.glob("speckit.*.toml")) + assert len(commands) > 0, f"No command files in {cmd_dir}" + + def test_init_options_includes_context_file(self, tmp_path): + """init-options.json must include context_file for the active integration.""" + import json + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"opts-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", self.KEY, "--script", "sh", + "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + opts = json.loads((project / ".specify" / "init-options.json").read_text()) + i = get_integration(self.KEY) + assert opts.get("context_file") == i.context_file, ( + f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}" + ) + + # -- Complete file inventory ------------------------------------------ + + COMMAND_STEMS = [ + "analyze", + "checklist", + "clarify", + "constitution", + "implement", + "plan", + "specify", + "tasks", + "taskstoissues", + ] + + def _expected_files(self, script_variant: str) -> list[str]: + """Build the expected file list for this integration + script variant.""" + i = get_integration(self.KEY) + cmd_dir = i.registrar_config["dir"] + files = [] + + # Command files (.toml) + for stem in self.COMMAND_STEMS: + files.append(f"{cmd_dir}/speckit.{stem}.toml") + + # Framework files + files.append(".specify/integration.json") + files.append(".specify/init-options.json") + files.append(f".specify/integrations/{self.KEY}.manifest.json") + files.append(".specify/integrations/speckit.manifest.json") + + if script_variant == "sh": + for name in [ + "check-prerequisites.sh", + "common.sh", + "create-new-feature.sh", + "setup-plan.sh", + ]: + files.append(f".specify/scripts/bash/{name}") + else: + for name in [ + "check-prerequisites.ps1", + "common.ps1", + "create-new-feature.ps1", + "setup-plan.ps1", + ]: + files.append(f".specify/scripts/powershell/{name}") + + for name in [ + "checklist-template.md", + "constitution-template.md", + "plan-template.md", + "spec-template.md", + "tasks-template.md", + ]: + files.append(f".specify/templates/{name}") + + files.append(".specify/memory/constitution.md") + # Bundled workflow + files.append(".specify/workflows/speckit/workflow.yml") + files.append(".specify/workflows/workflow-registry.json") + + # Agent context file (if set) + if i.context_file: + files.append(i.context_file) + + return sorted(files) + + def test_complete_file_inventory_sh(self, tmp_path): + """Every file produced by specify init --integration --script sh.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"inventory-sh-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke( + app, + [ + "init", + "--here", + "--integration", + self.KEY, + "--script", + "sh", + "--no-git", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + actual = sorted( + p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() + ) + expected = self._expected_files("sh") + assert actual == expected, ( + f"Missing: {sorted(set(expected) - set(actual))}\n" + f"Extra: {sorted(set(actual) - set(expected))}" + ) + + def test_complete_file_inventory_ps(self, tmp_path): + """Every file produced by specify init --integration --script ps.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"inventory-ps-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke( + app, + [ + "init", + "--here", + "--integration", + self.KEY, + "--script", + "ps", + "--no-git", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + actual = sorted( + p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() + ) + expected = self._expected_files("ps") + assert actual == expected, ( + f"Missing: {sorted(set(expected) - set(actual))}\n" + f"Extra: {sorted(set(actual) - set(expected))}" + ) diff --git a/tests/integrations/test_integration_base_yaml.py b/tests/integrations/test_integration_base_yaml.py new file mode 100644 index 0000000000..e1dee3bad7 --- /dev/null +++ b/tests/integrations/test_integration_base_yaml.py @@ -0,0 +1,499 @@ +"""Reusable test mixin for standard YamlIntegration subclasses. + +Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``, +``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification +logic from ``YamlIntegrationTests``. + +Mirrors ``TomlIntegrationTests`` closely — same test structure, +adapted for YAML recipe output format. +""" + +import os + +import yaml + +from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration +from specify_cli.integrations.base import YamlIntegration +from specify_cli.integrations.manifest import IntegrationManifest + + +class YamlIntegrationTests: + """Mixin — set class-level constants and inherit these tests. + + Required class attrs on subclass:: + + KEY: str — integration registry key + FOLDER: str — e.g. ".goose/" + COMMANDS_SUBDIR: str — e.g. "recipes" + REGISTRAR_DIR: str — e.g. ".goose/recipes" + CONTEXT_FILE: str — e.g. "AGENTS.md" + """ + + KEY: str + FOLDER: str + COMMANDS_SUBDIR: str + REGISTRAR_DIR: str + CONTEXT_FILE: str + + # -- Registration ----------------------------------------------------- + + def test_registered(self): + assert self.KEY in INTEGRATION_REGISTRY + assert get_integration(self.KEY) is not None + + def test_is_yaml_integration(self): + assert isinstance(get_integration(self.KEY), YamlIntegration) + + # -- Config ----------------------------------------------------------- + + def test_config_folder(self): + i = get_integration(self.KEY) + assert i.config["folder"] == self.FOLDER + + def test_config_commands_subdir(self): + i = get_integration(self.KEY) + assert i.config["commands_subdir"] == self.COMMANDS_SUBDIR + + def test_registrar_config(self): + i = get_integration(self.KEY) + assert i.registrar_config["dir"] == self.REGISTRAR_DIR + assert i.registrar_config["format"] == "yaml" + assert i.registrar_config["args"] == "{{args}}" + assert i.registrar_config["extension"] == ".yaml" + + def test_context_file(self): + i = get_integration(self.KEY) + assert i.context_file == self.CONTEXT_FILE + + # -- Setup / teardown ------------------------------------------------- + + def test_setup_creates_files(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + assert len(created) > 0 + cmd_files = [f for f in created if "scripts" not in f.parts] + for f in cmd_files: + assert f.exists() + assert f.name.startswith("speckit.") + assert f.name.endswith(".yaml") + + def test_setup_writes_to_correct_directory(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + expected_dir = i.commands_dest(tmp_path) + assert expected_dir.exists(), ( + f"Expected directory {expected_dir} was not created" + ) + cmd_files = [f for f in created if "scripts" not in f.parts] + assert len(cmd_files) > 0, "No command files were created" + for f in cmd_files: + assert f.resolve().parent == expected_dir.resolve(), ( + f"{f} is not under {expected_dir}" + ) + + def test_templates_are_processed(self, tmp_path): + """Command files must have placeholders replaced.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + assert len(cmd_files) > 0 + for f in cmd_files: + content = f.read_text(encoding="utf-8") + assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}" + assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__" + assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}" + assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__" + + def test_yaml_has_title(self, tmp_path): + """Every YAML recipe should have a title field.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + for f in cmd_files: + content = f.read_text(encoding="utf-8") + assert "title:" in content, f"{f.name} missing title field" + + def test_yaml_has_prompt(self, tmp_path): + """Every YAML recipe should have a prompt block scalar.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + for f in cmd_files: + content = f.read_text(encoding="utf-8") + assert "prompt: |" in content, f"{f.name} missing prompt block scalar" + + def test_yaml_uses_correct_arg_placeholder(self, tmp_path): + """YAML recipes must use {{args}} placeholder.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + has_args = any("{{args}}" in f.read_text(encoding="utf-8") for f in cmd_files) + assert has_args, "No YAML recipe contains {{args}} placeholder" + has_dollar_args = any( + "$ARGUMENTS" in f.read_text(encoding="utf-8") for f in cmd_files + ) + assert not has_dollar_args, ( + "YAML recipe still contains $ARGUMENTS instead of {{args}}" + ) + + def test_yaml_is_valid(self, tmp_path): + """Every generated YAML file must parse without errors.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + for f in cmd_files: + content = f.read_text(encoding="utf-8") + # Strip trailing source comment before parsing + lines = content.split("\n") + yaml_lines = [l for l in lines if not l.startswith("# Source:")] + try: + parsed = yaml.safe_load("\n".join(yaml_lines)) + except Exception as exc: + raise AssertionError(f"{f.name} is not valid YAML: {exc}") from exc + assert "prompt" in parsed, f"{f.name} parsed YAML has no 'prompt' key" + assert "title" in parsed, f"{f.name} parsed YAML has no 'title' key" + + def test_yaml_prompt_excludes_frontmatter(self, tmp_path, monkeypatch): + i = get_integration(self.KEY) + template = tmp_path / "sample.md" + template.write_text( + "---\n" + "description: Summary line one\n" + "scripts:\n" + " sh: scripts/bash/example.sh\n" + "---\n" + "Body line one\n" + "Body line two\n", + encoding="utf-8", + ) + monkeypatch.setattr(i, "list_command_templates", lambda: [template]) + + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + assert len(cmd_files) == 1 + + content = cmd_files[0].read_text(encoding="utf-8") + # Strip source comment for parsing + lines = content.split("\n") + yaml_lines = [l for l in lines if not l.startswith("# Source:")] + parsed = yaml.safe_load("\n".join(yaml_lines)) + + assert "description:" not in parsed["prompt"] + assert "scripts:" not in parsed["prompt"] + assert "---" not in parsed["prompt"] + + def test_plan_references_correct_context_file(self, tmp_path): + """The generated plan command must reference this integration's context file.""" + i = get_integration(self.KEY) + if not i.context_file: + return + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + plan_file = i.commands_dest(tmp_path) / i.command_filename("plan") + assert plan_file.exists(), f"Plan file {plan_file} not created" + content = plan_file.read_text(encoding="utf-8") + assert i.context_file in content, ( + f"Plan command should reference {i.context_file!r} but it was not found in {plan_file.name}" + ) + assert "__CONTEXT_FILE__" not in content, ( + f"Plan command has unprocessed __CONTEXT_FILE__ placeholder in {plan_file.name}" + ) + + def test_all_files_tracked_in_manifest(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + for f in created: + rel = f.resolve().relative_to(tmp_path.resolve()).as_posix() + assert rel in m.files, f"{rel} not tracked in manifest" + + def test_install_uninstall_roundtrip(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.install(tmp_path, m) + assert len(created) > 0 + m.save() + for f in created: + assert f.exists() + removed, skipped = i.uninstall(tmp_path, m) + assert len(removed) == len(created) + assert skipped == [] + + def test_modified_file_survives_uninstall(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.install(tmp_path, m) + m.save() + modified_file = created[0] + modified_file.write_text("user modified this", encoding="utf-8") + removed, skipped = i.uninstall(tmp_path, m) + assert modified_file.exists() + assert modified_file in skipped + + # -- Context section --------------------------------------------------- + + def test_setup_upserts_context_section(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + if i.context_file: + ctx_path = tmp_path / i.context_file + assert ctx_path.exists(), f"Context file {i.context_file} not created for {self.KEY}" + content = ctx_path.read_text(encoding="utf-8") + assert "" in content + assert "" in content + assert "read the current plan" in content + + def test_teardown_removes_context_section(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + m.save() + if i.context_file: + ctx_path = tmp_path / i.context_file + content = ctx_path.read_text(encoding="utf-8") + ctx_path.write_text("# My Rules\n\n" + content + "\n# Footer\n", encoding="utf-8") + i.teardown(tmp_path, m) + remaining = ctx_path.read_text(encoding="utf-8") + assert "" not in remaining + assert "" not in remaining + assert "# My Rules" in remaining + + # -- CLI auto-promote ------------------------------------------------- + + def test_ai_flag_auto_promotes(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"promote-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke( + app, + [ + "init", + "--here", + "--ai", + self.KEY, + "--script", + "sh", + "--no-git", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}" + i = get_integration(self.KEY) + cmd_dir = i.commands_dest(project) + assert cmd_dir.is_dir(), f"--ai {self.KEY} did not create commands directory" + + def test_integration_flag_creates_files(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"int-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke( + app, + [ + "init", + "--here", + "--integration", + self.KEY, + "--script", + "sh", + "--no-git", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, ( + f"init --integration {self.KEY} failed: {result.output}" + ) + i = get_integration(self.KEY) + cmd_dir = i.commands_dest(project) + assert cmd_dir.is_dir(), f"Commands directory {cmd_dir} not created" + commands = sorted(cmd_dir.glob("speckit.*.yaml")) + assert len(commands) > 0, f"No command files in {cmd_dir}" + + def test_init_options_includes_context_file(self, tmp_path): + """init-options.json must include context_file for the active integration.""" + import json + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"opts-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", self.KEY, "--script", "sh", + "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + opts = json.loads((project / ".specify" / "init-options.json").read_text()) + i = get_integration(self.KEY) + assert opts.get("context_file") == i.context_file, ( + f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}" + ) + + # -- Complete file inventory ------------------------------------------ + + COMMAND_STEMS = [ + "analyze", + "checklist", + "clarify", + "constitution", + "implement", + "plan", + "specify", + "tasks", + "taskstoissues", + ] + + def _expected_files(self, script_variant: str) -> list[str]: + """Build the expected file list for this integration + script variant.""" + i = get_integration(self.KEY) + cmd_dir = i.registrar_config["dir"] + files = [] + + # Command files (.yaml) + for stem in self.COMMAND_STEMS: + files.append(f"{cmd_dir}/speckit.{stem}.yaml") + + # Framework files + files.append(".specify/integration.json") + files.append(".specify/init-options.json") + files.append(f".specify/integrations/{self.KEY}.manifest.json") + files.append(".specify/integrations/speckit.manifest.json") + + if script_variant == "sh": + for name in [ + "check-prerequisites.sh", + "common.sh", + "create-new-feature.sh", + "setup-plan.sh", + ]: + files.append(f".specify/scripts/bash/{name}") + else: + for name in [ + "check-prerequisites.ps1", + "common.ps1", + "create-new-feature.ps1", + "setup-plan.ps1", + ]: + files.append(f".specify/scripts/powershell/{name}") + + for name in [ + "checklist-template.md", + "constitution-template.md", + "plan-template.md", + "spec-template.md", + "tasks-template.md", + ]: + files.append(f".specify/templates/{name}") + + files.append(".specify/memory/constitution.md") + # Bundled workflow + files.append(".specify/workflows/speckit/workflow.yml") + files.append(".specify/workflows/workflow-registry.json") + + # Agent context file (if set) + if i.context_file: + files.append(i.context_file) + + return sorted(files) + + def test_complete_file_inventory_sh(self, tmp_path): + """Every file produced by specify init --integration --script sh.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"inventory-sh-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke( + app, + [ + "init", + "--here", + "--integration", + self.KEY, + "--script", + "sh", + "--no-git", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + actual = sorted( + p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() + ) + expected = self._expected_files("sh") + assert actual == expected, ( + f"Missing: {sorted(set(expected) - set(actual))}\n" + f"Extra: {sorted(set(actual) - set(expected))}" + ) + + def test_complete_file_inventory_ps(self, tmp_path): + """Every file produced by specify init --integration --script ps.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"inventory-ps-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke( + app, + [ + "init", + "--here", + "--integration", + self.KEY, + "--script", + "ps", + "--no-git", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + actual = sorted( + p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() + ) + expected = self._expected_files("ps") + assert actual == expected, ( + f"Missing: {sorted(set(expected) - set(actual))}\n" + f"Extra: {sorted(set(actual) - set(expected))}" + ) diff --git a/tests/integrations/test_integration_bob.py b/tests/integrations/test_integration_bob.py new file mode 100644 index 0000000000..1562f0100c --- /dev/null +++ b/tests/integrations/test_integration_bob.py @@ -0,0 +1,11 @@ +"""Tests for BobIntegration.""" + +from .test_integration_base_markdown import MarkdownIntegrationTests + + +class TestBobIntegration(MarkdownIntegrationTests): + KEY = "bob" + FOLDER = ".bob/" + COMMANDS_SUBDIR = "commands" + REGISTRAR_DIR = ".bob/commands" + CONTEXT_FILE = "AGENTS.md" diff --git a/tests/integrations/test_integration_catalog.py b/tests/integrations/test_integration_catalog.py new file mode 100644 index 0000000000..6d82a6c390 --- /dev/null +++ b/tests/integrations/test_integration_catalog.py @@ -0,0 +1,656 @@ +"""Tests for the integration catalog system (catalog.py).""" + +import json +import os + +import pytest +import yaml + +from specify_cli.integrations.catalog import ( + IntegrationCatalog, + IntegrationCatalogEntry, + IntegrationCatalogError, + IntegrationDescriptor, + IntegrationDescriptorError, +) + + +# --------------------------------------------------------------------------- +# IntegrationCatalogEntry +# --------------------------------------------------------------------------- + + +class TestIntegrationCatalogEntry: + def test_create_entry(self): + entry = IntegrationCatalogEntry( + url="https://example.com/catalog.json", + name="test", + priority=1, + install_allowed=True, + description="Test catalog", + ) + assert entry.url == "https://example.com/catalog.json" + assert entry.name == "test" + assert entry.priority == 1 + assert entry.install_allowed is True + assert entry.description == "Test catalog" + + def test_default_description(self): + entry = IntegrationCatalogEntry( + url="https://example.com/catalog.json", + name="test", + priority=1, + install_allowed=False, + ) + assert entry.description == "" + + +# --------------------------------------------------------------------------- +# IntegrationCatalog — URL validation +# --------------------------------------------------------------------------- + + +class TestCatalogURLValidation: + def test_https_allowed(self): + IntegrationCatalog._validate_catalog_url("https://example.com/catalog.json") + + def test_http_rejected(self): + with pytest.raises(IntegrationCatalogError, match="HTTPS"): + IntegrationCatalog._validate_catalog_url("http://example.com/catalog.json") + + def test_http_localhost_allowed(self): + IntegrationCatalog._validate_catalog_url("http://localhost:8080/catalog.json") + IntegrationCatalog._validate_catalog_url("http://127.0.0.1/catalog.json") + + def test_missing_host_rejected(self): + with pytest.raises(IntegrationCatalogError, match="valid URL"): + IntegrationCatalog._validate_catalog_url("https:///no-host") + + +# --------------------------------------------------------------------------- +# IntegrationCatalog — active catalogs +# --------------------------------------------------------------------------- + + +class TestActiveCatalogs: + def test_defaults_when_no_config(self, tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + (tmp_path / ".specify").mkdir() + cat = IntegrationCatalog(tmp_path) + active = cat.get_active_catalogs() + assert len(active) == 2 + assert active[0].name == "default" + assert active[1].name == "community" + + def test_env_var_override(self, tmp_path, monkeypatch): + (tmp_path / ".specify").mkdir() + monkeypatch.setenv( + "SPECKIT_INTEGRATION_CATALOG_URL", + "https://custom.example.com/catalog.json", + ) + cat = IntegrationCatalog(tmp_path) + active = cat.get_active_catalogs() + assert len(active) == 1 + assert active[0].name == "custom" + + def test_project_config_overrides_defaults(self, tmp_path): + specify = tmp_path / ".specify" + specify.mkdir() + cfg = specify / "integration-catalogs.yml" + cfg.write_text(yaml.dump({ + "catalogs": [ + {"url": "https://my.example.com/cat.json", "name": "mine", "priority": 1, "install_allowed": True}, + ] + })) + cat = IntegrationCatalog(tmp_path) + active = cat.get_active_catalogs() + assert len(active) == 1 + assert active[0].name == "mine" + + def test_empty_config_raises(self, tmp_path): + specify = tmp_path / ".specify" + specify.mkdir() + cfg = specify / "integration-catalogs.yml" + cfg.write_text(yaml.dump({"catalogs": []})) + cat = IntegrationCatalog(tmp_path) + with pytest.raises(IntegrationCatalogError, match="no 'catalogs' entries"): + cat.get_active_catalogs() + + +# --------------------------------------------------------------------------- +# IntegrationCatalog — fetch & search (using monkeypatched urlopen responses) +# --------------------------------------------------------------------------- + + +class TestCatalogFetch: + """Tests that use a local HTTP server stub via monkeypatch.""" + + def _patch_urlopen(self, monkeypatch, catalog_data): + """Patch urllib.request.urlopen to return *catalog_data*.""" + + class FakeResponse: + def __init__(self, data, url=""): + self._data = json.dumps(data).encode() + self._url = url + + def read(self): + return self._data + + def geturl(self): + return self._url + + def __enter__(self): + return self + + def __exit__(self, *a): + pass + + def fake_urlopen(url, timeout=10): + return FakeResponse(catalog_data, url) + + import urllib.request + monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen) + + def test_fetch_and_search_all(self, tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + (tmp_path / ".specify").mkdir() + cat = IntegrationCatalog(tmp_path) + + catalog = { + "schema_version": "1.0", + "updated_at": "2026-01-01T00:00:00Z", + "integrations": { + "acme-coder": { + "id": "acme-coder", + "name": "Acme Coder", + "version": "2.0.0", + "description": "Community integration for Acme Coder", + "author": "acme-org", + "tags": ["cli"], + }, + }, + } + self._patch_urlopen(monkeypatch, catalog) + + results = cat.search() + assert len(results) >= 1 + ids = [r["id"] for r in results] + assert "acme-coder" in ids + + def test_search_by_tag(self, tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + (tmp_path / ".specify").mkdir() + cat = IntegrationCatalog(tmp_path) + + catalog = { + "schema_version": "1.0", + "updated_at": "2026-01-01T00:00:00Z", + "integrations": { + "a": {"id": "a", "name": "A", "version": "1.0.0", "tags": ["cli"]}, + "b": {"id": "b", "name": "B", "version": "1.0.0", "tags": ["ide"]}, + }, + } + self._patch_urlopen(monkeypatch, catalog) + + results = cat.search(tag="cli") + assert all("cli" in r.get("tags", []) for r in results) + + def test_search_by_query(self, tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + (tmp_path / ".specify").mkdir() + cat = IntegrationCatalog(tmp_path) + + catalog = { + "schema_version": "1.0", + "updated_at": "2026-01-01T00:00:00Z", + "integrations": { + "claude": {"id": "claude", "name": "Claude Code", "version": "1.0.0", "description": "Anthropic", "tags": []}, + "gemini": {"id": "gemini", "name": "Gemini CLI", "version": "1.0.0", "description": "Google", "tags": []}, + }, + } + self._patch_urlopen(monkeypatch, catalog) + + results = cat.search(query="claude") + assert len(results) == 1 + assert results[0]["id"] == "claude" + + def test_get_integration_info(self, tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + (tmp_path / ".specify").mkdir() + cat = IntegrationCatalog(tmp_path) + + catalog = { + "schema_version": "1.0", + "updated_at": "2026-01-01T00:00:00Z", + "integrations": { + "claude": {"id": "claude", "name": "Claude Code", "version": "1.0.0"}, + }, + } + self._patch_urlopen(monkeypatch, catalog) + + info = cat.get_integration_info("claude") + assert info is not None + assert info["name"] == "Claude Code" + + assert cat.get_integration_info("nonexistent") is None + + def test_invalid_catalog_format(self, tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + (tmp_path / ".specify").mkdir() + cat = IntegrationCatalog(tmp_path) + + self._patch_urlopen(monkeypatch, {"schema_version": "1.0"}) # missing "integrations" + + with pytest.raises(IntegrationCatalogError, match="Failed to fetch any integration catalog"): + cat.search() + + def test_clear_cache(self, tmp_path): + (tmp_path / ".specify").mkdir() + cat = IntegrationCatalog(tmp_path) + cat.cache_dir.mkdir(parents=True, exist_ok=True) + (cat.cache_dir / "catalog-abc123.json").write_text("{}") + cat.clear_cache() + assert not list(cat.cache_dir.glob("catalog-*.json")) + + +# --------------------------------------------------------------------------- +# IntegrationDescriptor (integration.yml) +# --------------------------------------------------------------------------- + +VALID_DESCRIPTOR = { + "schema_version": "1.0", + "integration": { + "id": "my-agent", + "name": "My Agent", + "version": "1.0.0", + "description": "Integration for My Agent", + "author": "my-org", + }, + "requires": { + "speckit_version": ">=0.6.0", + }, + "provides": { + "commands": [ + {"name": "speckit.specify", "file": "templates/speckit.specify.md"}, + ], + "scripts": [], + }, +} + + +class TestIntegrationDescriptor: + def _write(self, tmp_path, data): + p = tmp_path / "integration.yml" + p.write_text(yaml.dump(data)) + return p + + def test_valid_descriptor(self, tmp_path): + p = self._write(tmp_path, VALID_DESCRIPTOR) + desc = IntegrationDescriptor(p) + assert desc.id == "my-agent" + assert desc.name == "My Agent" + assert desc.version == "1.0.0" + assert desc.description == "Integration for My Agent" + assert desc.requires_speckit_version == ">=0.6.0" + assert len(desc.commands) == 1 + assert desc.scripts == [] + + def test_missing_schema_version(self, tmp_path): + data = {**VALID_DESCRIPTOR} + del data["schema_version"] + p = self._write(tmp_path, data) + with pytest.raises(IntegrationDescriptorError, match="Missing required field: schema_version"): + IntegrationDescriptor(p) + + def test_unsupported_schema_version(self, tmp_path): + data = {**VALID_DESCRIPTOR, "schema_version": "99.0"} + p = self._write(tmp_path, data) + with pytest.raises(IntegrationDescriptorError, match="Unsupported schema version"): + IntegrationDescriptor(p) + + def test_missing_integration_id(self, tmp_path): + data = {**VALID_DESCRIPTOR, "integration": {"name": "X", "version": "1.0.0", "description": "Y"}} + p = self._write(tmp_path, data) + with pytest.raises(IntegrationDescriptorError, match="Missing integration.id"): + IntegrationDescriptor(p) + + def test_invalid_id_format(self, tmp_path): + integ = {**VALID_DESCRIPTOR["integration"], "id": "BAD_ID"} + data = {**VALID_DESCRIPTOR, "integration": integ} + p = self._write(tmp_path, data) + with pytest.raises(IntegrationDescriptorError, match="Invalid integration ID"): + IntegrationDescriptor(p) + + def test_invalid_version(self, tmp_path): + integ = {**VALID_DESCRIPTOR["integration"], "version": "not-semver"} + data = {**VALID_DESCRIPTOR, "integration": integ} + p = self._write(tmp_path, data) + with pytest.raises(IntegrationDescriptorError, match="Invalid version"): + IntegrationDescriptor(p) + + def test_missing_speckit_version(self, tmp_path): + data = {**VALID_DESCRIPTOR, "requires": {}} + p = self._write(tmp_path, data) + with pytest.raises(IntegrationDescriptorError, match="requires.speckit_version"): + IntegrationDescriptor(p) + + def test_no_commands_or_scripts(self, tmp_path): + data = {**VALID_DESCRIPTOR, "provides": {}} + p = self._write(tmp_path, data) + with pytest.raises(IntegrationDescriptorError, match="at least one command or script"): + IntegrationDescriptor(p) + + def test_command_missing_name(self, tmp_path): + data = {**VALID_DESCRIPTOR, "provides": {"commands": [{"file": "x.md"}]}} + p = self._write(tmp_path, data) + with pytest.raises(IntegrationDescriptorError, match="missing 'name' or 'file'"): + IntegrationDescriptor(p) + + def test_commands_not_a_list(self, tmp_path): + data = {**VALID_DESCRIPTOR, "provides": {"commands": "not-a-list", "scripts": ["a.sh"]}} + p = self._write(tmp_path, data) + with pytest.raises(IntegrationDescriptorError, match="expected a list"): + IntegrationDescriptor(p) + + def test_scripts_not_a_list(self, tmp_path): + data = {**VALID_DESCRIPTOR, "provides": {"commands": [{"name": "a", "file": "b"}], "scripts": "not-a-list"}} + p = self._write(tmp_path, data) + with pytest.raises(IntegrationDescriptorError, match="expected a list"): + IntegrationDescriptor(p) + + def test_file_not_found(self, tmp_path): + with pytest.raises(IntegrationDescriptorError, match="Descriptor not found"): + IntegrationDescriptor(tmp_path / "nonexistent.yml") + + def test_invalid_yaml(self, tmp_path): + p = tmp_path / "integration.yml" + p.write_text(": : :") + with pytest.raises(IntegrationDescriptorError, match="Invalid YAML"): + IntegrationDescriptor(p) + + def test_get_hash(self, tmp_path): + p = self._write(tmp_path, VALID_DESCRIPTOR) + desc = IntegrationDescriptor(p) + h = desc.get_hash() + assert h.startswith("sha256:") + + def test_tools_accessor(self, tmp_path): + data = {**VALID_DESCRIPTOR, "requires": { + "speckit_version": ">=0.6.0", + "tools": [{"name": "my-agent", "version": ">=1.0.0", "required": True}], + }} + p = self._write(tmp_path, data) + desc = IntegrationDescriptor(p) + assert len(desc.tools) == 1 + assert desc.tools[0]["name"] == "my-agent" + + +# --------------------------------------------------------------------------- +# CLI: integration list --catalog +# --------------------------------------------------------------------------- + + +class TestIntegrationListCatalog: + """Test ``specify integration list --catalog``.""" + + def _init_project(self, tmp_path): + """Create a minimal spec-kit project.""" + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = tmp_path / "proj" + project.mkdir() + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "init", "--here", + "--integration", "copilot", + "--script", "sh", + "--no-git", + "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old) + assert result.exit_code == 0, result.output + return project + + def test_list_catalog_flag(self, tmp_path, monkeypatch): + """--catalog should show catalog entries.""" + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = self._init_project(tmp_path) + + catalog = { + "schema_version": "1.0", + "updated_at": "2026-01-01T00:00:00Z", + "integrations": { + "test-agent": { + "id": "test-agent", + "name": "Test Agent", + "version": "1.0.0", + "description": "A test agent", + "tags": ["cli"], + }, + }, + } + + import urllib.request + + class FakeResponse: + def __init__(self, data, url=""): + self._data = json.dumps(data).encode() + self._url = url + def read(self): + return self._data + def geturl(self): + return self._url + def __enter__(self): + return self + def __exit__(self, *a): + pass + + monkeypatch.setattr(urllib.request, "urlopen", lambda url, timeout=10: FakeResponse(catalog, url)) + + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "list", "--catalog"]) + finally: + os.chdir(old) + + assert result.exit_code == 0 + assert "test-agent" in result.output + assert "Test Agent" in result.output + + def test_list_without_catalog_still_works(self, tmp_path): + """Default list (no --catalog) works as before.""" + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = self._init_project(tmp_path) + + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "list"]) + finally: + os.chdir(old) + + assert result.exit_code == 0 + assert "copilot" in result.output + assert "installed" in result.output + + +# --------------------------------------------------------------------------- +# CLI: integration upgrade +# --------------------------------------------------------------------------- + + +class TestIntegrationUpgrade: + """Test ``specify integration upgrade``.""" + + def _init_project(self, tmp_path, integration="copilot"): + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = tmp_path / "proj" + project.mkdir() + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "init", "--here", + "--integration", integration, + "--script", "sh", + "--no-git", + "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old) + assert result.exit_code == 0, result.output + return project + + def test_upgrade_requires_speckit_project(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + old = os.getcwd() + try: + os.chdir(tmp_path) + result = runner.invoke(app, ["integration", "upgrade"]) + finally: + os.chdir(old) + assert result.exit_code != 0 + assert "Not a spec-kit project" in result.output + + def test_upgrade_no_integration_installed(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = tmp_path / "proj" + project.mkdir() + (project / ".specify").mkdir() + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "upgrade"]) + finally: + os.chdir(old) + assert result.exit_code == 0 + assert "No integration is currently installed" in result.output + + def test_upgrade_succeeds(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = self._init_project(tmp_path, "copilot") + + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "upgrade"], catch_exceptions=False) + finally: + os.chdir(old) + assert result.exit_code == 0 + assert "upgraded successfully" in result.output + + def test_upgrade_blocks_on_modified_files(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = self._init_project(tmp_path, "copilot") + + # Modify a tracked file so the manifest hash won't match + manifest_path = project / ".specify" / "integrations" / "copilot.manifest.json" + assert manifest_path.exists(), "Manifest should exist after init" + manifest_data = json.loads(manifest_path.read_text()) + tracked_files = manifest_data.get("files", {}) + assert tracked_files, "Manifest should track at least one file" + first_rel = next(iter(tracked_files)) + target_file = project / first_rel + assert target_file.exists(), f"Tracked file {first_rel} should exist" + target_file.write_text("MODIFIED CONTENT\n") + + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "upgrade"]) + finally: + os.chdir(old) + assert result.exit_code != 0 + assert "modified" in result.output.lower() + + def test_upgrade_force_overwrites_modified(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = self._init_project(tmp_path, "copilot") + + # Modify a tracked file + manifest_path = project / ".specify" / "integrations" / "copilot.manifest.json" + manifest_data = json.loads(manifest_path.read_text()) + tracked_files = manifest_data.get("files", {}) + assert tracked_files, "Manifest should track at least one file" + first_rel = next(iter(tracked_files)) + target_file = project / first_rel + assert target_file.exists(), f"Tracked file {first_rel} should exist" + target_file.write_text("MODIFIED CONTENT\n") + + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "upgrade", "--force"], catch_exceptions=False) + finally: + os.chdir(old) + assert result.exit_code == 0 + assert "upgraded successfully" in result.output + + def test_upgrade_wrong_integration_key(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = self._init_project(tmp_path, "copilot") + + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "upgrade", "claude"]) + finally: + os.chdir(old) + assert result.exit_code != 0 + assert "not the currently installed integration" in result.output + + def test_upgrade_no_manifest(self, tmp_path): + """Upgrade with missing manifest suggests fresh install.""" + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = self._init_project(tmp_path, "copilot") + + # Remove manifest + manifest_path = project / ".specify" / "integrations" / "copilot.manifest.json" + if manifest_path.exists(): + manifest_path.unlink() + + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "upgrade"]) + finally: + os.chdir(old) + assert result.exit_code == 0 + assert "Nothing to upgrade" in result.output diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py new file mode 100644 index 0000000000..b3236a66b7 --- /dev/null +++ b/tests/integrations/test_integration_claude.py @@ -0,0 +1,555 @@ +"""Tests for ClaudeIntegration.""" + +import codecs +import json +import os +from unittest.mock import patch + +import yaml + +from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration +from specify_cli.integrations.base import IntegrationBase +from specify_cli.integrations.claude import ARGUMENT_HINTS +from specify_cli.integrations.manifest import IntegrationManifest + + +class TestClaudeIntegration: + def test_registered(self): + assert "claude" in INTEGRATION_REGISTRY + assert get_integration("claude") is not None + + def test_is_base_integration(self): + assert isinstance(get_integration("claude"), IntegrationBase) + + def test_config_uses_skills(self): + integration = get_integration("claude") + assert integration.config["folder"] == ".claude/" + assert integration.config["commands_subdir"] == "skills" + + def test_registrar_config_uses_skill_layout(self): + integration = get_integration("claude") + assert integration.registrar_config["dir"] == ".claude/skills" + assert integration.registrar_config["format"] == "markdown" + assert integration.registrar_config["args"] == "$ARGUMENTS" + assert integration.registrar_config["extension"] == "/SKILL.md" + + def test_context_file(self): + integration = get_integration("claude") + assert integration.context_file == "CLAUDE.md" + + def test_setup_creates_skill_files(self, tmp_path): + integration = get_integration("claude") + manifest = IntegrationManifest("claude", tmp_path) + created = integration.setup(tmp_path, manifest, script_type="sh") + + skill_files = [path for path in created if path.name == "SKILL.md"] + assert skill_files + + skills_dir = tmp_path / ".claude" / "skills" + assert skills_dir.is_dir() + + plan_skill = skills_dir / "speckit-plan" / "SKILL.md" + assert plan_skill.exists() + + content = plan_skill.read_text(encoding="utf-8") + assert "{SCRIPT}" not in content + assert "{ARGS}" not in content + assert "__AGENT__" not in content + assert "__SPECKIT_COMMAND_" not in content, "unprocessed __SPECKIT_COMMAND_*__" + assert "/speckit." not in content, "skills agent must use /speckit- not /speckit." + + parts = content.split("---", 2) + parsed = yaml.safe_load(parts[1]) + assert parsed["name"] == "speckit-plan" + assert parsed["user-invocable"] is True + assert parsed["disable-model-invocation"] is False + assert parsed["metadata"]["source"] == "templates/commands/plan.md" + + def test_setup_upserts_context_section(self, tmp_path): + integration = get_integration("claude") + manifest = IntegrationManifest("claude", tmp_path) + integration.setup(tmp_path, manifest, script_type="sh") + + ctx_path = tmp_path / integration.context_file + assert ctx_path.exists() + content = ctx_path.read_text(encoding="utf-8") + assert "" in content + assert "" in content + assert "read the current plan" in content + + def test_upsert_context_section_strips_bom(self, tmp_path): + """Existing context file with UTF-8 BOM must be cleaned up on upsert.""" + integration = get_integration("claude") + ctx_path = tmp_path / integration.context_file + + # Write a file that starts with a UTF-8 BOM (as the old PowerShell script did) + bom = codecs.BOM_UTF8 + ctx_path.write_bytes(bom + b"# CLAUDE.md\n\nSome existing content.\n") + + integration.upsert_context_section(tmp_path) + + result = ctx_path.read_bytes() + assert not result.startswith(bom), "BOM must be stripped after upsert" + content = result.decode("utf-8") + assert "" in content + assert "Some existing content." in content + + def test_remove_context_section_strips_bom(self, tmp_path): + """remove_context_section must clean BOM from context file on Windows-authored files.""" + integration = get_integration("claude") + ctx_path = tmp_path / integration.context_file + + marker_content = ( + "# CLAUDE.md\n\n" + "\n" + "For additional context about technologies to be used, project structure,\n" + "shell commands, and other important information, read the current plan\n" + "\n" + ) + ctx_path.write_bytes(codecs.BOM_UTF8 + marker_content.encode("utf-8")) + + result = integration.remove_context_section(tmp_path) + + assert result is True + assert ctx_path.exists(), "File should exist (non-empty content remains)" + remaining = ctx_path.read_bytes() + assert not remaining.startswith(codecs.BOM_UTF8), "BOM must be stripped after remove" + assert b"" in content + assert "" in content + + # -- CLI integration test --------------------------------------------- + + def test_init_with_integration_options_skills(self, tmp_path): + """specify init --integration copilot --integration-options='--skills' scaffolds skills.""" + from typer.testing import CliRunner + from specify_cli import app + project = tmp_path / "copilot-skills" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", "copilot", + "--integration-options", "--skills", + "--script", "sh", "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + skills_dir = project / ".github" / "skills" + assert skills_dir.is_dir(), "Skills directory was not created" + plan_skill = skills_dir / "speckit-plan" / "SKILL.md" + assert plan_skill.exists(), "speckit-plan/SKILL.md not found" + # Verify no default-mode artifacts + assert not (project / ".github" / "agents").exists() + assert not (project / ".github" / "prompts").exists() + assert not (project / ".vscode" / "settings.json").exists() + + def test_complete_file_inventory_skills_sh(self, tmp_path): + """Every file produced by specify init --integration copilot --integration-options='--skills' --script sh.""" + from typer.testing import CliRunner + from specify_cli import app + project = tmp_path / "inventory-skills-sh" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", "copilot", + "--integration-options", "--skills", + "--script", "sh", "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()) + expected = sorted([ + # Skill files + *[f".github/skills/speckit-{cmd}/SKILL.md" for cmd in self._SKILL_COMMANDS], + # Context file + ".github/copilot-instructions.md", + # Integration metadata + ".specify/init-options.json", + ".specify/integration.json", + ".specify/integrations/copilot.manifest.json", + ".specify/integrations/speckit.manifest.json", + # Scripts (sh) + ".specify/scripts/bash/check-prerequisites.sh", + ".specify/scripts/bash/common.sh", + ".specify/scripts/bash/create-new-feature.sh", + ".specify/scripts/bash/setup-plan.sh", + # Templates + ".specify/templates/checklist-template.md", + ".specify/templates/constitution-template.md", + ".specify/templates/plan-template.md", + ".specify/templates/spec-template.md", + ".specify/templates/tasks-template.md", + ".specify/memory/constitution.md", + # Bundled workflow + ".specify/workflows/speckit/workflow.yml", + ".specify/workflows/workflow-registry.json", + ]) + assert actual == expected, ( + f"Missing: {sorted(set(expected) - set(actual))}\n" + f"Extra: {sorted(set(actual) - set(expected))}" + ) + + # -- Singleton leak: _skills_mode must reset -------------------------- + + def test_skills_mode_resets_on_default_setup(self, tmp_path): + """setup() with skills=True then without must reset _skills_mode.""" + copilot = self._make_copilot() + + # First call: skills mode + (tmp_path / "proj1").mkdir() + m1 = IntegrationManifest("copilot", tmp_path / "proj1") + copilot.setup(tmp_path / "proj1", m1, parsed_options={"skills": True}) + assert copilot._skills_mode is True + + # Second call: default mode (no skills option) + (tmp_path / "proj2").mkdir() + m2 = IntegrationManifest("copilot", tmp_path / "proj2") + copilot.setup(tmp_path / "proj2", m2) + assert copilot._skills_mode is False + + # build_command_invocation must use default (dotted) mode + assert copilot.build_command_invocation("plan", "args") == "args" + + # -- Auto-detection must ignore unrelated .github/skills/ ------------- + + def test_dispatch_ignores_unrelated_skills_directory(self, tmp_path): + """dispatch_command() must not treat unrelated .github/skills/ as skills mode.""" + copilot = self._make_copilot() + # Create a .github/skills/ with non-speckit content (e.g. GitHub Skills training) + unrelated = tmp_path / ".github" / "skills" / "introduction-to-github" + unrelated.mkdir(parents=True) + (unrelated / "README.md").write_text("# GitHub Skills training\n") + + # Should NOT detect skills mode — cli_args should contain --agent + import unittest.mock as mock + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.Mock(returncode=0, stdout="", stderr="") + copilot.dispatch_command("plan", "my args", project_root=tmp_path, stream=False) + call_args = mock_run.call_args[0][0] + assert "--agent" in call_args, ( + f"Expected --agent in cli_args but got: {call_args}" + ) + assert "speckit.plan" in call_args + + def test_dispatch_detects_speckit_skills_layout(self, tmp_path): + """dispatch_command() detects speckit-*/SKILL.md as skills mode.""" + copilot = self._make_copilot() + skill_dir = tmp_path / ".github" / "skills" / "speckit-plan" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text("---\nname: speckit-plan\n---\n") + + import unittest.mock as mock + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.Mock(returncode=0, stdout="", stderr="") + copilot.dispatch_command("plan", "my args", project_root=tmp_path, stream=False) + call_args = mock_run.call_args[0][0] + assert "--agent" not in call_args, ( + f"Skills mode should not use --agent, got: {call_args}" + ) + prompt = call_args[call_args.index("-p") + 1] + assert "/speckit-plan" in prompt, ( + f"Skills mode prompt should invoke /speckit-plan, got: {prompt}" + ) + assert "my args" in prompt, ( + f"Skills mode prompt should preserve user args, got: {prompt}" + ) + + # -- Next-steps display for Copilot skills mode ----------------------- + + def test_init_skills_next_steps_show_skill_syntax(self, tmp_path): + """specify init --integration copilot --integration-options='--skills' shows /speckit-plan not /speckit.plan.""" + from typer.testing import CliRunner + from specify_cli import app + project = tmp_path / "copilot-nextsteps" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", "copilot", + "--integration-options", "--skills", + "--script", "sh", "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + # Skills mode should show /speckit-plan (hyphenated) + assert "/speckit-plan" in result.output, ( + f"Expected /speckit-plan in next steps but got:\n{result.output}" + ) + # Must NOT show the dotted /speckit.plan form + assert "/speckit.plan" not in result.output, ( + f"Should not show /speckit.plan in skills mode:\n{result.output}" + ) \ No newline at end of file diff --git a/tests/integrations/test_integration_cursor_agent.py b/tests/integrations/test_integration_cursor_agent.py new file mode 100644 index 0000000000..352a0475b5 --- /dev/null +++ b/tests/integrations/test_integration_cursor_agent.py @@ -0,0 +1,108 @@ +"""Tests for CursorAgentIntegration.""" + +from pathlib import Path + +from specify_cli.integrations import get_integration +from specify_cli.integrations.manifest import IntegrationManifest + +from .test_integration_base_skills import SkillsIntegrationTests + + +class TestCursorAgentIntegration(SkillsIntegrationTests): + KEY = "cursor-agent" + FOLDER = ".cursor/" + COMMANDS_SUBDIR = "skills" + REGISTRAR_DIR = ".cursor/skills" + CONTEXT_FILE = ".cursor/rules/specify-rules.mdc" + + +class TestCursorMdcFrontmatter: + """Verify .mdc frontmatter handling in upsert/remove context section.""" + + def _setup(self, tmp_path: Path): + i = get_integration("cursor-agent") + m = IntegrationManifest("cursor-agent", tmp_path) + return i, m + + def test_new_mdc_gets_frontmatter(self, tmp_path): + """A freshly created .mdc file includes alwaysApply: true.""" + i, m = self._setup(tmp_path) + i.setup(tmp_path, m) + ctx = (tmp_path / i.context_file).read_text(encoding="utf-8") + assert ctx.startswith("---\n") + assert "alwaysApply: true" in ctx + + def test_existing_mdc_without_frontmatter_gets_it(self, tmp_path): + """An existing .mdc without frontmatter gets it added.""" + i, m = self._setup(tmp_path) + ctx_path = tmp_path / i.context_file + ctx_path.parent.mkdir(parents=True, exist_ok=True) + ctx_path.write_text("# User rules\n", encoding="utf-8") + i.upsert_context_section(tmp_path) + content = ctx_path.read_text(encoding="utf-8") + assert content.lstrip().startswith("---") + assert "alwaysApply: true" in content + assert "# User rules" in content + + def test_existing_mdc_with_frontmatter_preserves_it(self, tmp_path): + """An existing .mdc with custom frontmatter is preserved.""" + i, m = self._setup(tmp_path) + ctx_path = tmp_path / i.context_file + ctx_path.parent.mkdir(parents=True, exist_ok=True) + ctx_path.write_text( + "---\nalwaysApply: true\ncustomKey: hello\n---\n\n# Rules\n", + encoding="utf-8", + ) + i.upsert_context_section(tmp_path) + content = ctx_path.read_text(encoding="utf-8") + assert "alwaysApply: true" in content + assert "customKey: hello" in content + assert "" in content + + def test_existing_mdc_wrong_alwaysapply_fixed(self, tmp_path): + """An .mdc with alwaysApply: false gets corrected.""" + i, m = self._setup(tmp_path) + ctx_path = tmp_path / i.context_file + ctx_path.parent.mkdir(parents=True, exist_ok=True) + ctx_path.write_text( + "---\nalwaysApply: false\n---\n\n# Rules\n", + encoding="utf-8", + ) + i.upsert_context_section(tmp_path) + content = ctx_path.read_text(encoding="utf-8") + assert "alwaysApply: true" in content + assert "alwaysApply: false" not in content + + def test_upsert_idempotent_no_duplicate_frontmatter(self, tmp_path): + """Repeated upserts don't duplicate frontmatter.""" + i, m = self._setup(tmp_path) + i.upsert_context_section(tmp_path) + i.upsert_context_section(tmp_path) + content = (tmp_path / i.context_file).read_text(encoding="utf-8") + assert content.count("alwaysApply") == 1 + + def test_remove_deletes_mdc_with_only_frontmatter(self, tmp_path): + """Removing the section from a Speckit-only .mdc deletes the file.""" + i, m = self._setup(tmp_path) + i.upsert_context_section(tmp_path) + ctx_path = tmp_path / i.context_file + assert ctx_path.exists() + i.remove_context_section(tmp_path) + assert not ctx_path.exists() + + +class TestCursorAgentAutoPromote: + """--ai cursor-agent auto-promotes to integration path.""" + + def test_ai_cursor_agent_without_ai_skills_auto_promotes(self, tmp_path): + """--ai cursor-agent should work the same as --integration cursor-agent.""" + from typer.testing import CliRunner + from specify_cli import app + + runner = CliRunner() + target = tmp_path / "test-proj" + result = runner.invoke(app, ["init", str(target), "--ai", "cursor-agent", "--no-git", "--ignore-agent-tools", "--script", "sh"]) + + assert result.exit_code == 0, f"init --ai cursor-agent failed: {result.output}" + assert (target / ".cursor" / "skills" / "speckit-plan" / "SKILL.md").exists() + diff --git a/tests/integrations/test_integration_forge.py b/tests/integrations/test_integration_forge.py new file mode 100644 index 0000000000..8cd8b17c95 --- /dev/null +++ b/tests/integrations/test_integration_forge.py @@ -0,0 +1,403 @@ +"""Tests for ForgeIntegration.""" + +from specify_cli.integrations import get_integration +from specify_cli.integrations.manifest import IntegrationManifest +from specify_cli.integrations.forge import format_forge_command_name + + +class TestForgeCommandNameFormatter: + """Test the centralized Forge command name formatter.""" + + def test_simple_name_without_prefix(self): + """Test formatting a simple name without 'speckit.' prefix.""" + assert format_forge_command_name("plan") == "speckit-plan" + assert format_forge_command_name("tasks") == "speckit-tasks" + assert format_forge_command_name("specify") == "speckit-specify" + + def test_name_with_speckit_prefix(self): + """Test formatting a name that already has 'speckit.' prefix.""" + assert format_forge_command_name("speckit.plan") == "speckit-plan" + assert format_forge_command_name("speckit.tasks") == "speckit-tasks" + + def test_extension_command_name(self): + """Test formatting extension command names with dots.""" + assert format_forge_command_name("speckit.my-extension.example") == "speckit-my-extension-example" + assert format_forge_command_name("my-extension.example") == "speckit-my-extension-example" + + def test_complex_nested_name(self): + """Test formatting deeply nested command names.""" + assert format_forge_command_name("speckit.jira.sync-status") == "speckit-jira-sync-status" + assert format_forge_command_name("speckit.foo.bar.baz") == "speckit-foo-bar-baz" + + def test_name_with_hyphens_preserved(self): + """Test that existing hyphens are preserved.""" + assert format_forge_command_name("my-extension") == "speckit-my-extension" + assert format_forge_command_name("speckit.my-ext.test-cmd") == "speckit-my-ext-test-cmd" + + def test_alias_formatting(self): + """Test formatting alias names.""" + assert format_forge_command_name("speckit.my-extension.example-short") == "speckit-my-extension-example-short" + + def test_idempotent_already_hyphenated(self): + """Test that already-hyphenated names are returned unchanged (idempotent).""" + assert format_forge_command_name("speckit-plan") == "speckit-plan" + assert format_forge_command_name("speckit-my-extension-example") == "speckit-my-extension-example" + assert format_forge_command_name("speckit-jira-sync-status") == "speckit-jira-sync-status" + + +class TestForgeIntegration: + def test_forge_key_and_config(self): + forge = get_integration("forge") + assert forge is not None + assert forge.key == "forge" + assert forge.config["folder"] == ".forge/" + assert forge.config["commands_subdir"] == "commands" + assert forge.config["requires_cli"] is True + assert forge.registrar_config["args"] == "{{parameters}}" + assert forge.registrar_config["extension"] == ".md" + assert forge.context_file == "AGENTS.md" + + def test_command_filename_md(self): + forge = get_integration("forge") + assert forge.command_filename("plan") == "speckit.plan.md" + + def test_setup_creates_md_files(self, tmp_path): + from specify_cli.integrations.forge import ForgeIntegration + forge = ForgeIntegration() + m = IntegrationManifest("forge", tmp_path) + created = forge.setup(tmp_path, m) + assert len(created) > 0 + # Separate command files from scripts + command_files = [f for f in created if f.parent == tmp_path / ".forge" / "commands"] + assert len(command_files) > 0 + for f in command_files: + assert f.name.endswith(".md") + + def test_setup_upserts_context_section(self, tmp_path): + from specify_cli.integrations.forge import ForgeIntegration + forge = ForgeIntegration() + m = IntegrationManifest("forge", tmp_path) + forge.setup(tmp_path, m) + ctx_path = tmp_path / forge.context_file + assert ctx_path.exists() + content = ctx_path.read_text(encoding="utf-8") + assert "" in content + assert "" in content + + def test_all_created_files_tracked_in_manifest(self, tmp_path): + from specify_cli.integrations.forge import ForgeIntegration + forge = ForgeIntegration() + m = IntegrationManifest("forge", tmp_path) + created = forge.setup(tmp_path, m) + for f in created: + rel = f.resolve().relative_to(tmp_path.resolve()).as_posix() + assert rel in m.files, f"Created file {rel} not tracked in manifest" + + def test_install_uninstall_roundtrip(self, tmp_path): + from specify_cli.integrations.forge import ForgeIntegration + forge = ForgeIntegration() + m = IntegrationManifest("forge", tmp_path) + created = forge.install(tmp_path, m) + assert len(created) > 0 + m.save() + for f in created: + assert f.exists() + removed, skipped = forge.uninstall(tmp_path, m) + assert len(removed) == len(created) + assert skipped == [] + + def test_modified_file_survives_uninstall(self, tmp_path): + from specify_cli.integrations.forge import ForgeIntegration + forge = ForgeIntegration() + m = IntegrationManifest("forge", tmp_path) + created = forge.install(tmp_path, m) + m.save() + # Modify a command file (not a script) + command_files = [f for f in created if f.parent == tmp_path / ".forge" / "commands"] + modified_file = command_files[0] + modified_file.write_text("user modified this", encoding="utf-8") + removed, skipped = forge.uninstall(tmp_path, m) + assert modified_file.exists() + assert modified_file in skipped + + def test_directory_structure(self, tmp_path): + from specify_cli.integrations.forge import ForgeIntegration + forge = ForgeIntegration() + m = IntegrationManifest("forge", tmp_path) + forge.setup(tmp_path, m) + commands_dir = tmp_path / ".forge" / "commands" + assert commands_dir.is_dir() + + # Derive expected command names from the Forge command templates so the test + # stays in sync if templates are added/removed. + templates = forge.list_command_templates() + expected_commands = {t.stem for t in templates} + assert len(expected_commands) > 0, "No command templates found" + + # Check generated files match templates + command_files = sorted(commands_dir.glob("speckit.*.md")) + assert len(command_files) == len(expected_commands) + actual_commands = {f.name.removeprefix("speckit.").removesuffix(".md") for f in command_files} + assert actual_commands == expected_commands + + def test_templates_are_processed(self, tmp_path): + from specify_cli.integrations.forge import ForgeIntegration + forge = ForgeIntegration() + m = IntegrationManifest("forge", tmp_path) + forge.setup(tmp_path, m) + commands_dir = tmp_path / ".forge" / "commands" + for cmd_file in commands_dir.glob("speckit.*.md"): + content = cmd_file.read_text(encoding="utf-8") + # Check standard replacements + assert "{SCRIPT}" not in content, f"{cmd_file.name} has unprocessed {{SCRIPT}}" + assert "__AGENT__" not in content, f"{cmd_file.name} has unprocessed __AGENT__" + assert "{ARGS}" not in content, f"{cmd_file.name} has unprocessed {{ARGS}}" + assert "__SPECKIT_COMMAND_" not in content, f"{cmd_file.name} has unprocessed __SPECKIT_COMMAND_*__" + # Check Forge-specific: $ARGUMENTS should be replaced with {{parameters}} + assert "$ARGUMENTS" not in content, f"{cmd_file.name} has unprocessed $ARGUMENTS" + # Frontmatter sections should be stripped + assert "\nscripts:\n" not in content + + def test_plan_references_correct_context_file(self, tmp_path): + """The generated plan command must reference forge's context file.""" + from specify_cli.integrations.forge import ForgeIntegration + forge = ForgeIntegration() + m = IntegrationManifest("forge", tmp_path) + forge.setup(tmp_path, m) + plan_file = tmp_path / ".forge" / "commands" / "speckit.plan.md" + assert plan_file.exists() + content = plan_file.read_text(encoding="utf-8") + assert forge.context_file in content, ( + f"Plan command should reference {forge.context_file!r}" + ) + assert "__CONTEXT_FILE__" not in content + + def test_forge_specific_transformations(self, tmp_path): + """Test Forge-specific processing: name injection and handoffs stripping.""" + from specify_cli.integrations.forge import ForgeIntegration + from specify_cli.agents import CommandRegistrar + forge = ForgeIntegration() + m = IntegrationManifest("forge", tmp_path) + forge.setup(tmp_path, m) + commands_dir = tmp_path / ".forge" / "commands" + + registrar = CommandRegistrar() + for cmd_file in commands_dir.glob("speckit.*.md"): + content = cmd_file.read_text(encoding="utf-8") + frontmatter, _ = registrar.parse_frontmatter(content) + + # Check that name field is injected in frontmatter + assert "name" in frontmatter, f"{cmd_file.name} missing injected 'name' field in frontmatter" + + # Check that handoffs frontmatter key is stripped + assert "handoffs" not in frontmatter, f"{cmd_file.name} has unstripped 'handoffs' key in frontmatter" + + def test_uses_parameters_placeholder(self, tmp_path): + """Verify Forge replaces $ARGUMENTS with {{parameters}} in generated files.""" + from specify_cli.integrations.forge import ForgeIntegration + forge = ForgeIntegration() + + # The registrar_config should specify {{parameters}} + assert forge.registrar_config["args"] == "{{parameters}}" + + # Generate files and verify $ARGUMENTS is replaced with {{parameters}} + from specify_cli.integrations.manifest import IntegrationManifest + m = IntegrationManifest("forge", tmp_path) + forge.setup(tmp_path, m) + commands_dir = tmp_path / ".forge" / "commands" + + # Check all generated command files + for cmd_file in commands_dir.glob("speckit.*.md"): + content = cmd_file.read_text(encoding="utf-8") + # $ARGUMENTS should be replaced with {{parameters}} + assert "$ARGUMENTS" not in content, ( + f"{cmd_file.name} still contains $ARGUMENTS - it should be replaced with {{{{parameters}}}}" + ) + # At least some files should have {{parameters}} (those with user input sections) + # We'll check the checklist file specifically as it has a User Input section + + # Verify checklist specifically has {{parameters}} in the User Input section + checklist = commands_dir / "speckit.checklist.md" + if checklist.exists(): + content = checklist.read_text(encoding="utf-8") + assert "{{parameters}}" in content, ( + "checklist should contain {{parameters}} in User Input section" + ) + + def test_name_field_uses_hyphenated_format(self, tmp_path): + """Verify that injected name fields use hyphenated format (speckit-plan, not speckit.plan).""" + from specify_cli.integrations.forge import ForgeIntegration + from specify_cli.agents import CommandRegistrar + forge = ForgeIntegration() + m = IntegrationManifest("forge", tmp_path) + forge.setup(tmp_path, m) + commands_dir = tmp_path / ".forge" / "commands" + + # Check that name fields use hyphenated format + registrar = CommandRegistrar() + for cmd_file in commands_dir.glob("speckit.*.md"): + content = cmd_file.read_text(encoding="utf-8") + # Extract the name field from frontmatter using the parser + frontmatter, _ = registrar.parse_frontmatter(content) + assert "name" in frontmatter, ( + f"{cmd_file.name} missing injected 'name' field in frontmatter" + ) + name_value = frontmatter["name"] + # Name should use hyphens, not dots + assert "." not in name_value, ( + f"{cmd_file.name} has name field with dots: {name_value} " + f"(should use hyphens for Forge/ZSH compatibility)" + ) + assert name_value.startswith("speckit-"), ( + f"{cmd_file.name} name field should start with 'speckit-': {name_value}" + ) + + +class TestForgeCommandRegistrar: + """Test CommandRegistrar's Forge-specific name formatting.""" + + def test_registrar_formats_extension_command_names_for_forge(self, tmp_path): + """Verify CommandRegistrar converts dot notation to hyphens for Forge.""" + from specify_cli.agents import CommandRegistrar + + # Create a mock extension command file + ext_dir = tmp_path / "extension" + ext_dir.mkdir() + cmd_dir = ext_dir / "commands" + cmd_dir.mkdir() + + # Create a test command with dot notation name + cmd_file = cmd_dir / "example.md" + cmd_file.write_text( + "---\n" + "description: Test extension command\n" + "---\n\n" + "Test content with $ARGUMENTS\n", + encoding="utf-8" + ) + + # Register with Forge + registrar = CommandRegistrar() + commands = [ + { + "name": "speckit.my-extension.example", + "file": "commands/example.md" + } + ] + + registered = registrar.register_commands( + "forge", + commands, + "test-extension", + ext_dir, + tmp_path + ) + + # Verify registration succeeded + assert "speckit.my-extension.example" in registered + + # Check the generated file has hyphenated name in frontmatter + forge_cmd = tmp_path / ".forge" / "commands" / "speckit.my-extension.example.md" + assert forge_cmd.exists() + + content = forge_cmd.read_text(encoding="utf-8") + # Parse frontmatter to validate name field precisely + frontmatter, _ = registrar.parse_frontmatter(content) + assert "name" in frontmatter, "name field should be injected in frontmatter" + # Name field should use hyphens, not dots + assert frontmatter["name"] == "speckit-my-extension-example" + + def test_registrar_formats_alias_names_for_forge(self, tmp_path): + """Verify CommandRegistrar converts alias names to hyphens for Forge.""" + from specify_cli.agents import CommandRegistrar + + # Create a mock extension command file + ext_dir = tmp_path / "extension" + ext_dir.mkdir() + cmd_dir = ext_dir / "commands" + cmd_dir.mkdir() + + cmd_file = cmd_dir / "example.md" + cmd_file.write_text( + "---\n" + "description: Test command with alias\n" + "---\n\n" + "Test content\n", + encoding="utf-8" + ) + + # Register with Forge including an alias + registrar = CommandRegistrar() + commands = [ + { + "name": "speckit.my-extension.example", + "file": "commands/example.md", + "aliases": ["speckit.my-extension.ex"] + } + ] + + registrar.register_commands( + "forge", + commands, + "test-extension", + ext_dir, + tmp_path + ) + + # Check the alias file has hyphenated name in frontmatter + alias_file = tmp_path / ".forge" / "commands" / "speckit.my-extension.ex.md" + assert alias_file.exists() + + content = alias_file.read_text(encoding="utf-8") + # Parse frontmatter to validate alias name field precisely + frontmatter, _ = registrar.parse_frontmatter(content) + assert "name" in frontmatter, "name field should be injected in alias frontmatter" + # Alias name field should also use hyphens + assert frontmatter["name"] == "speckit-my-extension-ex" + + def test_registrar_does_not_affect_other_agents(self, tmp_path): + """Verify format_name callback is Forge-specific and doesn't affect other agents.""" + from specify_cli.agents import CommandRegistrar + + # Create a mock extension command file + ext_dir = tmp_path / "extension" + ext_dir.mkdir() + cmd_dir = ext_dir / "commands" + cmd_dir.mkdir() + + cmd_file = cmd_dir / "example.md" + cmd_file.write_text( + "---\n" + "description: Test command\n" + "---\n\n" + "Test content with $ARGUMENTS\n", + encoding="utf-8" + ) + + # Register with Windsurf (standard markdown agent without inject_name) + registrar = CommandRegistrar() + commands = [ + { + "name": "speckit.my-extension.example", + "file": "commands/example.md" + } + ] + + registrar.register_commands( + "windsurf", + commands, + "test-extension", + ext_dir, + tmp_path + ) + + # Windsurf uses standard markdown format without name injection. + # The format_name callback should not be invoked for non-Forge agents. + windsurf_cmd = tmp_path / ".windsurf" / "workflows" / "speckit.my-extension.example.md" + assert windsurf_cmd.exists() + + content = windsurf_cmd.read_text(encoding="utf-8") + # Windsurf should NOT have a name field injected + assert "name:" not in content, ( + "Windsurf should not inject name field - format_name callback should be Forge-only" + ) diff --git a/tests/integrations/test_integration_gemini.py b/tests/integrations/test_integration_gemini.py new file mode 100644 index 0000000000..9be5985e29 --- /dev/null +++ b/tests/integrations/test_integration_gemini.py @@ -0,0 +1,11 @@ +"""Tests for GeminiIntegration.""" + +from .test_integration_base_toml import TomlIntegrationTests + + +class TestGeminiIntegration(TomlIntegrationTests): + KEY = "gemini" + FOLDER = ".gemini/" + COMMANDS_SUBDIR = "commands" + REGISTRAR_DIR = ".gemini/commands" + CONTEXT_FILE = "GEMINI.md" diff --git a/tests/integrations/test_integration_generic.py b/tests/integrations/test_integration_generic.py new file mode 100644 index 0000000000..f0272afa8d --- /dev/null +++ b/tests/integrations/test_integration_generic.py @@ -0,0 +1,333 @@ +"""Tests for GenericIntegration.""" + +import os + +import pytest + +from specify_cli.integrations import get_integration +from specify_cli.integrations.base import MarkdownIntegration +from specify_cli.integrations.manifest import IntegrationManifest + + +class TestGenericIntegration: + """Tests for GenericIntegration — requires --commands-dir option.""" + + # -- Registration ----------------------------------------------------- + + def test_registered(self): + from specify_cli.integrations import INTEGRATION_REGISTRY + assert "generic" in INTEGRATION_REGISTRY + + def test_is_markdown_integration(self): + assert isinstance(get_integration("generic"), MarkdownIntegration) + + # -- Config ----------------------------------------------------------- + + def test_config_folder_is_none(self): + i = get_integration("generic") + assert i.config["folder"] is None + + def test_config_requires_cli_false(self): + i = get_integration("generic") + assert i.config["requires_cli"] is False + + def test_context_file_is_agents_md(self): + i = get_integration("generic") + assert i.context_file == "AGENTS.md" + + # -- Options ---------------------------------------------------------- + + def test_options_include_commands_dir(self): + i = get_integration("generic") + opts = i.options() + assert len(opts) == 1 + assert opts[0].name == "--commands-dir" + assert opts[0].required is True + assert opts[0].is_flag is False + + # -- Setup / teardown ------------------------------------------------- + + def test_setup_requires_commands_dir(self, tmp_path): + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + with pytest.raises(ValueError, match="--commands-dir is required"): + i.setup(tmp_path, m, parsed_options={}) + + def test_setup_requires_nonempty_commands_dir(self, tmp_path): + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + with pytest.raises(ValueError, match="--commands-dir is required"): + i.setup(tmp_path, m, parsed_options={"commands_dir": ""}) + + def test_setup_writes_to_correct_directory(self, tmp_path): + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + created = i.setup( + tmp_path, m, + parsed_options={"commands_dir": ".myagent/commands"}, + ) + expected_dir = tmp_path / ".myagent" / "commands" + assert expected_dir.exists(), f"Expected directory {expected_dir} was not created" + cmd_files = [f for f in created if "scripts" not in f.parts] + assert len(cmd_files) > 0, "No command files were created" + for f in cmd_files: + assert f.resolve().parent == expected_dir.resolve(), ( + f"{f} is not under {expected_dir}" + ) + + def test_setup_creates_md_files(self, tmp_path): + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + created = i.setup( + tmp_path, m, + parsed_options={"commands_dir": ".custom/cmds"}, + ) + cmd_files = [f for f in created if "scripts" not in f.parts] + assert len(cmd_files) > 0 + for f in cmd_files: + assert f.name.startswith("speckit.") + assert f.name.endswith(".md") + + def test_templates_are_processed(self, tmp_path): + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + created = i.setup( + tmp_path, m, + parsed_options={"commands_dir": ".custom/cmds"}, + ) + cmd_files = [f for f in created if "scripts" not in f.parts] + for f in cmd_files: + content = f.read_text(encoding="utf-8") + assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}" + assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__" + assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}" + assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__" + + def test_all_files_tracked_in_manifest(self, tmp_path): + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + created = i.setup( + tmp_path, m, + parsed_options={"commands_dir": ".custom/cmds"}, + ) + for f in created: + rel = f.resolve().relative_to(tmp_path.resolve()).as_posix() + assert rel in m.files, f"{rel} not tracked in manifest" + + def test_install_uninstall_roundtrip(self, tmp_path): + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + created = i.install( + tmp_path, m, + parsed_options={"commands_dir": ".custom/cmds"}, + ) + assert len(created) > 0 + m.save() + for f in created: + assert f.exists() + removed, skipped = i.uninstall(tmp_path, m) + assert len(removed) == len(created) + assert skipped == [] + + def test_modified_file_survives_uninstall(self, tmp_path): + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + created = i.install( + tmp_path, m, + parsed_options={"commands_dir": ".custom/cmds"}, + ) + m.save() + modified = created[0] + modified.write_text("user modified this", encoding="utf-8") + removed, skipped = i.uninstall(tmp_path, m) + assert modified.exists() + assert modified in skipped + + def test_different_commands_dirs(self, tmp_path): + """Generic should work with various user-specified paths.""" + for path in [".agent/commands", "tools/ai-cmds", ".custom/prompts"]: + project = tmp_path / path.replace("/", "-") + project.mkdir() + i = get_integration("generic") + m = IntegrationManifest("generic", project) + created = i.setup( + project, m, + parsed_options={"commands_dir": path}, + ) + expected = project / path + assert expected.is_dir(), f"Dir {expected} not created for {path}" + cmd_files = [f for f in created if "scripts" not in f.parts] + assert len(cmd_files) > 0 + + # -- Context section --------------------------------------------------- + + def test_setup_upserts_context_section(self, tmp_path): + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"}) + if i.context_file: + ctx_path = tmp_path / i.context_file + assert ctx_path.exists() + content = ctx_path.read_text(encoding="utf-8") + assert "" in content + assert "" in content + + def test_plan_references_correct_context_file(self, tmp_path): + """The generated plan command must reference generic's context file.""" + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"}) + plan_file = tmp_path / ".custom" / "cmds" / "speckit.plan.md" + assert plan_file.exists() + content = plan_file.read_text(encoding="utf-8") + assert i.context_file in content, ( + f"Plan command should reference {i.context_file!r}" + ) + assert "__CONTEXT_FILE__" not in content + + # -- CLI -------------------------------------------------------------- + + def test_cli_generic_without_commands_dir_fails(self, tmp_path): + """--integration generic without --ai-commands-dir should fail.""" + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + result = runner.invoke(app, [ + "init", str(tmp_path / "test-generic"), "--integration", "generic", + "--script", "sh", "--no-git", + ]) + # Generic requires --commands-dir / --ai-commands-dir + # The integration path validates via setup() + assert result.exit_code != 0 + + def test_init_options_includes_context_file(self, tmp_path): + """init-options.json must include context_file for the generic integration.""" + import json + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "opts-generic" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", "generic", + "--ai-commands-dir", ".myagent/commands", + "--script", "sh", "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + opts = json.loads((project / ".specify" / "init-options.json").read_text()) + assert opts.get("context_file") == "AGENTS.md" + + def test_complete_file_inventory_sh(self, tmp_path): + """Every file produced by specify init --integration generic --ai-commands-dir ... --script sh.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "inventory-generic-sh" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", "generic", + "--ai-commands-dir", ".myagent/commands", + "--script", "sh", "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + actual = sorted( + p.relative_to(project).as_posix() + for p in project.rglob("*") if p.is_file() + ) + expected = sorted([ + "AGENTS.md", + ".myagent/commands/speckit.analyze.md", + ".myagent/commands/speckit.checklist.md", + ".myagent/commands/speckit.clarify.md", + ".myagent/commands/speckit.constitution.md", + ".myagent/commands/speckit.implement.md", + ".myagent/commands/speckit.plan.md", + ".myagent/commands/speckit.specify.md", + ".myagent/commands/speckit.tasks.md", + ".myagent/commands/speckit.taskstoissues.md", + ".specify/init-options.json", + ".specify/integration.json", + ".specify/integrations/generic.manifest.json", + ".specify/integrations/speckit.manifest.json", + ".specify/memory/constitution.md", + ".specify/scripts/bash/check-prerequisites.sh", + ".specify/scripts/bash/common.sh", + ".specify/scripts/bash/create-new-feature.sh", + ".specify/scripts/bash/setup-plan.sh", + ".specify/templates/checklist-template.md", + ".specify/templates/constitution-template.md", + ".specify/templates/plan-template.md", + ".specify/templates/spec-template.md", + ".specify/templates/tasks-template.md", + ".specify/workflows/speckit/workflow.yml", + ".specify/workflows/workflow-registry.json", + ]) + assert actual == expected, ( + f"Missing: {sorted(set(expected) - set(actual))}\n" + f"Extra: {sorted(set(actual) - set(expected))}" + ) + + def test_complete_file_inventory_ps(self, tmp_path): + """Every file produced by specify init --integration generic --ai-commands-dir ... --script ps.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "inventory-generic-ps" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", "generic", + "--ai-commands-dir", ".myagent/commands", + "--script", "ps", "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + actual = sorted( + p.relative_to(project).as_posix() + for p in project.rglob("*") if p.is_file() + ) + expected = sorted([ + "AGENTS.md", + ".myagent/commands/speckit.analyze.md", + ".myagent/commands/speckit.checklist.md", + ".myagent/commands/speckit.clarify.md", + ".myagent/commands/speckit.constitution.md", + ".myagent/commands/speckit.implement.md", + ".myagent/commands/speckit.plan.md", + ".myagent/commands/speckit.specify.md", + ".myagent/commands/speckit.tasks.md", + ".myagent/commands/speckit.taskstoissues.md", + ".specify/init-options.json", + ".specify/integration.json", + ".specify/integrations/generic.manifest.json", + ".specify/integrations/speckit.manifest.json", + ".specify/memory/constitution.md", + ".specify/scripts/powershell/check-prerequisites.ps1", + ".specify/scripts/powershell/common.ps1", + ".specify/scripts/powershell/create-new-feature.ps1", + ".specify/scripts/powershell/setup-plan.ps1", + ".specify/templates/checklist-template.md", + ".specify/templates/constitution-template.md", + ".specify/templates/plan-template.md", + ".specify/templates/spec-template.md", + ".specify/templates/tasks-template.md", + ".specify/workflows/speckit/workflow.yml", + ".specify/workflows/workflow-registry.json", + ]) + assert actual == expected, ( + f"Missing: {sorted(set(expected) - set(actual))}\n" + f"Extra: {sorted(set(actual) - set(expected))}" + ) diff --git a/tests/integrations/test_integration_goose.py b/tests/integrations/test_integration_goose.py new file mode 100644 index 0000000000..6483666f36 --- /dev/null +++ b/tests/integrations/test_integration_goose.py @@ -0,0 +1,11 @@ +"""Tests for GooseIntegration.""" + +from .test_integration_base_yaml import YamlIntegrationTests + + +class TestGooseIntegration(YamlIntegrationTests): + KEY = "goose" + FOLDER = ".goose/" + COMMANDS_SUBDIR = "recipes" + REGISTRAR_DIR = ".goose/recipes" + CONTEXT_FILE = "AGENTS.md" diff --git a/tests/integrations/test_integration_iflow.py b/tests/integrations/test_integration_iflow.py new file mode 100644 index 0000000000..ea2f5ef97a --- /dev/null +++ b/tests/integrations/test_integration_iflow.py @@ -0,0 +1,11 @@ +"""Tests for IflowIntegration.""" + +from .test_integration_base_markdown import MarkdownIntegrationTests + + +class TestIflowIntegration(MarkdownIntegrationTests): + KEY = "iflow" + FOLDER = ".iflow/" + COMMANDS_SUBDIR = "commands" + REGISTRAR_DIR = ".iflow/commands" + CONTEXT_FILE = "IFLOW.md" diff --git a/tests/integrations/test_integration_junie.py b/tests/integrations/test_integration_junie.py new file mode 100644 index 0000000000..2b924ce434 --- /dev/null +++ b/tests/integrations/test_integration_junie.py @@ -0,0 +1,11 @@ +"""Tests for JunieIntegration.""" + +from .test_integration_base_markdown import MarkdownIntegrationTests + + +class TestJunieIntegration(MarkdownIntegrationTests): + KEY = "junie" + FOLDER = ".junie/" + COMMANDS_SUBDIR = "commands" + REGISTRAR_DIR = ".junie/commands" + CONTEXT_FILE = ".junie/AGENTS.md" diff --git a/tests/integrations/test_integration_kilocode.py b/tests/integrations/test_integration_kilocode.py new file mode 100644 index 0000000000..8e441c0833 --- /dev/null +++ b/tests/integrations/test_integration_kilocode.py @@ -0,0 +1,11 @@ +"""Tests for KilocodeIntegration.""" + +from .test_integration_base_markdown import MarkdownIntegrationTests + + +class TestKilocodeIntegration(MarkdownIntegrationTests): + KEY = "kilocode" + FOLDER = ".kilocode/" + COMMANDS_SUBDIR = "workflows" + REGISTRAR_DIR = ".kilocode/workflows" + CONTEXT_FILE = ".kilocode/rules/specify-rules.md" diff --git a/tests/integrations/test_integration_kimi.py b/tests/integrations/test_integration_kimi.py new file mode 100644 index 0000000000..25787e612e --- /dev/null +++ b/tests/integrations/test_integration_kimi.py @@ -0,0 +1,149 @@ +"""Tests for KimiIntegration — skills integration with legacy migration.""" + +from specify_cli.integrations import get_integration +from specify_cli.integrations.kimi import _migrate_legacy_kimi_dotted_skills +from specify_cli.integrations.manifest import IntegrationManifest + +from .test_integration_base_skills import SkillsIntegrationTests + + +class TestKimiIntegration(SkillsIntegrationTests): + KEY = "kimi" + FOLDER = ".kimi/" + COMMANDS_SUBDIR = "skills" + REGISTRAR_DIR = ".kimi/skills" + CONTEXT_FILE = "KIMI.md" + + +class TestKimiOptions: + """Kimi declares --skills and --migrate-legacy options.""" + + def test_migrate_legacy_option(self): + i = get_integration("kimi") + opts = i.options() + migrate_opts = [o for o in opts if o.name == "--migrate-legacy"] + assert len(migrate_opts) == 1 + assert migrate_opts[0].is_flag is True + assert migrate_opts[0].default is False + + +class TestKimiLegacyMigration: + """Test Kimi dotted → hyphenated skill directory migration.""" + + def test_migrate_dotted_to_hyphenated(self, tmp_path): + skills_dir = tmp_path / ".kimi" / "skills" + legacy = skills_dir / "speckit.plan" + legacy.mkdir(parents=True) + (legacy / "SKILL.md").write_text("# Plan Skill\n") + + migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir) + + assert migrated == 1 + assert removed == 0 + assert not legacy.exists() + assert (skills_dir / "speckit-plan" / "SKILL.md").exists() + + def test_skip_when_target_exists_different_content(self, tmp_path): + skills_dir = tmp_path / ".kimi" / "skills" + legacy = skills_dir / "speckit.plan" + legacy.mkdir(parents=True) + (legacy / "SKILL.md").write_text("# Old\n") + + target = skills_dir / "speckit-plan" + target.mkdir(parents=True) + (target / "SKILL.md").write_text("# New (different)\n") + + migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir) + + assert migrated == 0 + assert removed == 0 + assert legacy.exists() + assert target.exists() + + def test_remove_when_target_exists_same_content(self, tmp_path): + skills_dir = tmp_path / ".kimi" / "skills" + content = "# Identical\n" + legacy = skills_dir / "speckit.plan" + legacy.mkdir(parents=True) + (legacy / "SKILL.md").write_text(content) + + target = skills_dir / "speckit-plan" + target.mkdir(parents=True) + (target / "SKILL.md").write_text(content) + + migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir) + + assert migrated == 0 + assert removed == 1 + assert not legacy.exists() + assert target.exists() + + def test_preserve_legacy_with_extra_files(self, tmp_path): + skills_dir = tmp_path / ".kimi" / "skills" + content = "# Same\n" + legacy = skills_dir / "speckit.plan" + legacy.mkdir(parents=True) + (legacy / "SKILL.md").write_text(content) + (legacy / "extra.md").write_text("user file") + + target = skills_dir / "speckit-plan" + target.mkdir(parents=True) + (target / "SKILL.md").write_text(content) + + migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir) + + assert migrated == 0 + assert removed == 0 + assert legacy.exists() + + def test_nonexistent_dir_returns_zeros(self, tmp_path): + migrated, removed = _migrate_legacy_kimi_dotted_skills( + tmp_path / ".kimi" / "skills" + ) + assert migrated == 0 + assert removed == 0 + + def test_setup_with_migrate_legacy_option(self, tmp_path): + """KimiIntegration.setup() with --migrate-legacy migrates dotted dirs.""" + i = get_integration("kimi") + + skills_dir = tmp_path / ".kimi" / "skills" + legacy = skills_dir / "speckit.oldcmd" + legacy.mkdir(parents=True) + (legacy / "SKILL.md").write_text("# Legacy\n") + + m = IntegrationManifest("kimi", tmp_path) + i.setup(tmp_path, m, parsed_options={"migrate_legacy": True}) + + assert not legacy.exists() + assert (skills_dir / "speckit-oldcmd" / "SKILL.md").exists() + # New skills from templates should also exist + assert (skills_dir / "speckit-specify" / "SKILL.md").exists() + + +class TestKimiNextSteps: + """CLI output tests for kimi next-steps display.""" + + def test_next_steps_show_skill_invocation(self, tmp_path): + """Kimi next-steps guidance should display /skill:speckit-* usage.""" + import os + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "kimi-next-steps" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--ai", "kimi", "--no-git", + "--ignore-agent-tools", "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0 + assert "/skill:speckit-constitution" in result.output + assert "/speckit.constitution" not in result.output + assert "Optional skills that you can use for your specs" in result.output diff --git a/tests/integrations/test_integration_kiro_cli.py b/tests/integrations/test_integration_kiro_cli.py new file mode 100644 index 0000000000..e3b260bf05 --- /dev/null +++ b/tests/integrations/test_integration_kiro_cli.py @@ -0,0 +1,39 @@ +"""Tests for KiroCliIntegration.""" + +import os + +from .test_integration_base_markdown import MarkdownIntegrationTests + + +class TestKiroCliIntegration(MarkdownIntegrationTests): + KEY = "kiro-cli" + FOLDER = ".kiro/" + COMMANDS_SUBDIR = "prompts" + REGISTRAR_DIR = ".kiro/prompts" + CONTEXT_FILE = "AGENTS.md" + + +class TestKiroAlias: + """--ai kiro alias normalizes to kiro-cli and auto-promotes.""" + + def test_kiro_alias_normalized_to_kiro_cli(self, tmp_path): + """--ai kiro should normalize to canonical kiro-cli and auto-promote.""" + from typer.testing import CliRunner + from specify_cli import app + + target = tmp_path / "kiro-alias-proj" + target.mkdir() + + old_cwd = os.getcwd() + try: + os.chdir(target) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--ai", "kiro", + "--ignore-agent-tools", "--script", "sh", "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0 + assert (target / ".kiro" / "prompts" / "speckit.plan.md").exists() diff --git a/tests/integrations/test_integration_opencode.py b/tests/integrations/test_integration_opencode.py new file mode 100644 index 0000000000..4f3aee5d9b --- /dev/null +++ b/tests/integrations/test_integration_opencode.py @@ -0,0 +1,11 @@ +"""Tests for OpencodeIntegration.""" + +from .test_integration_base_markdown import MarkdownIntegrationTests + + +class TestOpencodeIntegration(MarkdownIntegrationTests): + KEY = "opencode" + FOLDER = ".opencode/" + COMMANDS_SUBDIR = "command" + REGISTRAR_DIR = ".opencode/command" + CONTEXT_FILE = "AGENTS.md" diff --git a/tests/integrations/test_integration_pi.py b/tests/integrations/test_integration_pi.py new file mode 100644 index 0000000000..5ac5676501 --- /dev/null +++ b/tests/integrations/test_integration_pi.py @@ -0,0 +1,11 @@ +"""Tests for PiIntegration.""" + +from .test_integration_base_markdown import MarkdownIntegrationTests + + +class TestPiIntegration(MarkdownIntegrationTests): + KEY = "pi" + FOLDER = ".pi/" + COMMANDS_SUBDIR = "prompts" + REGISTRAR_DIR = ".pi/prompts" + CONTEXT_FILE = "AGENTS.md" diff --git a/tests/integrations/test_integration_qodercli.py b/tests/integrations/test_integration_qodercli.py new file mode 100644 index 0000000000..1dbee480a0 --- /dev/null +++ b/tests/integrations/test_integration_qodercli.py @@ -0,0 +1,11 @@ +"""Tests for QodercliIntegration.""" + +from .test_integration_base_markdown import MarkdownIntegrationTests + + +class TestQodercliIntegration(MarkdownIntegrationTests): + KEY = "qodercli" + FOLDER = ".qoder/" + COMMANDS_SUBDIR = "commands" + REGISTRAR_DIR = ".qoder/commands" + CONTEXT_FILE = "QODER.md" diff --git a/tests/integrations/test_integration_qwen.py b/tests/integrations/test_integration_qwen.py new file mode 100644 index 0000000000..10a3c083f4 --- /dev/null +++ b/tests/integrations/test_integration_qwen.py @@ -0,0 +1,11 @@ +"""Tests for QwenIntegration.""" + +from .test_integration_base_markdown import MarkdownIntegrationTests + + +class TestQwenIntegration(MarkdownIntegrationTests): + KEY = "qwen" + FOLDER = ".qwen/" + COMMANDS_SUBDIR = "commands" + REGISTRAR_DIR = ".qwen/commands" + CONTEXT_FILE = "QWEN.md" diff --git a/tests/integrations/test_integration_roo.py b/tests/integrations/test_integration_roo.py new file mode 100644 index 0000000000..69d859c42f --- /dev/null +++ b/tests/integrations/test_integration_roo.py @@ -0,0 +1,11 @@ +"""Tests for RooIntegration.""" + +from .test_integration_base_markdown import MarkdownIntegrationTests + + +class TestRooIntegration(MarkdownIntegrationTests): + KEY = "roo" + FOLDER = ".roo/" + COMMANDS_SUBDIR = "commands" + REGISTRAR_DIR = ".roo/commands" + CONTEXT_FILE = ".roo/rules/specify-rules.md" diff --git a/tests/integrations/test_integration_shai.py b/tests/integrations/test_integration_shai.py new file mode 100644 index 0000000000..74f93396b1 --- /dev/null +++ b/tests/integrations/test_integration_shai.py @@ -0,0 +1,11 @@ +"""Tests for ShaiIntegration.""" + +from .test_integration_base_markdown import MarkdownIntegrationTests + + +class TestShaiIntegration(MarkdownIntegrationTests): + KEY = "shai" + FOLDER = ".shai/" + COMMANDS_SUBDIR = "commands" + REGISTRAR_DIR = ".shai/commands" + CONTEXT_FILE = "SHAI.md" diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py new file mode 100644 index 0000000000..f5322bdf5e --- /dev/null +++ b/tests/integrations/test_integration_subcommand.py @@ -0,0 +1,540 @@ +"""Tests for ``specify integration`` subcommand (list, install, uninstall, switch).""" + +import json +import os + +from typer.testing import CliRunner + +from specify_cli import app + + +runner = CliRunner() + + +def _init_project(tmp_path, integration="copilot"): + """Helper: init a spec-kit project with the given integration.""" + project = tmp_path / "proj" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "init", "--here", + "--integration", integration, + "--script", "sh", + "--no-git", + "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + return project + + +# ── list ───────────────────────────────────────────────────────────── + + +class TestIntegrationList: + def test_list_requires_speckit_project(self, tmp_path): + old_cwd = os.getcwd() + try: + os.chdir(tmp_path) + result = runner.invoke(app, ["integration", "list"]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "Not a spec-kit project" in result.output + + def test_list_shows_installed(self, tmp_path): + project = _init_project(tmp_path, "copilot") + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "list"]) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + assert "copilot" in result.output + assert "installed" in result.output + + def test_list_shows_available_integrations(self, tmp_path): + project = _init_project(tmp_path, "copilot") + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "list"]) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + # Should show multiple integrations + assert "claude" in result.output + assert "gemini" in result.output + + +# ── install ────────────────────────────────────────────────────────── + + +class TestIntegrationInstall: + def test_install_requires_speckit_project(self, tmp_path): + old_cwd = os.getcwd() + try: + os.chdir(tmp_path) + result = runner.invoke(app, ["integration", "install", "claude"]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "Not a spec-kit project" in result.output + + def test_install_unknown_integration(self, tmp_path): + project = _init_project(tmp_path) + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "install", "nonexistent"]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "Unknown integration" in result.output + + def test_install_already_installed(self, tmp_path): + project = _init_project(tmp_path, "copilot") + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "install", "copilot"]) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + assert "already installed" in result.output + assert "uninstall" in result.output + + def test_install_different_when_one_exists(self, tmp_path): + project = _init_project(tmp_path, "copilot") + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "install", "claude"]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "already installed" in result.output + assert "uninstall" in result.output + + def test_install_into_bare_project(self, tmp_path): + """Install into a project with .specify/ but no integration.""" + project = tmp_path / "bare" + project.mkdir() + (project / ".specify").mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "install", "claude", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, result.output + assert "installed successfully" in result.output + + # integration.json written + data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert data["integration"] == "claude" + + # Manifest created + assert (project / ".specify" / "integrations" / "claude.manifest.json").exists() + + # Claude uses skills directory (not commands) + assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() + + def test_install_bare_project_gets_shared_infra(self, tmp_path): + """Installing into a bare project should create shared scripts and templates.""" + project = tmp_path / "bare" + project.mkdir() + (project / ".specify").mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "install", "claude", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, result.output + + # Shared infrastructure should be present + assert (project / ".specify" / "scripts").is_dir() + assert (project / ".specify" / "templates").is_dir() + + +# ── uninstall ──────────────────────────────────────────────────────── + + +class TestIntegrationUninstall: + def test_uninstall_requires_speckit_project(self, tmp_path): + old_cwd = os.getcwd() + try: + os.chdir(tmp_path) + result = runner.invoke(app, ["integration", "uninstall"]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "Not a spec-kit project" in result.output + + def test_uninstall_no_integration(self, tmp_path): + project = tmp_path / "proj" + project.mkdir() + (project / ".specify").mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "uninstall"]) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + assert "No integration" in result.output + + def test_uninstall_removes_files(self, tmp_path): + project = _init_project(tmp_path, "claude") + # Claude uses skills directory + assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() + assert (project / ".specify" / "integrations" / "claude.manifest.json").exists() + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + assert "uninstalled" in result.output + + # Command files removed + assert not (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() + + # Manifest removed + assert not (project / ".specify" / "integrations" / "claude.manifest.json").exists() + + # integration.json removed + assert not (project / ".specify" / "integration.json").exists() + + def test_uninstall_preserves_modified_files(self, tmp_path): + """Full lifecycle: install → modify → uninstall → modified file kept.""" + project = _init_project(tmp_path, "claude") + plan_file = project / ".claude" / "skills" / "speckit-plan" / "SKILL.md" + assert plan_file.exists() + + # Modify a file + plan_file.write_text("# My custom plan command\n", encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + assert "preserved" in result.output + + # Modified file kept + assert plan_file.exists() + assert plan_file.read_text(encoding="utf-8") == "# My custom plan command\n" + + def test_uninstall_wrong_key(self, tmp_path): + project = _init_project(tmp_path, "copilot") + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "uninstall", "claude"]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "not the currently installed" in result.output + + def test_uninstall_preserves_shared_infra(self, tmp_path): + """Shared scripts and templates are not removed by integration uninstall.""" + project = _init_project(tmp_path, "claude") + shared_script = project / ".specify" / "scripts" / "bash" / "common.sh" + assert shared_script.exists() + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + + # Shared infrastructure preserved + assert shared_script.exists() + assert (project / ".specify" / "templates").is_dir() + + +# ── switch ─────────────────────────────────────────────────────────── + + +class TestIntegrationSwitch: + def test_switch_requires_speckit_project(self, tmp_path): + old_cwd = os.getcwd() + try: + os.chdir(tmp_path) + result = runner.invoke(app, ["integration", "switch", "claude"]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "Not a spec-kit project" in result.output + + def test_switch_unknown_target(self, tmp_path): + project = _init_project(tmp_path) + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "switch", "nonexistent"]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "Unknown integration" in result.output + + def test_switch_same_noop(self, tmp_path): + project = _init_project(tmp_path, "copilot") + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "switch", "copilot"]) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + assert "already installed" in result.output + + def test_switch_between_integrations(self, tmp_path): + project = _init_project(tmp_path, "claude") + # Verify claude files exist (claude uses skills) + assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "switch", "copilot", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, result.output + assert "Switched to" in result.output + + # Old claude files removed + assert not (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() + + # New copilot files created + assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists() + + # integration.json updated + data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert data["integration"] == "copilot" + + def test_switch_preserves_shared_infra(self, tmp_path): + """Switching preserves shared scripts, templates, and memory.""" + project = _init_project(tmp_path, "claude") + shared_script = project / ".specify" / "scripts" / "bash" / "common.sh" + assert shared_script.exists() + shared_content = shared_script.read_text(encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "switch", "copilot", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + + # Shared infra untouched + assert shared_script.exists() + assert shared_script.read_text(encoding="utf-8") == shared_content + + def test_switch_from_nothing(self, tmp_path): + """Switch when no integration is installed should just install the target.""" + project = tmp_path / "bare" + project.mkdir() + (project / ".specify").mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "switch", "claude", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + assert "Switched to" in result.output + + data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert data["integration"] == "claude" + + +# ── Full lifecycle ─────────────────────────────────────────────────── + + +class TestIntegrationLifecycle: + def test_install_modify_uninstall_preserves_modified(self, tmp_path): + """Full lifecycle: install → modify file → uninstall → verify modified file kept.""" + project = tmp_path / "lifecycle" + project.mkdir() + (project / ".specify").mkdir() + + old_cwd = os.getcwd() + try: + os.chdir(project) + + # Install + result = runner.invoke(app, [ + "integration", "install", "claude", + "--script", "sh", + ], catch_exceptions=False) + assert result.exit_code == 0 + assert "installed successfully" in result.output + + # Claude uses skills directory + plan_file = project / ".claude" / "skills" / "speckit-plan" / "SKILL.md" + assert plan_file.exists() + + # Modify one file + plan_file.write_text("# user customization\n", encoding="utf-8") + + # Uninstall + result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False) + assert result.exit_code == 0 + assert "preserved" in result.output + + # Modified file kept + assert plan_file.exists() + assert plan_file.read_text(encoding="utf-8") == "# user customization\n" + finally: + os.chdir(old_cwd) + + +# ── Edge-case fixes ───────────────────────────────────────────────── + + +class TestScriptTypeValidation: + def test_invalid_script_type_rejected(self, tmp_path): + """--script with an invalid value should fail with a clear error.""" + project = tmp_path / "proj" + project.mkdir() + (project / ".specify").mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "install", "claude", + "--script", "bash", + ]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "Invalid script type" in result.output + + def test_valid_script_types_accepted(self, tmp_path): + """Both 'sh' and 'ps' should be accepted.""" + project = tmp_path / "proj" + project.mkdir() + (project / ".specify").mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "install", "claude", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + + +class TestParseIntegrationOptionsEqualsForm: + def test_equals_form_parsed(self): + """--commands-dir=./x should be parsed the same as --commands-dir ./x.""" + from specify_cli import _parse_integration_options + from specify_cli.integrations import get_integration + + integration = get_integration("generic") + assert integration is not None + + result_space = _parse_integration_options(integration, "--commands-dir ./mydir") + result_equals = _parse_integration_options(integration, "--commands-dir=./mydir") + assert result_space is not None + assert result_equals is not None + assert result_space["commands_dir"] == "./mydir" + assert result_equals["commands_dir"] == "./mydir" + + +class TestUninstallNoManifestClearsInitOptions: + def test_init_options_cleared_on_no_manifest_uninstall(self, tmp_path): + """When no manifest exists, uninstall should still clear init-options.json.""" + project = tmp_path / "proj" + project.mkdir() + (project / ".specify").mkdir() + + # Write integration.json and init-options.json without a manifest + int_json = project / ".specify" / "integration.json" + int_json.write_text(json.dumps({"integration": "claude"}), encoding="utf-8") + + opts_json = project / ".specify" / "init-options.json" + opts_json.write_text(json.dumps({ + "integration": "claude", + "ai": "claude", + "ai_skills": True, + "script": "sh", + }), encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "uninstall", "claude"]) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + + # init-options.json should have integration keys cleared + opts = json.loads(opts_json.read_text(encoding="utf-8")) + assert "integration" not in opts + assert "ai" not in opts + assert "ai_skills" not in opts + # Non-integration keys preserved + assert opts.get("script") == "sh" + + +class TestSwitchClearsMetadataAfterTeardown: + def test_metadata_cleared_between_phases(self, tmp_path): + """After a successful switch, metadata should reference the new integration.""" + project = _init_project(tmp_path, "claude") + + # Verify initial state + int_json = project / ".specify" / "integration.json" + assert json.loads(int_json.read_text(encoding="utf-8"))["integration"] == "claude" + + old_cwd = os.getcwd() + try: + os.chdir(project) + # Switch to copilot — should succeed and update metadata + result = runner.invoke(app, [ + "integration", "switch", "copilot", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + + # integration.json should reference copilot, not claude + data = json.loads(int_json.read_text(encoding="utf-8")) + assert data["integration"] == "copilot" + + # init-options.json should reference copilot + opts_json = project / ".specify" / "init-options.json" + opts = json.loads(opts_json.read_text(encoding="utf-8")) + assert opts.get("ai") == "copilot" diff --git a/tests/integrations/test_integration_tabnine.py b/tests/integrations/test_integration_tabnine.py new file mode 100644 index 0000000000..95eb47cc16 --- /dev/null +++ b/tests/integrations/test_integration_tabnine.py @@ -0,0 +1,11 @@ +"""Tests for TabnineIntegration.""" + +from .test_integration_base_toml import TomlIntegrationTests + + +class TestTabnineIntegration(TomlIntegrationTests): + KEY = "tabnine" + FOLDER = ".tabnine/agent/" + COMMANDS_SUBDIR = "commands" + REGISTRAR_DIR = ".tabnine/agent/commands" + CONTEXT_FILE = "TABNINE.md" diff --git a/tests/integrations/test_integration_trae.py b/tests/integrations/test_integration_trae.py new file mode 100644 index 0000000000..74b8b41c3f --- /dev/null +++ b/tests/integrations/test_integration_trae.py @@ -0,0 +1,11 @@ +"""Tests for TraeIntegration.""" + +from .test_integration_base_skills import SkillsIntegrationTests + + +class TestTraeIntegration(SkillsIntegrationTests): + KEY = "trae" + FOLDER = ".trae/" + COMMANDS_SUBDIR = "skills" + REGISTRAR_DIR = ".trae/skills" + CONTEXT_FILE = ".trae/rules/project_rules.md" diff --git a/tests/integrations/test_integration_vibe.py b/tests/integrations/test_integration_vibe.py new file mode 100644 index 0000000000..bab4539f1e --- /dev/null +++ b/tests/integrations/test_integration_vibe.py @@ -0,0 +1,38 @@ +"""Tests for VibeIntegration.""" + +import yaml + +from specify_cli.integrations import get_integration +from specify_cli.integrations.manifest import IntegrationManifest + +from .test_integration_base_skills import SkillsIntegrationTests + + +class TestVibeIntegration(SkillsIntegrationTests): + KEY = "vibe" + FOLDER = ".vibe/" + COMMANDS_SUBDIR = "skills" + REGISTRAR_DIR = ".vibe/skills" + CONTEXT_FILE = "AGENTS.md" + + +class TestVibeUserInvocable: + def test_all_skills_have_user_invocable(self, tmp_path): + i = get_integration("vibe") + m = IntegrationManifest("vibe", tmp_path) + created = i.setup(tmp_path, m, script_type="sh") + skill_files = [f for f in created if f.name == "SKILL.md"] + assert skill_files + for f in skill_files: + content = f.read_text(encoding="utf-8") + assert content.startswith("---"), ( + f"{f.parent.name}/SKILL.md is missing the opening frontmatter delimiter '---'" + ) + parts = content.split("---", 2) + assert len(parts) >= 3, ( + f"{f.parent.name}/SKILL.md has malformed frontmatter; expected a '--- ... ---' block" + ) + parsed = yaml.safe_load(parts[1]) + assert parsed.get("user-invocable") is True, ( + f"{f.parent.name}/SKILL.md is missing user-invocable: true in frontmatter" + ) \ No newline at end of file diff --git a/tests/integrations/test_integration_windsurf.py b/tests/integrations/test_integration_windsurf.py new file mode 100644 index 0000000000..fa8d1e622a --- /dev/null +++ b/tests/integrations/test_integration_windsurf.py @@ -0,0 +1,11 @@ +"""Tests for WindsurfIntegration.""" + +from .test_integration_base_markdown import MarkdownIntegrationTests + + +class TestWindsurfIntegration(MarkdownIntegrationTests): + KEY = "windsurf" + FOLDER = ".windsurf/" + COMMANDS_SUBDIR = "workflows" + REGISTRAR_DIR = ".windsurf/workflows" + CONTEXT_FILE = ".windsurf/rules/specify-rules.md" diff --git a/tests/integrations/test_manifest.py b/tests/integrations/test_manifest.py new file mode 100644 index 0000000000..596397d4f7 --- /dev/null +++ b/tests/integrations/test_manifest.py @@ -0,0 +1,247 @@ +"""Tests for IntegrationManifest — record, hash, save, load, uninstall, modified detection.""" + +import hashlib +import json +import sys + +import pytest + +from specify_cli.integrations.manifest import IntegrationManifest, _sha256 + + +class TestManifestRecordFile: + def test_record_file_writes_and_hashes(self, tmp_path): + m = IntegrationManifest("test", tmp_path) + content = "hello world" + abs_path = m.record_file("a/b.txt", content) + assert abs_path == tmp_path / "a" / "b.txt" + assert abs_path.read_text(encoding="utf-8") == content + expected_hash = hashlib.sha256(content.encode()).hexdigest() + assert m.files["a/b.txt"] == expected_hash + + def test_record_file_bytes(self, tmp_path): + m = IntegrationManifest("test", tmp_path) + data = b"\x00\x01\x02" + abs_path = m.record_file("bin.dat", data) + assert abs_path.read_bytes() == data + assert m.files["bin.dat"] == hashlib.sha256(data).hexdigest() + + def test_record_existing(self, tmp_path): + f = tmp_path / "existing.txt" + f.write_text("content", encoding="utf-8") + m = IntegrationManifest("test", tmp_path) + m.record_existing("existing.txt") + assert m.files["existing.txt"] == _sha256(f) + + +class TestManifestPathTraversal: + def test_record_file_rejects_parent_traversal(self, tmp_path): + m = IntegrationManifest("test", tmp_path) + with pytest.raises(ValueError, match="outside"): + m.record_file("../escape.txt", "bad") + + def test_record_file_rejects_absolute_path(self, tmp_path): + m = IntegrationManifest("test", tmp_path) + abs_path = "C:\\tmp\\escape.txt" if sys.platform == "win32" else "/tmp/escape.txt" + with pytest.raises(ValueError, match="Absolute paths"): + m.record_file(abs_path, "bad") + + def test_record_existing_rejects_parent_traversal(self, tmp_path): + escape = tmp_path.parent / "escape.txt" + escape.write_text("evil", encoding="utf-8") + try: + m = IntegrationManifest("test", tmp_path) + with pytest.raises(ValueError, match="outside"): + m.record_existing("../escape.txt") + finally: + escape.unlink(missing_ok=True) + + def test_uninstall_skips_traversal_paths(self, tmp_path): + m = IntegrationManifest("test", tmp_path) + m.record_file("safe.txt", "good") + m._files["../outside.txt"] = "fakehash" + m.save() + removed, skipped = m.uninstall() + assert len(removed) == 1 + assert removed[0].name == "safe.txt" + + +class TestManifestCheckModified: + def test_unmodified_file(self, tmp_path): + m = IntegrationManifest("test", tmp_path) + m.record_file("f.txt", "original") + assert m.check_modified() == [] + + def test_modified_file(self, tmp_path): + m = IntegrationManifest("test", tmp_path) + m.record_file("f.txt", "original") + (tmp_path / "f.txt").write_text("changed", encoding="utf-8") + assert m.check_modified() == ["f.txt"] + + def test_deleted_file_not_reported(self, tmp_path): + m = IntegrationManifest("test", tmp_path) + m.record_file("f.txt", "original") + (tmp_path / "f.txt").unlink() + assert m.check_modified() == [] + + def test_symlink_treated_as_modified(self, tmp_path): + m = IntegrationManifest("test", tmp_path) + m.record_file("f.txt", "original") + target = tmp_path / "target.txt" + target.write_text("target", encoding="utf-8") + (tmp_path / "f.txt").unlink() + (tmp_path / "f.txt").symlink_to(target) + assert m.check_modified() == ["f.txt"] + + +class TestManifestUninstall: + def test_removes_unmodified(self, tmp_path): + m = IntegrationManifest("test", tmp_path) + m.record_file("d/f.txt", "content") + m.save() + removed, skipped = m.uninstall() + assert len(removed) == 1 + assert not (tmp_path / "d" / "f.txt").exists() + assert not (tmp_path / "d").exists() + assert skipped == [] + + def test_skips_modified(self, tmp_path): + m = IntegrationManifest("test", tmp_path) + m.record_file("f.txt", "original") + m.save() + (tmp_path / "f.txt").write_text("modified", encoding="utf-8") + removed, skipped = m.uninstall() + assert removed == [] + assert len(skipped) == 1 + assert (tmp_path / "f.txt").exists() + + def test_force_removes_modified(self, tmp_path): + m = IntegrationManifest("test", tmp_path) + m.record_file("f.txt", "original") + m.save() + (tmp_path / "f.txt").write_text("modified", encoding="utf-8") + removed, skipped = m.uninstall(force=True) + assert len(removed) == 1 + assert skipped == [] + + def test_already_deleted_file(self, tmp_path): + m = IntegrationManifest("test", tmp_path) + m.record_file("f.txt", "content") + m.save() + (tmp_path / "f.txt").unlink() + removed, skipped = m.uninstall() + assert removed == [] + assert skipped == [] + + def test_removes_manifest_file(self, tmp_path): + m = IntegrationManifest("test", tmp_path, version="1.0") + m.record_file("f.txt", "content") + m.save() + assert m.manifest_path.exists() + m.uninstall() + assert not m.manifest_path.exists() + + def test_cleans_empty_parent_dirs(self, tmp_path): + m = IntegrationManifest("test", tmp_path) + m.record_file("a/b/c/f.txt", "content") + m.save() + m.uninstall() + assert not (tmp_path / "a").exists() + + def test_preserves_nonempty_parent_dirs(self, tmp_path): + m = IntegrationManifest("test", tmp_path) + m.record_file("a/b/tracked.txt", "content") + (tmp_path / "a" / "b" / "other.txt").write_text("keep", encoding="utf-8") + m.save() + m.uninstall() + assert not (tmp_path / "a" / "b" / "tracked.txt").exists() + assert (tmp_path / "a" / "b" / "other.txt").exists() + + def test_symlink_skipped_without_force(self, tmp_path): + m = IntegrationManifest("test", tmp_path) + m.record_file("f.txt", "original") + m.save() + target = tmp_path / "target.txt" + target.write_text("target", encoding="utf-8") + (tmp_path / "f.txt").unlink() + (tmp_path / "f.txt").symlink_to(target) + removed, skipped = m.uninstall() + assert removed == [] + assert len(skipped) == 1 + + def test_symlink_removed_with_force(self, tmp_path): + m = IntegrationManifest("test", tmp_path) + m.record_file("f.txt", "original") + m.save() + target = tmp_path / "target.txt" + target.write_text("target", encoding="utf-8") + (tmp_path / "f.txt").unlink() + (tmp_path / "f.txt").symlink_to(target) + removed, skipped = m.uninstall(force=True) + assert len(removed) == 1 + assert target.exists() + + +class TestManifestPersistence: + def test_save_and_load_roundtrip(self, tmp_path): + m = IntegrationManifest("myagent", tmp_path, version="2.0.1") + m.record_file("dir/file.md", "# Hello") + m.save() + loaded = IntegrationManifest.load("myagent", tmp_path) + assert loaded.key == "myagent" + assert loaded.version == "2.0.1" + assert loaded.files == m.files + + def test_manifest_path(self, tmp_path): + m = IntegrationManifest("copilot", tmp_path) + assert m.manifest_path == tmp_path / ".specify" / "integrations" / "copilot.manifest.json" + + def test_load_missing_raises(self, tmp_path): + with pytest.raises(FileNotFoundError): + IntegrationManifest.load("nonexistent", tmp_path) + + def test_save_creates_directories(self, tmp_path): + m = IntegrationManifest("test", tmp_path) + m.record_file("f.txt", "content") + path = m.save() + assert path.exists() + data = json.loads(path.read_text(encoding="utf-8")) + assert data["integration"] == "test" + + def test_save_preserves_installed_at(self, tmp_path): + m = IntegrationManifest("test", tmp_path) + m.record_file("f.txt", "content") + m.save() + first_ts = m._installed_at + m.save() + assert m._installed_at == first_ts + + +class TestManifestLoadValidation: + def test_load_non_dict_raises(self, tmp_path): + path = tmp_path / ".specify" / "integrations" / "bad.manifest.json" + path.parent.mkdir(parents=True) + path.write_text('"just a string"', encoding="utf-8") + with pytest.raises(ValueError, match="JSON object"): + IntegrationManifest.load("bad", tmp_path) + + def test_load_bad_files_type_raises(self, tmp_path): + path = tmp_path / ".specify" / "integrations" / "bad.manifest.json" + path.parent.mkdir(parents=True) + path.write_text(json.dumps({"files": ["not", "a", "dict"]}), encoding="utf-8") + with pytest.raises(ValueError, match="mapping"): + IntegrationManifest.load("bad", tmp_path) + + def test_load_bad_files_values_raises(self, tmp_path): + path = tmp_path / ".specify" / "integrations" / "bad.manifest.json" + path.parent.mkdir(parents=True) + path.write_text(json.dumps({"files": {"a.txt": 123}}), encoding="utf-8") + with pytest.raises(ValueError, match="mapping"): + IntegrationManifest.load("bad", tmp_path) + + def test_load_invalid_json_raises(self, tmp_path): + path = tmp_path / ".specify" / "integrations" / "bad.manifest.json" + path.parent.mkdir(parents=True) + path.write_text("{not valid json", encoding="utf-8") + with pytest.raises(ValueError, match="invalid JSON"): + IntegrationManifest.load("bad", tmp_path) diff --git a/tests/integrations/test_registry.py b/tests/integrations/test_registry.py new file mode 100644 index 0000000000..8ab1425148 --- /dev/null +++ b/tests/integrations/test_registry.py @@ -0,0 +1,87 @@ +"""Tests for INTEGRATION_REGISTRY — mechanics, completeness, and registrar alignment.""" + +import pytest + +from specify_cli.integrations import ( + INTEGRATION_REGISTRY, + _register, + get_integration, +) +from specify_cli.integrations.base import MarkdownIntegration +from .conftest import StubIntegration + + +# Every integration key that must be registered (Stage 2 + Stage 3 + Stage 4 + Stage 5). +ALL_INTEGRATION_KEYS = [ + "copilot", + # Stage 3 — standard markdown integrations + "claude", "qwen", "opencode", "junie", "kilocode", "auggie", + "roo", "codebuddy", "qodercli", "amp", "shai", "bob", "trae", + "pi", "iflow", "kiro-cli", "windsurf", "vibe", "cursor-agent", + # Stage 4 — TOML integrations + "gemini", "tabnine", + # Stage 5 — skills, generic & option-driven integrations + "codex", "kimi", "agy", "generic", +] + + +class TestRegistry: + def test_registry_is_dict(self): + assert isinstance(INTEGRATION_REGISTRY, dict) + + def test_register_and_get(self): + stub = StubIntegration() + _register(stub) + try: + assert get_integration("stub") is stub + finally: + INTEGRATION_REGISTRY.pop("stub", None) + + def test_get_missing_returns_none(self): + assert get_integration("nonexistent-xyz") is None + + def test_register_empty_key_raises(self): + class EmptyKey(MarkdownIntegration): + key = "" + with pytest.raises(ValueError, match="empty key"): + _register(EmptyKey()) + + def test_register_duplicate_raises(self): + stub = StubIntegration() + _register(stub) + try: + with pytest.raises(KeyError, match="already registered"): + _register(StubIntegration()) + finally: + INTEGRATION_REGISTRY.pop("stub", None) + + +class TestRegistryCompleteness: + """Every expected integration must be registered.""" + + @pytest.mark.parametrize("key", ALL_INTEGRATION_KEYS) + def test_key_registered(self, key): + assert key in INTEGRATION_REGISTRY, f"{key} missing from registry" + + +class TestRegistrarKeyAlignment: + """Every integration key must have a matching AGENT_CONFIGS entry. + + ``generic`` is excluded because it has no fixed directory — its + output path comes from ``--commands-dir`` at runtime. + """ + + @pytest.mark.parametrize( + "key", + [k for k in ALL_INTEGRATION_KEYS if k != "generic"], + ) + def test_integration_key_in_registrar(self, key): + from specify_cli.agents import CommandRegistrar + assert key in CommandRegistrar.AGENT_CONFIGS, ( + f"Integration '{key}' is registered but has no AGENT_CONFIGS entry" + ) + + def test_no_stale_cursor_shorthand(self): + """The old 'cursor' shorthand must not appear in AGENT_CONFIGS.""" + from specify_cli.agents import CommandRegistrar + assert "cursor" not in CommandRegistrar.AGENT_CONFIGS diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py new file mode 100644 index 0000000000..75e80fdf33 --- /dev/null +++ b/tests/test_agent_config_consistency.py @@ -0,0 +1,201 @@ +"""Consistency checks for agent configuration across runtime surfaces.""" + +from pathlib import Path + +from specify_cli import AGENT_CONFIG, AI_ASSISTANT_ALIASES, AI_ASSISTANT_HELP +from specify_cli.extensions import CommandRegistrar + + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +class TestAgentConfigConsistency: + """Ensure kiro-cli migration stays synchronized across key surfaces.""" + + def test_runtime_config_uses_kiro_cli_and_removes_q(self): + """AGENT_CONFIG should include kiro-cli and exclude legacy q.""" + assert "kiro-cli" in AGENT_CONFIG + assert AGENT_CONFIG["kiro-cli"]["folder"] == ".kiro/" + assert AGENT_CONFIG["kiro-cli"]["commands_subdir"] == "prompts" + assert "q" not in AGENT_CONFIG + + def test_extension_registrar_uses_kiro_cli_and_removes_q(self): + """Extension command registrar should target .kiro/prompts.""" + cfg = CommandRegistrar.AGENT_CONFIGS + + assert "kiro-cli" in cfg + assert cfg["kiro-cli"]["dir"] == ".kiro/prompts" + assert "q" not in cfg + + def test_extension_registrar_includes_codex(self): + """Extension command registrar should include codex targeting .agents/skills.""" + cfg = CommandRegistrar.AGENT_CONFIGS + + assert "codex" in cfg + assert cfg["codex"]["dir"] == ".agents/skills" + assert cfg["codex"]["extension"] == "/SKILL.md" + + def test_runtime_codex_uses_native_skills(self): + """Codex runtime config should point at .agents/skills.""" + assert AGENT_CONFIG["codex"]["folder"] == ".agents/" + assert AGENT_CONFIG["codex"]["commands_subdir"] == "skills" + + def test_init_ai_help_includes_roo_and_kiro_alias(self): + """CLI help text for --ai should stay in sync with agent config and alias guidance.""" + assert "roo" in AI_ASSISTANT_HELP + for alias, target in AI_ASSISTANT_ALIASES.items(): + assert alias in AI_ASSISTANT_HELP + assert target in AI_ASSISTANT_HELP + + def test_devcontainer_kiro_installer_uses_pinned_checksum(self): + """Devcontainer installer should always verify Kiro installer via pinned SHA256.""" + post_create_text = (REPO_ROOT / ".devcontainer" / "post-create.sh").read_text( + encoding="utf-8" + ) + + assert ( + 'KIRO_INSTALLER_SHA256="7487a65cf310b7fb59b357c4b5e6e3f3259d383f4394ecedb39acf70f307cffb"' + in post_create_text + ) + assert "sha256sum -c -" in post_create_text + assert "KIRO_SKIP_KIRO_INSTALLER_VERIFY" not in post_create_text + + # --- Tabnine CLI consistency checks --- + + def test_runtime_config_includes_tabnine(self): + """AGENT_CONFIG should include tabnine with correct folder and subdir.""" + assert "tabnine" in AGENT_CONFIG + assert AGENT_CONFIG["tabnine"]["folder"] == ".tabnine/agent/" + assert AGENT_CONFIG["tabnine"]["commands_subdir"] == "commands" + assert AGENT_CONFIG["tabnine"]["requires_cli"] is True + assert AGENT_CONFIG["tabnine"]["install_url"] is not None + + def test_extension_registrar_includes_tabnine(self): + """CommandRegistrar.AGENT_CONFIGS should include tabnine with correct TOML config.""" + from specify_cli.extensions import CommandRegistrar + + assert "tabnine" in CommandRegistrar.AGENT_CONFIGS + cfg = CommandRegistrar.AGENT_CONFIGS["tabnine"] + assert cfg["dir"] == ".tabnine/agent/commands" + assert cfg["format"] == "toml" + assert cfg["args"] == "{{args}}" + assert cfg["extension"] == ".toml" + + def test_ai_help_includes_tabnine(self): + """CLI help text for --ai should include tabnine.""" + assert "tabnine" in AI_ASSISTANT_HELP + + # --- Kimi Code CLI consistency checks --- + + def test_kimi_in_agent_config(self): + """AGENT_CONFIG should include kimi with correct folder and commands_subdir.""" + assert "kimi" in AGENT_CONFIG + assert AGENT_CONFIG["kimi"]["folder"] == ".kimi/" + assert AGENT_CONFIG["kimi"]["commands_subdir"] == "skills" + assert AGENT_CONFIG["kimi"]["requires_cli"] is True + + def test_kimi_in_extension_registrar(self): + """Extension command registrar should include kimi using .kimi/skills and SKILL.md.""" + cfg = CommandRegistrar.AGENT_CONFIGS + + assert "kimi" in cfg + kimi_cfg = cfg["kimi"] + assert kimi_cfg["dir"] == ".kimi/skills" + assert kimi_cfg["extension"] == "/SKILL.md" + + def test_ai_help_includes_kimi(self): + """CLI help text for --ai should include kimi.""" + assert "kimi" in AI_ASSISTANT_HELP + + # --- Trae IDE consistency checks --- + + def test_trae_in_agent_config(self): + """AGENT_CONFIG should include trae with correct folder and commands_subdir.""" + assert "trae" in AGENT_CONFIG + assert AGENT_CONFIG["trae"]["folder"] == ".trae/" + assert AGENT_CONFIG["trae"]["commands_subdir"] == "skills" + assert AGENT_CONFIG["trae"]["requires_cli"] is False + assert AGENT_CONFIG["trae"]["install_url"] is None + + def test_trae_in_extension_registrar(self): + """Extension command registrar should include trae using .trae/rules and markdown, if present.""" + cfg = CommandRegistrar.AGENT_CONFIGS + + assert "trae" in cfg + trae_cfg = cfg["trae"] + assert trae_cfg["format"] == "markdown" + assert trae_cfg["args"] == "$ARGUMENTS" + assert trae_cfg["extension"] == "/SKILL.md" + + def test_ai_help_includes_trae(self): + """CLI help text for --ai should include trae.""" + assert "trae" in AI_ASSISTANT_HELP + + # --- Pi Coding Agent consistency checks --- + + def test_pi_in_agent_config(self): + """AGENT_CONFIG should include pi with correct folder and commands_subdir.""" + assert "pi" in AGENT_CONFIG + assert AGENT_CONFIG["pi"]["folder"] == ".pi/" + assert AGENT_CONFIG["pi"]["commands_subdir"] == "prompts" + assert AGENT_CONFIG["pi"]["requires_cli"] is True + assert AGENT_CONFIG["pi"]["install_url"] is not None + + def test_pi_in_extension_registrar(self): + """Extension command registrar should include pi using .pi/prompts.""" + cfg = CommandRegistrar.AGENT_CONFIGS + + assert "pi" in cfg + pi_cfg = cfg["pi"] + assert pi_cfg["dir"] == ".pi/prompts" + assert pi_cfg["format"] == "markdown" + assert pi_cfg["args"] == "$ARGUMENTS" + assert pi_cfg["extension"] == ".md" + + def test_ai_help_includes_pi(self): + """CLI help text for --ai should include pi.""" + assert "pi" in AI_ASSISTANT_HELP + + # --- iFlow CLI consistency checks --- + + def test_iflow_in_agent_config(self): + """AGENT_CONFIG should include iflow with correct folder and commands_subdir.""" + assert "iflow" in AGENT_CONFIG + assert AGENT_CONFIG["iflow"]["folder"] == ".iflow/" + assert AGENT_CONFIG["iflow"]["commands_subdir"] == "commands" + assert AGENT_CONFIG["iflow"]["requires_cli"] is True + + def test_iflow_in_extension_registrar(self): + """Extension command registrar should include iflow targeting .iflow/commands.""" + cfg = CommandRegistrar.AGENT_CONFIGS + + assert "iflow" in cfg + assert cfg["iflow"]["dir"] == ".iflow/commands" + assert cfg["iflow"]["format"] == "markdown" + assert cfg["iflow"]["args"] == "$ARGUMENTS" + + def test_ai_help_includes_iflow(self): + """CLI help text for --ai should include iflow.""" + assert "iflow" in AI_ASSISTANT_HELP + + # --- Goose consistency checks --- + + def test_goose_in_agent_config(self): + """AGENT_CONFIG should include goose with correct folder and commands_subdir.""" + assert "goose" in AGENT_CONFIG + assert AGENT_CONFIG["goose"]["folder"] == ".goose/" + assert AGENT_CONFIG["goose"]["commands_subdir"] == "recipes" + assert AGENT_CONFIG["goose"]["requires_cli"] is True + + def test_goose_in_extension_registrar(self): + """Extension command registrar should include goose targeting .goose/recipes.""" + cfg = CommandRegistrar.AGENT_CONFIGS + + assert "goose" in cfg + assert cfg["goose"]["dir"] == ".goose/recipes" + assert cfg["goose"]["format"] == "yaml" + assert cfg["goose"]["args"] == "{{args}}" + + def test_ai_help_includes_goose(self): + """CLI help text for --ai should include goose.""" + assert "goose" in AI_ASSISTANT_HELP diff --git a/tests/test_branch_numbering.py b/tests/test_branch_numbering.py new file mode 100644 index 0000000000..9b28082cbd --- /dev/null +++ b/tests/test_branch_numbering.py @@ -0,0 +1,74 @@ +""" +Unit tests for branch numbering options (sequential vs timestamp). + +Tests cover: +- Persisting branch_numbering in init-options.json +- Default value when branch_numbering is None +- Validation of branch_numbering values +""" + +import json +from pathlib import Path + +from specify_cli import save_init_options + + +class TestSaveBranchNumbering: + """Tests for save_init_options with branch_numbering.""" + + def test_save_branch_numbering_timestamp(self, tmp_path: Path): + opts = {"branch_numbering": "timestamp", "ai": "claude"} + save_init_options(tmp_path, opts) + + saved = json.loads((tmp_path / ".specify/init-options.json").read_text()) + assert saved["branch_numbering"] == "timestamp" + + def test_save_branch_numbering_sequential(self, tmp_path: Path): + opts = {"branch_numbering": "sequential", "ai": "claude"} + save_init_options(tmp_path, opts) + + saved = json.loads((tmp_path / ".specify/init-options.json").read_text()) + assert saved["branch_numbering"] == "sequential" + + def test_branch_numbering_defaults_to_sequential(self, tmp_path: Path): + from typer.testing import CliRunner + from specify_cli import app + + project_dir = tmp_path / "proj" + runner = CliRunner() + result = runner.invoke(app, ["init", str(project_dir), "--ai", "claude", "--ignore-agent-tools", "--no-git", "--script", "sh"]) + assert result.exit_code == 0 + + saved = json.loads((project_dir / ".specify/init-options.json").read_text()) + assert saved["branch_numbering"] == "sequential" + + +class TestBranchNumberingValidation: + """Tests for branch_numbering CLI validation via CliRunner.""" + + def test_invalid_branch_numbering_rejected(self, tmp_path: Path): + from typer.testing import CliRunner + from specify_cli import app + + runner = CliRunner() + result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "foobar", "--ignore-agent-tools"]) + assert result.exit_code == 1 + assert "Invalid --branch-numbering" in result.output + + def test_valid_branch_numbering_sequential(self, tmp_path: Path): + from typer.testing import CliRunner + from specify_cli import app + + runner = CliRunner() + result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "sequential", "--ignore-agent-tools", "--no-git", "--script", "sh"]) + assert result.exit_code == 0 + assert "Invalid --branch-numbering" not in (result.output or "") + + def test_valid_branch_numbering_timestamp(self, tmp_path: Path): + from typer.testing import CliRunner + from specify_cli import app + + runner = CliRunner() + result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "timestamp", "--ignore-agent-tools", "--no-git", "--script", "sh"]) + assert result.exit_code == 0 + assert "Invalid --branch-numbering" not in (result.output or "") diff --git a/tests/test_check_tool.py b/tests/test_check_tool.py new file mode 100644 index 0000000000..0eb267ba24 --- /dev/null +++ b/tests/test_check_tool.py @@ -0,0 +1,96 @@ +"""Tests for check_tool() — Claude Code CLI detection across install methods. + +Covers issue https://github.com/github/spec-kit/issues/550: + `specify check` reports "Claude Code CLI (not found)" even when claude is + installed via npm-local (the default `claude` installer path). +""" + +from unittest.mock import patch, MagicMock + +from specify_cli import check_tool + + +class TestCheckToolClaude: + """Claude CLI detection must work for all install methods.""" + + def test_detected_via_migrate_installer_path(self, tmp_path): + """claude migrate-installer puts binary at ~/.claude/local/claude.""" + fake_claude = tmp_path / "claude" + fake_claude.touch() + + # Ensure npm-local path is missing so we only exercise migrate-installer path + fake_missing = tmp_path / "nonexistent" / "claude" + + with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_claude), \ + patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_missing), \ + patch("shutil.which", return_value=None): + assert check_tool("claude") is True + + def test_detected_via_npm_local_path(self, tmp_path): + """npm-local install puts binary at ~/.claude/local/node_modules/.bin/claude.""" + fake_npm_claude = tmp_path / "node_modules" / ".bin" / "claude" + fake_npm_claude.parent.mkdir(parents=True) + fake_npm_claude.touch() + + # Neither the migrate-installer path nor PATH has claude + fake_migrate = tmp_path / "nonexistent" / "claude" + + with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_migrate), \ + patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_npm_claude), \ + patch("shutil.which", return_value=None): + assert check_tool("claude") is True + + def test_detected_via_path(self, tmp_path): + """claude on PATH (global npm install) should still work.""" + fake_missing = tmp_path / "nonexistent" / "claude" + + with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_missing), \ + patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_missing), \ + patch("shutil.which", return_value="/usr/local/bin/claude"): + assert check_tool("claude") is True + + def test_not_found_when_nowhere(self, tmp_path): + """Should return False when claude is genuinely not installed.""" + fake_missing = tmp_path / "nonexistent" / "claude" + + with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_missing), \ + patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_missing), \ + patch("shutil.which", return_value=None): + assert check_tool("claude") is False + + def test_tracker_updated_on_npm_local_detection(self, tmp_path): + """StepTracker should be marked 'available' for npm-local installs.""" + fake_npm_claude = tmp_path / "node_modules" / ".bin" / "claude" + fake_npm_claude.parent.mkdir(parents=True) + fake_npm_claude.touch() + + fake_missing = tmp_path / "nonexistent" / "claude" + tracker = MagicMock() + + with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_missing), \ + patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_npm_claude), \ + patch("shutil.which", return_value=None): + result = check_tool("claude", tracker=tracker) + + assert result is True + tracker.complete.assert_called_once_with("claude", "available") + + +class TestCheckToolOther: + """Non-Claude tools should be unaffected by the fix.""" + + def test_git_detected_via_path(self): + with patch("shutil.which", return_value="/usr/bin/git"): + assert check_tool("git") is True + + def test_missing_tool(self): + with patch("shutil.which", return_value=None): + assert check_tool("nonexistent-tool") is False + + def test_kiro_fallback(self): + """kiro-cli detection should try both kiro-cli and kiro.""" + def fake_which(name): + return "/usr/bin/kiro" if name == "kiro" else None + + with patch("shutil.which", side_effect=fake_which): + assert check_tool("kiro-cli") is True \ No newline at end of file diff --git a/tests/test_cli_version.py b/tests/test_cli_version.py new file mode 100644 index 0000000000..80555d8b77 --- /dev/null +++ b/tests/test_cli_version.py @@ -0,0 +1,35 @@ +"""Tests for the --version CLI flag.""" + +from unittest.mock import patch + +from typer.testing import CliRunner + +from specify_cli import app + + +runner = CliRunner() + + +class TestVersionFlag: + """Test --version / -V flag on the root command.""" + + def test_version_long_flag(self): + """specify --version prints version and exits 0.""" + with patch("specify_cli.get_speckit_version", return_value="1.2.3"): + result = runner.invoke(app, ["--version"]) + assert result.exit_code == 0 + assert "specify 1.2.3" in result.output + + def test_version_short_flag(self): + """specify -V prints version and exits 0.""" + with patch("specify_cli.get_speckit_version", return_value="1.2.3"): + result = runner.invoke(app, ["-V"]) + assert result.exit_code == 0 + assert "specify 1.2.3" in result.output + + def test_version_flag_takes_precedence_over_subcommand(self): + """--version should work even when a subcommand follows.""" + with patch("specify_cli.get_speckit_version", return_value="0.7.2"): + result = runner.invoke(app, ["--version", "init"]) + assert result.exit_code == 0 + assert "specify 0.7.2" in result.output diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py new file mode 100644 index 0000000000..89e8b4a8b8 --- /dev/null +++ b/tests/test_extension_skills.py @@ -0,0 +1,737 @@ +""" +Unit tests for extension skill auto-registration. + +Tests cover: +- SKILL.md generation when --ai-skills was used during init +- No skills created when ai_skills not active +- SKILL.md content correctness +- Existing user-modified skills not overwritten +- Skill cleanup on extension removal +- Registry metadata includes registered_skills +""" + +import json +import pytest +import tempfile +import shutil +import yaml +from pathlib import Path + +from specify_cli.extensions import ( + ExtensionManifest, + ExtensionManager, + ExtensionError, +) + + +# ===== Helpers ===== + +def _create_init_options(project_root: Path, ai: str = "claude", ai_skills: bool = True): + """Write a .specify/init-options.json file.""" + opts_dir = project_root / ".specify" + opts_dir.mkdir(parents=True, exist_ok=True) + opts_file = opts_dir / "init-options.json" + opts_file.write_text(json.dumps({ + "ai": ai, + "ai_skills": ai_skills, + "script": "sh", + })) + + +def _create_skills_dir(project_root: Path, ai: str = "claude") -> Path: + """Create and return the expected skills directory for the given agent.""" + # Match the logic in _get_skills_dir() from specify_cli + from specify_cli import AGENT_CONFIG + + agent_config = AGENT_CONFIG.get(ai, {}) + agent_folder = agent_config.get("folder", "") + if agent_folder: + skills_dir = project_root / agent_folder.rstrip("/") / "skills" + else: + skills_dir = project_root / ".agents" / "skills" + + skills_dir.mkdir(parents=True, exist_ok=True) + return skills_dir + + +def _create_extension_dir(temp_dir: Path, ext_id: str = "test-ext") -> Path: + """Create a complete extension directory with manifest and command files.""" + ext_dir = temp_dir / ext_id + ext_dir.mkdir() + + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": ext_id, + "name": "Test Extension", + "version": "1.0.0", + "description": "A test extension for skill registration", + }, + "requires": { + "speckit_version": ">=0.1.0", + }, + "provides": { + "commands": [ + { + "name": f"speckit.{ext_id}.hello", + "file": "commands/hello.md", + "description": "Test hello command", + }, + { + "name": f"speckit.{ext_id}.world", + "file": "commands/world.md", + "description": "Test world command", + }, + ] + }, + } + + with open(ext_dir / "extension.yml", "w") as f: + yaml.dump(manifest_data, f) + + commands_dir = ext_dir / "commands" + commands_dir.mkdir() + + (commands_dir / "hello.md").write_text( + "---\n" + "description: \"Test hello command\"\n" + "---\n" + "\n" + "# Hello Command\n" + "\n" + "Run this to say hello.\n" + "$ARGUMENTS\n" + ) + + (commands_dir / "world.md").write_text( + "---\n" + "description: \"Test world command\"\n" + "---\n" + "\n" + "# World Command\n" + "\n" + "Run this to greet the world.\n" + ) + + return ext_dir + + +# ===== Fixtures ===== + +@pytest.fixture +def temp_dir(): + """Create a temporary directory for tests.""" + tmpdir = tempfile.mkdtemp() + yield Path(tmpdir) + shutil.rmtree(tmpdir) + + +@pytest.fixture +def project_dir(temp_dir): + """Create a mock spec-kit project directory.""" + proj_dir = temp_dir / "project" + proj_dir.mkdir() + + # Create .specify directory + specify_dir = proj_dir / ".specify" + specify_dir.mkdir() + + return proj_dir + + +@pytest.fixture +def extension_dir(temp_dir): + """Create a complete extension directory.""" + return _create_extension_dir(temp_dir) + + +@pytest.fixture +def skills_project(project_dir): + """Create a project with --ai-skills enabled and skills directory.""" + _create_init_options(project_dir, ai="claude", ai_skills=True) + skills_dir = _create_skills_dir(project_dir, ai="claude") + return project_dir, skills_dir + + +@pytest.fixture +def no_skills_project(project_dir): + """Create a project without --ai-skills.""" + _create_init_options(project_dir, ai="claude", ai_skills=False) + return project_dir + + +# ===== ExtensionManager._get_skills_dir Tests ===== + +class TestExtensionManagerGetSkillsDir: + """Test _get_skills_dir() on ExtensionManager.""" + + def test_returns_skills_dir_when_active(self, skills_project): + """Should return skills dir when ai_skills is true and dir exists.""" + project_dir, skills_dir = skills_project + manager = ExtensionManager(project_dir) + result = manager._get_skills_dir() + assert result == skills_dir + + def test_returns_none_when_no_ai_skills(self, no_skills_project): + """Should return None when ai_skills is false.""" + manager = ExtensionManager(no_skills_project) + result = manager._get_skills_dir() + assert result is None + + def test_returns_none_when_no_init_options(self, project_dir): + """Should return None when init-options.json is missing.""" + manager = ExtensionManager(project_dir) + result = manager._get_skills_dir() + assert result is None + + def test_returns_none_when_skills_dir_missing(self, project_dir): + """Should return None when skills dir doesn't exist on disk.""" + _create_init_options(project_dir, ai="claude", ai_skills=True) + # Don't create the skills directory + manager = ExtensionManager(project_dir) + result = manager._get_skills_dir() + assert result is None + + def test_returns_kimi_skills_dir_when_ai_skills_disabled(self, project_dir): + """Kimi should still use its native skills dir when ai_skills is false.""" + _create_init_options(project_dir, ai="kimi", ai_skills=False) + skills_dir = _create_skills_dir(project_dir, ai="kimi") + manager = ExtensionManager(project_dir) + result = manager._get_skills_dir() + assert result == skills_dir + + def test_returns_none_for_non_dict_init_options(self, project_dir): + """Corrupted-but-parseable init-options should not crash skill-dir lookup.""" + opts_file = project_dir / ".specify" / "init-options.json" + opts_file.parent.mkdir(parents=True, exist_ok=True) + opts_file.write_text("[]") + _create_skills_dir(project_dir, ai="claude") + manager = ExtensionManager(project_dir) + result = manager._get_skills_dir() + assert result is None + + +# ===== Extension Skill Registration Tests ===== + +class TestExtensionSkillRegistration: + """Test _register_extension_skills() on ExtensionManager.""" + + def test_skills_created_when_ai_skills_active(self, skills_project, extension_dir): + """Skills should be created when ai_skills is enabled.""" + project_dir, skills_dir = skills_project + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + # Check that skill directories were created + skill_dirs = sorted([d.name for d in skills_dir.iterdir() if d.is_dir()]) + assert "speckit-test-ext-hello" in skill_dirs + assert "speckit-test-ext-world" in skill_dirs + + def test_skill_md_content_correct(self, skills_project, extension_dir): + """SKILL.md should have correct agentskills.io structure.""" + project_dir, skills_dir = skills_project + manager = ExtensionManager(project_dir) + manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md" + assert skill_file.exists() + content = skill_file.read_text() + + # Check structure + assert content.startswith("---\n") + assert "name: speckit-test-ext-hello" in content + assert "description:" in content + assert "Test hello command" in content + assert "source: extension:test-ext" in content + assert "author: github-spec-kit" in content + assert "compatibility:" in content + assert "Run this to say hello." in content + + def test_skill_md_has_parseable_yaml(self, skills_project, extension_dir): + """Generated SKILL.md should contain valid, parseable YAML frontmatter.""" + project_dir, skills_dir = skills_project + manager = ExtensionManager(project_dir) + manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md" + content = skill_file.read_text() + + assert content.startswith("---\n") + parts = content.split("---", 2) + assert len(parts) >= 3 + parsed = yaml.safe_load(parts[1]) + assert isinstance(parsed, dict) + assert parsed["name"] == "speckit-test-ext-hello" + assert "description" in parsed + assert parsed["disable-model-invocation"] is False + + def test_no_skills_when_ai_skills_disabled(self, no_skills_project, extension_dir): + """No skills should be created when ai_skills is false.""" + manager = ExtensionManager(no_skills_project) + manifest = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + # Verify registry + metadata = manager.registry.get(manifest.id) + assert metadata["registered_skills"] == [] + + def test_no_skills_when_init_options_missing(self, project_dir, extension_dir): + """No skills should be created when init-options.json is absent.""" + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + metadata = manager.registry.get(manifest.id) + assert metadata["registered_skills"] == [] + + def test_existing_skill_not_overwritten(self, skills_project, extension_dir): + """Pre-existing SKILL.md should not be overwritten.""" + project_dir, skills_dir = skills_project + + # Pre-create a custom skill + custom_dir = skills_dir / "speckit-test-ext-hello" + custom_dir.mkdir(parents=True) + custom_content = "# My Custom Hello Skill\nUser-modified content\n" + (custom_dir / "SKILL.md").write_text(custom_content) + + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + # Custom skill should be untouched + assert (custom_dir / "SKILL.md").read_text() == custom_content + + # But the other skill should still be created + metadata = manager.registry.get(manifest.id) + assert "speckit-test-ext-world" in metadata["registered_skills"] + # The pre-existing one should NOT be in registered_skills (it was skipped) + assert "speckit-test-ext-hello" not in metadata["registered_skills"] + + def test_registered_skills_in_registry(self, skills_project, extension_dir): + """Registry should contain registered_skills list.""" + project_dir, skills_dir = skills_project + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + metadata = manager.registry.get(manifest.id) + assert "registered_skills" in metadata + assert len(metadata["registered_skills"]) == 2 + assert "speckit-test-ext-hello" in metadata["registered_skills"] + assert "speckit-test-ext-world" in metadata["registered_skills"] + + def test_kimi_uses_hyphenated_skill_names(self, project_dir, temp_dir): + """Kimi agent should use the same hyphenated skill names as hooks.""" + _create_init_options(project_dir, ai="kimi", ai_skills=True) + _create_skills_dir(project_dir, ai="kimi") + ext_dir = _create_extension_dir(temp_dir, ext_id="test-ext") + + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + ext_dir, "0.1.0", register_commands=False + ) + + metadata = manager.registry.get(manifest.id) + assert "speckit-test-ext-hello" in metadata["registered_skills"] + assert "speckit-test-ext-world" in metadata["registered_skills"] + + def test_kimi_creates_skills_when_ai_skills_disabled(self, project_dir, temp_dir): + """Kimi should still auto-register extension skills in native-skills mode.""" + _create_init_options(project_dir, ai="kimi", ai_skills=False) + skills_dir = _create_skills_dir(project_dir, ai="kimi") + ext_dir = _create_extension_dir(temp_dir, ext_id="test-ext") + + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + ext_dir, "0.1.0", register_commands=False + ) + + metadata = manager.registry.get(manifest.id) + assert "speckit-test-ext-hello" in metadata["registered_skills"] + assert "speckit-test-ext-world" in metadata["registered_skills"] + assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists() + + def test_skill_registration_resolves_script_placeholders(self, project_dir, temp_dir): + """Auto-registered extension skills should resolve script placeholders.""" + _create_init_options(project_dir, ai="claude", ai_skills=True) + skills_dir = _create_skills_dir(project_dir, ai="claude") + + ext_dir = temp_dir / "scripted-ext" + ext_dir.mkdir() + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "scripted-ext", + "name": "Scripted Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.scripted-ext.plan", + "file": "commands/plan.md", + "description": "Scripted plan command", + } + ] + }, + } + with open(ext_dir / "extension.yml", "w") as f: + yaml.dump(manifest_data, f) + + (ext_dir / "commands").mkdir() + (ext_dir / "commands" / "plan.md").write_text( + "---\n" + "description: Scripted plan command\n" + "scripts:\n" + " sh: ../../scripts/bash/setup-plan.sh --json \"{ARGS}\"\n" + "---\n\n" + "Run {SCRIPT}\n" + "Review templates/checklist.md and memory/constitution.md for __AGENT__.\n" + ) + + manager = ExtensionManager(project_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + content = (skills_dir / "speckit-scripted-ext-plan" / "SKILL.md").read_text() + assert "{SCRIPT}" not in content + assert "{ARGS}" not in content + assert "__AGENT__" not in content + assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content + assert ".specify/templates/checklist.md" in content + assert ".specify/memory/constitution.md" in content + + def test_missing_command_file_skipped(self, skills_project, temp_dir): + """Commands with missing source files should be skipped gracefully.""" + project_dir, skills_dir = skills_project + + ext_dir = temp_dir / "missing-cmd-ext" + ext_dir.mkdir() + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "missing-cmd-ext", + "name": "Missing Cmd Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.missing-cmd-ext.exists", + "file": "commands/exists.md", + "description": "Exists", + }, + { + "name": "speckit.missing-cmd-ext.ghost", + "file": "commands/ghost.md", + "description": "Does not exist", + }, + ] + }, + } + with open(ext_dir / "extension.yml", "w") as f: + yaml.dump(manifest_data, f) + + (ext_dir / "commands").mkdir() + (ext_dir / "commands" / "exists.md").write_text( + "---\ndescription: Exists\n---\n\n# Exists\n\nBody.\n" + ) + # Intentionally do NOT create ghost.md + + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + ext_dir, "0.1.0", register_commands=False + ) + + metadata = manager.registry.get(manifest.id) + assert "speckit-missing-cmd-ext-exists" in metadata["registered_skills"] + assert "speckit-missing-cmd-ext-ghost" not in metadata["registered_skills"] + + +# ===== Extension Skill Unregistration Tests ===== + +class TestExtensionSkillUnregistration: + """Test _unregister_extension_skills() on ExtensionManager.""" + + def test_skills_removed_on_extension_remove(self, skills_project, extension_dir): + """Removing an extension should clean up its skill directories.""" + project_dir, skills_dir = skills_project + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + # Verify skills exist + assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists() + assert (skills_dir / "speckit-test-ext-world" / "SKILL.md").exists() + + # Remove extension + result = manager.remove(manifest.id, keep_config=False) + assert result is True + + # Skills should be gone + assert not (skills_dir / "speckit-test-ext-hello").exists() + assert not (skills_dir / "speckit-test-ext-world").exists() + + def test_other_skills_preserved_on_remove(self, skills_project, extension_dir): + """Non-extension skills should not be affected by extension removal.""" + project_dir, skills_dir = skills_project + + # Pre-create a custom skill + custom_dir = skills_dir / "my-custom-skill" + custom_dir.mkdir(parents=True) + (custom_dir / "SKILL.md").write_text("# My Custom Skill\n") + + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + manager.remove(manifest.id, keep_config=False) + + # Custom skill should still exist + assert (custom_dir / "SKILL.md").exists() + assert (custom_dir / "SKILL.md").read_text() == "# My Custom Skill\n" + + def test_remove_handles_already_deleted_skills(self, skills_project, extension_dir): + """Gracefully handle case where skill dirs were already deleted.""" + project_dir, skills_dir = skills_project + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + # Manually delete skill dirs before calling remove + shutil.rmtree(skills_dir / "speckit-test-ext-hello") + shutil.rmtree(skills_dir / "speckit-test-ext-world") + + # Should not raise + result = manager.remove(manifest.id, keep_config=False) + assert result is True + + def test_remove_no_skills_when_not_active(self, no_skills_project, extension_dir): + """Removal without active skills should not attempt skill cleanup.""" + manager = ExtensionManager(no_skills_project) + manifest = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + # Should not raise even though no skills exist + result = manager.remove(manifest.id, keep_config=False) + assert result is True + + +# ===== Command File Without Frontmatter ===== + +class TestExtensionSkillEdgeCases: + """Test edge cases in extension skill registration.""" + + def test_install_with_non_dict_init_options_does_not_crash(self, project_dir, extension_dir): + """Corrupted init-options payloads should disable skill registration, not crash install.""" + opts_file = project_dir / ".specify" / "init-options.json" + opts_file.parent.mkdir(parents=True, exist_ok=True) + opts_file.write_text("[]") + _create_skills_dir(project_dir, ai="claude") + + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + metadata = manager.registry.get(manifest.id) + assert metadata["registered_skills"] == [] + + def test_command_without_frontmatter(self, skills_project, temp_dir): + """Commands without YAML frontmatter should still produce valid skills.""" + project_dir, skills_dir = skills_project + + ext_dir = temp_dir / "nofm-ext" + ext_dir.mkdir() + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "nofm-ext", + "name": "No Frontmatter Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.nofm-ext.plain", + "file": "commands/plain.md", + "description": "Plain command", + } + ] + }, + } + with open(ext_dir / "extension.yml", "w") as f: + yaml.dump(manifest_data, f) + + (ext_dir / "commands").mkdir() + (ext_dir / "commands" / "plain.md").write_text( + "# Plain Command\n\nBody without frontmatter.\n" + ) + + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + ext_dir, "0.1.0", register_commands=False + ) + + skill_file = skills_dir / "speckit-nofm-ext-plain" / "SKILL.md" + assert skill_file.exists() + content = skill_file.read_text() + assert "name: speckit-nofm-ext-plain" in content + # Fallback description when no frontmatter description + assert "Extension command: speckit.nofm-ext.plain" in content + assert "Body without frontmatter." in content + + def test_gemini_agent_skills(self, project_dir, temp_dir): + """Gemini agent should use .gemini/skills/ for skill directory.""" + _create_init_options(project_dir, ai="gemini", ai_skills=True) + _create_skills_dir(project_dir, ai="gemini") + ext_dir = _create_extension_dir(temp_dir, ext_id="test-ext") + + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + ext_dir, "0.1.0", register_commands=False + ) + + skills_dir = project_dir / ".gemini" / "skills" + assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists() + assert (skills_dir / "speckit-test-ext-world" / "SKILL.md").exists() + + def test_multiple_extensions_independent_skills(self, skills_project, temp_dir): + """Installing and removing different extensions should be independent.""" + project_dir, skills_dir = skills_project + + ext_dir_a = _create_extension_dir(temp_dir, ext_id="ext-a") + ext_dir_b = _create_extension_dir(temp_dir, ext_id="ext-b") + + manager = ExtensionManager(project_dir) + manifest_a = manager.install_from_directory( + ext_dir_a, "0.1.0", register_commands=False + ) + manifest_b = manager.install_from_directory( + ext_dir_b, "0.1.0", register_commands=False + ) + + # Both should have skills + assert (skills_dir / "speckit-ext-a-hello" / "SKILL.md").exists() + assert (skills_dir / "speckit-ext-b-hello" / "SKILL.md").exists() + + # Remove ext-a + manager.remove("ext-a", keep_config=False) + + # ext-a skills gone, ext-b skills preserved + assert not (skills_dir / "speckit-ext-a-hello").exists() + assert (skills_dir / "speckit-ext-b-hello" / "SKILL.md").exists() + + def test_malformed_frontmatter_handled(self, skills_project, temp_dir): + """Commands with invalid YAML frontmatter should still produce valid skills.""" + project_dir, skills_dir = skills_project + + ext_dir = temp_dir / "badfm-ext" + ext_dir.mkdir() + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "badfm-ext", + "name": "Bad Frontmatter Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.badfm-ext.broken", + "file": "commands/broken.md", + "description": "Broken frontmatter", + } + ] + }, + } + with open(ext_dir / "extension.yml", "w") as f: + yaml.dump(manifest_data, f) + + (ext_dir / "commands").mkdir() + # Malformed YAML: invalid key-value syntax + (ext_dir / "commands" / "broken.md").write_text( + "---\n" + "description: [invalid yaml\n" + " unclosed: bracket\n" + "---\n" + "\n" + "# Broken Command\n" + "\n" + "This body should still be used.\n" + ) + + manager = ExtensionManager(project_dir) + # Should not raise + manifest = manager.install_from_directory( + ext_dir, "0.1.0", register_commands=False + ) + + skill_file = skills_dir / "speckit-badfm-ext-broken" / "SKILL.md" + assert skill_file.exists() + content = skill_file.read_text() + # Fallback description since frontmatter was invalid + assert "Extension command: speckit.badfm-ext.broken" in content + assert "This body should still be used." in content + + def test_remove_cleans_up_when_init_options_deleted(self, skills_project, extension_dir): + """Skills should be cleaned up even if init-options.json is deleted after install.""" + project_dir, skills_dir = skills_project + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + # Verify skills exist + assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists() + + # Delete init-options.json to simulate user change + init_opts = project_dir / ".specify" / "init-options.json" + init_opts.unlink() + + # Remove should still clean up via fallback scan + result = manager.remove(manifest.id, keep_config=False) + assert result is True + assert not (skills_dir / "speckit-test-ext-hello").exists() + assert not (skills_dir / "speckit-test-ext-world").exists() + + def test_remove_cleans_up_when_ai_skills_toggled(self, skills_project, extension_dir): + """Skills should be cleaned up even if ai_skills is toggled to false after install.""" + project_dir, skills_dir = skills_project + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + # Verify skills exist + assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists() + + # Toggle ai_skills to false + _create_init_options(project_dir, ai="claude", ai_skills=False) + + # Remove should still clean up via fallback scan + result = manager.remove(manifest.id, keep_config=False) + assert result is True + assert not (skills_dir / "speckit-test-ext-hello").exists() + assert not (skills_dir / "speckit-test-ext-world").exists() diff --git a/tests/test_extensions.py b/tests/test_extensions.py index a2c4121ed4..fdeb5a24ee 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -6,24 +6,32 @@ - Extension registry operations - Extension manager installation/removal - Command registration +- Catalog stack (multi-catalog support) """ import pytest import json +import platform import tempfile import shutil +import tomllib from pathlib import Path from datetime import datetime, timezone +from tests.conftest import strip_ansi from specify_cli.extensions import ( + CatalogEntry, + CORE_COMMAND_NAMES, ExtensionManifest, ExtensionRegistry, ExtensionManager, CommandRegistrar, + HookExecutor, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError, + normalize_priority, version_satisfies, ) @@ -59,7 +67,7 @@ def valid_manifest_data(): "provides": { "commands": [ { - "name": "speckit.test.hello", + "name": "speckit.test-ext.hello", "file": "commands/hello.md", "description": "Test command", } @@ -67,7 +75,7 @@ def valid_manifest_data(): }, "hooks": { "after_tasks": { - "command": "speckit.test.hello", + "command": "speckit.test-ext.hello", "optional": True, "prompt": "Run test?", } @@ -119,6 +127,57 @@ def project_dir(temp_dir): return proj_dir +# ===== normalize_priority Tests ===== + +class TestNormalizePriority: + """Test normalize_priority helper function.""" + + def test_valid_integer(self): + """Test with valid integer priority.""" + assert normalize_priority(5) == 5 + assert normalize_priority(1) == 1 + assert normalize_priority(100) == 100 + + def test_valid_string_number(self): + """Test with string that can be converted to int.""" + assert normalize_priority("5") == 5 + assert normalize_priority("10") == 10 + + def test_zero_returns_default(self): + """Test that zero priority returns default.""" + assert normalize_priority(0) == 10 + assert normalize_priority(0, default=5) == 5 + + def test_negative_returns_default(self): + """Test that negative priority returns default.""" + assert normalize_priority(-1) == 10 + assert normalize_priority(-100, default=5) == 5 + + def test_none_returns_default(self): + """Test that None returns default.""" + assert normalize_priority(None) == 10 + assert normalize_priority(None, default=5) == 5 + + def test_invalid_string_returns_default(self): + """Test that non-numeric string returns default.""" + assert normalize_priority("invalid") == 10 + assert normalize_priority("abc", default=5) == 5 + + def test_float_truncates(self): + """Test that float is truncated to int.""" + assert normalize_priority(5.9) == 5 + assert normalize_priority(3.1) == 3 + + def test_empty_string_returns_default(self): + """Test that empty string returns default.""" + assert normalize_priority("") == 10 + + def test_custom_default(self): + """Test custom default value.""" + assert normalize_priority(None, default=20) == 20 + assert normalize_priority("invalid", default=1) == 1 + + # ===== ExtensionManifest Tests ===== class TestExtensionManifest: @@ -134,7 +193,18 @@ def test_valid_manifest(self, extension_dir): assert manifest.version == "1.0.0" assert manifest.description == "A test extension" assert len(manifest.commands) == 1 - assert manifest.commands[0]["name"] == "speckit.test.hello" + assert manifest.commands[0]["name"] == "speckit.test-ext.hello" + + def test_core_command_names_match_bundled_templates(self): + """Core command reservations should stay aligned with bundled templates.""" + commands_dir = Path(__file__).resolve().parent.parent / "templates" / "commands" + expected = { + command_file.stem + for command_file in commands_dir.iterdir() + if command_file.is_file() and command_file.suffix == ".md" + } + + assert CORE_COMMAND_NAMES == expected def test_missing_required_field(self, temp_dir): """Test manifest missing required field.""" @@ -147,6 +217,14 @@ def test_missing_required_field(self, temp_dir): with pytest.raises(ValidationError, match="Missing required field"): ExtensionManifest(manifest_path) + def test_non_mapping_yaml_raises_validation_error(self, temp_dir): + """Manifest whose YAML root is a scalar or list raises ValidationError, not TypeError.""" + manifest_path = temp_dir / "extension.yml" + for bad_content in ("42\n", "[]\n", "null\n"): + manifest_path.write_text(bad_content) + with pytest.raises(ValidationError, match="YAML mapping"): + ExtensionManifest(manifest_path) + def test_invalid_extension_id(self, temp_dir, valid_manifest_data): """Test manifest with invalid extension ID format.""" import yaml @@ -174,7 +252,7 @@ def test_invalid_version(self, temp_dir, valid_manifest_data): ExtensionManifest(manifest_path) def test_invalid_command_name(self, temp_dir, valid_manifest_data): - """Test manifest with invalid command name format.""" + """Test manifest with command name that cannot be auto-corrected raises ValidationError.""" import yaml valid_manifest_data["provides"]["commands"][0]["name"] = "invalid-name" @@ -186,17 +264,156 @@ def test_invalid_command_name(self, temp_dir, valid_manifest_data): with pytest.raises(ValidationError, match="Invalid command name"): ExtensionManifest(manifest_path) - def test_no_commands(self, temp_dir, valid_manifest_data): - """Test manifest with no commands provided.""" + def test_command_name_autocorrect_speckit_prefix(self, temp_dir, valid_manifest_data): + """Test that 'speckit.command' is auto-corrected to 'speckit.{ext_id}.command'.""" + import yaml + + valid_manifest_data["provides"]["commands"][0]["name"] = "speckit.hello" + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_manifest_data, f) + + manifest = ExtensionManifest(manifest_path) + + assert manifest.commands[0]["name"] == "speckit.test-ext.hello" + assert len(manifest.warnings) == 1 + assert "speckit.hello" in manifest.warnings[0] + assert "speckit.test-ext.hello" in manifest.warnings[0] + + def test_command_name_autocorrect_matching_ext_id_prefix(self, temp_dir, valid_manifest_data): + """Test that '{ext_id}.command' is auto-corrected to 'speckit.{ext_id}.command'.""" + import yaml + + # Set ext_id to match the legacy namespace so correction is valid + valid_manifest_data["extension"]["id"] = "docguard" + valid_manifest_data["provides"]["commands"][0]["name"] = "docguard.guard" + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_manifest_data, f) + + manifest = ExtensionManifest(manifest_path) + + assert manifest.commands[0]["name"] == "speckit.docguard.guard" + assert len(manifest.warnings) == 1 + assert "docguard.guard" in manifest.warnings[0] + assert "speckit.docguard.guard" in manifest.warnings[0] + + def test_command_name_mismatched_namespace_not_corrected(self, temp_dir, valid_manifest_data): + """Test that 'X.command' is NOT corrected when X doesn't match ext_id.""" + import yaml + + # ext_id is "test-ext" but command uses a different namespace + valid_manifest_data["provides"]["commands"][0]["name"] = "docguard.guard" + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_manifest_data, f) + + with pytest.raises(ValidationError, match="Invalid command name"): + ExtensionManifest(manifest_path) + + def test_alias_free_form_accepted(self, temp_dir, valid_manifest_data): + """Aliases are free-form — a 'speckit.command' alias must be accepted unchanged.""" + import yaml + + valid_manifest_data["provides"]["commands"][0]["aliases"] = ["speckit.hello"] + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_manifest_data, f) + + manifest = ExtensionManifest(manifest_path) + + assert manifest.commands[0]["aliases"] == ["speckit.hello"] + assert manifest.warnings == [] + + def test_valid_command_name_has_no_warnings(self, temp_dir, valid_manifest_data): + """Test that a correctly-named command produces no warnings.""" + import yaml + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_manifest_data, f) + + manifest = ExtensionManifest(manifest_path) + + assert manifest.warnings == [] + + def test_no_commands_no_hooks(self, temp_dir, valid_manifest_data): + """Test manifest with no commands and no hooks provided.""" + import yaml + + valid_manifest_data["provides"]["commands"] = [] + valid_manifest_data.pop("hooks", None) + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_manifest_data, f) + + with pytest.raises(ValidationError, match="must provide at least one command or hook"): + ExtensionManifest(manifest_path) + + def test_hooks_only_extension(self, temp_dir, valid_manifest_data): + """Test manifest with hooks but no commands is valid.""" import yaml valid_manifest_data["provides"]["commands"] = [] + valid_manifest_data["hooks"] = { + "after_specify": { + "command": "speckit.test-ext.notify", + "optional": True, + "prompt": "Run notification?", + } + } + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_manifest_data, f) + + manifest = ExtensionManifest(manifest_path) + assert manifest.id == valid_manifest_data["extension"]["id"] + assert len(manifest.commands) == 0 + assert len(manifest.hooks) == 1 + + def test_commands_null_rejected(self, temp_dir, valid_manifest_data): + """Test manifest with commands: null is rejected.""" + import yaml + + valid_manifest_data["provides"]["commands"] = None + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_manifest_data, f) + + with pytest.raises(ValidationError, match="Invalid provides.commands"): + ExtensionManifest(manifest_path) + + def test_hooks_not_dict_rejected(self, temp_dir, valid_manifest_data): + """Test manifest with hooks as a list is rejected.""" + import yaml + + valid_manifest_data["hooks"] = ["not", "a", "dict"] manifest_path = temp_dir / "extension.yml" with open(manifest_path, 'w') as f: yaml.dump(valid_manifest_data, f) - with pytest.raises(ValidationError, match="must provide at least one command"): + with pytest.raises(ValidationError, match="Invalid hooks"): + ExtensionManifest(manifest_path) + + def test_non_dict_hook_entry_raises_validation_error(self, temp_dir, valid_manifest_data): + """Non-mapping hook entries must raise ValidationError, not silently skip.""" + import yaml + + valid_manifest_data["hooks"]["after_tasks"] = "speckit.test-ext.hello" + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_manifest_data, f) + + with pytest.raises(ValidationError, match="Invalid hook 'after_tasks'"): ExtensionManifest(manifest_path) def test_manifest_hash(self, extension_dir): @@ -275,6 +492,211 @@ def test_registry_persistence(self, temp_dir): assert registry2.is_installed("test-ext") assert registry2.get("test-ext")["version"] == "1.0.0" + def test_update_preserves_installed_at(self, temp_dir): + """Test that update() preserves the original installed_at timestamp.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + + registry = ExtensionRegistry(extensions_dir) + registry.add("test-ext", {"version": "1.0.0", "enabled": True}) + + # Get original installed_at + original_data = registry.get("test-ext") + original_installed_at = original_data["installed_at"] + + # Update with new metadata + registry.update("test-ext", {"version": "2.0.0", "enabled": False}) + + # Verify installed_at is preserved + updated_data = registry.get("test-ext") + assert updated_data["installed_at"] == original_installed_at + assert updated_data["version"] == "2.0.0" + assert updated_data["enabled"] is False + + def test_update_merges_with_existing(self, temp_dir): + """Test that update() merges new metadata with existing fields.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + + registry = ExtensionRegistry(extensions_dir) + registry.add("test-ext", { + "version": "1.0.0", + "enabled": True, + "registered_commands": {"claude": ["cmd1", "cmd2"]}, + }) + + # Update with partial metadata (only enabled field) + registry.update("test-ext", {"enabled": False}) + + # Verify existing fields are preserved + updated_data = registry.get("test-ext") + assert updated_data["enabled"] is False + assert updated_data["version"] == "1.0.0" # Preserved + assert updated_data["registered_commands"] == {"claude": ["cmd1", "cmd2"]} # Preserved + + def test_update_raises_for_missing_extension(self, temp_dir): + """Test that update() raises KeyError for non-installed extension.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + + registry = ExtensionRegistry(extensions_dir) + + with pytest.raises(KeyError, match="not installed"): + registry.update("nonexistent-ext", {"enabled": False}) + + def test_restore_overwrites_completely(self, temp_dir): + """Test that restore() overwrites the registry entry completely.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + + registry = ExtensionRegistry(extensions_dir) + registry.add("test-ext", {"version": "2.0.0", "enabled": True}) + + # Restore with complete backup data + backup_data = { + "version": "1.0.0", + "enabled": False, + "installed_at": "2024-01-01T00:00:00+00:00", + "registered_commands": {"claude": ["old-cmd"]}, + } + registry.restore("test-ext", backup_data) + + # Verify entry is exactly as restored + restored_data = registry.get("test-ext") + assert restored_data == backup_data + + def test_restore_can_recreate_removed_entry(self, temp_dir): + """Test that restore() can recreate an entry after remove().""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + + registry = ExtensionRegistry(extensions_dir) + registry.add("test-ext", {"version": "1.0.0"}) + + # Save backup and remove + backup = registry.get("test-ext").copy() + registry.remove("test-ext") + assert not registry.is_installed("test-ext") + + # Restore should recreate the entry + registry.restore("test-ext", backup) + assert registry.is_installed("test-ext") + assert registry.get("test-ext")["version"] == "1.0.0" + + def test_restore_rejects_none_metadata(self, temp_dir): + """Test restore() raises ValueError for None metadata.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + registry = ExtensionRegistry(extensions_dir) + + with pytest.raises(ValueError, match="metadata must be a dict"): + registry.restore("test-ext", None) + + def test_restore_rejects_non_dict_metadata(self, temp_dir): + """Test restore() raises ValueError for non-dict metadata.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + registry = ExtensionRegistry(extensions_dir) + + with pytest.raises(ValueError, match="metadata must be a dict"): + registry.restore("test-ext", "not-a-dict") + + with pytest.raises(ValueError, match="metadata must be a dict"): + registry.restore("test-ext", ["list", "not", "dict"]) + + def test_restore_uses_deep_copy(self, temp_dir): + """Test restore() deep copies metadata to prevent mutation.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + registry = ExtensionRegistry(extensions_dir) + + original_metadata = { + "version": "1.0.0", + "nested": {"key": "original"}, + } + registry.restore("test-ext", original_metadata) + + # Mutate the original metadata after restore + original_metadata["version"] = "MUTATED" + original_metadata["nested"]["key"] = "MUTATED" + + # Registry should have the original values + stored = registry.get("test-ext") + assert stored["version"] == "1.0.0" + assert stored["nested"]["key"] == "original" + + def test_get_returns_deep_copy(self, temp_dir): + """Test that get() returns deep copies for nested structures.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + + registry = ExtensionRegistry(extensions_dir) + metadata = { + "version": "1.0.0", + "registered_commands": {"claude": ["cmd1"]}, + } + registry.add("test-ext", metadata) + + fetched = registry.get("test-ext") + fetched["registered_commands"]["claude"].append("cmd2") + + # Internal registry must remain unchanged. + internal = registry.data["extensions"]["test-ext"] + assert internal["registered_commands"] == {"claude": ["cmd1"]} + + def test_get_returns_none_for_corrupted_entry(self, temp_dir): + """Test that get() returns None for corrupted (non-dict) entries.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + + registry = ExtensionRegistry(extensions_dir) + + # Directly corrupt the registry with non-dict entries + registry.data["extensions"]["corrupted-string"] = "not a dict" + registry.data["extensions"]["corrupted-list"] = ["not", "a", "dict"] + registry.data["extensions"]["corrupted-int"] = 42 + registry._save() + + # All corrupted entries should return None + assert registry.get("corrupted-string") is None + assert registry.get("corrupted-list") is None + assert registry.get("corrupted-int") is None + # Non-existent should also return None + assert registry.get("nonexistent") is None + + def test_list_returns_deep_copy(self, temp_dir): + """Test that list() returns deep copies for nested structures.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + + registry = ExtensionRegistry(extensions_dir) + metadata = { + "version": "1.0.0", + "registered_commands": {"claude": ["cmd1"]}, + } + registry.add("test-ext", metadata) + + listed = registry.list() + listed["test-ext"]["registered_commands"]["claude"].append("cmd2") + + # Internal registry must remain unchanged. + internal = registry.data["extensions"]["test-ext"] + assert internal["registered_commands"] == {"claude": ["cmd1"]} + + def test_list_returns_empty_dict_for_corrupted_registry(self, temp_dir): + """Test that list() returns empty dict when extensions is not a dict.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() + registry = ExtensionRegistry(extensions_dir) + + # Corrupt the registry - extensions is a list instead of dict + registry.data["extensions"] = ["not", "a", "dict"] + registry._save() + + # list() should return empty dict, not crash + result = registry.list() + assert result == {} + # ===== ExtensionManager Tests ===== @@ -329,31 +751,199 @@ def test_install_duplicate(self, extension_dir, project_dir): with pytest.raises(ExtensionError, match="already installed"): manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) - def test_remove_extension(self, extension_dir, project_dir): - """Test removing an installed extension.""" + def test_install_rejects_extension_id_in_core_namespace(self, temp_dir, project_dir): + """Install should reject extension IDs that shadow core commands.""" + import yaml + + ext_dir = temp_dir / "analyze-ext" + ext_dir.mkdir() + (ext_dir / "commands").mkdir() + + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "analyze", + "name": "Analyze Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.analyze.extra", + "file": "commands/cmd.md", + } + ] + }, + } + + (ext_dir / "extension.yml").write_text(yaml.dump(manifest_data)) + (ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody") + manager = ExtensionManager(project_dir) + with pytest.raises(ValidationError, match="conflicts with core command namespace"): + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) - # Install extension - manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) + def test_install_accepts_free_form_alias(self, temp_dir, project_dir): + """Aliases are free-form — a short 'speckit.shortcut' alias must be preserved unchanged.""" + import yaml - ext_dir = project_dir / ".specify" / "extensions" / "test-ext" - assert ext_dir.exists() + ext_dir = temp_dir / "alias-shortcut" + ext_dir.mkdir() + (ext_dir / "commands").mkdir() - # Remove extension - result = manager.remove("test-ext", keep_config=False) + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "alias-shortcut", + "name": "Alias Shortcut", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.alias-shortcut.cmd", + "file": "commands/cmd.md", + "aliases": ["speckit.shortcut"], + } + ] + }, + } - assert result is True - assert not manager.registry.is_installed("test-ext") - assert not ext_dir.exists() + (ext_dir / "extension.yml").write_text(yaml.dump(manifest_data)) + (ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody") - def test_remove_nonexistent(self, project_dir): - """Test removing non-existent extension.""" manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) - result = manager.remove("nonexistent") - assert result is False + assert manifest.commands[0]["aliases"] == ["speckit.shortcut"] + assert manifest.warnings == [] - def test_list_installed(self, extension_dir, project_dir): + def test_install_rejects_namespace_squatting(self, temp_dir, project_dir): + """Install should reject commands and aliases outside the extension namespace.""" + import yaml + + ext_dir = temp_dir / "squat-ext" + ext_dir.mkdir() + (ext_dir / "commands").mkdir() + + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "squat-ext", + "name": "Squat Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.other-ext.cmd", + "file": "commands/cmd.md", + "aliases": ["speckit.squat-ext.ok"], + } + ] + }, + } + + (ext_dir / "extension.yml").write_text(yaml.dump(manifest_data)) + (ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody") + + manager = ExtensionManager(project_dir) + with pytest.raises(ValidationError, match="must use extension namespace 'squat-ext'"): + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + def test_install_rejects_command_collision_with_installed_extension(self, temp_dir, project_dir): + """Install should reject names already claimed by an installed legacy extension.""" + import yaml + + first_dir = temp_dir / "ext-one" + first_dir.mkdir() + (first_dir / "commands").mkdir() + first_manifest = { + "schema_version": "1.0", + "extension": { + "id": "ext-one", + "name": "Extension One", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.ext-one.sync", + "file": "commands/cmd.md", + "aliases": ["speckit.shared.sync"], + } + ] + }, + } + (first_dir / "extension.yml").write_text(yaml.dump(first_manifest)) + (first_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody") + installed_ext_dir = project_dir / ".specify" / "extensions" / "ext-one" + installed_ext_dir.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(first_dir, installed_ext_dir) + + second_dir = temp_dir / "ext-two" + second_dir.mkdir() + (second_dir / "commands").mkdir() + second_manifest = { + "schema_version": "1.0", + "extension": { + "id": "shared", + "name": "Shared Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.shared.sync", + "file": "commands/cmd.md", + } + ] + }, + } + (second_dir / "extension.yml").write_text(yaml.dump(second_manifest)) + (second_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody") + + manager = ExtensionManager(project_dir) + manager.registry.add("ext-one", {"version": "1.0.0", "source": "local"}) + + with pytest.raises(ValidationError, match="already provided by extension 'ext-one'"): + manager.install_from_directory(second_dir, "0.1.0", register_commands=False) + + def test_remove_extension(self, extension_dir, project_dir): + """Test removing an installed extension.""" + manager = ExtensionManager(project_dir) + + # Install extension + manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) + + ext_dir = project_dir / ".specify" / "extensions" / "test-ext" + assert ext_dir.exists() + + # Remove extension + result = manager.remove("test-ext", keep_config=False) + + assert result is True + assert not manager.registry.is_installed("test-ext") + assert not ext_dir.exists() + + def test_remove_nonexistent(self, project_dir): + """Test removing non-existent extension.""" + manager = ExtensionManager(project_dir) + + result = manager.remove("nonexistent") + assert result is False + + def test_list_installed(self, extension_dir, project_dir): """Test listing installed extensions.""" manager = ExtensionManager(project_dir) @@ -399,6 +989,36 @@ def test_config_backup_on_remove(self, extension_dir, project_dir): class TestCommandRegistrar: """Test CommandRegistrar command registration.""" + def test_kiro_cli_agent_config_present(self): + """Kiro CLI should be mapped to .kiro/prompts and legacy q removed.""" + assert "kiro-cli" in CommandRegistrar.AGENT_CONFIGS + assert CommandRegistrar.AGENT_CONFIGS["kiro-cli"]["dir"] == ".kiro/prompts" + assert "q" not in CommandRegistrar.AGENT_CONFIGS + + def test_codex_agent_config_present(self): + """Codex should be mapped to .agents/skills.""" + assert "codex" in CommandRegistrar.AGENT_CONFIGS + assert CommandRegistrar.AGENT_CONFIGS["codex"]["dir"] == ".agents/skills" + assert CommandRegistrar.AGENT_CONFIGS["codex"]["extension"] == "/SKILL.md" + + def test_pi_agent_config_present(self): + """Pi should be mapped to .pi/prompts.""" + assert "pi" in CommandRegistrar.AGENT_CONFIGS + cfg = CommandRegistrar.AGENT_CONFIGS["pi"] + assert cfg["dir"] == ".pi/prompts" + assert cfg["format"] == "markdown" + assert cfg["args"] == "$ARGUMENTS" + assert cfg["extension"] == ".md" + + def test_qwen_agent_config_is_markdown(self): + """Qwen should use Markdown format with $ARGUMENTS (not TOML).""" + assert "qwen" in CommandRegistrar.AGENT_CONFIGS + cfg = CommandRegistrar.AGENT_CONFIGS["qwen"] + assert cfg["dir"] == ".qwen/commands" + assert cfg["format"] == "markdown" + assert cfg["args"] == "$ARGUMENTS" + assert cfg["extension"] == ".md" + def test_parse_frontmatter_valid(self): """Test parsing valid YAML frontmatter.""" content = """--- @@ -429,6 +1049,21 @@ def test_parse_frontmatter_no_frontmatter(self): assert frontmatter == {} assert body == content + def test_parse_frontmatter_non_mapping_returns_empty_dict(self): + """Non-mapping YAML frontmatter should not crash downstream renderers.""" + content = """--- +- item1 +- item2 +--- + +# Command body +""" + registrar = CommandRegistrar() + frontmatter, body = registrar.parse_frontmatter(content) + + assert frontmatter == {} + assert "Command body" in body + def test_render_frontmatter(self): """Test rendering frontmatter to YAML.""" frontmatter = { @@ -443,10 +1078,112 @@ def test_render_frontmatter(self): assert output.endswith("---\n") assert "description: Test command" in output + def test_render_frontmatter_unicode(self): + """Test rendering frontmatter preserves non-ASCII characters.""" + frontmatter = { + "description": "Prüfe KonformitƤt der Implementierung" + } + + registrar = CommandRegistrar() + output = registrar.render_frontmatter(frontmatter) + + assert "Prüfe KonformitƤt" in output + assert "\\u" not in output + + def test_adjust_script_paths_does_not_mutate_input(self): + """Path adjustments should not mutate caller-owned frontmatter dicts.""" + from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar + registrar = AgentCommandRegistrar() + original = { + "scripts": { + "sh": "../../scripts/bash/setup-plan.sh {ARGS}", + "ps": "../../scripts/powershell/setup-plan.ps1 {ARGS}", + } + } + before = json.loads(json.dumps(original)) + + adjusted = registrar._adjust_script_paths(original) + + assert original == before + assert adjusted["scripts"]["sh"] == ".specify/scripts/bash/setup-plan.sh {ARGS}" + assert adjusted["scripts"]["ps"] == ".specify/scripts/powershell/setup-plan.ps1 {ARGS}" + + def test_adjust_script_paths_preserves_extension_local_paths(self): + """Extension-local script paths should not be rewritten into .specify/.specify.""" + from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar + registrar = AgentCommandRegistrar() + original = { + "scripts": { + "sh": ".specify/extensions/test-ext/scripts/setup.sh {ARGS}", + "ps": "scripts/powershell/setup-plan.ps1 {ARGS}", + } + } + + adjusted = registrar._adjust_script_paths(original) + + assert adjusted["scripts"]["sh"] == ".specify/extensions/test-ext/scripts/setup.sh {ARGS}" + assert adjusted["scripts"]["ps"] == ".specify/scripts/powershell/setup-plan.ps1 {ARGS}" + + def test_rewrite_project_relative_paths_preserves_extension_local_body_paths(self): + """Body rewrites should preserve extension-local assets while fixing top-level refs.""" + from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar + + body = ( + "Read `.specify/extensions/test-ext/templates/spec.md`\n" + "Run scripts/bash/setup-plan.sh\n" + ) + + rewritten = AgentCommandRegistrar.rewrite_project_relative_paths(body) + + assert ".specify/extensions/test-ext/templates/spec.md" in rewritten + assert ".specify/scripts/bash/setup-plan.sh" in rewritten + + def test_render_toml_command_handles_embedded_triple_double_quotes(self): + """TOML renderer should stay valid when body includes triple double-quotes.""" + from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar + registrar = AgentCommandRegistrar() + output = registrar.render_toml_command( + {"description": "x"}, + 'line1\n"""danger"""\nline2', + "extension:test-ext", + ) + + assert "prompt = '''" in output + assert '"""danger"""' in output + + def test_render_toml_command_escapes_when_both_triple_quote_styles_exist(self): + """If body has both triple quote styles, fall back to escaped basic string.""" + from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar + registrar = AgentCommandRegistrar() + output = registrar.render_toml_command( + {"description": "x"}, + 'a """ b\nc \'\'\' d', + "extension:test-ext", + ) + + assert 'prompt = "' in output + assert "\\n" in output + assert "\\\"\\\"\\\"" in output + + def test_render_toml_command_preserves_multiline_description(self): + """Multiline descriptions should render as parseable TOML with preserved semantics.""" + from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar + + registrar = AgentCommandRegistrar() + output = registrar.render_toml_command( + {"description": "first line\nsecond line\n"}, + "body", + "extension:test-ext", + ) + + parsed = tomllib.loads(output) + + assert parsed["description"] == "first line\nsecond line\n" + def test_register_commands_for_claude(self, extension_dir, project_dir): """Test registering commands for Claude agent.""" # Create .claude directory - claude_dir = project_dir / ".claude" / "commands" + claude_dir = project_dir / ".claude" / "skills" claude_dir.mkdir(parents=True) ExtensionManager(project_dir) # Initialize manager (side effects only) @@ -460,16 +1197,15 @@ def test_register_commands_for_claude(self, extension_dir, project_dir): ) assert len(registered) == 1 - assert "speckit.test.hello" in registered + assert "speckit.test-ext.hello" in registered # Check command file was created - cmd_file = claude_dir / "speckit.test.hello.md" + cmd_file = claude_dir / "speckit-test-ext-hello" / "SKILL.md" assert cmd_file.exists() content = cmd_file.read_text() assert "description: Test hello command" in content - assert "" in content - assert "" in content + assert "test-ext" in content def test_command_with_aliases(self, project_dir, temp_dir): """Test registering a command with aliases.""" @@ -493,9 +1229,9 @@ def test_command_with_aliases(self, project_dir, temp_dir): "provides": { "commands": [ { - "name": "speckit.alias.cmd", + "name": "speckit.ext-alias.cmd", "file": "commands/cmd.md", - "aliases": ["speckit.shortcut"], + "aliases": ["speckit.ext-alias.shortcut"], } ] }, @@ -507,7 +1243,7 @@ def test_command_with_aliases(self, project_dir, temp_dir): (ext_dir / "commands").mkdir() (ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nTest") - claude_dir = project_dir / ".claude" / "commands" + claude_dir = project_dir / ".claude" / "skills" claude_dir.mkdir(parents=True) manifest = ExtensionManifest(ext_dir / "extension.yml") @@ -515,475 +1251,2869 @@ def test_command_with_aliases(self, project_dir, temp_dir): registered = registrar.register_commands_for_claude(manifest, ext_dir, project_dir) assert len(registered) == 2 - assert "speckit.alias.cmd" in registered - assert "speckit.shortcut" in registered - assert (claude_dir / "speckit.alias.cmd.md").exists() - assert (claude_dir / "speckit.shortcut.md").exists() - - -# ===== Utility Function Tests ===== + assert "speckit.ext-alias.cmd" in registered + assert "speckit.ext-alias.shortcut" in registered + assert (claude_dir / "speckit-ext-alias-cmd" / "SKILL.md").exists() + assert (claude_dir / "speckit-ext-alias-shortcut" / "SKILL.md").exists() + + def test_unregister_commands_for_codex_skills_uses_mapped_names(self, project_dir): + """Codex skill cleanup should use the same mapped names as registration.""" + skills_dir = project_dir / ".agents" / "skills" + (skills_dir / "speckit-specify").mkdir(parents=True) + (skills_dir / "speckit-specify" / "SKILL.md").write_text("body") + (skills_dir / "speckit-shortcut").mkdir(parents=True) + (skills_dir / "speckit-shortcut" / "SKILL.md").write_text("body") -class TestVersionSatisfies: - """Test version_satisfies utility function.""" + registrar = CommandRegistrar() + registrar.unregister_commands( + {"codex": ["speckit.specify", "speckit.shortcut"]}, + project_dir, + ) - def test_version_satisfies_simple(self): - """Test simple version comparison.""" - assert version_satisfies("1.0.0", ">=1.0.0") - assert version_satisfies("1.0.1", ">=1.0.0") - assert not version_satisfies("0.9.9", ">=1.0.0") + assert not (skills_dir / "speckit-specify" / "SKILL.md").exists() + assert not (skills_dir / "speckit-shortcut" / "SKILL.md").exists() - def test_version_satisfies_range(self): - """Test version range.""" - assert version_satisfies("1.5.0", ">=1.0.0,<2.0.0") - assert not version_satisfies("2.0.0", ">=1.0.0,<2.0.0") - assert not version_satisfies("0.9.0", ">=1.0.0,<2.0.0") + def test_register_commands_for_all_agents_distinguishes_codex_from_amp(self, extension_dir, project_dir): + """A Codex project under .agents/skills should not implicitly activate Amp.""" + skills_dir = project_dir / ".agents" / "skills" + skills_dir.mkdir(parents=True) - def test_version_satisfies_complex(self): - """Test complex version specifier.""" - assert version_satisfies("1.0.5", ">=1.0.0,!=1.0.3") - assert not version_satisfies("1.0.3", ">=1.0.0,!=1.0.3") + manifest = ExtensionManifest(extension_dir / "extension.yml") + registrar = CommandRegistrar() + registered = registrar.register_commands_for_all_agents(manifest, extension_dir, project_dir) - def test_version_satisfies_invalid(self): - """Test invalid version strings.""" - assert not version_satisfies("invalid", ">=1.0.0") - assert not version_satisfies("1.0.0", "invalid specifier") + assert "codex" in registered + assert "amp" not in registered + assert not (project_dir / ".agents" / "commands").exists() + def test_codex_skill_registration_writes_skill_frontmatter(self, extension_dir, project_dir): + """Codex SKILL.md output should use skills-oriented frontmatter.""" + skills_dir = project_dir / ".agents" / "skills" + skills_dir.mkdir(parents=True) -# ===== Integration Tests ===== + manifest = ExtensionManifest(extension_dir / "extension.yml") + registrar = CommandRegistrar() + registrar.register_commands_for_agent("codex", manifest, extension_dir, project_dir) -class TestIntegration: - """Integration tests for complete workflows.""" + skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md" + assert skill_file.exists() - def test_full_install_and_remove_workflow(self, extension_dir, project_dir): - """Test complete installation and removal workflow.""" - # Create Claude directory - (project_dir / ".claude" / "commands").mkdir(parents=True) + content = skill_file.read_text() + assert "name: speckit-test-ext-hello" in content + assert "description: Test hello command" in content + assert "compatibility:" in content + assert "metadata:" in content + assert "source: test-ext:commands/hello.md" in content + assert " B["WorkflowEngine.load_workflow()"] + B --> C["WorkflowDefinition.from_yaml()"] + C --> D["_resolve_inputs()"] + D --> E["validate_workflow()"] + E --> F["RunState.create()"] + F --> G["_execute_steps()"] + G --> H{Step type?} + H -- command --> I["CommandStep.execute()"] + H -- shell --> J["ShellStep.execute()"] + H -- gate --> K["GateStep.execute()"] + H -- "if" --> L["IfThenStep.execute()"] + H -- switch --> M["SwitchStep.execute()"] + H -- "while/do-while" --> N["Loop steps"] + H -- "fan-out/fan-in" --> O["Fan-out/fan-in"] + + I --> P{Result status?} + J --> P + K --> P + L --> P + M --> P + N --> P + O --> P + P -- COMPLETED --> Q{Has next_steps?} + P -- PAUSED --> R["Save state → exit"] + P -- FAILED --> S["Log error → exit"] + Q -- Yes --> G + Q -- No --> T{More steps?} + T -- Yes --> G + T -- No --> U["Status = COMPLETED"] + + style R fill:#ff9800,color:#fff + style S fill:#f44336,color:#fff + style U fill:#4caf50,color:#fff +``` + +### Sequential Execution + +Steps execute sequentially. Each step receives a `StepContext` containing resolved inputs, accumulated step results, and workflow-level defaults. After execution, the step's output is stored in `context.steps[step_id]` and made available to subsequent steps via expressions like `{{ steps.specify.output.file }}`. + +### Nested Steps (Control Flow) + +Steps like `if`, `switch`, `while`, and `do-while` return `next_steps` — inline step definitions that the engine executes recursively via `_execute_steps()`. Nested steps share the same `StepContext` and `RunState`, so their outputs are visible to later top-level steps. + +### State Persistence and Resume + +The engine saves `RunState` to disk after each step, enabling resume from the exact point of interruption: + +```mermaid +flowchart LR + A["CREATED"] --> B["RUNNING"] + B --> C["COMPLETED"] + B --> D["PAUSED"] + B --> E["FAILED"] + B --> F["ABORTED"] + D -- "resume()" --> B + E -- "resume()" --> B +``` + +When a `gate` step pauses execution, the engine persists `current_step_index` and all accumulated `step_results`. On `specify workflow resume `, the engine restores the context and continues from the paused step. + +> **Note:** Resume tracking is at the top-level step index only. If a +> nested step (inside `if`/`switch`/`while`) pauses, resume re-runs +> the parent control-flow step and its nested body. A nested step-path +> stack for exact resume is a planned enhancement. + +## Step Types + +The engine ships with 10 built-in step types, each in its own subpackage under `src/specify_cli/workflows/steps/`: + +| Type Key | Class | Purpose | Returns `next_steps`? | +|----------|-------|---------|-----------------------| +| `command` | `CommandStep` | Invoke an installed Spec Kit command via integration CLI | No | +| `prompt` | `PromptStep` | Send an arbitrary inline prompt to integration CLI | No | +| `shell` | `ShellStep` | Run a shell command, capture output | No | +| `gate` | `GateStep` | Interactive human review/approval | No (pauses in CI) | +| `if` | `IfThenStep` | Conditional branching (then/else) | Yes | +| `switch` | `SwitchStep` | Multi-branch dispatch on expression | Yes | +| `while` | `WhileStep` | Loop while condition is truthy | Yes (if true) | +| `do-while` | `DoWhileStep` | Loop, always runs body at least once | Yes (always) | +| `fan-out` | `FanOutStep` | Dispatch per item over a collection | No (engine expands) | +| `fan-in` | `FanInStep` | Aggregate results from fan-out | No | + +## Step Registry + +All step types register into `STEP_REGISTRY` via `_register_builtin_steps()` in `src/specify_cli/workflows/__init__.py`. The registry maps `type_key` strings to step instances: + +```python +STEP_REGISTRY: dict[str, StepBase] # e.g., {"command": CommandStep(), "gate": GateStep(), ...} +``` + +Registration is explicit — each step class is imported and instantiated. New step types follow the same pattern: subclass `StepBase`, set `type_key`, implement `execute()` and optionally `validate()`. + +## Expression Engine + +Workflow definitions use Jinja2-like `{{ expression }}` syntax for dynamic values. The expression engine in `src/specify_cli/workflows/expressions.py` supports: + +| Feature | Syntax | Example | +|---------|--------|---------| +| Variable access | `{{ inputs.name }}` | Dot-path traversal into context | +| Step outputs | `{{ steps.plan.output.file }}` | Access previous step results | +| Comparisons | `==`, `!=`, `>`, `<`, `>=`, `<=` | `{{ count > 5 }}` | +| Boolean logic | `and`, `or`, `not` | `{{ items and status == 'ok' }}` | +| Membership | `in`, `not in` | `{{ 'error' not in status }}` | +| Literals | strings, numbers, booleans, lists | `{{ true }}`, `{{ [1, 2] }}` | +| Filter: `default` | `{{ val \| default('fallback') }}` | Fallback for None/empty | +| Filter: `join` | `{{ list \| join(', ') }}` | Join list elements | +| Filter: `contains` | `{{ text \| contains('sub') }}` | Substring/membership check | +| Filter: `map` | `{{ list \| map('attr') }}` | Extract attribute from each item | + +**Single expressions** (`{{ expr }}` only) return typed values. **Mixed templates** (`"text {{ expr }} more"`) return interpolated strings. + +### Namespace + +The expression evaluator builds a namespace from the `StepContext`: + +| Key | Source | Available when | +|-----|--------|----------------| +| `inputs` | Resolved workflow inputs | Always | +| `steps` | Accumulated step results | After first step | +| `item` | Current iteration item | Inside fan-out | +| `fan_in` | Aggregated results | Inside fan-in | + +## Input Resolution + +When a workflow is executed, `_resolve_inputs()` validates and coerces provided values against the `inputs:` schema: + +| Declared Type | Coercion | Example | +|---------------|----------|---------| +| `string` | None (pass-through) | `"my-feature"` | +| `number` | `float()` → `int()` if whole | `"42"` → `42` | +| `boolean` | `"true"/"1"/"yes"` → `True` | `"false"` → `False` | +| `enum` | Validates against allowed values | `["full", "backend-only"]` | + +Missing required inputs raise `ValueError`. Inputs with `default` values use the default when not provided. + +## Catalog System + +```mermaid +flowchart TD + A["specify workflow search"] --> B["WorkflowCatalog.get_active_catalogs()"] + B --> C{SPECKIT_WORKFLOW_CATALOG_URL set?} + C -- Yes --> D["Single custom catalog"] + C -- No --> E{.specify/workflow-catalogs.yml exists?} + E -- Yes --> F["Project-level catalog stack"] + E -- No --> G{"~/.specify/workflow-catalogs.yml exists?"} + G -- Yes --> H["User-level catalog stack"] + G -- No --> I["Built-in defaults"] + I --> J["default (install allowed)"] + I --> K["community (discovery only)"] + + style D fill:#ff9800,color:#fff + style F fill:#2196f3,color:#fff + style H fill:#2196f3,color:#fff + style J fill:#4caf50,color:#fff + style K fill:#9e9e9e,color:#fff +``` + +Catalogs are fetched with a 1-hour cache (per-URL, SHA256-hashed cache files in `.specify/workflows/.cache/`). Each catalog entry has a `priority` (for merge ordering) and `install_allowed` flag. + +When `specify workflow add ` installs from catalog, it downloads the workflow YAML from the catalog entry's `url` field into `.specify/workflows//workflow.yml`. + +## State and Configuration Locations + +| Component | Location | Format | Purpose | +|-----------|----------|--------|---------| +| Workflow definitions | `.specify/workflows/{id}/workflow.yml` | YAML | Installed workflow definitions | +| Workflow registry | `.specify/workflows/workflow-registry.json` | JSON | Installed workflows metadata | +| Run state | `.specify/workflows/runs/{run_id}/state.json` | JSON | Persisted execution state | +| Run inputs | `.specify/workflows/runs/{run_id}/inputs.json` | JSON | Resolved input values | +| Run log | `.specify/workflows/runs/{run_id}/log.jsonl` | JSONL | Append-only event log | +| Catalog cache | `.specify/workflows/.cache/*.json` | JSON | Cached catalog entries (1hr TTL) | +| Project catalogs | `.specify/workflow-catalogs.yml` | YAML | Project-level catalog sources | +| User catalogs | `~/.specify/workflow-catalogs.yml` | YAML | User-level catalog sources | + +## Module Structure + +``` +src/specify_cli/ +ā”œā”€ā”€ workflows/ +│ ā”œā”€ā”€ __init__.py # STEP_REGISTRY + _register_builtin_steps() +│ ā”œā”€ā”€ base.py # StepBase, StepContext, StepResult, StepStatus, RunStatus +│ ā”œā”€ā”€ catalog.py # WorkflowCatalog, WorkflowCatalogEntry, WorkflowRegistry +│ ā”œā”€ā”€ engine.py # WorkflowDefinition, WorkflowEngine, RunState, validate_workflow() +│ ā”œā”€ā”€ expressions.py # evaluate_expression(), evaluate_condition(), filters +│ └── steps/ +│ ā”œā”€ā”€ command/ # Dispatch command to AI integration +│ ā”œā”€ā”€ shell/ # Run shell command +│ ā”œā”€ā”€ gate/ # Human review checkpoint +│ ā”œā”€ā”€ if_then/ # Conditional branching +│ ā”œā”€ā”€ prompt/ # Arbitrary inline prompts +│ ā”œā”€ā”€ switch/ # Multi-branch dispatch +│ ā”œā”€ā”€ while_loop/ # While loop +│ ā”œā”€ā”€ do_while/ # Do-while loop +│ ā”œā”€ā”€ fan_out/ # Sequential per-item dispatch +│ └── fan_in/ # Result aggregation +└── __init__.py # CLI commands: specify workflow run/resume/status/ + # list/add/remove/search/info, + # specify workflow catalog list/add/remove +``` diff --git a/workflows/PUBLISHING.md b/workflows/PUBLISHING.md new file mode 100644 index 0000000000..ce0d251826 --- /dev/null +++ b/workflows/PUBLISHING.md @@ -0,0 +1,285 @@ +# Workflow Publishing Guide + +This guide explains how to publish your workflow to the Spec Kit workflow catalog, making it discoverable by `specify workflow search`. + +## Table of Contents + +1. [Prerequisites](#prerequisites) +2. [Prepare Your Workflow](#prepare-your-workflow) +3. [Submit to Catalog](#submit-to-catalog) +4. [Verification Process](#verification-process) +5. [Release Workflow](#release-workflow) +6. [Best Practices](#best-practices) + +--- + +## Prerequisites + +Before publishing a workflow, ensure you have: + +1. **Valid Workflow**: A working `workflow.yml` that passes `specify workflow run` validation +2. **Git Repository**: Workflow hosted on GitHub (or other public git hosting) +3. **Documentation**: README.md with description, inputs, and step graph +4. **License**: Open source license file (MIT, Apache 2.0, etc.) +5. **Versioning**: Semantic versioning in the `workflow.version` field +6. **Testing**: Workflow tested on real projects + +--- + +## Prepare Your Workflow + +### 1. Workflow Structure + +Host your workflow in a repository with this structure: + +```text +your-workflow/ +ā”œā”€ā”€ workflow.yml # Required: Workflow definition +ā”œā”€ā”€ README.md # Required: Documentation +ā”œā”€ā”€ LICENSE # Required: License file +└── CHANGELOG.md # Recommended: Version history +``` + +### 2. workflow.yml Validation + +Verify your definition is valid: + +```yaml +schema_version: "1.0" + +workflow: + id: "your-workflow" # Unique lowercase-hyphenated ID + name: "Your Workflow Name" # Human-readable name + version: "1.0.0" # Semantic version + author: "Your Name or Organization" + description: "Brief description (one sentence)" + integration: claude # Default integration (optional) + model: "claude-sonnet-4-20250514" # Default model (optional) + +requires: + speckit_version: ">=0.6.1" + integrations: + any: ["claude", "gemini"] # At least one required + +inputs: + spec: + type: string + required: true + prompt: "Describe what you want to build" + scope: + type: string + default: "full" + enum: ["full", "backend-only", "frontend-only"] + +steps: + - id: specify + command: speckit.specify + input: + args: "{{ inputs.spec }}" + + - id: review + type: gate + message: "Review the output." + options: [approve, reject] + on_reject: abort +``` + +**Validation Checklist**: + +- āœ… `id` is lowercase alphanumeric with hyphens (single-character IDs are allowed) +- āœ… `version` follows semantic versioning (X.Y.Z) +- āœ… `description` is concise +- āœ… All step IDs are unique +- āœ… Step types are valid: `command`, `prompt`, `shell`, `gate`, `if`, `switch`, `while`, `do-while`, `fan-out`, `fan-in` +- āœ… Required fields present per step type (e.g., `condition` for `if`, `expression` for `switch`) +- āœ… Input types are valid: `string`, `number`, `boolean` +- āœ… Step IDs do not contain `:` (reserved for engine-generated nested IDs like `parentId:childId`) + +### 3. Test Locally + +```bash +# Run with required inputs +specify workflow run ./workflow.yml --input spec="Build a user authentication system with OAuth support" + +# Check validation +specify workflow info ./workflow.yml + +# Resume after a gate pause +specify workflow resume + +# Check run status +specify workflow status +``` + +### 4. Create GitHub Release + +Create a GitHub release for your workflow version: + +```bash +git tag v1.0.0 +git push origin v1.0.0 +``` + +The raw YAML URL will be: + +```text +https://raw.githubusercontent.com/your-org/spec-kit-workflow-your-workflow/v1.0.0/workflow.yml +``` + +### 5. Test Installation from URL + +```bash +specify workflow add your-workflow +# (once published to catalog) +``` + +--- + +## Submit to Catalog + +### Understanding the Catalogs + +Spec Kit uses a dual-catalog system: + +- **`catalog.json`** — Official, verified workflows (install allowed by default) +- **`catalog.community.json`** — Community-contributed workflows (discovery only by default) + +All community workflows should be submitted to `catalog.community.json`. + +### 1. Fork the spec-kit Repository + +```bash +git clone https://github.com/YOUR-USERNAME/spec-kit.git +cd spec-kit +``` + +### 2. Add Workflow to Community Catalog + +Edit `workflows/catalog.community.json` and add your workflow. + +> **āš ļø Entries must be sorted alphabetically by workflow ID.** Insert your workflow in the correct position within the `"workflows"` object. + +```json +{ + "schema_version": "1.0", + "updated_at": "2026-04-10T00:00:00Z", + "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/catalog.community.json", + "workflows": { + "your-workflow": { + "id": "your-workflow", + "name": "Your Workflow Name", + "description": "Brief description of what your workflow automates", + "author": "Your Name", + "version": "1.0.0", + "url": "https://raw.githubusercontent.com/your-org/spec-kit-workflow-your-workflow/v1.0.0/workflow.yml", + "repository": "https://github.com/your-org/spec-kit-workflow-your-workflow", + "license": "MIT", + "requires": { + "speckit_version": ">=0.15.0" + }, + "tags": [ + "category", + "automation" + ], + "created_at": "2026-04-10T00:00:00Z", + "updated_at": "2026-04-10T00:00:00Z" + } + } +} +``` + +### 3. Submit Pull Request + +```bash +git checkout -b add-your-workflow +git add workflows/catalog.community.json +git commit -m "Add your-workflow to community catalog + +- Workflow ID: your-workflow +- Version: 1.0.0 +- Author: Your Name +- Description: Brief description +" +git push origin add-your-workflow +``` + +**Pull Request Checklist**: + +```markdown +## Workflow Submission + +**Workflow Name**: Your Workflow Name +**Workflow ID**: your-workflow +**Version**: 1.0.0 +**Repository**: https://github.com/your-org/spec-kit-workflow-your-workflow + +### Checklist +- [ ] Valid workflow.yml (passes `specify workflow info`) +- [ ] README.md with description, inputs, and step graph +- [ ] LICENSE file included +- [ ] GitHub release created with raw YAML URL +- [ ] Workflow tested end-to-end with `specify workflow run` +- [ ] All gate steps have clear review messages +- [ ] Input prompts are descriptive +- [ ] Added to workflows/catalog.community.json (alphabetical order) +``` + +--- + +## Verification Process + +After submission, maintainers will review: + +1. **Definition validation** — valid `workflow.yml`, correct schema +2. **Step correctness** — all step types used correctly, no dangling references +3. **Input design** — clear prompts, sensible defaults and enums +4. **Security** — no malicious shell commands, safe operations +5. **Documentation** — clear README explaining what the workflow does and when to use it + +Once verified, the workflow appears in `specify workflow search`. + +--- + +## Release Workflow + +When releasing a new version: + +1. Update `version` in `workflow.yml` +2. Update CHANGELOG.md +3. Tag and push: `git tag v1.1.0 && git push origin v1.1.0` +4. Submit PR to update `version` and `url` in `workflows/catalog.community.json` + +--- + +## Best Practices + +### Step Design + +- **Use gates at decision points** — place `gate` steps after each major output so users can review before proceeding +- **Keep steps focused** — each step should do one thing; prefer more steps over complex single steps +- **Provide clear gate messages** — explain what to review and what approve/reject means + +### Inputs + +- **Use descriptive prompts** — the `prompt` field is shown to users when running the workflow +- **Set sensible defaults** — optional inputs should have defaults that work for the common case +- **Constrain with enums** — when there's a fixed set of valid values, use `enum` for validation +- **Type appropriately** — use `number` for counts, `boolean` for flags, `string` for names + +### Shell Steps + +- **Avoid destructive commands** — don't delete files or directories without explicit confirmation via a gate +- **Quote variables** — use proper quoting in shell commands to handle spaces +- **Check exit codes** — shell step failures stop the workflow; make sure commands are robust + +### Integration Flexibility + +- **Set `integration` at workflow level** — use the `workflow.integration` field as the default +- **Allow per-step overrides** — let individual steps specify a different integration if needed +- **Document required integrations** — list which integrations must be installed in `requires.integrations` + +### Expression References + +- **Only reference prior steps** — expressions like `{{ steps.plan.output.file }}` only work if `plan` ran before the current step +- **Use `default` filter** — `{{ val | default('fallback') }}` prevents failures from missing values +- **Keep expressions simple** — complex logic should be in shell steps, not expressions diff --git a/workflows/README.md b/workflows/README.md new file mode 100644 index 0000000000..31f736ff76 --- /dev/null +++ b/workflows/README.md @@ -0,0 +1,339 @@ +# Workflows + +Workflows are multi-step, resumable automation pipelines defined in YAML. They orchestrate Spec Kit commands across integrations, evaluate control flow, and pause at human review gates — enabling end-to-end Spec-Driven Development cycles without manual step-by-step invocation. + +## How It Works + +A workflow definition declares a sequence of steps. The engine executes them in order, dispatching commands to AI integrations, running shell commands, evaluating conditions for branching, and pausing at gates for human review. State is persisted after each step, so workflows can be resumed after interruption. + +```yaml +steps: + - id: specify + command: speckit.specify + input: + args: "{{ inputs.spec }}" + + - id: review + type: gate + message: "Review the spec before planning." + options: [approve, reject] + on_reject: abort + + - id: plan + command: speckit.plan +``` + +For detailed architecture and internals, see [ARCHITECTURE.md](ARCHITECTURE.md). + +## Quick Start + +```bash +# Search available workflows +specify workflow search + +# Install the built-in SDD workflow +specify workflow add speckit + +# Or run directly from a local YAML file +specify workflow run ./workflow.yml --input spec="Build a user authentication system with OAuth support" + +# Run an installed workflow with inputs +specify workflow run speckit --input spec="Build a user authentication system with OAuth support" + +# Check run status +specify workflow status + +# Resume after a gate pause +specify workflow resume + +# Get detailed workflow info +specify workflow info speckit + +# Remove a workflow +specify workflow remove speckit +``` + +## Running Workflows + +### From an Installed Workflow + +```bash +specify workflow add speckit +specify workflow run speckit --input spec="Build a user authentication system with OAuth support" +``` + +### From a Local YAML File + +```bash +specify workflow run ./my-workflow.yml --input spec="Build a user authentication system with OAuth support" +``` + +### Multiple Inputs + +```bash +specify workflow run speckit \ + --input spec="Build a user authentication system with OAuth support" \ + --input scope="backend-only" +``` + +## Step Types + +Workflows support 10 built-in step types: + +### Command Steps (default) + +Invoke an installed Spec Kit command by name via the integration CLI: + +```yaml +- id: specify + command: speckit.specify + input: + args: "{{ inputs.spec }}" + integration: claude # Optional: override workflow default + model: "claude-sonnet-4-20250514" # Optional: override model +``` + +### Prompt Steps + +Send an arbitrary inline prompt to an integration CLI (no command file needed): + +```yaml +- id: security-review + type: prompt + prompt: "Review {{ inputs.file }} for security vulnerabilities" + integration: claude +``` + +### Shell Steps + +Run a shell command and capture output: + +```yaml +- id: run-tests + type: shell + run: "cd {{ inputs.project_dir }} && npm test" +``` + +### Gate Steps + +Pause for human review. The workflow resumes when `specify workflow resume` is called: + +```yaml +- id: review-spec + type: gate + message: "Review the generated spec before planning." + options: [approve, edit, reject] + on_reject: abort +``` + +### If/Then/Else Steps + +Conditional branching based on an expression: + +```yaml +- id: check-scope + type: if + condition: "{{ inputs.scope == 'full' }}" + then: + - id: full-plan + command: speckit.plan + else: + - id: quick-plan + command: speckit.plan + options: + quick: true +``` + +### Switch Steps + +Multi-branch dispatch on an expression value: + +```yaml +- id: route + type: switch + expression: "{{ steps.review.output.choice }}" + cases: + approve: + - id: plan + command: speckit.plan + reject: + - id: log + type: shell + run: "echo 'Rejected'" + default: + - id: fallback + type: gate + message: "Unexpected choice" +``` + +### While Loop Steps + +Repeat steps while a condition is truthy: + +```yaml +- id: retry + type: while + condition: "{{ steps.run-tests.output.exit_code != 0 }}" + max_iterations: 5 + steps: + - id: fix + command: speckit.implement +``` + +### Do-While Loop Steps + +Execute steps at least once, then repeat while condition holds: + +```yaml +- id: refine + type: do-while + condition: "{{ steps.review.output.choice == 'edit' }}" + max_iterations: 3 + steps: + - id: revise + command: speckit.specify +``` + +### Fan-Out Steps + +Dispatch a step template for each item in a collection (sequential): + +```yaml +- id: parallel-impl + type: fan-out + items: "{{ steps.tasks.output.task_list }}" + max_concurrency: 3 + step: + id: impl + command: speckit.implement +``` + +### Fan-In Steps + +Aggregate results from fan-out steps: + +```yaml +- id: collect + type: fan-in + wait_for: [parallel-impl] + output: {} +``` + +## Expressions + +Workflow definitions use `{{ expression }}` syntax for dynamic values: + +```yaml +# Access inputs +args: "{{ inputs.spec }}" + +# Access previous step outputs +args: "{{ steps.specify.output.file }}" + +# Comparisons +condition: "{{ steps.run-tests.output.exit_code != 0 }}" + +# Filters +message: "{{ status | default('pending') }}" +``` + +Supported filters: `default`, `join`, `contains`, `map`. + +## Input Types + +Workflow inputs are type-checked and coerced from CLI string values: + +```yaml +inputs: + spec: + type: string + required: true + prompt: "Describe what you want to build" + task_count: + type: number + default: 5 + dry_run: + type: boolean + default: false + scope: + type: string + default: "full" + enum: ["full", "backend-only", "frontend-only"] +``` + +| Type | Accepts | Example | +|------|---------|---------| +| `string` | Any string | `"user-auth"` | +| `number` | Numeric strings → int/float | `"42"` → `42` | +| `boolean` | `true`/`1`/`yes` → `True`, `false`/`0`/`no` → `False` | `"true"` → `True` | + +## State and Resume + +Every workflow run persists state to `.specify/workflows/runs//`: + +```bash +# List all runs with status +specify workflow status + +# Check a specific run +specify workflow status + +# Resume a paused run (after approving a gate) +specify workflow resume + +# Resume a failed run (retries from the failed step) +specify workflow resume +``` + +Run states: `created` → `running` → `completed` | `paused` | `failed` | `aborted` + +## Catalog Management + +Workflows are discovered through catalogs. By default, Spec Kit uses the official and community catalogs: + +> [!NOTE] +> Community workflows are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting and structure, but they do **not review, audit, endorse, or support the workflow definitions themselves**. Review workflow source before installation and use at your own discretion. + +```bash +# List active catalogs +specify workflow catalog list + +# Add a custom catalog +specify workflow catalog add https://example.com/catalog.json --name my-org + +# Remove a catalog +specify workflow catalog remove +``` + +## Creating a Workflow + +1. Create a `workflow.yml` following the schema above +2. Test locally with `specify workflow run ./workflow.yml --input key=value` +3. Verify with `specify workflow info ./workflow.yml` +4. See [PUBLISHING.md](PUBLISHING.md) to submit to the catalog + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `SPECKIT_WORKFLOW_CATALOG_URL` | Override the catalog URL (replaces all defaults) | + +## Configuration Files + +| File | Scope | Description | +|------|-------|-------------| +| `.specify/workflow-catalogs.yml` | Project | Custom catalog stack for this project | +| `~/.specify/workflow-catalogs.yml` | User | Custom catalog stack for all projects | + +## Repository Layout + +``` +workflows/ +ā”œā”€ā”€ ARCHITECTURE.md # Internal architecture documentation +ā”œā”€ā”€ PUBLISHING.md # Guide for submitting workflows to the catalog +ā”œā”€ā”€ README.md # This file +ā”œā”€ā”€ catalog.json # Official workflow catalog +ā”œā”€ā”€ catalog.community.json # Community workflow catalog +└── speckit/ # Built-in SDD cycle workflow + └── workflow.yml +``` diff --git a/workflows/catalog.community.json b/workflows/catalog.community.json new file mode 100644 index 0000000000..c654f5ed22 --- /dev/null +++ b/workflows/catalog.community.json @@ -0,0 +1,6 @@ +{ + "schema_version": "1.0", + "updated_at": "2026-04-10T00:00:00Z", + "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/catalog.community.json", + "workflows": {} +} diff --git a/workflows/catalog.json b/workflows/catalog.json new file mode 100644 index 0000000000..967120afb0 --- /dev/null +++ b/workflows/catalog.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "updated_at": "2026-04-13T00:00:00Z", + "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/catalog.json", + "workflows": { + "speckit": { + "id": "speckit", + "name": "Full SDD Cycle", + "description": "Runs specify \u2192 plan \u2192 tasks \u2192 implement with review gates", + "author": "GitHub", + "version": "1.0.0", + "url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/speckit/workflow.yml", + "tags": ["sdd", "full-cycle"] + } + } +} diff --git a/workflows/speckit/workflow.yml b/workflows/speckit/workflow.yml new file mode 100644 index 0000000000..bf18451029 --- /dev/null +++ b/workflows/speckit/workflow.yml @@ -0,0 +1,63 @@ +schema_version: "1.0" +workflow: + id: "speckit" + name: "Full SDD Cycle" + version: "1.0.0" + author: "GitHub" + description: "Runs specify → plan → tasks → implement with review gates" + +requires: + speckit_version: ">=0.7.2" + integrations: + any: ["copilot", "claude", "gemini"] + +inputs: + spec: + type: string + required: true + prompt: "Describe what you want to build" + integration: + type: string + default: "copilot" + prompt: "Integration to use (e.g. claude, copilot, gemini)" + scope: + type: string + default: "full" + enum: ["full", "backend-only", "frontend-only"] + +steps: + - id: specify + command: speckit.specify + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}" + + - id: review-spec + type: gate + message: "Review the generated spec before planning." + options: [approve, reject] + on_reject: abort + + - id: plan + command: speckit.plan + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}" + + - id: review-plan + type: gate + message: "Review the plan before generating tasks." + options: [approve, reject] + on_reject: abort + + - id: tasks + command: speckit.tasks + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}" + + - id: implement + command: speckit.implement + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}"